@openclaw/feishu 2026.3.13 → 2026.5.2-beta.1
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/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1827 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +95 -7
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +778 -775
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1253 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +135 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +406 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +63 -1
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +33 -95
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +116 -20
- package/src/directory.ts +60 -92
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +403 -26
- package/src/media.ts +509 -132
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +218 -312
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +108 -48
- package/src/monitor.startup.test.ts +11 -9
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +220 -60
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +65 -7
- package/src/monitor.webhook-security.test.ts +122 -0
- package/src/monitor.webhook.test-helpers.ts +44 -26
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +616 -37
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +14 -9
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +4 -34
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +660 -29
- package/src/reply-dispatcher.ts +407 -154
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +105 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +414 -95
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +453 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { ClawdbotConfig, HistoryEntry, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
|
2
|
+
import type { FeishuMessageEvent } from "./event-types.js";
|
|
3
|
+
import { isMentionForwardRequest } from "./mention.js";
|
|
4
|
+
import {
|
|
5
|
+
releaseFeishuMessageProcessing,
|
|
6
|
+
tryBeginFeishuMessageProcessing,
|
|
7
|
+
} from "./processing-claims.js";
|
|
8
|
+
import { createSequentialQueue } from "./sequential-queue.js";
|
|
9
|
+
import type { FeishuChatType } from "./types.js";
|
|
10
|
+
|
|
11
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function readString(value: unknown): string | undefined {
|
|
16
|
+
return typeof value === "string" ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type FeishuMessageReceiveHandlerContext = {
|
|
20
|
+
cfg: ClawdbotConfig;
|
|
21
|
+
core: PluginRuntime;
|
|
22
|
+
accountId: string;
|
|
23
|
+
runtime?: RuntimeEnv;
|
|
24
|
+
chatHistories: Map<string, HistoryEntry[]>;
|
|
25
|
+
fireAndForget?: boolean;
|
|
26
|
+
handleMessage: (params: {
|
|
27
|
+
cfg: ClawdbotConfig;
|
|
28
|
+
event: FeishuMessageEvent;
|
|
29
|
+
botOpenId?: string;
|
|
30
|
+
botName?: string;
|
|
31
|
+
runtime?: RuntimeEnv;
|
|
32
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
33
|
+
accountId?: string;
|
|
34
|
+
processingClaimHeld?: boolean;
|
|
35
|
+
}) => Promise<void>;
|
|
36
|
+
resolveDebounceText: (params: {
|
|
37
|
+
event: FeishuMessageEvent;
|
|
38
|
+
botOpenId?: string;
|
|
39
|
+
botName?: string;
|
|
40
|
+
}) => string;
|
|
41
|
+
hasProcessedMessage: (
|
|
42
|
+
messageId: string | undefined | null,
|
|
43
|
+
namespace: string,
|
|
44
|
+
log?: (...args: unknown[]) => void,
|
|
45
|
+
) => Promise<boolean>;
|
|
46
|
+
recordProcessedMessage: (
|
|
47
|
+
messageId: string | undefined | null,
|
|
48
|
+
namespace: string,
|
|
49
|
+
log?: (...args: unknown[]) => void,
|
|
50
|
+
) => Promise<boolean>;
|
|
51
|
+
getBotOpenId?: (accountId: string) => string | undefined;
|
|
52
|
+
getBotName?: (accountId: string) => string | undefined;
|
|
53
|
+
resolveSequentialKey?: (params: {
|
|
54
|
+
accountId: string;
|
|
55
|
+
event: FeishuMessageEvent;
|
|
56
|
+
botOpenId?: string;
|
|
57
|
+
botName?: string;
|
|
58
|
+
}) => string;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function normalizeFeishuChatType(value: unknown): FeishuChatType | undefined {
|
|
62
|
+
return value === "group" || value === "topic_group" || value === "private" || value === "p2p"
|
|
63
|
+
? value
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
|
|
68
|
+
if (!isRecord(value)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
const sender = value.sender;
|
|
72
|
+
const message = value.message;
|
|
73
|
+
if (!isRecord(sender) || !isRecord(message)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const senderId = sender.sender_id;
|
|
77
|
+
if (!isRecord(senderId)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const messageId = readString(message.message_id);
|
|
81
|
+
const chatId = readString(message.chat_id);
|
|
82
|
+
const chatType = normalizeFeishuChatType(message.chat_type);
|
|
83
|
+
const messageType = readString(message.message_type);
|
|
84
|
+
const content = readString(message.content);
|
|
85
|
+
if (!messageId || !chatId || !chatType || !messageType || !content) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return value as FeishuMessageEvent;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function mergeFeishuDebounceMentions(
|
|
92
|
+
entries: FeishuMessageEvent[],
|
|
93
|
+
): FeishuMessageEvent["message"]["mentions"] | undefined {
|
|
94
|
+
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
for (const mention of entry.message.mentions ?? []) {
|
|
97
|
+
const stableId =
|
|
98
|
+
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
|
|
99
|
+
const mentionName = mention.name?.trim();
|
|
100
|
+
const mentionKey = mention.key?.trim();
|
|
101
|
+
const fallback =
|
|
102
|
+
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
|
|
103
|
+
const key = stableId || fallback;
|
|
104
|
+
if (!key || merged.has(key)) {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
merged.set(key, mention);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return merged.size > 0 ? Array.from(merged.values()) : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function dedupeFeishuDebounceEntriesByMessageId(
|
|
114
|
+
entries: FeishuMessageEvent[],
|
|
115
|
+
): FeishuMessageEvent[] {
|
|
116
|
+
const seen = new Set<string>();
|
|
117
|
+
const deduped: FeishuMessageEvent[] = [];
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const messageId = entry.message.message_id?.trim();
|
|
120
|
+
if (!messageId) {
|
|
121
|
+
deduped.push(entry);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (seen.has(messageId)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
seen.add(messageId);
|
|
128
|
+
deduped.push(entry);
|
|
129
|
+
}
|
|
130
|
+
return deduped;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveFeishuDebounceMentions(params: {
|
|
134
|
+
entries: FeishuMessageEvent[];
|
|
135
|
+
botOpenId?: string;
|
|
136
|
+
}): FeishuMessageEvent["message"]["mentions"] | undefined {
|
|
137
|
+
const { entries, botOpenId } = params;
|
|
138
|
+
if (entries.length === 0) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
142
|
+
const entry = entries[index];
|
|
143
|
+
if (isMentionForwardRequest(entry, botOpenId)) {
|
|
144
|
+
return mergeFeishuDebounceMentions([entry]);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const merged = mergeFeishuDebounceMentions(entries);
|
|
148
|
+
if (!merged) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const normalizedBotOpenId = botOpenId?.trim();
|
|
152
|
+
if (!normalizedBotOpenId) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
const botMentions = merged.filter(
|
|
156
|
+
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
|
|
157
|
+
);
|
|
158
|
+
return botMentions.length > 0 ? botMentions : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createFeishuMessageReceiveHandler({
|
|
162
|
+
cfg,
|
|
163
|
+
core,
|
|
164
|
+
accountId,
|
|
165
|
+
runtime,
|
|
166
|
+
chatHistories,
|
|
167
|
+
fireAndForget,
|
|
168
|
+
handleMessage,
|
|
169
|
+
resolveDebounceText: resolveText,
|
|
170
|
+
hasProcessedMessage,
|
|
171
|
+
recordProcessedMessage,
|
|
172
|
+
getBotOpenId = () => undefined,
|
|
173
|
+
getBotName = () => undefined,
|
|
174
|
+
resolveSequentialKey = ({ accountId, event }) =>
|
|
175
|
+
`feishu:${accountId}:${event.message.chat_id?.trim() || "unknown"}`,
|
|
176
|
+
}: FeishuMessageReceiveHandlerContext): (data: unknown) => Promise<void> {
|
|
177
|
+
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
|
178
|
+
cfg,
|
|
179
|
+
channel: "feishu",
|
|
180
|
+
});
|
|
181
|
+
const log = runtime?.log ?? console.log;
|
|
182
|
+
const error = runtime?.error ?? console.error;
|
|
183
|
+
const enqueue = createSequentialQueue();
|
|
184
|
+
|
|
185
|
+
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
|
186
|
+
const sequentialKey = resolveSequentialKey({
|
|
187
|
+
accountId,
|
|
188
|
+
event,
|
|
189
|
+
botOpenId: getBotOpenId(accountId),
|
|
190
|
+
botName: getBotName(accountId),
|
|
191
|
+
});
|
|
192
|
+
const task = () =>
|
|
193
|
+
handleMessage({
|
|
194
|
+
cfg,
|
|
195
|
+
event,
|
|
196
|
+
botOpenId: getBotOpenId(accountId),
|
|
197
|
+
botName: getBotName(accountId),
|
|
198
|
+
runtime,
|
|
199
|
+
chatHistories,
|
|
200
|
+
accountId,
|
|
201
|
+
processingClaimHeld: true,
|
|
202
|
+
});
|
|
203
|
+
await enqueue(sequentialKey, task);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
|
|
207
|
+
const senderId =
|
|
208
|
+
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
|
|
209
|
+
return senderId || undefined;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const resolveDebounceText = (event: FeishuMessageEvent): string => {
|
|
213
|
+
return resolveText({
|
|
214
|
+
event,
|
|
215
|
+
botOpenId: getBotOpenId(accountId),
|
|
216
|
+
botName: getBotName(accountId),
|
|
217
|
+
}).trim();
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const recordSuppressedMessageIds = async (
|
|
221
|
+
entries: FeishuMessageEvent[],
|
|
222
|
+
dispatchMessageId?: string,
|
|
223
|
+
) => {
|
|
224
|
+
const keepMessageId = dispatchMessageId?.trim();
|
|
225
|
+
const suppressedIds = new Set(
|
|
226
|
+
entries
|
|
227
|
+
.map((entry) => entry.message.message_id?.trim())
|
|
228
|
+
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
|
|
229
|
+
);
|
|
230
|
+
for (const messageId of suppressedIds) {
|
|
231
|
+
try {
|
|
232
|
+
await recordProcessedMessage(messageId, accountId, log);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
error(
|
|
235
|
+
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
|
242
|
+
debounceMs: inboundDebounceMs,
|
|
243
|
+
buildKey: (event) => {
|
|
244
|
+
const chatId = event.message.chat_id?.trim();
|
|
245
|
+
const senderId = resolveSenderDebounceId(event);
|
|
246
|
+
if (!chatId || !senderId) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const rootId = event.message.root_id?.trim();
|
|
250
|
+
const threadKey = rootId ? `thread:${rootId}` : "chat";
|
|
251
|
+
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
|
|
252
|
+
},
|
|
253
|
+
shouldDebounce: (event) => {
|
|
254
|
+
if (event.message.message_type !== "text") {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
const text = resolveDebounceText(event);
|
|
258
|
+
return Boolean(text) && !core.channel.text.hasControlCommand(text, cfg);
|
|
259
|
+
},
|
|
260
|
+
onFlush: async (entries) => {
|
|
261
|
+
const last = entries.at(-1);
|
|
262
|
+
if (!last) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (entries.length === 1) {
|
|
266
|
+
await dispatchFeishuMessage(last);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
|
|
270
|
+
const freshEntries: FeishuMessageEvent[] = [];
|
|
271
|
+
for (const entry of dedupedEntries) {
|
|
272
|
+
if (!(await hasProcessedMessage(entry.message.message_id, accountId, log))) {
|
|
273
|
+
freshEntries.push(entry);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
const dispatchEntry = freshEntries.at(-1);
|
|
277
|
+
if (!dispatchEntry) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
|
|
281
|
+
const combinedText = freshEntries
|
|
282
|
+
.map((entry) => resolveDebounceText(entry))
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
.join("\n");
|
|
285
|
+
const mergedMentions = resolveFeishuDebounceMentions({
|
|
286
|
+
entries: freshEntries,
|
|
287
|
+
botOpenId: getBotOpenId(accountId),
|
|
288
|
+
});
|
|
289
|
+
await dispatchFeishuMessage({
|
|
290
|
+
...dispatchEntry,
|
|
291
|
+
message: {
|
|
292
|
+
...dispatchEntry.message,
|
|
293
|
+
...(combinedText.trim()
|
|
294
|
+
? {
|
|
295
|
+
message_type: "text",
|
|
296
|
+
content: JSON.stringify({ text: combinedText }),
|
|
297
|
+
}
|
|
298
|
+
: {}),
|
|
299
|
+
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
},
|
|
303
|
+
onError: (err, entries) => {
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
releaseFeishuMessageProcessing(entry.message.message_id, accountId);
|
|
306
|
+
}
|
|
307
|
+
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return async (data) => {
|
|
312
|
+
const event = parseFeishuMessageEventPayload(data);
|
|
313
|
+
if (!event) {
|
|
314
|
+
error(`feishu[${accountId}]: ignoring malformed message event payload`);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const messageId = event.message?.message_id?.trim();
|
|
318
|
+
if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
|
|
319
|
+
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const processMessage = async () => {
|
|
323
|
+
await inboundDebouncer.enqueue(event);
|
|
324
|
+
};
|
|
325
|
+
if (fireAndForget) {
|
|
326
|
+
void processMessage().catch((err) => {
|
|
327
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
328
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await processMessage();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
releaseFeishuMessageProcessing(messageId, accountId);
|
|
336
|
+
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
3
|
+
import {
|
|
4
|
+
resolveReactionSyntheticEvent,
|
|
5
|
+
type FeishuReactionCreatedEvent,
|
|
6
|
+
} from "./monitor.account.js";
|
|
7
|
+
|
|
8
|
+
const cfg = {} as ClawdbotConfig;
|
|
9
|
+
|
|
10
|
+
function makeReactionEvent(
|
|
11
|
+
overrides: Partial<FeishuReactionCreatedEvent> = {},
|
|
12
|
+
): FeishuReactionCreatedEvent {
|
|
13
|
+
return {
|
|
14
|
+
message_id: "om_msg1",
|
|
15
|
+
reaction_type: { emoji_type: "THUMBSUP" },
|
|
16
|
+
operator_type: "user",
|
|
17
|
+
user_id: { open_id: "ou_user1" },
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("Feishu reaction lifecycle", () => {
|
|
23
|
+
it("builds a created synthetic interaction payload", async () => {
|
|
24
|
+
const result = await resolveReactionSyntheticEvent({
|
|
25
|
+
cfg,
|
|
26
|
+
accountId: "default",
|
|
27
|
+
event: makeReactionEvent({ user_id: { open_id: "ou_user1", user_id: "on_user1" } }),
|
|
28
|
+
botOpenId: "ou_bot",
|
|
29
|
+
fetchMessage: async () => ({
|
|
30
|
+
messageId: "om_msg1",
|
|
31
|
+
chatId: "oc_group_1",
|
|
32
|
+
chatType: "group",
|
|
33
|
+
senderOpenId: "ou_bot",
|
|
34
|
+
senderType: "app",
|
|
35
|
+
content: "hello",
|
|
36
|
+
contentType: "text",
|
|
37
|
+
}),
|
|
38
|
+
uuid: () => "fixed-uuid",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result?.sender.sender_id).toEqual({ open_id: "ou_user1", user_id: "on_user1" });
|
|
42
|
+
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("builds a deleted synthetic interaction payload", async () => {
|
|
46
|
+
const result = await resolveReactionSyntheticEvent({
|
|
47
|
+
cfg,
|
|
48
|
+
accountId: "default",
|
|
49
|
+
event: makeReactionEvent(),
|
|
50
|
+
botOpenId: "ou_bot",
|
|
51
|
+
fetchMessage: async () => ({
|
|
52
|
+
messageId: "om_msg1",
|
|
53
|
+
chatId: "oc_group_1",
|
|
54
|
+
chatType: "group",
|
|
55
|
+
senderOpenId: "ou_bot",
|
|
56
|
+
senderType: "app",
|
|
57
|
+
content: "hello",
|
|
58
|
+
contentType: "text",
|
|
59
|
+
}),
|
|
60
|
+
uuid: () => "fixed-uuid",
|
|
61
|
+
action: "deleted",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(result?.message.content).toBe(
|
|
65
|
+
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
-
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
|
|
4
1
|
import {
|
|
5
2
|
createInboundDebouncer,
|
|
6
3
|
resolveInboundDebounceMs,
|
|
7
|
-
} from "
|
|
8
|
-
import {
|
|
4
|
+
} from "openclaw/plugin-sdk/channel-inbound-debounce";
|
|
5
|
+
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
6
|
+
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
|
9
9
|
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
|
10
10
|
import * as dedup from "./dedup.js";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
monitorSingleAccount,
|
|
13
|
+
resolveReactionSyntheticEvent,
|
|
14
|
+
type FeishuReactionCreatedEvent,
|
|
15
|
+
} from "./monitor.account.js";
|
|
13
16
|
import { setFeishuRuntime } from "./runtime.js";
|
|
14
17
|
import type { ResolvedFeishuAccount } from "./types.js";
|
|
15
18
|
|
|
@@ -17,6 +20,7 @@ const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?:
|
|
|
17
20
|
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
|
18
21
|
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
19
22
|
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
23
|
+
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
|
|
20
24
|
|
|
21
25
|
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
|
22
26
|
|
|
@@ -37,6 +41,10 @@ vi.mock("./monitor.transport.js", () => ({
|
|
|
37
41
|
monitorWebhook: monitorWebhookMock,
|
|
38
42
|
}));
|
|
39
43
|
|
|
44
|
+
vi.mock("./thread-bindings.js", () => ({
|
|
45
|
+
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
|
|
46
|
+
}));
|
|
47
|
+
|
|
40
48
|
const cfg = {} as ClawdbotConfig;
|
|
41
49
|
|
|
42
50
|
function makeReactionEvent(
|
|
@@ -167,11 +175,7 @@ async function setupDebounceMonitor(params?: {
|
|
|
167
175
|
await monitorSingleAccount({
|
|
168
176
|
cfg: buildDebounceConfig(),
|
|
169
177
|
account: buildDebounceAccount(),
|
|
170
|
-
runtime:
|
|
171
|
-
log: vi.fn(),
|
|
172
|
-
error: vi.fn(),
|
|
173
|
-
exit: vi.fn(),
|
|
174
|
-
} as RuntimeEnv,
|
|
178
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
175
179
|
botOpenIdSource: {
|
|
176
180
|
kind: "prefetched",
|
|
177
181
|
botOpenId: params?.botOpenId ?? "ou_bot",
|
|
@@ -225,6 +229,24 @@ function createMention(params: { openId: string; name: string; key?: string }):
|
|
|
225
229
|
};
|
|
226
230
|
}
|
|
227
231
|
|
|
232
|
+
function createFeishuMonitorRuntime(params?: {
|
|
233
|
+
createInboundDebouncer?: PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
|
|
234
|
+
resolveInboundDebounceMs?: PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"];
|
|
235
|
+
hasControlCommand?: PluginRuntime["channel"]["text"]["hasControlCommand"];
|
|
236
|
+
}): PluginRuntime {
|
|
237
|
+
return {
|
|
238
|
+
channel: {
|
|
239
|
+
debounce: {
|
|
240
|
+
createInboundDebouncer: params?.createInboundDebouncer ?? createInboundDebouncer,
|
|
241
|
+
resolveInboundDebounceMs: params?.resolveInboundDebounceMs ?? resolveInboundDebounceMs,
|
|
242
|
+
},
|
|
243
|
+
text: {
|
|
244
|
+
hasControlCommand: params?.hasControlCommand ?? hasControlCommand,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
} as unknown as PluginRuntime;
|
|
248
|
+
}
|
|
249
|
+
|
|
228
250
|
async function enqueueDebouncedMessage(
|
|
229
251
|
onMessage: (data: unknown) => Promise<void>,
|
|
230
252
|
event: FeishuMessageEvent,
|
|
@@ -419,24 +441,68 @@ describe("resolveReactionSyntheticEvent", () => {
|
|
|
419
441
|
});
|
|
420
442
|
});
|
|
421
443
|
|
|
444
|
+
describe("monitorSingleAccount lifecycle", () => {
|
|
445
|
+
beforeEach(() => {
|
|
446
|
+
createFeishuThreadBindingManagerMock.mockReset().mockImplementation(() => ({
|
|
447
|
+
stop: vi.fn(),
|
|
448
|
+
}));
|
|
449
|
+
createEventDispatcherMock.mockReset().mockReturnValue({
|
|
450
|
+
register: vi.fn(),
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("stops the Feishu thread binding manager when the monitor exits", async () => {
|
|
455
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
456
|
+
|
|
457
|
+
await monitorSingleAccount({
|
|
458
|
+
cfg: buildDebounceConfig(),
|
|
459
|
+
account: buildDebounceAccount(),
|
|
460
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
461
|
+
botOpenIdSource: {
|
|
462
|
+
kind: "prefetched",
|
|
463
|
+
botOpenId: "ou_bot",
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
|
468
|
+
| { stop: ReturnType<typeof vi.fn> }
|
|
469
|
+
| undefined;
|
|
470
|
+
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("stops the Feishu thread binding manager when setup fails before transport starts", async () => {
|
|
474
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
475
|
+
createEventDispatcherMock.mockReturnValue({
|
|
476
|
+
get register() {
|
|
477
|
+
throw new Error("register failed");
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
await expect(
|
|
482
|
+
monitorSingleAccount({
|
|
483
|
+
cfg: buildDebounceConfig(),
|
|
484
|
+
account: buildDebounceAccount(),
|
|
485
|
+
runtime: createNonExitingRuntimeEnv(),
|
|
486
|
+
botOpenIdSource: {
|
|
487
|
+
kind: "prefetched",
|
|
488
|
+
botOpenId: "ou_bot",
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
).rejects.toThrow("register failed");
|
|
492
|
+
|
|
493
|
+
const manager = createFeishuThreadBindingManagerMock.mock.results[0]?.value as
|
|
494
|
+
| { stop: ReturnType<typeof vi.fn> }
|
|
495
|
+
| undefined;
|
|
496
|
+
expect(manager?.stop).toHaveBeenCalledTimes(1);
|
|
497
|
+
});
|
|
498
|
+
});
|
|
499
|
+
|
|
422
500
|
describe("Feishu inbound debounce regressions", () => {
|
|
423
501
|
beforeEach(() => {
|
|
424
502
|
vi.useFakeTimers();
|
|
425
503
|
handlers = {};
|
|
426
504
|
handleFeishuMessageMock.mockClear();
|
|
427
|
-
setFeishuRuntime(
|
|
428
|
-
createPluginRuntimeMock({
|
|
429
|
-
channel: {
|
|
430
|
-
debounce: {
|
|
431
|
-
createInboundDebouncer,
|
|
432
|
-
resolveInboundDebounceMs,
|
|
433
|
-
},
|
|
434
|
-
text: {
|
|
435
|
-
hasControlCommand,
|
|
436
|
-
},
|
|
437
|
-
},
|
|
438
|
-
}),
|
|
439
|
-
);
|
|
505
|
+
setFeishuRuntime(createFeishuMonitorRuntime());
|
|
440
506
|
});
|
|
441
507
|
|
|
442
508
|
afterEach(() => {
|
|
@@ -585,47 +651,41 @@ describe("Feishu inbound debounce regressions", () => {
|
|
|
585
651
|
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
|
586
652
|
vi.spyOn(dedup, "tryBeginFeishuMessageProcessing").mockReturnValue(true);
|
|
587
653
|
const recordSpy = vi.spyOn(dedup, "recordProcessedFeishuMessage").mockResolvedValue(true);
|
|
588
|
-
setStaleRetryMocks();
|
|
654
|
+
setStaleRetryMocks("om_old_latest_fresh");
|
|
589
655
|
const onMessage = await setupDebounceMonitor();
|
|
590
656
|
|
|
591
|
-
await onMessage(createTextEvent({ messageId: "
|
|
657
|
+
await onMessage(createTextEvent({ messageId: "om_new_latest_fresh", text: "fresh" }));
|
|
592
658
|
await Promise.resolve();
|
|
593
659
|
await Promise.resolve();
|
|
594
|
-
await onMessage(createTextEvent({ messageId: "
|
|
660
|
+
await onMessage(createTextEvent({ messageId: "om_old_latest_fresh", text: "stale" }));
|
|
595
661
|
await Promise.resolve();
|
|
596
662
|
await Promise.resolve();
|
|
597
663
|
await vi.advanceTimersByTimeAsync(25);
|
|
598
664
|
|
|
599
665
|
const dispatched = expectSingleDispatchedEvent();
|
|
600
|
-
expect(dispatched.message.message_id).toBe("
|
|
666
|
+
expect(dispatched.message.message_id).toBe("om_new_latest_fresh");
|
|
601
667
|
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
|
602
668
|
expect(combined.text).toBe("fresh");
|
|
603
|
-
expect(recordSpy).toHaveBeenCalledWith("
|
|
604
|
-
expect(recordSpy).not.toHaveBeenCalledWith(
|
|
669
|
+
expect(recordSpy).toHaveBeenCalledWith("om_old_latest_fresh", "default", expect.any(Function));
|
|
670
|
+
expect(recordSpy).not.toHaveBeenCalledWith(
|
|
671
|
+
"om_new_latest_fresh",
|
|
672
|
+
"default",
|
|
673
|
+
expect.any(Function),
|
|
674
|
+
);
|
|
605
675
|
});
|
|
606
676
|
|
|
607
677
|
it("releases early event dedupe when debounced dispatch fails", async () => {
|
|
608
678
|
setDedupPassThroughMocks();
|
|
609
679
|
const enqueueMock = vi.fn();
|
|
610
680
|
setFeishuRuntime(
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}) => ({
|
|
617
|
-
enqueue: async (item: T) => {
|
|
618
|
-
enqueueMock(item);
|
|
619
|
-
params.onError?.(new Error("dispatch failed"), [item]);
|
|
620
|
-
},
|
|
621
|
-
flushKey: async () => {},
|
|
622
|
-
}),
|
|
623
|
-
resolveInboundDebounceMs,
|
|
624
|
-
},
|
|
625
|
-
text: {
|
|
626
|
-
hasControlCommand,
|
|
681
|
+
createFeishuMonitorRuntime({
|
|
682
|
+
createInboundDebouncer: <T>(params: { onError?: (err: unknown, items: T[]) => void }) => ({
|
|
683
|
+
enqueue: async (item: T) => {
|
|
684
|
+
enqueueMock(item);
|
|
685
|
+
params.onError?.(new Error("dispatch failed"), [item]);
|
|
627
686
|
},
|
|
628
|
-
|
|
687
|
+
flushKey: async () => {},
|
|
688
|
+
}),
|
|
629
689
|
}),
|
|
630
690
|
);
|
|
631
691
|
const onMessage = await setupDebounceMonitor();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
3
4
|
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
|
4
5
|
|
|
5
6
|
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
|
@@ -39,9 +40,12 @@ function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig
|
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
async function waitForStartedAccount(started: string[], accountId: string) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
await vi.waitFor(
|
|
44
|
+
() => {
|
|
45
|
+
expect(started).toContain(accountId);
|
|
46
|
+
},
|
|
47
|
+
{ timeout: 10_000 },
|
|
48
|
+
);
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
afterEach(() => {
|
|
@@ -73,9 +77,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
73
77
|
});
|
|
74
78
|
|
|
75
79
|
try {
|
|
76
|
-
await
|
|
77
|
-
await Promise.resolve();
|
|
78
|
-
|
|
80
|
+
await waitForStartedAccount(started, "alpha");
|
|
79
81
|
expect(started).toEqual(["alpha"]);
|
|
80
82
|
expect(maxInFlight).toBe(1);
|
|
81
83
|
} finally {
|
|
@@ -134,7 +136,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
134
136
|
});
|
|
135
137
|
|
|
136
138
|
const abortController = new AbortController();
|
|
137
|
-
const runtime =
|
|
139
|
+
const runtime = createNonExitingRuntimeEnv();
|
|
138
140
|
const monitorPromise = monitorFeishuProvider({
|
|
139
141
|
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
|
140
142
|
runtime,
|
|
@@ -176,7 +178,7 @@ describe("Feishu monitor startup preflight", () => {
|
|
|
176
178
|
});
|
|
177
179
|
|
|
178
180
|
try {
|
|
179
|
-
await
|
|
181
|
+
await waitForStartedAccount(started, "alpha");
|
|
180
182
|
expect(started).toEqual(["alpha"]);
|
|
181
183
|
|
|
182
184
|
abortController.abort();
|