@openclaw/feishu 2026.2.12 → 2026.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.12",
3
+ "version": "2026.2.13",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -0,0 +1,265 @@
1
+ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { FeishuMessageEvent } from "./bot.js";
4
+ import { handleFeishuMessage } from "./bot.js";
5
+ import { setFeishuRuntime } from "./runtime.js";
6
+
7
+ const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted(
8
+ () => ({
9
+ mockCreateFeishuReplyDispatcher: vi.fn(() => ({
10
+ dispatcher: vi.fn(),
11
+ replyOptions: {},
12
+ markDispatchIdle: vi.fn(),
13
+ })),
14
+ mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
15
+ mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
16
+ }),
17
+ );
18
+
19
+ vi.mock("./reply-dispatcher.js", () => ({
20
+ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
21
+ }));
22
+
23
+ vi.mock("./send.js", () => ({
24
+ sendMessageFeishu: mockSendMessageFeishu,
25
+ getMessageFeishu: mockGetMessageFeishu,
26
+ }));
27
+
28
+ describe("handleFeishuMessage command authorization", () => {
29
+ const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
30
+ const mockDispatchReplyFromConfig = vi
31
+ .fn()
32
+ .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
33
+ const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
34
+ const mockShouldComputeCommandAuthorized = vi.fn(() => true);
35
+ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
36
+ const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
37
+ const mockBuildPairingReply = vi.fn(() => "Pairing response");
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ setFeishuRuntime({
42
+ system: {
43
+ enqueueSystemEvent: vi.fn(),
44
+ },
45
+ channel: {
46
+ routing: {
47
+ resolveAgentRoute: vi.fn(() => ({
48
+ agentId: "main",
49
+ accountId: "default",
50
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
51
+ matchedBy: "default",
52
+ })),
53
+ },
54
+ reply: {
55
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
56
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
57
+ finalizeInboundContext: mockFinalizeInboundContext,
58
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
59
+ },
60
+ commands: {
61
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
62
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
63
+ },
64
+ pairing: {
65
+ readAllowFromStore: mockReadAllowFromStore,
66
+ upsertPairingRequest: mockUpsertPairingRequest,
67
+ buildPairingReply: mockBuildPairingReply,
68
+ },
69
+ },
70
+ } as unknown as PluginRuntime);
71
+ });
72
+
73
+ it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
74
+ const cfg: ClawdbotConfig = {
75
+ commands: { useAccessGroups: true },
76
+ channels: {
77
+ feishu: {
78
+ dmPolicy: "open",
79
+ allowFrom: ["ou-admin"],
80
+ },
81
+ },
82
+ } as ClawdbotConfig;
83
+
84
+ const event: FeishuMessageEvent = {
85
+ sender: {
86
+ sender_id: {
87
+ open_id: "ou-attacker",
88
+ },
89
+ },
90
+ message: {
91
+ message_id: "msg-auth-bypass-regression",
92
+ chat_id: "oc-dm",
93
+ chat_type: "p2p",
94
+ message_type: "text",
95
+ content: JSON.stringify({ text: "/status" }),
96
+ },
97
+ };
98
+
99
+ await handleFeishuMessage({
100
+ cfg,
101
+ event,
102
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
103
+ });
104
+
105
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
106
+ useAccessGroups: true,
107
+ authorizers: [{ configured: true, allowed: false }],
108
+ });
109
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
110
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ CommandAuthorized: false,
113
+ SenderId: "ou-attacker",
114
+ Surface: "feishu",
115
+ }),
116
+ );
117
+ });
118
+
119
+ it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
120
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
121
+ mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
122
+
123
+ const cfg: ClawdbotConfig = {
124
+ commands: { useAccessGroups: true },
125
+ channels: {
126
+ feishu: {
127
+ dmPolicy: "pairing",
128
+ allowFrom: [],
129
+ },
130
+ },
131
+ } as ClawdbotConfig;
132
+
133
+ const event: FeishuMessageEvent = {
134
+ sender: {
135
+ sender_id: {
136
+ open_id: "ou-attacker",
137
+ },
138
+ },
139
+ message: {
140
+ message_id: "msg-read-store-non-command",
141
+ chat_id: "oc-dm",
142
+ chat_type: "p2p",
143
+ message_type: "text",
144
+ content: JSON.stringify({ text: "hello there" }),
145
+ },
146
+ };
147
+
148
+ await handleFeishuMessage({
149
+ cfg,
150
+ event,
151
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
152
+ });
153
+
154
+ expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
155
+ expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
156
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
157
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
161
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
162
+ mockReadAllowFromStore.mockResolvedValue([]);
163
+ mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
164
+
165
+ const cfg: ClawdbotConfig = {
166
+ channels: {
167
+ feishu: {
168
+ dmPolicy: "pairing",
169
+ allowFrom: [],
170
+ },
171
+ },
172
+ } as ClawdbotConfig;
173
+
174
+ const event: FeishuMessageEvent = {
175
+ sender: {
176
+ sender_id: {
177
+ open_id: "ou-unapproved",
178
+ },
179
+ },
180
+ message: {
181
+ message_id: "msg-pairing-flow",
182
+ chat_id: "oc-dm",
183
+ chat_type: "p2p",
184
+ message_type: "text",
185
+ content: JSON.stringify({ text: "hello" }),
186
+ },
187
+ };
188
+
189
+ await handleFeishuMessage({
190
+ cfg,
191
+ event,
192
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
193
+ });
194
+
195
+ expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
196
+ channel: "feishu",
197
+ id: "ou-unapproved",
198
+ meta: { name: undefined },
199
+ });
200
+ expect(mockBuildPairingReply).toHaveBeenCalledWith({
201
+ channel: "feishu",
202
+ idLine: "Your Feishu user id: ou-unapproved",
203
+ code: "ABCDEFGH",
204
+ });
205
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ to: "user:ou-unapproved",
208
+ accountId: "default",
209
+ }),
210
+ );
211
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
212
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("computes group command authorization from group allowFrom", async () => {
216
+ mockShouldComputeCommandAuthorized.mockReturnValue(true);
217
+ mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
218
+
219
+ const cfg: ClawdbotConfig = {
220
+ commands: { useAccessGroups: true },
221
+ channels: {
222
+ feishu: {
223
+ groups: {
224
+ "oc-group": {
225
+ requireMention: false,
226
+ },
227
+ },
228
+ },
229
+ },
230
+ } as ClawdbotConfig;
231
+
232
+ const event: FeishuMessageEvent = {
233
+ sender: {
234
+ sender_id: {
235
+ open_id: "ou-attacker",
236
+ },
237
+ },
238
+ message: {
239
+ message_id: "msg-group-command-auth",
240
+ chat_id: "oc-group",
241
+ chat_type: "group",
242
+ message_type: "text",
243
+ content: JSON.stringify({ text: "/status" }),
244
+ },
245
+ };
246
+
247
+ await handleFeishuMessage({
248
+ cfg,
249
+ event,
250
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
251
+ });
252
+
253
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
254
+ useAccessGroups: true,
255
+ authorizers: [{ configured: false, allowed: false }],
256
+ });
257
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
258
+ expect.objectContaining({
259
+ ChatType: "group",
260
+ CommandAuthorized: false,
261
+ SenderId: "ou-attacker",
262
+ }),
263
+ );
264
+ });
265
+ });
package/src/bot.ts CHANGED
@@ -10,6 +10,7 @@ import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } fro
10
10
  import type { DynamicAgentCreationConfig } from "./types.js";
