mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 10:47:27 +00:00
feat(feishu): add replyInThread configuration for message replies (openclaw#27325) thanks @kcinzgg
Verified: - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: kcinzgg <13964709+kcinzgg@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Feishu/Reply media attachments: send Feishu reply `mediaUrl`/`mediaUrls` payloads as attachments alongside text/streamed replies in the reply dispatcher, including legacy fallback when `mediaUrls` is empty. (#28959)
|
||||
- Feishu/Group session routing: add configurable group session scopes (`group`, `group_sender`, `group_topic`, `group_topic_sender`) with legacy `topicSessionMode=enabled` compatibility so Feishu group conversations can isolate sessions by sender/topic as configured. (#17798)
|
||||
- Feishu/Reply-in-thread routing: add `replyInThread` config (`disabled|enabled`) for group replies, propagate `reply_in_thread` across text/card/media/streaming sends, and align topic-scoped session routing so newly created reply threads stay on the same session root. (#27325)
|
||||
- Feishu/Typing backoff: re-throw Feishu typing add/remove rate-limit and quota errors (`429`, `99991400`, `99991403`) and detect SDK non-throwing backoff responses so the typing keepalive circuit breaker can stop retries instead of looping indefinitely. (#28494)
|
||||
- Feishu/Probe status caching: cache successful `probeFeishu()` bot-info results for 10 minutes (bounded cache with per-account keying) to reduce repeated status/onboarding probe API calls, while bypassing cache for failures and exceptions. (#28907) Thanks @Glucksberg.
|
||||
- Feishu/Opus media send type: send `.opus` attachments with `msg_type: "audio"` (instead of `"media"`) so Feishu voice messages deliver correctly while `.mp4` remains `msg_type: "media"` and documents remain `msg_type: "file"`. (#28269) Thanks @Glucksberg.
|
||||
|
||||
@@ -661,4 +661,42 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
replyInThread: "enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-init" } },
|
||||
message: {
|
||||
message_id: "msg-new-topic-root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "create topic" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:msg-new-topic-root" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -760,6 +760,10 @@ export async function handleFeishuMessage(params: {
|
||||
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
||||
let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
|
||||
"group";
|
||||
let topicRootForSession: string | null = null;
|
||||
const replyInThread =
|
||||
isGroup &&
|
||||
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
||||
|
||||
if (isGroup) {
|
||||
const legacyTopicSessionMode =
|
||||
@@ -769,16 +773,22 @@ export async function handleFeishuMessage(params: {
|
||||
feishuCfg?.groupSessionScope ??
|
||||
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
||||
|
||||
// When topic-scoped sessions are enabled and replyInThread is on, the first
|
||||
// bot reply creates the thread rooted at the current message ID.
|
||||
if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
|
||||
topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
|
||||
}
|
||||
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = ctx.rootId ? `${ctx.chatId}:topic:${ctx.rootId}` : ctx.chatId;
|
||||
peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = ctx.rootId
|
||||
? `${ctx.chatId}:topic:${ctx.rootId}:sender:${ctx.senderOpenId}`
|
||||
peerId = topicRootForSession
|
||||
? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
|
||||
: `${ctx.chatId}:sender:${ctx.senderOpenId}`;
|
||||
break;
|
||||
case "group":
|
||||
@@ -801,7 +811,7 @@ export async function handleFeishuMessage(params: {
|
||||
// Add parentPeer for binding inheritance in topic-scoped modes.
|
||||
parentPeer:
|
||||
isGroup &&
|
||||
ctx.rootId &&
|
||||
topicRootForSession &&
|
||||
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
||||
? {
|
||||
kind: "group",
|
||||
@@ -965,6 +975,7 @@ export async function handleFeishuMessage(params: {
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
replyToMessageId: ctx.messageId,
|
||||
replyInThread,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
@@ -106,6 +106,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
|
||||
},
|
||||
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
||||
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
|
||||
historyLimit: { type: "integer", minimum: 0 },
|
||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||
textChunkLimit: { type: "integer", minimum: 1 },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import { FeishuConfigSchema, FeishuGroupSchema } from "./config-schema.js";
|
||||
|
||||
describe("FeishuConfigSchema webhook validation", () => {
|
||||
it("applies top-level defaults", () => {
|
||||
@@ -86,3 +86,34 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuConfigSchema replyInThread", () => {
|
||||
it("accepts replyInThread at top level", () => {
|
||||
const result = FeishuConfigSchema.parse({ replyInThread: "enabled" });
|
||||
expect(result.replyInThread).toBe("enabled");
|
||||
});
|
||||
|
||||
it("defaults replyInThread to undefined when not set", () => {
|
||||
const result = FeishuConfigSchema.parse({});
|
||||
expect(result.replyInThread).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects invalid replyInThread value", () => {
|
||||
const result = FeishuConfigSchema.safeParse({ replyInThread: "always" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts replyInThread in group config", () => {
|
||||
const result = FeishuGroupSchema.parse({ replyInThread: "enabled" });
|
||||
expect(result.replyInThread).toBe("enabled");
|
||||
});
|
||||
|
||||
it("accepts replyInThread in account config", () => {
|
||||
const result = FeishuConfigSchema.parse({
|
||||
accounts: {
|
||||
main: { replyInThread: "enabled" },
|
||||
},
|
||||
});
|
||||
expect(result.accounts?.main?.replyInThread).toBe("enabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,6 +111,16 @@ const GroupSessionScopeSchema = z
|
||||
*/
|
||||
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
||||
|
||||
/**
|
||||
* Reply-in-thread mode for group chats.
|
||||
* - "disabled" (default): Bot replies are normal inline replies
|
||||
* - "enabled": Bot replies create or continue a Feishu topic thread
|
||||
*
|
||||
* When enabled, the Feishu reply API is called with `reply_in_thread: true`,
|
||||
* causing the reply to appear as a topic (话题) under the original message.
|
||||
*/
|
||||
const ReplyInThreadSchema = z.enum(["disabled", "enabled"]).optional();
|
||||
|
||||
export const FeishuGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
@@ -121,6 +131,7 @@ export const FeishuGroupSchema = z
|
||||
systemPrompt: z.string().optional(),
|
||||
groupSessionScope: GroupSessionScopeSchema,
|
||||
topicSessionMode: TopicSessionModeSchema,
|
||||
replyInThread: ReplyInThreadSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -147,6 +158,7 @@ const FeishuSharedConfigShape = {
|
||||
renderMode: RenderModeSchema,
|
||||
streaming: StreamingModeSchema,
|
||||
tools: FeishuToolsConfigSchema,
|
||||
replyInThread: ReplyInThreadSchema,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -190,6 +190,38 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
expect(messageCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes reply_in_thread when replyInThread is true", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("video"),
|
||||
fileName: "reply.mp4",
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
expect(messageReplyMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { message_id: "om_parent" },
|
||||
data: expect.objectContaining({ msg_type: "media", reply_in_thread: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits reply_in_thread when replyInThread is false", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("video"),
|
||||
fileName: "reply.mp4",
|
||||
replyToMessageId: "om_parent",
|
||||
replyInThread: false,
|
||||
});
|
||||
|
||||
const callData = messageReplyMock.mock.calls[0][0].data;
|
||||
expect(callData).not.toHaveProperty("reply_in_thread");
|
||||
});
|
||||
|
||||
it("passes mediaLocalRoots as localRoots to loadWebMedia for local paths (#27884)", async () => {
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("local-file"),
|
||||
|
||||
@@ -265,9 +265,10 @@ export async function sendImageFeishu(params: {
|
||||
to: string;
|
||||
imageKey: string;
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
||||
const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params;
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
||||
cfg,
|
||||
to,
|
||||
@@ -281,6 +282,7 @@ export async function sendImageFeishu(params: {
|
||||
data: {
|
||||
content,
|
||||
msg_type: "image",
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
|
||||
@@ -309,9 +311,10 @@ export async function sendFileFeishu(params: {
|
||||
/** Use "media" for video, "audio" for audio, "file" for documents */
|
||||
msgType?: "file" | "media" | "audio";
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
||||
const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params;
|
||||
const msgType = params.msgType ?? "file";
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
||||
cfg,
|
||||
@@ -326,6 +329,7 @@ export async function sendFileFeishu(params: {
|
||||
data: {
|
||||
content,
|
||||
msg_type: msgType,
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
|
||||
@@ -387,12 +391,22 @@ export async function sendMediaFeishu(params: {
|
||||
mediaBuffer?: Buffer;
|
||||
fileName?: string;
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
/** Allowed roots for local path reads; required for local filePath to work. */
|
||||
mediaLocalRoots?: readonly string[];
|
||||
}): Promise<SendMediaResult> {
|
||||
const { cfg, to, mediaUrl, mediaBuffer, fileName, replyToMessageId, accountId, mediaLocalRoots } =
|
||||
params;
|
||||
const {
|
||||
cfg,
|
||||
to,
|
||||
mediaUrl,
|
||||
mediaBuffer,
|
||||
fileName,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
} = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
@@ -423,7 +437,7 @@ export async function sendMediaFeishu(params: {
|
||||
|
||||
if (isImage) {
|
||||
const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
|
||||
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, accountId });
|
||||
return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
|
||||
} else {
|
||||
const fileType = detectFileType(name);
|
||||
const { fileKey } = await uploadFileFeishu({
|
||||
@@ -441,6 +455,7 @@ export async function sendMediaFeishu(params: {
|
||||
fileKey,
|
||||
msgType,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,4 +186,98 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
config: {
|
||||
renderMode: "card",
|
||||
streaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "card text" }, { kind: "final" });
|
||||
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes replyInThread to media attachments", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: {} as never,
|
||||
chatId: "oc_chat",
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
||||
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,13 +28,15 @@ export type CreateFeishuReplyDispatcherParams = {
|
||||
runtime: RuntimeEnv;
|
||||
chatId: string;
|
||||
replyToMessageId?: string;
|
||||
replyInThread?: boolean;
|
||||
mentionTargets?: MentionTarget[];
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
||||
const core = getFeishuRuntime();
|
||||
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
||||
const { cfg, agentId, chatId, replyToMessageId, replyInThread, mentionTargets, accountId } =
|
||||
params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
||||
|
||||
@@ -100,7 +102,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
||||
);
|
||||
try {
|
||||
await streaming.start(chatId, resolveReceiveIdType(chatId));
|
||||
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
});
|
||||
} catch (error) {
|
||||
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
||||
streaming = null;
|
||||
@@ -170,7 +175,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
// Send media even when streaming handled the text
|
||||
if (hasMedia) {
|
||||
for (const mediaUrl of mediaList) {
|
||||
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
|
||||
await sendMediaFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
mediaUrl,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -188,6 +200,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
@@ -205,6 +218,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
@@ -215,7 +229,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
|
||||
if (hasMedia) {
|
||||
for (const mediaUrl of mediaList) {
|
||||
await sendMediaFeishu({ cfg, to: chatId, mediaUrl, replyToMessageId, accountId });
|
||||
await sendMediaFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
mediaUrl,
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -96,6 +96,8 @@ export type SendFeishuMessageParams = {
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
||||
replyInThread?: boolean;
|
||||
/** Mention target users */
|
||||
mentions?: MentionTarget[];
|
||||
/** Account ID (optional, uses default if not specified) */
|
||||
@@ -127,7 +129,7 @@ function buildFeishuPostMessagePayload(params: { messageText: string }): {
|
||||
export async function sendMessageFeishu(
|
||||
params: SendFeishuMessageParams,
|
||||
): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
||||
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
||||
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
@@ -149,6 +151,7 @@ export async function sendMessageFeishu(
|
||||
data: {
|
||||
content,
|
||||
msg_type: msgType,
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu reply failed");
|
||||
@@ -172,11 +175,13 @@ export type SendFeishuCardParams = {
|
||||
to: string;
|
||||
card: Record<string, unknown>;
|
||||
replyToMessageId?: string;
|
||||
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
||||
replyInThread?: boolean;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
||||
const { cfg, to, card, replyToMessageId, accountId } = params;
|
||||
const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
|
||||
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
||||
const content = JSON.stringify(card);
|
||||
|
||||
@@ -186,6 +191,7 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
|
||||
data: {
|
||||
content,
|
||||
msg_type: "interactive",
|
||||
...(replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
assertFeishuMessageApiSuccess(response, "Feishu card reply failed");
|
||||
@@ -260,18 +266,19 @@ export async function sendMarkdownCardFeishu(params: {
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
||||
replyInThread?: boolean;
|
||||
/** Mention target users */
|
||||
mentions?: MentionTarget[];
|
||||
accountId?: string;
|
||||
}): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
||||
// Build message content (with @mention support)
|
||||
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId } = params;
|
||||
let cardText = text;
|
||||
if (mentions && mentions.length > 0) {
|
||||
cardText = buildMentionedCardContent(mentions, text);
|
||||
}
|
||||
const card = buildMarkdownCard(cardText);
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId, accountId });
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
||||
import type { FeishuDomain } from "./types.js";
|
||||
|
||||
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
||||
@@ -21,6 +22,20 @@ function resolveApiBase(domain?: FeishuDomain): string {
|
||||
return "https://open.feishu.cn/open-apis";
|
||||
}
|
||||
|
||||
function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
||||
if (domain === "lark") {
|
||||
return ["open.larksuite.com"];
|
||||
}
|
||||
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
||||
try {
|
||||
return [new URL(domain).hostname];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return ["open.feishu.cn"];
|
||||
}
|
||||
|
||||
async function getToken(creds: Credentials): Promise<string> {
|
||||
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
||||
const cached = tokenCache.get(key);
|
||||
@@ -28,17 +43,23 @@ async function getToken(creds: Credentials): Promise<string> {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
||||
},
|
||||
policy: { allowedHostnames: resolveAllowedHostnames(creds.domain) },
|
||||
auditContext: "feishu.streaming-card.token",
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
const data = (await response.json()) as {
|
||||
code: number;
|
||||
msg: string;
|
||||
tenant_access_token?: string;
|
||||
expire?: number;
|
||||
};
|
||||
await release();
|
||||
if (data.code !== 0 || !data.tenant_access_token) {
|
||||
throw new Error(`Token error: ${data.msg}`);
|
||||
}
|
||||
@@ -78,6 +99,7 @@ export class FeishuStreamingSession {
|
||||
async start(
|
||||
receiveId: string,
|
||||
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
||||
options?: { replyToMessageId?: string; replyInThread?: boolean },
|
||||
): Promise<void> {
|
||||
if (this.state) {
|
||||
return;
|
||||
@@ -97,33 +119,52 @@ export class FeishuStreamingSession {
|
||||
};
|
||||
|
||||
// Create card entity
|
||||
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
const { response: createRes, release: releaseCreate } = await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/cardkit/v1/cards`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
||||
},
|
||||
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
||||
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
||||
auditContext: "feishu.streaming-card.create",
|
||||
});
|
||||
const createData = (await createRes.json()) as {
|
||||
code: number;
|
||||
msg: string;
|
||||
data?: { card_id: string };
|
||||
};
|
||||
await releaseCreate();
|
||||
if (createData.code !== 0 || !createData.data?.card_id) {
|
||||
throw new Error(`Create card failed: ${createData.msg}`);
|
||||
}
|
||||
const cardId = createData.data.card_id;
|
||||
const cardContent = JSON.stringify({ type: "card", data: { card_id: cardId } });
|
||||
|
||||
// Send card message
|
||||
const sendRes = await this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: "interactive",
|
||||
content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
|
||||
},
|
||||
});
|
||||
// Send card message — reply into thread when configured
|
||||
let sendRes;
|
||||
if (options?.replyToMessageId) {
|
||||
sendRes = await this.client.im.message.reply({
|
||||
path: { message_id: options.replyToMessageId },
|
||||
data: {
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
...(options.replyInThread ? { reply_in_thread: true } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
sendRes = await this.client.im.message.create({
|
||||
params: { receive_id_type: receiveIdType },
|
||||
data: {
|
||||
receive_id: receiveId,
|
||||
msg_type: "interactive",
|
||||
content: cardContent,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
||||
throw new Error(`Send card failed: ${sendRes.msg}`);
|
||||
}
|
||||
@@ -138,18 +179,27 @@ export class FeishuStreamingSession {
|
||||
}
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
this.state.sequence += 1;
|
||||
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
}).catch((error) => onError?.(error));
|
||||
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
||||
auditContext: "feishu.streaming-card.update",
|
||||
})
|
||||
.then(async ({ release }) => {
|
||||
await release();
|
||||
})
|
||||
.catch((error) => onError?.(error));
|
||||
}
|
||||
|
||||
async update(text: string): Promise<void> {
|
||||
@@ -194,20 +244,29 @@ export class FeishuStreamingSession {
|
||||
|
||||
// Close streaming mode
|
||||
this.state.sequence += 1;
|
||||
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: JSON.stringify({
|
||||
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
||||
await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`,
|
||||
init: {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
settings: JSON.stringify({
|
||||
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
||||
}),
|
||||
sequence: this.state.sequence,
|
||||
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
sequence: this.state.sequence,
|
||||
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
||||
},
|
||||
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
||||
auditContext: "feishu.streaming-card.close",
|
||||
})
|
||||
.then(async ({ release }) => {
|
||||
await release();
|
||||
})
|
||||
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
||||
|
||||
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user