@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 +1 -1
- package/src/bot.test.ts +265 -0
- package/src/bot.ts +73 -48
- package/src/config-schema.ts +6 -0
- package/src/dedup.ts +33 -0
- package/src/monitor.ts +28 -2
- package/src/reply-dispatcher.test.ts +116 -0
- package/src/reply-dispatcher.ts +124 -67
- package/src/streaming-card.ts +223 -0
- package/src/targets.test.ts +16 -0
- package/src/targets.ts +1 -1
package/package.json
CHANGED
package/src/bot.test.ts
ADDED
|
@@ -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:
|
|
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:
|
|
931
|
+
CommandAuthorized: commandAuthorized,
|
|
907
932
|
OriginatingChannel: "feishu" as const,
|
|
908
933
|
OriginatingTo: feishuTo,
|
|
909
934
|
...mediaPayload,
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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:
|
|
174
|
+
mentions: first ? mentionTargets : undefined,
|
|
138
175
|
accountId,
|
|
139
176
|
});
|
|
140
|
-
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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:
|
|
191
|
+
mentions: first ? mentionTargets : undefined,
|
|
156
192
|
accountId,
|
|
157
193
|
});
|
|
158
|
-
|
|
194
|
+
first = false;
|
|
159
195
|
}
|
|
160
196
|
}
|
|
161
197
|
},
|
|
162
|
-
onError: (
|
|
198
|
+
onError: async (error, info) => {
|
|
163
199
|
params.runtime.error?.(
|
|
164
|
-
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(
|
|
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 "
|
|
60
|
+
return "user_id";
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function looksLikeFeishuId(raw: string): boolean {
|