11
11
  import { resolveFeishuAccount } from "./accounts.js";
12
12
  import { createFeishuClient } from "./client.js";
13
+ import { tryRecordMessage } from "./dedup.js";
13
14
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
15
  import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
15
16
  import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
@@ -21,38 +22,7 @@ import {
21
22
  } from "./policy.js";
22
23
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
23
24
  import { getFeishuRuntime } from "./runtime.js";
24
- import { getMessageFeishu } from "./send.js";
25
-
26
- // --- Message deduplication ---
27
- // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
28
- const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
29
- const DEDUP_MAX_SIZE = 1_000;
30
- const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
31
- const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
32
- let lastCleanupTime = Date.now();
33
-
34
- function tryRecordMessage(messageId: string): boolean {
35
- const now = Date.now();
36
-
37
- // Throttled cleanup: evict expired entries at most once per interval
38
- if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
39
- for (const [id, ts] of processedMessageIds) {
40
- if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
41
- }
42
- lastCleanupTime = now;
43
- }
44
-
45
- if (processedMessageIds.has(messageId)) return false;
46
-
47
- // Evict oldest entries if cache is full
48
- if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
49
- const first = processedMessageIds.keys().next().value!;
50
- processedMessageIds.delete(first);
51
- }
52
-
53
- processedMessageIds.set(messageId, now);
54
- return true;
55
- }
25
+ import { getMessageFeishu, sendMessageFeishu } from "./send.js";
56
26
 
57
27
  // --- Permission error extraction ---
58
28
  // Extract permission grant URL from Feishu API error response.
