mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-29 03:01:50 +03:00
feat(feishu): add streaming card support via Card Kit API (openclaw#10379) thanks @xzq-xu
Verified: - pnpm build - pnpm check - pnpm test Co-authored-by: xzq-xu <53989315+xzq-xu@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
|
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
|
||||||
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
|
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
|
||||||
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
|
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
|
||||||
|
- Feishu: add streaming card replies via Card Kit API and preserve `renderMode=auto` fallback behavior for plain-text responses. (#10379) Thanks @xzq-xu.
|
||||||
- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159.
|
- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159.
|
||||||
- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
|
- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
|
||||||
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
|
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
|
|||||||
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
||||||
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
||||||
|
|
||||||
|
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
|
||||||
|
// for incremental text display with a "Thinking..." placeholder
|
||||||
|
const StreamingModeSchema = z.boolean().optional();
|
||||||
|
|
||||||
const BlockStreamingCoalesceSchema = z
|
const BlockStreamingCoalesceSchema = z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -142,6 +146,7 @@ export const FeishuAccountConfigSchema = z
|
|||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
renderMode: RenderModeSchema,
|
renderMode: RenderModeSchema,
|
||||||
|
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
||||||
tools: FeishuToolsConfigSchema,
|
tools: FeishuToolsConfigSchema,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -177,6 +182,7 @@ export const FeishuConfigSchema = z
|
|||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||||
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
||||||
|
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
||||||
tools: FeishuToolsConfigSchema,
|
tools: FeishuToolsConfigSchema,
|
||||||
// Dynamic agent creation for DM users
|
// Dynamic agent creation for DM users
|
||||||
dynamicAgentCreation: DynamicAgentCreationSchema,
|
dynamicAgentCreation: DynamicAgentCreationSchema,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
||||||
|
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||||
|
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||||
|
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||||
|
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
||||||
|
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
||||||
|
const streamingInstances = vi.hoisted(() => [] as any[]);
|
||||||
|
|
||||||
|
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
|
||||||
|
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessageFeishu: sendMessageFeishuMock,
|
||||||
|
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||||
|
}));
|
||||||
|
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
||||||
|
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
|
||||||
|
vi.mock("./streaming-card.js", () => ({
|
||||||
|
FeishuStreamingSession: class {
|
||||||
|
active = false;
|
||||||
|
start = vi.fn(async () => {
|
||||||
|
this.active = true;
|
||||||
|
});
|
||||||
|
update = vi.fn(async () => {});
|
||||||
|
close = vi.fn(async () => {
|
||||||
|
this.active = false;
|
||||||
|
});
|
||||||
|
isActive = vi.fn(() => this.active);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
streamingInstances.push(this);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
|
|
||||||
|
describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
streamingInstances.length = 0;
|
||||||
|
|
||||||
|
resolveFeishuAccountMock.mockReturnValue({
|
||||||
|
accountId: "main",
|
||||||
|
appId: "app_id",
|
||||||
|
appSecret: "app_secret",
|
||||||
|
domain: "feishu",
|
||||||
|
config: {
|
||||||
|
renderMode: "auto",
|
||||||
|
streaming: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
resolveReceiveIdTypeMock.mockReturnValue("chat_id");
|
||||||
|
createFeishuClientMock.mockReturnValue({});
|
||||||
|
|
||||||
|
createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
|
||||||
|
dispatcher: {},
|
||||||
|
replyOptions: {},
|
||||||
|
markDispatchIdle: vi.fn(),
|
||||||
|
_opts: opts,
|
||||||
|
}));
|
||||||
|
|
||||||
|
getFeishuRuntimeMock.mockReturnValue({
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
resolveTextChunkLimit: vi.fn(() => 4000),
|
||||||
|
resolveChunkMode: vi.fn(() => "line"),
|
||||||
|
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
||||||
|
convertMarkdownTables: vi.fn((text) => text),
|
||||||
|
chunkTextWithMode: vi.fn((text) => [text]),
|
||||||
|
},
|
||||||
|
reply: {
|
||||||
|
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
||||||
|
resolveHumanDelayConfig: vi.fn(() => undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps auto mode plain text on non-streaming send path", async () => {
|
||||||
|
createFeishuReplyDispatcher({
|
||||||
|
cfg: {} as never,
|
||||||
|
agentId: "agent",
|
||||||
|
runtime: {} as never,
|
||||||
|
chatId: "oc_chat",
|
||||||
|
});
|
||||||
|
|
||||||
|
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||||
|
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||||
|
|
||||||
|
expect(streamingInstances).toHaveLength(0);
|
||||||
|
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses streaming session for auto mode markdown payloads", async () => {
|
||||||
|
createFeishuReplyDispatcher({
|
||||||
|
cfg: {} as never,
|
||||||
|
agentId: "agent",
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||||
|
chatId: "oc_chat",
|
||||||
|
});
|
||||||
|
|
||||||
|
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).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||||
|
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,29 +3,22 @@ import {
|
|||||||
createTypingCallbacks,
|
createTypingCallbacks,
|
||||||
logTypingFailure,
|
logTypingFailure,
|
||||||
type ClawdbotConfig,
|
type ClawdbotConfig,
|
||||||
type RuntimeEnv,
|
|
||||||
type ReplyPayload,
|
type ReplyPayload,
|
||||||
|
type RuntimeEnv,
|
||||||
} from "openclaw/plugin-sdk";
|
} from "openclaw/plugin-sdk";
|
||||||
import type { MentionTarget } from "./mention.js";
|
import type { MentionTarget } from "./mention.js";
|
||||||
import { resolveFeishuAccount } from "./accounts.js";
|
import { resolveFeishuAccount } from "./accounts.js";
|
||||||
|
import { createFeishuClient } from "./client.js";
|
||||||
|
import { buildMentionedCardContent } from "./mention.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
|
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
||||||
|
import { FeishuStreamingSession } from "./streaming-card.js";
|
||||||
|
import { resolveReceiveIdType } from "./targets.js";
|
||||||
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
||||||
|
|
||||||
/**
|
/** Detect if text contains markdown elements that benefit from card rendering */
|
||||||
* Detect if text contains markdown elements that benefit from card rendering.
|
|
||||||
* Used by auto render mode.
|
|
||||||
*/
|
|
||||||
function shouldUseCard(text: string): boolean {
|
function shouldUseCard(text: string): boolean {
|
||||||
// Code blocks (fenced)
|
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
||||||
if (/```[\s\S]*?```/.test(text)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Tables (at least header + separator row with |)
|
|
||||||
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateFeishuReplyDispatcherParams = {
|
export type CreateFeishuReplyDispatcherParams = {
|
||||||
@@ -34,35 +27,23 @@ export type CreateFeishuReplyDispatcherParams = {
|
|||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
chatId: string;
|
chatId: string;
|
||||||
replyToMessageId?: string;
|
replyToMessageId?: string;
|
||||||
/** Mention targets, will be auto-included in replies */
|
|
||||||
mentionTargets?: MentionTarget[];
|
mentionTargets?: MentionTarget[];
|
||||||
/** Account ID for multi-account support */
|
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
||||||
const core = getFeishuRuntime();
|
const core = getFeishuRuntime();
|
||||||
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
||||||
|
|
||||||
// Resolve account for config access
|
|
||||||
const account = resolveFeishuAccount({ cfg, accountId });
|
const account = resolveFeishuAccount({ cfg, accountId });
|
||||||
|
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
||||||
|
|
||||||
const prefixContext = createReplyPrefixContext({
|
|
||||||
cfg,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Feishu doesn't have a native typing indicator API.
|
|
||||||
// We use message reactions as a typing indicator substitute.
|
|
||||||
let typingState: TypingIndicatorState | null = null;
|
let typingState: TypingIndicatorState | null = null;
|
||||||
|
|
||||||
const typingCallbacks = createTypingCallbacks({
|
const typingCallbacks = createTypingCallbacks({
|
||||||
start: async () => {
|
start: async () => {
|
||||||
if (!replyToMessageId) {
|
if (!replyToMessageId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
||||||
params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
|
|
||||||
},
|
},
|
||||||
stop: async () => {
|
stop: async () => {
|
||||||
if (!typingState) {
|
if (!typingState) {
|
||||||
@@ -70,24 +51,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
}
|
}
|
||||||
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
||||||
typingState = null;
|
typingState = null;
|
||||||
params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
|
|
||||||
},
|
},
|
||||||
onStartError: (err) => {
|
onStartError: (err) =>
|
||||||
logTypingFailure({
|
logTypingFailure({
|
||||||
log: (message) => params.runtime.log?.(message),
|
log: (message) => params.runtime.log?.(message),
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
action: "start",
|
action: "start",
|
||||||
error: err,
|
error: err,
|
||||||
});
|
}),
|
||||||
},
|
onStopError: (err) =>
|
||||||
onStopError: (err) => {
|
|
||||||
logTypingFailure({
|
logTypingFailure({
|
||||||
log: (message) => params.runtime.log?.(message),
|
log: (message) => params.runtime.log?.(message),
|
||||||
channel: "feishu",
|
channel: "feishu",
|
||||||
action: "stop",
|
action: "stop",
|
||||||
error: err,
|
error: err,
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
|
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
|
||||||
@@ -95,77 +73,139 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
});
|
});
|
||||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
||||||
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
||||||
|
const renderMode = account.config?.renderMode ?? "auto";
|
||||||
|
const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
|
||||||
|
|
||||||
|
let streaming: FeishuStreamingSession | null = null;
|
||||||
|
let streamText = "";
|
||||||
|
let lastPartial = "";
|
||||||
|
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
||||||
|
let streamingStartPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const startStreaming = () => {
|
||||||
|
if (!streamingEnabled || streamingStartPromise || streaming) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
streamingStartPromise = (async () => {
|
||||||
|
const creds =
|
||||||
|
account.appId && account.appSecret
|
||||||
|
? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
|
||||||
|
: null;
|
||||||
|
if (!creds) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
|
||||||
|
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await streaming.start(chatId, resolveReceiveIdType(chatId));
|
||||||
|
} catch (error) {
|
||||||
|
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
||||||
|
streaming = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStreaming = async () => {
|
||||||
|
if (streamingStartPromise) {
|
||||||
|
await streamingStartPromise;
|
||||||
|
}
|
||||||
|
await partialUpdateQueue;
|
||||||
|
if (streaming?.isActive()) {
|
||||||
|
let text = streamText;
|
||||||
|
if (mentionTargets?.length) {
|
||||||
|
text = buildMentionedCardContent(mentionTargets, text);
|
||||||
|
}
|
||||||
|
await streaming.close(text);
|
||||||
|
}
|
||||||
|
streaming = null;
|
||||||
|
streamingStartPromise = null;
|
||||||
|
streamText = "";
|
||||||
|
lastPartial = "";
|
||||||
|
};
|
||||||
|
|
||||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||||
core.channel.reply.createReplyDispatcherWithTyping({
|
core.channel.reply.createReplyDispatcherWithTyping({
|
||||||
responsePrefix: prefixContext.responsePrefix,
|
responsePrefix: prefixContext.responsePrefix,
|
||||||
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
||||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
||||||
onReplyStart: typingCallbacks.onReplyStart,
|
onReplyStart: () => {
|
||||||
deliver: async (payload: ReplyPayload) => {
|
if (streamingEnabled && renderMode === "card") {
|
||||||
params.runtime.log?.(
|
startStreaming();
|
||||||
`feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`,
|
}
|
||||||
);
|
void typingCallbacks.onReplyStart?.();
|
||||||
|
},
|
||||||
|
deliver: async (payload: ReplyPayload, info) => {
|
||||||
const text = payload.text ?? "";
|
const text = payload.text ?? "";
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check render mode: auto (default), raw, or card
|
|
||||||
const feishuCfg = account.config;
|
|
||||||
const renderMode = feishuCfg?.renderMode ?? "auto";
|
|
||||||
|
|
||||||
// Determine if we should use card for this message
|
|
||||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||||
|
|
||||||
// Only include @mentions in the first chunk (avoid duplicate @s)
|
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
|
||||||
let isFirstChunk = true;
|
startStreaming();
|
||||||
|
if (streamingStartPromise) {
|
||||||
|
await streamingStartPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (streaming?.isActive()) {
|
||||||
|
if (info?.kind === "final") {
|
||||||
|
streamText = text;
|
||||||
|
await closeStreaming();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = true;
|
||||||
if (useCard) {
|
if (useCard) {
|
||||||
// Card mode: send as interactive card with markdown rendering
|
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||||
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
text,
|
||||||
params.runtime.log?.(
|
textChunkLimit,
|
||||||
`feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`,
|
chunkMode,
|
||||||
);
|
)) {
|
||||||
for (const chunk of chunks) {
|
|
||||||
await sendMarkdownCardFeishu({
|
await sendMarkdownCardFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
to: chatId,
|
to: chatId,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
mentions: isFirstChunk ? mentionTargets : undefined,
|
mentions: first ? mentionTargets : undefined,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
isFirstChunk = false;
|
first = false;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Raw mode: send as plain text with table conversion
|
|
||||||
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
||||||
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||||
params.runtime.log?.(
|
converted,
|
||||||
`feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`,
|
textChunkLimit,
|
||||||
);
|
chunkMode,
|
||||||
for (const chunk of chunks) {
|
)) {
|
||||||
await sendMessageFeishu({
|
await sendMessageFeishu({
|
||||||
cfg,
|
cfg,
|
||||||
to: chatId,
|
to: chatId,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
mentions: isFirstChunk ? mentionTargets : undefined,
|
mentions: first ? mentionTargets : undefined,
|
||||||
accountId,
|
accountId,
|
||||||
});
|
});
|
||||||
isFirstChunk = false;
|
first = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err, info) => {
|
onError: async (error, info) => {
|
||||||
params.runtime.error?.(
|
params.runtime.error?.(
|
||||||
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`,
|
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
||||||
);
|
);
|
||||||
|
await closeStreaming();
|
||||||
|
typingCallbacks.onIdle?.();
|
||||||
|
},
|
||||||
|
onIdle: async () => {
|
||||||
|
await closeStreaming();
|
||||||
typingCallbacks.onIdle?.();
|
typingCallbacks.onIdle?.();
|
||||||
},
|
},
|
||||||
onIdle: typingCallbacks.onIdle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -173,6 +213,23 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|||||||
replyOptions: {
|
replyOptions: {
|
||||||
...replyOptions,
|
...replyOptions,
|
||||||
onModelSelected: prefixContext.onModelSelected,
|
onModelSelected: prefixContext.onModelSelected,
|
||||||
|
onPartialReply: streamingEnabled
|
||||||
|
? (payload: ReplyPayload) => {
|
||||||
|
if (!payload.text || payload.text === lastPartial) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPartial = payload.text;
|
||||||
|
streamText = payload.text;
|
||||||
|
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
||||||
|
if (streamingStartPromise) {
|
||||||
|
await streamingStartPromise;
|
||||||
|
}
|
||||||
|
if (streaming?.isActive()) {
|
||||||
|
await streaming.update(streamText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
markDispatchIdle,
|
markDispatchIdle,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Feishu Streaming Card - Card Kit streaming API for real-time text output
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||||
|
import type { FeishuDomain } from "./types.js";
|
||||||
|
|
||||||
|
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
||||||
|
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
||||||
|
|
||||||
|
// Token cache (keyed by domain + appId)
|
||||||
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
function resolveApiBase(domain?: FeishuDomain): string {
|
||||||
|
if (domain === "lark") {
|
||||||
|
return "https://open.larksuite.com/open-apis";
|
||||||
|
}
|
||||||
|
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
||||||
|
return `${domain.replace(/\/+$/, "")}/open-apis`;
|
||||||
|
}
|
||||||
|
return "https://open.feishu.cn/open-apis";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken(creds: Credentials): Promise<string> {
|
||||||
|
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
||||||
|
const cached = tokenCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||||
|
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 data = (await res.json()) as {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
tenant_access_token?: string;
|
||||||
|
expire?: number;
|
||||||
|
};
|
||||||
|
if (data.code !== 0 || !data.tenant_access_token) {
|
||||||
|
throw new Error(`Token error: ${data.msg}`);
|
||||||
|
}
|
||||||
|
tokenCache.set(key, {
|
||||||
|
token: data.tenant_access_token,
|
||||||
|
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
|
||||||
|
});
|
||||||
|
return data.tenant_access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateSummary(text: string, max = 50): string {
|
||||||
|
if (!text) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const clean = text.replace(/\n/g, " ").trim();
|
||||||
|
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Streaming card session manager */
|
||||||
|
export class FeishuStreamingSession {
|
||||||
|
private client: Client;
|
||||||
|
private creds: Credentials;
|
||||||
|
private state: CardState | null = null;
|
||||||
|
private queue: Promise<void> = Promise.resolve();
|
||||||
|
private closed = false;
|
||||||
|
private log?: (msg: string) => void;
|
||||||
|
private lastUpdateTime = 0;
|
||||||
|
private pendingText: string | null = null;
|
||||||
|
private updateThrottleMs = 100; // Throttle updates to max 10/sec
|
||||||
|
|
||||||
|
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
||||||
|
this.client = client;
|
||||||
|
this.creds = creds;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(
|
||||||
|
receiveId: string,
|
||||||
|
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
||||||
|
): Promise<void> {
|
||||||
|
if (this.state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBase = resolveApiBase(this.creds.domain);
|
||||||
|
const cardJson = {
|
||||||
|
schema: "2.0",
|
||||||
|
config: {
|
||||||
|
streaming_mode: true,
|
||||||
|
summary: { content: "[Generating...]" },
|
||||||
|
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create card entity
|
||||||
|
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
||||||
|
});
|
||||||
|
const createData = (await createRes.json()) as {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data?: { card_id: string };
|
||||||
|
};
|
||||||
|
if (createData.code !== 0 || !createData.data?.card_id) {
|
||||||
|
throw new Error(`Create card failed: ${createData.msg}`);
|
||||||
|
}
|
||||||
|
const cardId = createData.data.card_id;
|
||||||
|
|
||||||
|
// 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 } }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
||||||
|
throw new Error(`Send card failed: ${sendRes.msg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
|
||||||
|
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(text: string): Promise<void> {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Throttle: skip if updated recently, but remember pending text
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastUpdateTime < this.updateThrottleMs) {
|
||||||
|
this.pendingText = text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingText = null;
|
||||||
|
this.lastUpdateTime = now;
|
||||||
|
|
||||||
|
this.queue = this.queue.then(async () => {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.state.currentText = text;
|
||||||
|
this.state.sequence += 1;
|
||||||
|
const apiBase = resolveApiBase(this.creds.domain);
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: text,
|
||||||
|
sequence: this.state.sequence,
|
||||||
|
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||||
|
}),
|
||||||
|
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
|
||||||
|
});
|
||||||
|
await this.queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async close(finalText?: string): Promise<void> {
|
||||||
|
if (!this.state || this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closed = true;
|
||||||
|
await this.queue;
|
||||||
|
|
||||||
|
// Use finalText, or pending throttled text, or current text
|
||||||
|
const text = finalText ?? this.pendingText ?? this.state.currentText;
|
||||||
|
const apiBase = resolveApiBase(this.creds.domain);
|
||||||
|
|
||||||
|
// Only send final update if content differs from what's already displayed
|
||||||
|
if (text && text !== this.state.currentText) {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: text,
|
||||||
|
sequence: this.state.sequence,
|
||||||
|
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
||||||
|
}),
|
||||||
|
}).catch(() => {});
|
||||||
|
this.state.currentText = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) } },
|
||||||
|
}),
|
||||||
|
sequence: this.state.sequence,
|
||||||
|
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
||||||
|
}),
|
||||||
|
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
||||||
|
|
||||||
|
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return this.state !== null && !this.closed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveReceiveIdType } from "./targets.js";
|
||||||
|
|
||||||
|
describe("resolveReceiveIdType", () => {
|
||||||
|
it("resolves chat IDs by oc_ prefix", () => {
|
||||||
|
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves open IDs by ou_ prefix", () => {
|
||||||
|
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults unprefixed IDs to user_id", () => {
|
||||||
|
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,7 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|||||||
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
|
||||||
return "open_id";
|
return "open_id";
|
||||||
}
|
}
|
||||||
return "open_id";
|
return "user_id";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeFeishuId(raw: string): boolean {
|
export function looksLikeFeishuId(raw: string): boolean {
|
||||||
|
|||||||
Reference in New Issue
Block a user