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:
kcinzgg
2026-02-28 09:53:02 +08:00
committed by GitHub
parent 50aa6a43ed
commit 89669a33bd
12 changed files with 385 additions and 63 deletions

View File

@@ -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.

View File

@@ -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" },
}),
);
});
});

View File

@@ -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,
});

View File

@@ -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 },

View File

@@ -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");
});
});

View File

@@ -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,
};
/**

View File

@@ -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"),

View 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,
});
}

View File

@@ -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,
}),
);
});
});

View File

@@ -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,
});
}
}
},

View File

@@ -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 });
}
/**

View File

@@ -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}`);
}