@@ -581,12 +551,17 @@ export async function handleFeishuMessage(params: {
581
551
  0,
582
552
  feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
583
553
  );
554
+ const groupConfig = isGroup
555
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
556
+ : undefined;
557
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
558
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
559
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
584
560
 
585
561
  if (isGroup) {
586
562
  const groupPolicy = feishuCfg?.groupPolicy ?? "open";
587
563
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
588
564
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
589
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
590
565
 
591
566
  // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
592
567
  const groupAllowed = isFeishuGroupAllowed({
@@ -642,23 +617,73 @@ export async function handleFeishuMessage(params: {
642
617
  return;
643
618
  }
644
619
  } else {
645
- const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
646
- const allowFrom = feishuCfg?.allowFrom ?? [];
647
-
648
- if (dmPolicy === "allowlist") {
649
- const match = resolveFeishuAllowlistMatch({
650
- allowFrom,
651
- senderId: ctx.senderOpenId,
652
- });
653
- if (!match.allowed) {
654
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
655
- return;
656
- }
657
- }
658
620
  }
659
621
 
660
622
  try {
661
623
  const core = getFeishuRuntime();
624
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
625
+ ctx.content,
626
+ cfg,
627
+ );
628
+ const storeAllowFrom =
629
+ !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
630
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
631
+ : [];
632
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
633
+ const dmAllowed = resolveFeishuAllowlistMatch({
634
+ allowFrom: effectiveDmAllowFrom,
635
+ senderId: ctx.senderOpenId,
636
+ senderName: ctx.senderName,
637
+ }).allowed;
638
+
639
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
640
+ if (dmPolicy === "pairing") {
641
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
642
+ channel: "feishu",
643
+ id: ctx.senderOpenId,
644
+ meta: { name: ctx.senderName },
645
+ });
646
+ if (created) {
647
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
648
+ try {
649
+ await sendMessageFeishu({
650
+ cfg,
651
+ to: `user:${ctx.senderOpenId}`,
652
+ text: core.channel.pairing.buildPairingReply({
653
+ channel: "feishu",
654
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
655
+ code,
656
+ }),
657
+ accountId: account.accountId,
658
+ });
659
+ } catch (err) {
660
+ log(
661
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
662
+ );
663
+ }
664
+ }
665
+ } else {
666
+ log(
667
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
668
+ );
669
+ }
670
+ return;
671
+ }
672
+
673
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
674
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
675
+ allowFrom: commandAllowFrom,
676
+ senderId: ctx.senderOpenId,
677
+ senderName: ctx.senderName,
678
+ }).allowed;
679
+ const commandAuthorized = shouldComputeCommandAuthorized
680
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
681
+ useAccessGroups,
682
+ authorizers: [
683
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
684
+ ],
685
+ })
686
+ : undefined;
662
687
 
663
688
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
664
689
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -815,7 +840,7 @@ export async function handleFeishuMessage(params: {
815
840
  MessageSid: `${ctx.messageId}:permission-error`,
816
841
  Timestamp: Date.now(),
817
842
  WasMentioned: false,
818
- CommandAuthorized: true,
843
+ CommandAuthorized: commandAuthorized,
819
844
  OriginatingChannel: "feishu" as const,
820
845
  OriginatingTo: feishuTo,
821
846
  });
@@ -903,7 +928,7 @@ export async function handleFeishuMessage(params: {
903
928
  ReplyToBody: quotedContent ?? undefined,
904
929
  Timestamp: Date.now(),
905
930
  WasMentioned: ctx.mentionedBot,
906
- CommandAuthorized: true,
931
+ CommandAuthorized: commandAuthorized,
907
932
  OriginatingChannel: "feishu" as const,
908
933
  OriginatingTo: feishuTo,
909
934
  ...mediaPayload,
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
36
36
  // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
37
37
  const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
38
38
 
39
+ // Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
40
+ // for incremental text display with a "Thinking..." placeholder
41
+ const StreamingModeSchema = z.boolean().optional();
42
+
39
43
  const BlockStreamingCoalesceSchema = z
40
44
  .object({
41
45
  enabled: z.boolean().optional(),
@@ -142,6 +146,7 @@ export const FeishuAccountConfigSchema = z
142
146
  mediaMaxMb: z.number().positive().optional(),
143
147
  heartbeat: ChannelHeartbeatVisibilitySchema,
144
148
  renderMode: RenderModeSchema,
149
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
145
150
  tools: FeishuToolsConfigSchema,
146
151
  })
147
152
  .strict();
@@ -177,6 +182,7 @@ export const FeishuConfigSchema = z
177
182
  mediaMaxMb: z.number().positive().optional(),
178
183
  heartbeat: ChannelHeartbeatVisibilitySchema,
179
184
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
185
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
180
186
  tools: FeishuToolsConfigSchema,
181
187
  // Dynamic agent creation for DM users
182
188
  dynamicAgentCreation: DynamicAgentCreationSchema,
package/src/dedup.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
2
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
3
+ const DEDUP_MAX_SIZE = 1_000;
4
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
5
+ const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
6
+ let lastCleanupTime = Date.now();
7
+
8
+ export function tryRecordMessage(messageId: string): boolean {
9
+ const now = Date.now();
10
+
11
+ // Throttled cleanup: evict expired entries at most once per interval.
12
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
13
+ for (const [id, ts] of processedMessageIds) {
14
+ if (now - ts > DEDUP_TTL_MS) {
15
+ processedMessageIds.delete(id);
16
+ }
17
+ }
18
+ lastCleanupTime = now;
19
+ }
20
+
21
+ if (processedMessageIds.has(messageId)) {
22
+ return false;
23
+ }
24
+
25
+ // Evict oldest entries if cache is full.
26
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
27
+ const first = processedMessageIds.keys().next().value!;
28
+ processedMessageIds.delete(first);
29
+ }
30
+
31
+ processedMessageIds.set(messageId, now);
32
+ return true;
33
+ }
package/src/monitor.ts CHANGED
@@ -1,6 +1,11 @@
1
- import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
2
1
  import * as Lark from "@larksuiteoapi/node-sdk";
3
2
  import * as http from "http";
3
+ import {
4
+ type ClawdbotConfig,
5
+ type RuntimeEnv,
6
+ type HistoryEntry,
7
+ installRequestBodyLimitGuard,
8
+ } from "openclaw/plugin-sdk";
4
9
  import type { ResolvedFeishuAccount } from "./types.js";
5
10
  import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
6
11
  import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
@@ -18,6 +23,8 @@ export type MonitorFeishuOpts = {
18
23
  const wsClients = new Map<string, Lark.WSClient>();
19
24
  const httpServers = new Map<string, http.Server>();
20
25
  const botOpenIds = new Map<string, string>();
26
+ const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
+ const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
21
28
 
22
29
  async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
23
30
  try {
@@ -197,7 +204,26 @@ async function monitorWebhook({
197
204
  log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
198
205
 
199
206
  const server = http.createServer();
200
- server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
207
+ const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
208
+ server.on("request", (req, res) => {
209
+ const guard = installRequestBodyLimitGuard(req, res, {
210
+ maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
211
+ timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
212
+ responseFormat: "text",
213
+ });
214
+ if (guard.isTripped()) {
215
+ return;
216
+ }
217
+ void Promise.resolve(webhookHandler(req, res))
218
+ .catch((err) => {
219
+ if (!guard.isTripped()) {
220
+ error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
221
+ }
222
+ })
223
+ .finally(() => {
224
+ guard.dispose();
225
+ });
226
+ });
201
227
  httpServers.set(accountId, server);
202
228
 
203
229
  return new Promise((resolve, reject) => {
@@ -0,0 +1,116 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
4
+ const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
5
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
6
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
8
+ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
9
+ const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
10
+ const streamingInstances = vi.hoisted(() => [] as any[]);
11
+
12
+ vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
13
+ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
14
+ vi.mock("./send.js", () => ({
15
+ sendMessageFeishu: sendMessageFeishuMock,
16
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
17
+ }));
18
+ vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
19
+ vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
20
+ vi.mock("./streaming-card.js", () => ({
21
+ FeishuStreamingSession: class {
22
+ active = false;
23
+ start = vi.fn(async () => {
24
+ this.active = true;
25
+ });
26
+ update = vi.fn(async () => {});
27
+ close = vi.fn(async () => {
28
+ this.active = false;
29
+ });
30
+ isActive = vi.fn(() => this.active);
31
+
32
+ constructor() {
33
+ streamingInstances.push(this);
34
+ }
35
+ },
36
+ }));
37
+
38
+ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
39
+
40
+ describe("createFeishuReplyDispatcher streaming behavior", () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks();
43
+ streamingInstances.length = 0;
44
+
45
+ resolveFeishuAccountMock.mockReturnValue({
46
+ accountId: "main",
47
+ appId: "app_id",
48
+ appSecret: "app_secret",
49
+ domain: "feishu",
50
+ config: {
51
+ renderMode: "auto",
52
+ streaming: true,
53
+ },
54
+ });
55
+
56
+ resolveReceiveIdTypeMock.mockReturnValue("chat_id");
57
+ createFeishuClientMock.mockReturnValue({});
58
+
59
+ createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
60
+ dispatcher: {},
61
+ replyOptions: {},
62
+ markDispatchIdle: vi.fn(),
63
+ _opts: opts,
64
+ }));
65
+
66
+ getFeishuRuntimeMock.mockReturnValue({
67
+ channel: {
68
+ text: {
69
+ resolveTextChunkLimit: vi.fn(() => 4000),
70
+ resolveChunkMode: vi.fn(() => "line"),
71
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
72
+ convertMarkdownTables: vi.fn((text) => text),
73
+ chunkTextWithMode: vi.fn((text) => [text]),
74
+ },
75
+ reply: {
76
+ createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
77
+ resolveHumanDelayConfig: vi.fn(() => undefined),
78
+ },
79
+ },
80
+ });
81
+ });
82
+
83
+ it("keeps auto mode plain text on non-streaming send path", async () => {
84
+ createFeishuReplyDispatcher({
85
+ cfg: {} as never,
86
+ agentId: "agent",
87
+ runtime: {} as never,
88
+ chatId: "oc_chat",
89
+ });
90
+
91
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
92
+ await options.deliver({ text: "plain text" }, { kind: "final" });
93
+
94
+ expect(streamingInstances).toHaveLength(0);
95
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
96
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("uses streaming session for auto mode markdown payloads", async () => {
100
+ createFeishuReplyDispatcher({
101
+ cfg: {} as never,
102
+ agentId: "agent",
103
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
104
+ chatId: "oc_chat",
105
+ });
106
+
107
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
108
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
109
+
110
+ expect(streamingInstances).toHaveLength(1);
111
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
112
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
113
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
114
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -3,29 +3,22 @@ import {
3
3
  createTypingCallbacks,
4
4
  logTypingFailure,
5
5
  type ClawdbotConfig,
6
- type RuntimeEnv,
7
6
  type ReplyPayload,
7
+ type RuntimeEnv,
8
8
  } from "openclaw/plugin-sdk";
9
9
  import type { MentionTarget } from "./mention.js";
10
10
  import { resolveFeishuAccount } from "./accounts.js";
11
+ import { createFeishuClient } from "./client.js";
12
+ import { buildMentionedCardContent } from "./mention.js";
11
13
  import { getFeishuRuntime } from "./runtime.js";
12
- import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
14
+ import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
15
+ import { FeishuStreamingSession } from "./streaming-card.js";
16
+ import { resolveReceiveIdType } from "./targets.js";
13
17
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
14
18
 
15
- /**
16
- * Detect if text contains markdown elements that benefit from card rendering.
17
- * Used by auto render mode.
18
- */
19
+ /** Detect if text contains markdown elements that benefit from card rendering */
19
20
  function shouldUseCard(text: string): boolean {
20
- // Code blocks (fenced)
21
- if (/```[\s\S]*?```/.test(text)) {
22
- return true;
23
- }
24
- // Tables (at least header + separator row with |)
25
- if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
26
- return true;
27
- }
28
- return false;
21
+ return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
29
22
  }
30
23
 
31
24
  export type CreateFeishuReplyDispatcherParams = {
@@ -34,35 +27,23 @@ export type CreateFeishuReplyDispatcherParams = {
34
27
  runtime: RuntimeEnv;
35
28
  chatId: string;
36
29
  replyToMessageId?: string;
37
- /** Mention targets, will be auto-included in replies */
38
30
  mentionTargets?: MentionTarget[];
39
- /** Account ID for multi-account support */
40
31
  accountId?: string;
41
32
  };
42
33
 
43
34
  export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
44
35
  const core = getFeishuRuntime();
45
36
  const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
46
-
47
- // Resolve account for config access
48
37
  const account = resolveFeishuAccount({ cfg, accountId });
38
+ const prefixContext = createReplyPrefixContext({ cfg, agentId });
49
39
 
50
- const prefixContext = createReplyPrefixContext({
51
- cfg,
52
- agentId,
53
- });
54
-
55
- // Feishu doesn't have a native typing indicator API.
56
- // We use message reactions as a typing indicator substitute.
57
40
  let typingState: TypingIndicatorState | null = null;
58
-
59
41
  const typingCallbacks = createTypingCallbacks({
60
42
  start: async () => {
61
43
  if (!replyToMessageId) {
62
44
  return;
63
45
  }
64
46
  typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
65
- params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
66
47
  },
67
48
  stop: async () => {
68
49
  if (!typingState) {
@@ -70,24 +51,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
70
51
  }
71
52
  await removeTypingIndicator({ cfg, state: typingState, accountId });
72
53
  typingState = null;
73
- params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
74
54
  },
75
- onStartError: (err) => {
55
+ onStartError: (err) =>
76
56
  logTypingFailure({
77
57
  log: (message) => params.runtime.log?.(message),
78
58
  channel: "feishu",
79
59
  action: "start",
80
60
  error: err,
81
- });
82
- },
83
- onStopError: (err) => {
61
+ }),
62
+ onStopError: (err) =>
84
63
  logTypingFailure({
85
64
  log: (message) => params.runtime.log?.(message),
86
65
  channel: "feishu",
87
66
  action: "stop",
88
67
  error: err,
89
- });
90
- },
68
+ }),
91
69
  });
92
70
 
93
71
  const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
@@ -95,77 +73,139 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
95
73
  });
96
74
  const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
97
75
  const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
76
+ const renderMode = account.config?.renderMode ?? "auto";
77
+ const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
78
+
79
+ let streaming: FeishuStreamingSession | null = null;
80
+ let streamText = "";
81
+ let lastPartial = "";
82
+ let partialUpdateQueue: Promise<void> = Promise.resolve();
83
+ let streamingStartPromise: Promise<void> | null = null;
84
+
85
+ const startStreaming = () => {
86
+ if (!streamingEnabled || streamingStartPromise || streaming) {
87
+ return;
88
+ }
89
+ streamingStartPromise = (async () => {
90
+ const creds =
91
+ account.appId && account.appSecret
92
+ ? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
93
+ : null;
94
+ if (!creds) {
95
+ return;
96
+ }
97
+
98
+ streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
99
+ params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
100
+ );
101
+ try {
102
+ await streaming.start(chatId, resolveReceiveIdType(chatId));
103
+ } catch (error) {
104
+ params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
105
+ streaming = null;
106
+ }
107
+ })();
108
+ };
109
+
110
+ const closeStreaming = async () => {
111
+ if (streamingStartPromise) {
112
+ await streamingStartPromise;
113
+ }
114
+ await partialUpdateQueue;
115
+ if (streaming?.isActive()) {
116
+ let text = streamText;
117
+ if (mentionTargets?.length) {
118
+ text = buildMentionedCardContent(mentionTargets, text);
119
+ }
120
+ await streaming.close(text);
121
+ }
122
+ streaming = null;
123
+ streamingStartPromise = null;
124
+ streamText = "";
125
+ lastPartial = "";
126
+ };
98
127
 
99
128
  const { dispatcher, replyOptions, markDispatchIdle } =
100
129
  core.channel.reply.createReplyDispatcherWithTyping({
101
130
  responsePrefix: prefixContext.responsePrefix,
102
131
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
103
132
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
104
- onReplyStart: typingCallbacks.onReplyStart,
105
- deliver: async (payload: ReplyPayload) => {
106
- params.runtime.log?.(
107
- `feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`,
108
- );
133
+ onReplyStart: () => {
134
+ if (streamingEnabled && renderMode === "card") {
135
+ startStreaming();
136
+ }
137
+ void typingCallbacks.onReplyStart?.();
138
+ },
139
+ deliver: async (payload: ReplyPayload, info) => {
109
140
  const text = payload.text ?? "";
110
141
  if (!text.trim()) {
111
- params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
112
142
  return;
113
143
  }
114
144
 
115
- // Check render mode: auto (default), raw, or card
116
- const feishuCfg = account.config;
117
- const renderMode = feishuCfg?.renderMode ?? "auto";
118
-
119
- // Determine if we should use card for this message
120
145
  const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
121
146
 
122
- // Only include @mentions in the first chunk (avoid duplicate @s)
123
- let isFirstChunk = true;
147
+ if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
148
+ startStreaming();
149
+ if (streamingStartPromise) {
150
+ await streamingStartPromise;
151
+ }
152
+ }
153
+
154
+ if (streaming?.isActive()) {
155
+ if (info?.kind === "final") {
156
+ streamText = text;
157
+ await closeStreaming();
158
+ }
159
+ return;
160
+ }
124
161
 
162
+ let first = true;
125
163
  if (useCard) {
126
- // Card mode: send as interactive card with markdown rendering
127
- const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
128
- params.runtime.log?.(
129
- `feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`,
130
- );
131
- for (const chunk of chunks) {
164
+ for (const chunk of core.channel.text.chunkTextWithMode(
165
+ text,
166
+ textChunkLimit,
167
+ chunkMode,
168
+ )) {
132
169
  await sendMarkdownCardFeishu({
133
170
  cfg,
134
171
  to: chatId,
135
172
  text: chunk,
136
173
  replyToMessageId,
137
- mentions: isFirstChunk ? mentionTargets : undefined,
174
+ mentions: first ? mentionTargets : undefined,
138
175
  accountId,
139
176
  });
140
- isFirstChunk = false;
177
+ first = false;
141
178
  }
142
179
  } else {
143
- // Raw mode: send as plain text with table conversion
144
180
  const converted = core.channel.text.convertMarkdownTables(text, tableMode);
145
- const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
146
- params.runtime.log?.(
147
- `feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`,
148
- );
149
- for (const chunk of chunks) {
181
+ for (const chunk of core.channel.text.chunkTextWithMode(
182
+ converted,
183
+ textChunkLimit,
184
+ chunkMode,
185
+ )) {
150
186
  await sendMessageFeishu({
151
187
  cfg,
152
188
  to: chatId,
153
189
  text: chunk,
154
190
  replyToMessageId,
155
- mentions: isFirstChunk ? mentionTargets : undefined,
191
+ mentions: first ? mentionTargets : undefined,
156
192
  accountId,
157
193
  });
158
- isFirstChunk = false;
194
+ first = false;
159
195
  }
160
196
  }
161
197
  },
162
- onError: (err, info) => {
198
+ onError: async (error, info) => {
163
199
  params.runtime.error?.(
164
- `feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`,
200
+ `feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
165
201
  );
202
+ await closeStreaming();
203
+ typingCallbacks.onIdle?.();
204
+ },
205
+ onIdle: async () => {
206
+ await closeStreaming();
166
207
  typingCallbacks.onIdle?.();
167
208
  },
168
- onIdle: typingCallbacks.onIdle,
169
209
  });
170
210
 
171
211
  return {
@@ -173,6 +213,23 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
173
213
  replyOptions: {
174
214
  ...replyOptions,
175
215
  onModelSelected: prefixContext.onModelSelected,
216
+ onPartialReply: streamingEnabled
217
+ ? (payload: ReplyPayload) => {
218
+ if (!payload.text || payload.text === lastPartial) {
219
+ return;
220
+ }
221
+ lastPartial = payload.text;
222
+ streamText = payload.text;
223
+ partialUpdateQueue = partialUpdateQueue.then(async () => {
224
+ if (streamingStartPromise) {
225
+ await streamingStartPromise;
226
+ }
227
+ if (streaming?.isActive()) {
228
+ await streaming.update(streamText);
229
+ }
230
+ });
231
+ }
232
+ : undefined,
176
233
  },
177
234
  markDispatchIdle,
178
235
  };
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Feishu Streaming Card - Card Kit streaming API for real-time text output
3
+ */
4
+
5
+ import type { Client } from "@larksuiteoapi/node-sdk";
6
+ import type { FeishuDomain } from "./types.js";
7
+
8
+ type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
9
+ type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
10
+
11
+ // Token cache (keyed by domain + appId)
12
+ const tokenCache = new Map<string, { token: string; expiresAt: number }>();
13
+
14
+ function resolveApiBase(domain?: FeishuDomain): string {
15
+ if (domain === "lark") {
16
+ return "https://open.larksuite.com/open-apis";
17
+ }
18
+ if (domain && domain !== "feishu" && domain.startsWith("http")) {
19
+ return `${domain.replace(/\/+$/, "")}/open-apis`;
20
+ }
21
+ return "https://open.feishu.cn/open-apis";
22
+ }
23
+
24
+ async function getToken(creds: Credentials): Promise<string> {
25
+ const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
26
+ const cached = tokenCache.get(key);
27
+ if (cached && cached.expiresAt > Date.now() + 60000) {
28
+ return cached.token;
29
+ }
30
+
31
+ const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
35
+ });
36
+ const data = (await res.json()) as {
37
+ code: number;
38
+ msg: string;
39
+ tenant_access_token?: string;
40
+ expire?: number;
41
+ };
42
+ if (data.code !== 0 || !data.tenant_access_token) {
43
+ throw new Error(`Token error: ${data.msg}`);
44
+ }
45
+ tokenCache.set(key, {
46
+ token: data.tenant_access_token,
47
+ expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
48
+ });
49
+ return data.tenant_access_token;
50
+ }
51
+
52
+ function truncateSummary(text: string, max = 50): string {
53
+ if (!text) {
54
+ return "";
55
+ }
56
+ const clean = text.replace(/\n/g, " ").trim();
57
+ return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
58
+ }
59
+
60
+ /** Streaming card session manager */
61
+ export class FeishuStreamingSession {
62
+ private client: Client;
63
+ private creds: Credentials;
64
+ private state: CardState | null = null;
65
+ private queue: Promise<void> = Promise.resolve();
66
+ private closed = false;
67
+ private log?: (msg: string) => void;
68
+ private lastUpdateTime = 0;
69
+ private pendingText: string | null = null;
70
+ private updateThrottleMs = 100; // Throttle updates to max 10/sec
71
+
72
+ constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
73
+ this.client = client;
74
+ this.creds = creds;
75
+ this.log = log;
76
+ }
77
+
78
+ async start(
79
+ receiveId: string,
80
+ receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
81
+ ): Promise<void> {
82
+ if (this.state) {
83
+ return;
84
+ }
85
+
86
+ const apiBase = resolveApiBase(this.creds.domain);
87
+ const cardJson = {
88
+ schema: "2.0",
89
+ config: {
90
+ streaming_mode: true,
91
+ summary: { content: "[Generating...]" },
92
+ streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
93
+ },
94
+ body: {
95
+ elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
96
+ },
97
+ };
98
+
99
+ // Create card entity
100
+ const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
101
+ method: "POST",
102
+ headers: {
103
+ Authorization: `Bearer ${await getToken(this.creds)}`,
104
+ "Content-Type": "application/json",
105
+ },
106
+ body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
107
+ });
108
+ const createData = (await createRes.json()) as {
109
+ code: number;
110
+ msg: string;
111
+ data?: { card_id: string };
112
+ };
113
+ if (createData.code !== 0 || !createData.data?.card_id) {
114
+ throw new Error(`Create card failed: ${createData.msg}`);
115
+ }
116
+ const cardId = createData.data.card_id;
117
+
118
+ // Send card message
119
+ const sendRes = await this.client.im.message.create({
120
+ params: { receive_id_type: receiveIdType },
121
+ data: {
122
+ receive_id: receiveId,
123
+ msg_type: "interactive",
124
+ content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
125
+ },
126
+ });
127
+ if (sendRes.code !== 0 || !sendRes.data?.message_id) {
128
+ throw new Error(`Send card failed: ${sendRes.msg}`);
129
+ }
130
+
131
+ this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
132
+ this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
133
+ }
134
+
135
+ async update(text: string): Promise<void> {
136
+ if (!this.state || this.closed) {
137
+ return;
138
+ }
139
+ // Throttle: skip if updated recently, but remember pending text
140
+ const now = Date.now();
141
+ if (now - this.lastUpdateTime < this.updateThrottleMs) {
142
+ this.pendingText = text;
143
+ return;
144
+ }
145
+ this.pendingText = null;
146
+ this.lastUpdateTime = now;
147
+
148
+ this.queue = this.queue.then(async () => {
149
+ if (!this.state || this.closed) {
150
+ return;
151
+ }
152
+ this.state.currentText = text;
153
+ this.state.sequence += 1;
154
+ const apiBase = resolveApiBase(this.creds.domain);
155
+ await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
156
+ method: "PUT",
157
+ headers: {
158
+ Authorization: `Bearer ${await getToken(this.creds)}`,
159
+ "Content-Type": "application/json",
160
+ },
161
+ body: JSON.stringify({
162
+ content: text,
163
+ sequence: this.state.sequence,
164
+ uuid: `s_${this.state.cardId}_${this.state.sequence}`,
165
+ }),
166
+ }).catch((e) => this.log?.(`Update failed: ${String(e)}`));
167
+ });
168
+ await this.queue;
169
+ }
170
+
171
+ async close(finalText?: string): Promise<void> {
172
+ if (!this.state || this.closed) {
173
+ return;
174
+ }
175
+ this.closed = true;
176
+ await this.queue;
177
+
178
+ // Use finalText, or pending throttled text, or current text
179
+ const text = finalText ?? this.pendingText ?? this.state.currentText;
180
+ const apiBase = resolveApiBase(this.creds.domain);
181
+
182
+ // Only send final update if content differs from what's already displayed
183
+ if (text && text !== this.state.currentText) {
184
+ this.state.sequence += 1;
185
+ await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
186
+ method: "PUT",
187
+ headers: {
188
+ Authorization: `Bearer ${await getToken(this.creds)}`,
189
+ "Content-Type": "application/json",
190
+ },
191
+ body: JSON.stringify({
192
+ content: text,
193
+ sequence: this.state.sequence,
194
+ uuid: `s_${this.state.cardId}_${this.state.sequence}`,
195
+ }),
196
+ }).catch(() => {});
197
+ this.state.currentText = text;
198
+ }
199
+
200
+ // Close streaming mode
201
+ this.state.sequence += 1;
202
+ await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
203
+ method: "PATCH",
204
+ headers: {
205
+ Authorization: `Bearer ${await getToken(this.creds)}`,
206
+ "Content-Type": "application/json; charset=utf-8",
207
+ },
208
+ body: JSON.stringify({
209
+ settings: JSON.stringify({
210
+ config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
211
+ }),
212
+ sequence: this.state.sequence,
213
+ uuid: `c_${this.state.cardId}_${this.state.sequence}`,
214
+ }),
215
+ }).catch((e) => this.log?.(`Close failed: ${String(e)}`));
216
+
217
+ this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
218
+ }
219
+
220
+ isActive(): boolean {
221
+ return this.state !== null && !this.closed;
222
+ }
223
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { resolveReceiveIdType } from "./targets.js";
3
+
4
+ describe("resolveReceiveIdType", () => {
5
+ it("resolves chat IDs by oc_ prefix", () => {
6
+ expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
7
+ });
8
+
9
+ it("resolves open IDs by ou_ prefix", () => {
10
+ expect(resolveReceiveIdType("ou_123")).toBe("open_id");
11
+ });
12
+
13
+ it("defaults unprefixed IDs to user_id", () => {
14
+ expect(resolveReceiveIdType("u_123")).toBe("user_id");
15
+ });
16
+ });
package/src/targets.ts CHANGED
@@ -57,7 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
57
57
  if (trimmed.startsWith(OPEN_ID_PREFIX)) {
58
58
  return "open_id";
59
59
  }
60
- return "open_id";
60
+ return "user_id";
61
61
  }
62
62
 
63
63
  export function looksLikeFeishuId(raw: string): boolean {