@openclaw/feishu 2026.3.13 → 2026.5.1-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 +1653 -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 +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -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 +365 -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 +32 -94
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- 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 +375 -26
- package/src/media.ts +434 -88
- 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.reply-once.lifecycle.test-support.ts +190 -0
- 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 +77 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +399 -86
- 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 +479 -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
package/src/bot.test.ts
CHANGED
|
@@ -1,108 +1,543 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import type * as ConversationRuntime from "openclaw/plugin-sdk/conversation-runtime";
|
|
2
|
+
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
3
|
+
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
2
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import {
|
|
5
|
+
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
|
4
6
|
import type { FeishuMessageEvent } from "./bot.js";
|
|
5
|
-
import {
|
|
6
|
-
buildBroadcastSessionKey,
|
|
7
|
-
buildFeishuAgentBody,
|
|
8
|
-
handleFeishuMessage,
|
|
9
|
-
resolveBroadcastAgents,
|
|
10
|
-
toMessageResourceType,
|
|
11
|
-
} from "./bot.js";
|
|
7
|
+
import { handleFeishuMessage } from "./bot.js";
|
|
12
8
|
import { setFeishuRuntime } from "./runtime.js";
|
|
13
9
|
|
|
10
|
+
type ConfiguredBindingRoute = ReturnType<typeof ConversationRuntime.resolveConfiguredBindingRoute>;
|
|
11
|
+
type BoundConversation = ReturnType<
|
|
12
|
+
ReturnType<typeof ConversationRuntime.getSessionBindingService>["resolveByConversation"]
|
|
13
|
+
>;
|
|
14
|
+
type BindingReadiness = Awaited<
|
|
15
|
+
ReturnType<typeof ConversationRuntime.ensureConfiguredBindingRouteReady>
|
|
16
|
+
>;
|
|
17
|
+
type ReplyDispatcher = Parameters<
|
|
18
|
+
PluginRuntime["channel"]["reply"]["withReplyDispatcher"]
|
|
19
|
+
>[0]["dispatcher"];
|
|
20
|
+
type DeepPartial<T> = {
|
|
21
|
+
[K in keyof T]?: T[K] extends (...args: never[]) => unknown
|
|
22
|
+
? T[K]
|
|
23
|
+
: T[K] extends ReadonlyArray<unknown>
|
|
24
|
+
? T[K]
|
|
25
|
+
: T[K] extends object
|
|
26
|
+
? DeepPartial<T[K]>
|
|
27
|
+
: T[K];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function createReplyDispatcher(): ReplyDispatcher {
|
|
31
|
+
return {
|
|
32
|
+
sendToolResult: vi.fn(),
|
|
33
|
+
sendBlockReply: vi.fn(),
|
|
34
|
+
sendFinalReply: vi.fn(),
|
|
35
|
+
waitForIdle: vi.fn(),
|
|
36
|
+
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
37
|
+
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
|
38
|
+
markComplete: vi.fn(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createConfiguredFeishuRoute(): NonNullable<ConfiguredBindingRoute> {
|
|
43
|
+
return {
|
|
44
|
+
bindingResolution: {
|
|
45
|
+
conversation: {
|
|
46
|
+
channel: "feishu",
|
|
47
|
+
accountId: "default",
|
|
48
|
+
conversationId: "ou_sender_1",
|
|
49
|
+
},
|
|
50
|
+
compiledBinding: {
|
|
51
|
+
channel: "feishu",
|
|
52
|
+
accountPattern: "default",
|
|
53
|
+
binding: {
|
|
54
|
+
type: "acp",
|
|
55
|
+
agentId: "codex",
|
|
56
|
+
match: {
|
|
57
|
+
channel: "feishu",
|
|
58
|
+
accountId: "default",
|
|
59
|
+
peer: { kind: "direct", id: "ou_sender_1" },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
bindingConversationId: "ou_sender_1",
|
|
63
|
+
target: {
|
|
64
|
+
conversationId: "ou_sender_1",
|
|
65
|
+
},
|
|
66
|
+
agentId: "codex",
|
|
67
|
+
provider: {
|
|
68
|
+
compileConfiguredBinding: () => ({ conversationId: "ou_sender_1" }),
|
|
69
|
+
matchInboundConversation: () => ({ conversationId: "ou_sender_1" }),
|
|
70
|
+
},
|
|
71
|
+
targetFactory: {
|
|
72
|
+
driverId: "acp",
|
|
73
|
+
materialize: () => ({
|
|
74
|
+
record: {
|
|
75
|
+
bindingId: "config:acp:feishu:default:ou_sender_1",
|
|
76
|
+
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
77
|
+
targetKind: "session",
|
|
78
|
+
conversation: {
|
|
79
|
+
channel: "feishu",
|
|
80
|
+
accountId: "default",
|
|
81
|
+
conversationId: "ou_sender_1",
|
|
82
|
+
},
|
|
83
|
+
status: "active",
|
|
84
|
+
boundAt: 0,
|
|
85
|
+
metadata: { source: "config" },
|
|
86
|
+
},
|
|
87
|
+
statefulTarget: {
|
|
88
|
+
kind: "stateful",
|
|
89
|
+
driverId: "acp",
|
|
90
|
+
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
91
|
+
agentId: "codex",
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
match: {
|
|
97
|
+
conversationId: "ou_sender_1",
|
|
98
|
+
},
|
|
99
|
+
record: {
|
|
100
|
+
bindingId: "config:acp:feishu:default:ou_sender_1",
|
|
101
|
+
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
102
|
+
targetKind: "session",
|
|
103
|
+
conversation: {
|
|
104
|
+
channel: "feishu",
|
|
105
|
+
accountId: "default",
|
|
106
|
+
conversationId: "ou_sender_1",
|
|
107
|
+
},
|
|
108
|
+
status: "active",
|
|
109
|
+
boundAt: 0,
|
|
110
|
+
metadata: { source: "config" },
|
|
111
|
+
},
|
|
112
|
+
statefulTarget: {
|
|
113
|
+
kind: "stateful",
|
|
114
|
+
driverId: "acp",
|
|
115
|
+
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
116
|
+
agentId: "codex",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
route: {
|
|
120
|
+
agentId: "codex",
|
|
121
|
+
channel: "feishu",
|
|
122
|
+
accountId: "default",
|
|
123
|
+
sessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
|
124
|
+
mainSessionKey: "agent:codex:main",
|
|
125
|
+
lastRoutePolicy: "session",
|
|
126
|
+
matchedBy: "binding.channel",
|
|
127
|
+
} as ResolvedAgentRoute,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createConfiguredBindingReadiness(ok: boolean, error?: string): BindingReadiness {
|
|
132
|
+
return (ok ? { ok: true } : { ok: false, error: error ?? "unknown error" }) as BindingReadiness;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function createBoundConversation(): NonNullable<BoundConversation> {
|
|
136
|
+
return {
|
|
137
|
+
bindingId: "default:oc_group_chat:topic:om_topic_root",
|
|
138
|
+
targetSessionKey: "agent:codex:acp:binding:feishu:default:feedface",
|
|
139
|
+
targetKind: "session",
|
|
140
|
+
conversation: {
|
|
141
|
+
channel: "feishu",
|
|
142
|
+
accountId: "default",
|
|
143
|
+
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
144
|
+
parentConversationId: "oc_group_chat",
|
|
145
|
+
},
|
|
146
|
+
status: "active",
|
|
147
|
+
boundAt: 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function buildDefaultResolveRoute(): ResolvedAgentRoute {
|
|
152
|
+
return {
|
|
153
|
+
agentId: "main",
|
|
154
|
+
channel: "feishu",
|
|
155
|
+
accountId: "default",
|
|
156
|
+
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
157
|
+
mainSessionKey: "agent:main:main",
|
|
158
|
+
lastRoutePolicy: "session",
|
|
159
|
+
matchedBy: "default",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _createUnboundConfiguredRoute(
|
|
164
|
+
route: NonNullable<ConfiguredBindingRoute>["route"],
|
|
165
|
+
): ConfiguredBindingRoute {
|
|
166
|
+
return { bindingResolution: null, route };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): PluginRuntime {
|
|
170
|
+
return {
|
|
171
|
+
channel: {
|
|
172
|
+
routing: {
|
|
173
|
+
resolveAgentRoute: resolveAgentRouteMock,
|
|
174
|
+
},
|
|
175
|
+
session: {
|
|
176
|
+
readSessionUpdatedAt: readSessionUpdatedAtMock,
|
|
177
|
+
resolveStorePath: resolveStorePathMock,
|
|
178
|
+
recordInboundSession: vi.fn(async () => undefined),
|
|
179
|
+
},
|
|
180
|
+
reply: {
|
|
181
|
+
resolveEnvelopeFormatOptions:
|
|
182
|
+
resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
183
|
+
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
184
|
+
finalizeInboundContext: finalizeInboundContextMock as never,
|
|
185
|
+
dispatchReplyFromConfig: vi.fn().mockResolvedValue({
|
|
186
|
+
queuedFinal: false,
|
|
187
|
+
counts: { final: 1 },
|
|
188
|
+
}),
|
|
189
|
+
withReplyDispatcher: withReplyDispatcherMock as never,
|
|
190
|
+
},
|
|
191
|
+
commands: {
|
|
192
|
+
shouldComputeCommandAuthorized: vi.fn(() => false),
|
|
193
|
+
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
194
|
+
},
|
|
195
|
+
pairing: {
|
|
196
|
+
readAllowFromStore: vi.fn().mockResolvedValue(["ou_sender_1"]),
|
|
197
|
+
upsertPairingRequest: vi.fn(),
|
|
198
|
+
buildPairingReply: vi.fn(),
|
|
199
|
+
},
|
|
200
|
+
turn: {
|
|
201
|
+
run: vi.fn(async (params) => {
|
|
202
|
+
const input = await params.adapter.ingest(params.raw);
|
|
203
|
+
const turn = await params.adapter.resolveTurn(input, {
|
|
204
|
+
kind: "message",
|
|
205
|
+
canStartAgentTurn: true,
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
dispatchResult: await turn.runDispatch(),
|
|
209
|
+
};
|
|
210
|
+
}),
|
|
211
|
+
runPrepared: vi.fn(async (params) => ({
|
|
212
|
+
dispatchResult: await params.runDispatch(),
|
|
213
|
+
})),
|
|
214
|
+
},
|
|
215
|
+
...overrides.channel,
|
|
216
|
+
},
|
|
217
|
+
...(overrides.system ? { system: overrides.system as PluginRuntime["system"] } : {}),
|
|
218
|
+
...(overrides.media ? { media: overrides.media as PluginRuntime["media"] } : {}),
|
|
219
|
+
} as unknown as PluginRuntime;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const resolveAgentRouteMock: PluginRuntime["channel"]["routing"]["resolveAgentRoute"] = (params) =>
|
|
223
|
+
mockResolveAgentRoute(params);
|
|
224
|
+
const readSessionUpdatedAtMock: PluginRuntime["channel"]["session"]["readSessionUpdatedAt"] = (
|
|
225
|
+
params,
|
|
226
|
+
) => mockReadSessionUpdatedAt(params);
|
|
227
|
+
const resolveStorePathMock: PluginRuntime["channel"]["session"]["resolveStorePath"] = (params) =>
|
|
228
|
+
mockResolveStorePath(params);
|
|
229
|
+
const resolveEnvelopeFormatOptionsMock = () => ({});
|
|
230
|
+
const finalizeInboundContextMock = (ctx: Record<string, unknown>) => ctx;
|
|
231
|
+
const withReplyDispatcherMock = async ({
|
|
232
|
+
run,
|
|
233
|
+
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => await run();
|
|
234
|
+
|
|
14
235
|
const {
|
|
15
236
|
mockCreateFeishuReplyDispatcher,
|
|
16
237
|
mockSendMessageFeishu,
|
|
17
238
|
mockGetMessageFeishu,
|
|
239
|
+
mockListFeishuThreadMessages,
|
|
18
240
|
mockDownloadMessageResourceFeishu,
|
|
19
241
|
mockCreateFeishuClient,
|
|
20
242
|
mockResolveAgentRoute,
|
|
243
|
+
mockReadSessionUpdatedAt,
|
|
244
|
+
mockResolveStorePath,
|
|
245
|
+
mockResolveConfiguredBindingRoute,
|
|
246
|
+
mockEnsureConfiguredBindingRouteReady,
|
|
247
|
+
mockResolveBoundConversation,
|
|
248
|
+
mockTouchBinding,
|
|
249
|
+
mockResolveFeishuReasoningPreviewEnabled,
|
|
250
|
+
mockTranscribeFirstAudio,
|
|
21
251
|
} = vi.hoisted(() => ({
|
|
22
252
|
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
|
23
|
-
dispatcher:
|
|
253
|
+
dispatcher: createReplyDispatcher(),
|
|
24
254
|
replyOptions: {},
|
|
25
255
|
markDispatchIdle: vi.fn(),
|
|
26
256
|
})),
|
|
27
257
|
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
|
28
258
|
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
|
259
|
+
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
|
|
29
260
|
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
|
30
261
|
buffer: Buffer.from("video"),
|
|
31
262
|
contentType: "video/mp4",
|
|
32
263
|
fileName: "clip.mp4",
|
|
33
264
|
}),
|
|
34
265
|
mockCreateFeishuClient: vi.fn(),
|
|
35
|
-
mockResolveAgentRoute: vi.fn(() => (
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
266
|
+
mockResolveAgentRoute: vi.fn((_params?: unknown) => buildDefaultResolveRoute()),
|
|
267
|
+
mockReadSessionUpdatedAt: vi.fn((_params?: unknown): number | undefined => undefined),
|
|
268
|
+
mockResolveStorePath: vi.fn((_params?: unknown) => "/tmp/feishu-sessions.json"),
|
|
269
|
+
mockResolveConfiguredBindingRoute: vi.fn(
|
|
270
|
+
({
|
|
271
|
+
route,
|
|
272
|
+
}: {
|
|
273
|
+
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
274
|
+
}): ConfiguredBindingRoute => ({
|
|
275
|
+
bindingResolution: null,
|
|
276
|
+
route,
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
mockEnsureConfiguredBindingRouteReady: vi.fn(
|
|
280
|
+
async (_params?: unknown): Promise<BindingReadiness> => ({ ok: true }),
|
|
281
|
+
),
|
|
282
|
+
mockResolveBoundConversation: vi.fn((_ref?: unknown) => null as BoundConversation),
|
|
283
|
+
mockTouchBinding: vi.fn(),
|
|
284
|
+
mockResolveFeishuReasoningPreviewEnabled: vi.fn(() => false),
|
|
285
|
+
mockTranscribeFirstAudio: vi.fn(),
|
|
43
286
|
}));
|
|
44
287
|
|
|
45
288
|
vi.mock("./reply-dispatcher.js", () => ({
|
|
46
289
|
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
|
|
47
290
|
}));
|
|
48
291
|
|
|
292
|
+
vi.mock("./reasoning-preview.js", () => ({
|
|
293
|
+
resolveFeishuReasoningPreviewEnabled: mockResolveFeishuReasoningPreviewEnabled,
|
|
294
|
+
}));
|
|
295
|
+
|
|
49
296
|
vi.mock("./send.js", () => ({
|
|
50
297
|
sendMessageFeishu: mockSendMessageFeishu,
|
|
51
298
|
getMessageFeishu: mockGetMessageFeishu,
|
|
299
|
+
listFeishuThreadMessages: mockListFeishuThreadMessages,
|
|
52
300
|
}));
|
|
53
301
|
|
|
54
302
|
vi.mock("./media.js", () => ({
|
|
55
303
|
downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
|
|
56
304
|
}));
|
|
57
305
|
|
|
306
|
+
vi.mock("./audio-preflight.runtime.js", () => ({
|
|
307
|
+
transcribeFirstAudio: mockTranscribeFirstAudio,
|
|
308
|
+
}));
|
|
309
|
+
|
|
58
310
|
vi.mock("./client.js", () => ({
|
|
59
311
|
createFeishuClient: mockCreateFeishuClient,
|
|
60
312
|
}));
|
|
61
313
|
|
|
62
|
-
|
|
314
|
+
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
|
315
|
+
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
|
316
|
+
"openclaw/plugin-sdk/conversation-runtime",
|
|
317
|
+
);
|
|
63
318
|
return {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
319
|
+
...actual,
|
|
320
|
+
resolveConfiguredBindingRoute: (params: unknown) =>
|
|
321
|
+
mockResolveConfiguredBindingRoute(params as { route: ResolvedAgentRoute }),
|
|
322
|
+
resolveRuntimeConversationBindingRoute: (params: {
|
|
323
|
+
route: ResolvedAgentRoute;
|
|
324
|
+
conversation: Parameters<
|
|
325
|
+
ReturnType<typeof actual.getSessionBindingService>["resolveByConversation"]
|
|
326
|
+
>[0];
|
|
327
|
+
}) => {
|
|
328
|
+
const bindingRecord = mockResolveBoundConversation(params.conversation);
|
|
329
|
+
const boundSessionKey = bindingRecord?.targetSessionKey?.trim();
|
|
330
|
+
if (!bindingRecord || !boundSessionKey) {
|
|
331
|
+
return { bindingRecord: null, route: params.route };
|
|
332
|
+
}
|
|
333
|
+
mockTouchBinding(bindingRecord.bindingId);
|
|
334
|
+
return {
|
|
335
|
+
bindingRecord,
|
|
336
|
+
boundSessionKey,
|
|
337
|
+
boundAgentId: params.route.agentId,
|
|
338
|
+
route: {
|
|
339
|
+
...params.route,
|
|
340
|
+
sessionKey: boundSessionKey,
|
|
341
|
+
lastRoutePolicy: boundSessionKey === params.route.mainSessionKey ? "main" : "session",
|
|
342
|
+
matchedBy: "binding.channel",
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
ensureConfiguredBindingRouteReady: (params: unknown) =>
|
|
347
|
+
mockEnsureConfiguredBindingRouteReady(params),
|
|
348
|
+
getSessionBindingService: () => ({
|
|
349
|
+
resolveByConversation: mockResolveBoundConversation,
|
|
350
|
+
touch: mockTouchBinding,
|
|
68
351
|
}),
|
|
69
|
-
}
|
|
70
|
-
}
|
|
352
|
+
};
|
|
353
|
+
});
|
|
71
354
|
|
|
72
355
|
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
|
|
356
|
+
const runtime = createRuntimeEnv();
|
|
357
|
+
const feishuConfig = params.cfg.channels?.feishu;
|
|
358
|
+
const cfg =
|
|
359
|
+
feishuConfig?.dmPolicy === "open" && feishuConfig.allowFrom === undefined
|
|
360
|
+
? ({
|
|
361
|
+
...params.cfg,
|
|
362
|
+
channels: {
|
|
363
|
+
...params.cfg.channels,
|
|
364
|
+
feishu: {
|
|
365
|
+
...feishuConfig,
|
|
366
|
+
allowFrom: ["*"],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
} as ClawdbotConfig)
|
|
370
|
+
: params.cfg;
|
|
73
371
|
await handleFeishuMessage({
|
|
74
|
-
cfg
|
|
372
|
+
cfg,
|
|
75
373
|
event: params.event,
|
|
76
|
-
runtime
|
|
374
|
+
runtime,
|
|
77
375
|
});
|
|
376
|
+
return runtime;
|
|
78
377
|
}
|
|
79
378
|
|
|
80
|
-
describe("
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
379
|
+
describe("handleFeishuMessage ACP routing", () => {
|
|
380
|
+
beforeEach(() => {
|
|
381
|
+
vi.clearAllMocks();
|
|
382
|
+
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
|
383
|
+
({
|
|
384
|
+
route,
|
|
385
|
+
}: {
|
|
386
|
+
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
387
|
+
}): ConfiguredBindingRoute => ({
|
|
388
|
+
bindingResolution: null,
|
|
389
|
+
route,
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
|
393
|
+
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
|
394
|
+
mockTouchBinding.mockReset();
|
|
395
|
+
mockResolveFeishuReasoningPreviewEnabled.mockReset().mockReturnValue(false);
|
|
396
|
+
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
|
|
397
|
+
mockResolveAgentRoute.mockReset().mockReturnValue({
|
|
398
|
+
...buildDefaultResolveRoute(),
|
|
399
|
+
sessionKey: "agent:main:feishu:direct:ou_sender_1",
|
|
400
|
+
});
|
|
401
|
+
mockSendMessageFeishu
|
|
402
|
+
.mockReset()
|
|
403
|
+
.mockResolvedValue({ messageId: "reply-msg", chatId: "oc_dm" });
|
|
404
|
+
mockCreateFeishuReplyDispatcher.mockReset().mockReturnValue({
|
|
405
|
+
dispatcher: createReplyDispatcher(),
|
|
406
|
+
replyOptions: {},
|
|
407
|
+
markDispatchIdle: vi.fn(),
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
setFeishuRuntime(createFeishuBotRuntime());
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("ensures configured ACP routes for Feishu DMs", async () => {
|
|
414
|
+
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
|
|
415
|
+
|
|
416
|
+
await dispatchMessage({
|
|
417
|
+
cfg: {
|
|
418
|
+
session: { mainKey: "main", scope: "per-sender" },
|
|
419
|
+
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
420
|
+
},
|
|
421
|
+
event: {
|
|
422
|
+
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
423
|
+
message: {
|
|
424
|
+
message_id: "msg-1",
|
|
425
|
+
chat_id: "oc_dm",
|
|
426
|
+
chat_type: "p2p",
|
|
427
|
+
message_type: "text",
|
|
428
|
+
content: JSON.stringify({ text: "hello" }),
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(mockResolveConfiguredBindingRoute).toHaveBeenCalledTimes(1);
|
|
434
|
+
expect(mockEnsureConfiguredBindingRouteReady).toHaveBeenCalledTimes(1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("surfaces configured ACP initialization failures to the Feishu conversation", async () => {
|
|
438
|
+
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
|
|
439
|
+
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
|
|
440
|
+
createConfiguredBindingReadiness(false, "runtime unavailable"),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
await dispatchMessage({
|
|
444
|
+
cfg: {
|
|
445
|
+
session: { mainKey: "main", scope: "per-sender" },
|
|
446
|
+
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
447
|
+
},
|
|
448
|
+
event: {
|
|
449
|
+
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
450
|
+
message: {
|
|
451
|
+
message_id: "msg-2",
|
|
452
|
+
chat_id: "oc_dm",
|
|
453
|
+
chat_type: "p2p",
|
|
454
|
+
message_type: "text",
|
|
455
|
+
content: JSON.stringify({ text: "hello" }),
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
461
|
+
expect.objectContaining({
|
|
462
|
+
to: "chat:oc_dm",
|
|
463
|
+
text: expect.stringContaining("runtime unavailable"),
|
|
464
|
+
}),
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("routes Feishu topic messages through active bound conversations", async () => {
|
|
469
|
+
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
|
|
470
|
+
|
|
471
|
+
await dispatchMessage({
|
|
472
|
+
cfg: {
|
|
473
|
+
session: { mainKey: "main", scope: "per-sender" },
|
|
474
|
+
channels: {
|
|
475
|
+
feishu: {
|
|
476
|
+
enabled: true,
|
|
477
|
+
allowFrom: ["ou_sender_1"],
|
|
478
|
+
groups: {
|
|
479
|
+
oc_group_chat: {
|
|
480
|
+
allow: true,
|
|
481
|
+
requireMention: false,
|
|
482
|
+
groupSessionScope: "group_topic",
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
},
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
event: {
|
|
489
|
+
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
490
|
+
message: {
|
|
491
|
+
message_id: "msg-3",
|
|
492
|
+
chat_id: "oc_group_chat",
|
|
493
|
+
chat_type: "group",
|
|
494
|
+
message_type: "text",
|
|
495
|
+
root_id: "om_topic_root",
|
|
496
|
+
content: JSON.stringify({ text: "hello topic" }),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
expect(mockResolveBoundConversation).toHaveBeenCalledWith(
|
|
502
|
+
expect.objectContaining({
|
|
503
|
+
channel: "feishu",
|
|
504
|
+
conversationId: "oc_group_chat:topic:om_topic_root",
|
|
505
|
+
}),
|
|
506
|
+
);
|
|
507
|
+
expect(mockTouchBinding).toHaveBeenCalledWith("default:oc_group_chat:topic:om_topic_root");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("passes reasoning preview permission from session state into the dispatcher", async () => {
|
|
511
|
+
mockResolveFeishuReasoningPreviewEnabled.mockReturnValue(true);
|
|
512
|
+
|
|
513
|
+
await dispatchMessage({
|
|
514
|
+
cfg: {
|
|
515
|
+
session: { mainKey: "main", scope: "per-sender" },
|
|
516
|
+
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
|
|
517
|
+
},
|
|
518
|
+
event: {
|
|
519
|
+
sender: { sender_id: { open_id: "ou_sender_1" } },
|
|
520
|
+
message: {
|
|
521
|
+
message_id: "msg-reasoning",
|
|
522
|
+
chat_id: "oc_dm",
|
|
523
|
+
chat_type: "p2p",
|
|
524
|
+
message_type: "text",
|
|
525
|
+
content: JSON.stringify({ text: "hello" }),
|
|
526
|
+
},
|
|
95
527
|
},
|
|
96
528
|
});
|
|
97
529
|
|
|
98
|
-
expect(
|
|
99
|
-
|
|
530
|
+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
|
531
|
+
expect.objectContaining({ allowReasoningPreview: true }),
|
|
100
532
|
);
|
|
101
533
|
});
|
|
102
534
|
});
|
|
103
535
|
|
|
104
536
|
describe("handleFeishuMessage command authorization", () => {
|
|
105
|
-
const mockFinalizeInboundContext = vi.fn((ctx: unknown) =>
|
|
537
|
+
const mockFinalizeInboundContext = vi.fn((ctx: Record<string, unknown>) => ({
|
|
538
|
+
...ctx,
|
|
539
|
+
CommandAuthorized: typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : false,
|
|
540
|
+
}));
|
|
106
541
|
const mockDispatchReplyFromConfig = vi
|
|
107
542
|
.fn()
|
|
108
543
|
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
@@ -140,14 +575,25 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
140
575
|
beforeEach(() => {
|
|
141
576
|
vi.clearAllMocks();
|
|
142
577
|
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
578
|
+
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
|
579
|
+
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
|
580
|
+
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
|
581
|
+
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
|
582
|
+
mockResolveConfiguredBindingRoute.mockReset().mockImplementation(
|
|
583
|
+
({
|
|
584
|
+
route,
|
|
585
|
+
}: {
|
|
586
|
+
route: NonNullable<ConfiguredBindingRoute>["route"];
|
|
587
|
+
}): ConfiguredBindingRoute => ({
|
|
588
|
+
bindingResolution: null,
|
|
589
|
+
route,
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
mockEnsureConfiguredBindingRouteReady.mockReset().mockResolvedValue({ ok: true });
|
|
593
|
+
mockResolveBoundConversation.mockReset().mockReturnValue(null);
|
|
594
|
+
mockTouchBinding.mockReset();
|
|
595
|
+
mockTranscribeFirstAudio.mockReset().mockResolvedValue(undefined);
|
|
596
|
+
mockResolveAgentRoute.mockReturnValue(buildDefaultResolveRoute());
|
|
151
597
|
mockCreateFeishuClient.mockReturnValue({
|
|
152
598
|
contact: {
|
|
153
599
|
user: {
|
|
@@ -157,39 +603,31 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
157
603
|
});
|
|
158
604
|
mockEnqueueSystemEvent.mockReset();
|
|
159
605
|
setFeishuRuntime(
|
|
160
|
-
|
|
606
|
+
createFeishuBotRuntime({
|
|
161
607
|
system: {
|
|
162
608
|
enqueueSystemEvent: mockEnqueueSystemEvent,
|
|
163
609
|
},
|
|
164
610
|
channel: {
|
|
165
|
-
routing: {
|
|
166
|
-
resolveAgentRoute:
|
|
167
|
-
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
|
168
|
-
},
|
|
169
611
|
reply: {
|
|
170
|
-
resolveEnvelopeFormatOptions:
|
|
171
|
-
|
|
172
|
-
) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
612
|
+
resolveEnvelopeFormatOptions:
|
|
613
|
+
resolveEnvelopeFormatOptionsMock as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
|
173
614
|
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
174
|
-
finalizeInboundContext:
|
|
175
|
-
mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
|
615
|
+
finalizeInboundContext: mockFinalizeInboundContext as never,
|
|
176
616
|
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
177
|
-
withReplyDispatcher:
|
|
178
|
-
mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
|
|
617
|
+
withReplyDispatcher: mockWithReplyDispatcher as never,
|
|
179
618
|
},
|
|
180
619
|
commands: {
|
|
181
620
|
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
182
621
|
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
|
183
622
|
},
|
|
184
|
-
media: {
|
|
185
|
-
saveMediaBuffer:
|
|
186
|
-
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
|
187
|
-
},
|
|
188
623
|
pairing: {
|
|
189
624
|
readAllowFromStore: mockReadAllowFromStore,
|
|
190
625
|
upsertPairingRequest: mockUpsertPairingRequest,
|
|
191
626
|
buildPairingReply: mockBuildPairingReply,
|
|
192
627
|
},
|
|
628
|
+
media: {
|
|
629
|
+
saveMediaBuffer: mockSaveMediaBuffer,
|
|
630
|
+
},
|
|
193
631
|
},
|
|
194
632
|
media: {
|
|
195
633
|
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
@@ -229,7 +667,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
229
667
|
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
|
230
668
|
});
|
|
231
669
|
|
|
232
|
-
it("
|
|
670
|
+
it("blocks open DMs when a restrictive allowlist does not match", async () => {
|
|
233
671
|
const cfg: ClawdbotConfig = {
|
|
234
672
|
commands: { useAccessGroups: true },
|
|
235
673
|
channels: {
|
|
@@ -257,18 +695,8 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
257
695
|
|
|
258
696
|
await dispatchMessage({ cfg, event });
|
|
259
697
|
|
|
260
|
-
expect(mockResolveCommandAuthorizedFromAuthorizers).
|
|
261
|
-
|
|
262
|
-
authorizers: [{ configured: true, allowed: false }],
|
|
263
|
-
});
|
|
264
|
-
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
|
|
265
|
-
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
266
|
-
expect.objectContaining({
|
|
267
|
-
CommandAuthorized: false,
|
|
268
|
-
SenderId: "ou-attacker",
|
|
269
|
-
Surface: "feishu",
|
|
270
|
-
}),
|
|
271
|
-
);
|
|
698
|
+
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
|
699
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
272
700
|
});
|
|
273
701
|
|
|
274
702
|
it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
|
|
@@ -387,6 +815,77 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
387
815
|
);
|
|
388
816
|
});
|
|
389
817
|
|
|
818
|
+
it("uses message create_time as Timestamp instead of Date.now()", async () => {
|
|
819
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
820
|
+
|
|
821
|
+
const cfg: ClawdbotConfig = {
|
|
822
|
+
channels: {
|
|
823
|
+
feishu: {
|
|
824
|
+
dmPolicy: "open",
|
|
825
|
+
},
|
|
826
|
+
},
|
|
827
|
+
} as ClawdbotConfig;
|
|
828
|
+
|
|
829
|
+
const event: FeishuMessageEvent = {
|
|
830
|
+
sender: {
|
|
831
|
+
sender_id: {
|
|
832
|
+
open_id: "ou-attacker",
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
message: {
|
|
836
|
+
message_id: "msg-create-time",
|
|
837
|
+
chat_id: "oc-dm",
|
|
838
|
+
chat_type: "p2p",
|
|
839
|
+
message_type: "text",
|
|
840
|
+
content: JSON.stringify({ text: "delete this" }),
|
|
841
|
+
create_time: "1700000000000",
|
|
842
|
+
},
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
await dispatchMessage({ cfg, event });
|
|
846
|
+
|
|
847
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
848
|
+
expect.objectContaining({
|
|
849
|
+
Timestamp: 1700000000000,
|
|
850
|
+
}),
|
|
851
|
+
);
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
it("falls back to Date.now() when create_time is absent", async () => {
|
|
855
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
856
|
+
|
|
857
|
+
const cfg: ClawdbotConfig = {
|
|
858
|
+
channels: {
|
|
859
|
+
feishu: {
|
|
860
|
+
dmPolicy: "open",
|
|
861
|
+
},
|
|
862
|
+
},
|
|
863
|
+
} as ClawdbotConfig;
|
|
864
|
+
|
|
865
|
+
const event: FeishuMessageEvent = {
|
|
866
|
+
sender: {
|
|
867
|
+
sender_id: {
|
|
868
|
+
open_id: "ou-attacker",
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
message: {
|
|
872
|
+
message_id: "msg-no-create-time",
|
|
873
|
+
chat_id: "oc-dm",
|
|
874
|
+
chat_type: "p2p",
|
|
875
|
+
message_type: "text",
|
|
876
|
+
content: JSON.stringify({ text: "hello" }),
|
|
877
|
+
},
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const before = Date.now();
|
|
881
|
+
await dispatchMessage({ cfg, event });
|
|
882
|
+
const after = Date.now();
|
|
883
|
+
|
|
884
|
+
const call = mockFinalizeInboundContext.mock.calls[0]?.[0] as { Timestamp: number };
|
|
885
|
+
expect(call.Timestamp).toBeGreaterThanOrEqual(before);
|
|
886
|
+
expect(call.Timestamp).toBeLessThanOrEqual(after);
|
|
887
|
+
});
|
|
888
|
+
|
|
390
889
|
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
|
|
391
890
|
const cfg: ClawdbotConfig = {
|
|
392
891
|
channels: {
|
|
@@ -469,7 +968,14 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
469
968
|
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
470
969
|
expect.objectContaining({
|
|
471
970
|
to: "chat:oc-dm",
|
|
472
|
-
text: expect.stringContaining("Pairing code:
|
|
971
|
+
text: expect.stringContaining("Pairing code:"),
|
|
972
|
+
accountId: "default",
|
|
973
|
+
}),
|
|
974
|
+
);
|
|
975
|
+
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
|
976
|
+
expect.objectContaining({
|
|
977
|
+
to: "chat:oc-dm",
|
|
978
|
+
text: expect.stringContaining("ABCDEFGH"),
|
|
473
979
|
accountId: "default",
|
|
474
980
|
}),
|
|
475
981
|
);
|
|
@@ -728,13 +1234,26 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
728
1234
|
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
729
1235
|
});
|
|
730
1236
|
|
|
731
|
-
it("drops
|
|
1237
|
+
it("drops quoted group context from senders outside the group sender allowlist in allowlist mode", async () => {
|
|
1238
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1239
|
+
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
1240
|
+
messageId: "om_parent_blocked",
|
|
1241
|
+
chatId: "oc-group",
|
|
1242
|
+
senderId: "ou-blocked",
|
|
1243
|
+
senderType: "user",
|
|
1244
|
+
content: "blocked quoted content",
|
|
1245
|
+
contentType: "text",
|
|
1246
|
+
});
|
|
1247
|
+
|
|
732
1248
|
const cfg: ClawdbotConfig = {
|
|
733
1249
|
channels: {
|
|
734
1250
|
feishu: {
|
|
1251
|
+
groupPolicy: "open",
|
|
1252
|
+
groupSenderAllowFrom: ["ou-allowed"],
|
|
1253
|
+
contextVisibility: "allowlist",
|
|
735
1254
|
groups: {
|
|
736
|
-
"oc-
|
|
737
|
-
|
|
1255
|
+
"oc-group": {
|
|
1256
|
+
requireMention: false,
|
|
738
1257
|
},
|
|
739
1258
|
},
|
|
740
1259
|
},
|
|
@@ -743,11 +1262,14 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
743
1262
|
|
|
744
1263
|
const event: FeishuMessageEvent = {
|
|
745
1264
|
sender: {
|
|
746
|
-
sender_id: {
|
|
1265
|
+
sender_id: {
|
|
1266
|
+
open_id: "ou-allowed",
|
|
1267
|
+
},
|
|
747
1268
|
},
|
|
748
1269
|
message: {
|
|
749
|
-
message_id: "msg-
|
|
750
|
-
|
|
1270
|
+
message_id: "msg-group-quoted-filter",
|
|
1271
|
+
parent_id: "om_parent_blocked",
|
|
1272
|
+
chat_id: "oc-group",
|
|
751
1273
|
chat_type: "group",
|
|
752
1274
|
message_type: "text",
|
|
753
1275
|
content: JSON.stringify({ text: "hello" }),
|
|
@@ -756,17 +1278,35 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
756
1278
|
|
|
757
1279
|
await dispatchMessage({ cfg, event });
|
|
758
1280
|
|
|
759
|
-
expect(mockFinalizeInboundContext).
|
|
760
|
-
|
|
1281
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
1282
|
+
expect.objectContaining({
|
|
1283
|
+
ReplyToId: "om_parent_blocked",
|
|
1284
|
+
ReplyToBody: undefined,
|
|
1285
|
+
}),
|
|
1286
|
+
);
|
|
761
1287
|
});
|
|
762
1288
|
|
|
763
|
-
it("
|
|
1289
|
+
it("keeps quoted group context from non-allowlisted senders in default all mode", async () => {
|
|
764
1290
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1291
|
+
mockGetMessageFeishu.mockResolvedValueOnce({
|
|
1292
|
+
messageId: "om_parent_visible",
|
|
1293
|
+
chatId: "oc-group",
|
|
1294
|
+
senderId: "ou-blocked",
|
|
1295
|
+
senderType: "user",
|
|
1296
|
+
content: "visible quoted content",
|
|
1297
|
+
contentType: "text",
|
|
1298
|
+
});
|
|
765
1299
|
|
|
766
1300
|
const cfg: ClawdbotConfig = {
|
|
767
1301
|
channels: {
|
|
768
1302
|
feishu: {
|
|
769
|
-
|
|
1303
|
+
groupPolicy: "open",
|
|
1304
|
+
groupSenderAllowFrom: ["ou-allowed"],
|
|
1305
|
+
groups: {
|
|
1306
|
+
"oc-group": {
|
|
1307
|
+
requireMention: false,
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
770
1310
|
},
|
|
771
1311
|
},
|
|
772
1312
|
} as ClawdbotConfig;
|
|
@@ -774,16 +1314,367 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
774
1314
|
const event: FeishuMessageEvent = {
|
|
775
1315
|
sender: {
|
|
776
1316
|
sender_id: {
|
|
777
|
-
open_id: "ou-
|
|
1317
|
+
open_id: "ou-allowed",
|
|
778
1318
|
},
|
|
779
1319
|
},
|
|
780
1320
|
message: {
|
|
781
|
-
message_id: "msg-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1321
|
+
message_id: "msg-group-quoted-visible",
|
|
1322
|
+
parent_id: "om_parent_visible",
|
|
1323
|
+
chat_id: "oc-group",
|
|
1324
|
+
chat_type: "group",
|
|
1325
|
+
message_type: "text",
|
|
1326
|
+
content: JSON.stringify({ text: "hello" }),
|
|
1327
|
+
},
|
|
1328
|
+
};
|
|
1329
|
+
|
|
1330
|
+
await dispatchMessage({ cfg, event });
|
|
1331
|
+
|
|
1332
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
1333
|
+
expect.objectContaining({
|
|
1334
|
+
ReplyToId: "om_parent_visible",
|
|
1335
|
+
ReplyToBody: "visible quoted content",
|
|
1336
|
+
}),
|
|
1337
|
+
);
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
it("dispatches group image message when groupPolicy is open (requireMention defaults to false)", async () => {
|
|
1341
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1342
|
+
|
|
1343
|
+
const cfg: ClawdbotConfig = {
|
|
1344
|
+
channels: {
|
|
1345
|
+
feishu: {
|
|
1346
|
+
groupPolicy: "open",
|
|
1347
|
+
// requireMention is NOT set — should default to false for open policy
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
} as ClawdbotConfig;
|
|
1351
|
+
|
|
1352
|
+
const event: FeishuMessageEvent = {
|
|
1353
|
+
sender: {
|
|
1354
|
+
sender_id: { open_id: "ou-sender" },
|
|
1355
|
+
},
|
|
1356
|
+
message: {
|
|
1357
|
+
message_id: "msg-group-image-open",
|
|
1358
|
+
chat_id: "oc-group-open",
|
|
1359
|
+
chat_type: "group",
|
|
1360
|
+
message_type: "image",
|
|
1361
|
+
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
await dispatchMessage({ cfg, event });
|
|
1366
|
+
|
|
1367
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
it("drops group image message when groupPolicy is open but requireMention is explicitly true", async () => {
|
|
1371
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1372
|
+
|
|
1373
|
+
const cfg: ClawdbotConfig = {
|
|
1374
|
+
channels: {
|
|
1375
|
+
feishu: {
|
|
1376
|
+
groupPolicy: "open",
|
|
1377
|
+
requireMention: true, // explicit override — user opts into mention-required even for open
|
|
1378
|
+
},
|
|
1379
|
+
},
|
|
1380
|
+
} as ClawdbotConfig;
|
|
1381
|
+
|
|
1382
|
+
const event: FeishuMessageEvent = {
|
|
1383
|
+
sender: {
|
|
1384
|
+
sender_id: { open_id: "ou-sender" },
|
|
1385
|
+
},
|
|
1386
|
+
message: {
|
|
1387
|
+
message_id: "msg-group-image-open-explicit-mention",
|
|
1388
|
+
chat_id: "oc-group-open",
|
|
1389
|
+
chat_type: "group",
|
|
1390
|
+
message_type: "image",
|
|
1391
|
+
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
1392
|
+
},
|
|
1393
|
+
};
|
|
1394
|
+
|
|
1395
|
+
await dispatchMessage({ cfg, event });
|
|
1396
|
+
|
|
1397
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
1398
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("drops group image message when groupPolicy is allowlist and requireMention is not set (defaults to true)", async () => {
|
|
1402
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1403
|
+
|
|
1404
|
+
const cfg: ClawdbotConfig = {
|
|
1405
|
+
channels: {
|
|
1406
|
+
feishu: {
|
|
1407
|
+
groupPolicy: "allowlist",
|
|
1408
|
+
// requireMention not set — for non-open policy defaults to true
|
|
1409
|
+
groups: {
|
|
1410
|
+
"oc-allowlist-group": {
|
|
1411
|
+
allow: true,
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
},
|
|
1415
|
+
},
|
|
1416
|
+
} as ClawdbotConfig;
|
|
1417
|
+
|
|
1418
|
+
const event: FeishuMessageEvent = {
|
|
1419
|
+
sender: {
|
|
1420
|
+
sender_id: { open_id: "ou-sender" },
|
|
1421
|
+
},
|
|
1422
|
+
message: {
|
|
1423
|
+
message_id: "msg-group-image-allowlist",
|
|
1424
|
+
chat_id: "oc-allowlist-group",
|
|
1425
|
+
chat_type: "group",
|
|
1426
|
+
message_type: "image",
|
|
1427
|
+
content: JSON.stringify({ image_key: "img_v3_test" }),
|
|
1428
|
+
},
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
await dispatchMessage({ cfg, event });
|
|
1432
|
+
|
|
1433
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
1434
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it("admits group when chat_id is explicitly configured under groups, even with empty groupAllowFrom (#67687)", async () => {
|
|
1438
|
+
// Regression for #67687: a group that only sets `groups.<chat_id>.requireMention=false`
|
|
1439
|
+
// (and leaves `groupAllowFrom` empty) should still be admitted under the schema-default
|
|
1440
|
+
// `groupPolicy="allowlist"`. The group's explicit presence in `channels.feishu.groups`
|
|
1441
|
+
// is the operator's allowlist signal, and the per-group `requireMention` override should
|
|
1442
|
+
// then control mention gating for inbound text events.
|
|
1443
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1444
|
+
|
|
1445
|
+
const cfg: ClawdbotConfig = {
|
|
1446
|
+
channels: {
|
|
1447
|
+
feishu: {
|
|
1448
|
+
groupPolicy: "allowlist",
|
|
1449
|
+
// groupAllowFrom intentionally omitted -> empty []
|
|
1450
|
+
groups: {
|
|
1451
|
+
"oc-explicit-group": {
|
|
1452
|
+
requireMention: false,
|
|
1453
|
+
},
|
|
1454
|
+
},
|
|
1455
|
+
},
|
|
1456
|
+
},
|
|
1457
|
+
} as ClawdbotConfig;
|
|
1458
|
+
|
|
1459
|
+
const event: FeishuMessageEvent = {
|
|
1460
|
+
sender: {
|
|
1461
|
+
sender_id: { open_id: "ou-sender" },
|
|
1462
|
+
},
|
|
1463
|
+
message: {
|
|
1464
|
+
message_id: "msg-explicit-group-67687",
|
|
1465
|
+
chat_id: "oc-explicit-group",
|
|
1466
|
+
chat_type: "group",
|
|
1467
|
+
message_type: "text",
|
|
1468
|
+
content: JSON.stringify({ text: "hello bot" }),
|
|
1469
|
+
},
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
await dispatchMessage({ cfg, event });
|
|
1473
|
+
|
|
1474
|
+
// Group must be admitted: the inbound finalize/dispatch path runs.
|
|
1475
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalled();
|
|
1476
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalled();
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it("does not let explicit group config override disabled group policy", async () => {
|
|
1480
|
+
const cfg: ClawdbotConfig = {
|
|
1481
|
+
channels: {
|
|
1482
|
+
feishu: {
|
|
1483
|
+
groupPolicy: "disabled",
|
|
1484
|
+
groups: {
|
|
1485
|
+
"oc-disabled-policy-group": {
|
|
1486
|
+
requireMention: false,
|
|
1487
|
+
},
|
|
1488
|
+
},
|
|
1489
|
+
},
|
|
1490
|
+
},
|
|
1491
|
+
} as ClawdbotConfig;
|
|
1492
|
+
|
|
1493
|
+
const event: FeishuMessageEvent = {
|
|
1494
|
+
sender: {
|
|
1495
|
+
sender_id: { open_id: "ou-sender" },
|
|
1496
|
+
},
|
|
1497
|
+
message: {
|
|
1498
|
+
message_id: "msg-disabled-policy-group",
|
|
1499
|
+
chat_id: "oc-disabled-policy-group",
|
|
1500
|
+
chat_type: "group",
|
|
1501
|
+
message_type: "text",
|
|
1502
|
+
content: JSON.stringify({ text: "hello bot" }),
|
|
1503
|
+
},
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
await dispatchMessage({ cfg, event });
|
|
1507
|
+
|
|
1508
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
1509
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
it("does not treat wildcard group defaults as allowlist admission", async () => {
|
|
1513
|
+
const cfg: ClawdbotConfig = {
|
|
1514
|
+
channels: {
|
|
1515
|
+
feishu: {
|
|
1516
|
+
groupPolicy: "allowlist",
|
|
1517
|
+
groups: {
|
|
1518
|
+
"*": {
|
|
1519
|
+
requireMention: false,
|
|
1520
|
+
},
|
|
1521
|
+
},
|
|
1522
|
+
},
|
|
1523
|
+
},
|
|
1524
|
+
} as ClawdbotConfig;
|
|
1525
|
+
|
|
1526
|
+
const event: FeishuMessageEvent = {
|
|
1527
|
+
sender: {
|
|
1528
|
+
sender_id: { open_id: "ou-sender" },
|
|
1529
|
+
},
|
|
1530
|
+
message: {
|
|
1531
|
+
message_id: "msg-wildcard-group-default",
|
|
1532
|
+
chat_id: "oc-wildcard-only",
|
|
1533
|
+
chat_type: "group",
|
|
1534
|
+
message_type: "text",
|
|
1535
|
+
content: JSON.stringify({ text: "hello bot" }),
|
|
1536
|
+
},
|
|
1537
|
+
};
|
|
1538
|
+
|
|
1539
|
+
await dispatchMessage({ cfg, event });
|
|
1540
|
+
|
|
1541
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
1542
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it("drops message when groupConfig.enabled is false", async () => {
|
|
1546
|
+
const cfg: ClawdbotConfig = {
|
|
1547
|
+
channels: {
|
|
1548
|
+
feishu: {
|
|
1549
|
+
groups: {
|
|
1550
|
+
"oc-disabled-group": {
|
|
1551
|
+
enabled: false,
|
|
1552
|
+
},
|
|
1553
|
+
},
|
|
1554
|
+
},
|
|
1555
|
+
},
|
|
1556
|
+
} as ClawdbotConfig;
|
|
1557
|
+
|
|
1558
|
+
const event: FeishuMessageEvent = {
|
|
1559
|
+
sender: {
|
|
1560
|
+
sender_id: { open_id: "ou-sender" },
|
|
1561
|
+
},
|
|
1562
|
+
message: {
|
|
1563
|
+
message_id: "msg-disabled-group",
|
|
1564
|
+
chat_id: "oc-disabled-group",
|
|
1565
|
+
chat_type: "group",
|
|
1566
|
+
message_type: "text",
|
|
1567
|
+
content: JSON.stringify({ text: "hello" }),
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
await dispatchMessage({ cfg, event });
|
|
1572
|
+
|
|
1573
|
+
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
|
1574
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
it("transcribes inbound audio before building the agent turn", async () => {
|
|
1578
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1579
|
+
mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
|
|
1580
|
+
buffer: Buffer.from("voice"),
|
|
1581
|
+
contentType: "audio/ogg",
|
|
1582
|
+
fileName: "voice.ogg",
|
|
1583
|
+
});
|
|
1584
|
+
mockSaveMediaBuffer.mockResolvedValueOnce({
|
|
1585
|
+
id: "inbound-voice.ogg",
|
|
1586
|
+
path: "/tmp/inbound-voice.ogg",
|
|
1587
|
+
size: Buffer.byteLength("voice"),
|
|
1588
|
+
contentType: "audio/ogg",
|
|
1589
|
+
});
|
|
1590
|
+
mockTranscribeFirstAudio.mockResolvedValueOnce("voice transcript");
|
|
1591
|
+
|
|
1592
|
+
const cfg: ClawdbotConfig = {
|
|
1593
|
+
channels: {
|
|
1594
|
+
feishu: {
|
|
1595
|
+
dmPolicy: "open",
|
|
1596
|
+
},
|
|
1597
|
+
},
|
|
1598
|
+
} as ClawdbotConfig;
|
|
1599
|
+
|
|
1600
|
+
const event: FeishuMessageEvent = {
|
|
1601
|
+
sender: {
|
|
1602
|
+
sender_id: {
|
|
1603
|
+
open_id: "ou-voice",
|
|
1604
|
+
},
|
|
1605
|
+
},
|
|
1606
|
+
message: {
|
|
1607
|
+
message_id: "msg-audio-inbound",
|
|
1608
|
+
chat_id: "oc-dm",
|
|
1609
|
+
chat_type: "p2p",
|
|
1610
|
+
message_type: "audio",
|
|
1611
|
+
content: JSON.stringify({
|
|
1612
|
+
file_key: "file_audio_payload",
|
|
1613
|
+
duration: 1200,
|
|
1614
|
+
}),
|
|
1615
|
+
},
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
await dispatchMessage({ cfg, event });
|
|
1619
|
+
|
|
1620
|
+
expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
|
|
1621
|
+
expect.objectContaining({
|
|
1622
|
+
messageId: "msg-audio-inbound",
|
|
1623
|
+
fileKey: "file_audio_payload",
|
|
1624
|
+
type: "file",
|
|
1625
|
+
}),
|
|
1626
|
+
);
|
|
1627
|
+
expect(mockTranscribeFirstAudio).toHaveBeenCalledWith({
|
|
1628
|
+
ctx: {
|
|
1629
|
+
MediaPaths: ["/tmp/inbound-voice.ogg"],
|
|
1630
|
+
MediaTypes: ["audio/ogg"],
|
|
1631
|
+
ChatType: "direct",
|
|
1632
|
+
},
|
|
1633
|
+
cfg: expect.objectContaining({
|
|
1634
|
+
channels: expect.objectContaining({
|
|
1635
|
+
feishu: expect.objectContaining({ dmPolicy: "open" }),
|
|
1636
|
+
}),
|
|
1637
|
+
}),
|
|
1638
|
+
});
|
|
1639
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
1640
|
+
expect.objectContaining({
|
|
1641
|
+
BodyForAgent: "[message_id: msg-audio-inbound]\nou-voice: voice transcript",
|
|
1642
|
+
RawBody: "voice transcript",
|
|
1643
|
+
CommandBody: "voice transcript",
|
|
1644
|
+
Transcript: "voice transcript",
|
|
1645
|
+
MediaPaths: ["/tmp/inbound-voice.ogg"],
|
|
1646
|
+
MediaTypes: ["audio/ogg"],
|
|
1647
|
+
MediaTranscribedIndexes: [0],
|
|
1648
|
+
}),
|
|
1649
|
+
);
|
|
1650
|
+
const finalized = mockFinalizeInboundContext.mock.calls[0]?.[0];
|
|
1651
|
+
expect(finalized.BodyForAgent).not.toContain("file_audio_payload");
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
|
|
1655
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1656
|
+
|
|
1657
|
+
const cfg: ClawdbotConfig = {
|
|
1658
|
+
channels: {
|
|
1659
|
+
feishu: {
|
|
1660
|
+
dmPolicy: "open",
|
|
1661
|
+
},
|
|
1662
|
+
},
|
|
1663
|
+
} as ClawdbotConfig;
|
|
1664
|
+
|
|
1665
|
+
const event: FeishuMessageEvent = {
|
|
1666
|
+
sender: {
|
|
1667
|
+
sender_id: {
|
|
1668
|
+
open_id: "ou-sender",
|
|
1669
|
+
},
|
|
1670
|
+
},
|
|
1671
|
+
message: {
|
|
1672
|
+
message_id: "msg-video-inbound",
|
|
1673
|
+
chat_id: "oc-dm",
|
|
1674
|
+
chat_type: "p2p",
|
|
1675
|
+
message_type: "video",
|
|
1676
|
+
content: JSON.stringify({
|
|
1677
|
+
file_key: "file_video_payload",
|
|
787
1678
|
image_key: "img_thumb_payload",
|
|
788
1679
|
file_name: "clip.mp4",
|
|
789
1680
|
}),
|
|
@@ -856,6 +1747,51 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
856
1747
|
);
|
|
857
1748
|
});
|
|
858
1749
|
|
|
1750
|
+
it("falls back to the message payload filename when download metadata omits it", async () => {
|
|
1751
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1752
|
+
mockDownloadMessageResourceFeishu.mockResolvedValueOnce({
|
|
1753
|
+
buffer: Buffer.from("video"),
|
|
1754
|
+
contentType: "video/mp4",
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
const cfg: ClawdbotConfig = {
|
|
1758
|
+
channels: {
|
|
1759
|
+
feishu: {
|
|
1760
|
+
dmPolicy: "open",
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
} as ClawdbotConfig;
|
|
1764
|
+
|
|
1765
|
+
const event: FeishuMessageEvent = {
|
|
1766
|
+
sender: {
|
|
1767
|
+
sender_id: {
|
|
1768
|
+
open_id: "ou-sender",
|
|
1769
|
+
},
|
|
1770
|
+
},
|
|
1771
|
+
message: {
|
|
1772
|
+
message_id: "msg-media-payload-name",
|
|
1773
|
+
chat_id: "oc-dm",
|
|
1774
|
+
chat_type: "p2p",
|
|
1775
|
+
message_type: "media",
|
|
1776
|
+
content: JSON.stringify({
|
|
1777
|
+
file_key: "file_media_payload",
|
|
1778
|
+
image_key: "img_media_thumb",
|
|
1779
|
+
file_name: "payload-name.mp4",
|
|
1780
|
+
}),
|
|
1781
|
+
},
|
|
1782
|
+
};
|
|
1783
|
+
|
|
1784
|
+
await dispatchMessage({ cfg, event });
|
|
1785
|
+
|
|
1786
|
+
expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
|
|
1787
|
+
expect.any(Buffer),
|
|
1788
|
+
"video/mp4",
|
|
1789
|
+
"inbound",
|
|
1790
|
+
expect.any(Number),
|
|
1791
|
+
"payload-name.mp4",
|
|
1792
|
+
);
|
|
1793
|
+
});
|
|
1794
|
+
|
|
859
1795
|
it("downloads embedded media tags from post messages as files", async () => {
|
|
860
1796
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
861
1797
|
|
|
@@ -984,7 +1920,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
984
1920
|
get: mockGetMerged,
|
|
985
1921
|
},
|
|
986
1922
|
},
|
|
987
|
-
});
|
|
1923
|
+
} as unknown as PluginRuntime);
|
|
988
1924
|
|
|
989
1925
|
const cfg: ClawdbotConfig = {
|
|
990
1926
|
channels: {
|
|
@@ -1213,11 +2149,49 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1213
2149
|
const event: FeishuMessageEvent = {
|
|
1214
2150
|
sender: { sender_id: { open_id: "ou-scope-user" } },
|
|
1215
2151
|
message: {
|
|
1216
|
-
message_id: "msg-scope-group-sender",
|
|
2152
|
+
message_id: "msg-scope-group-sender",
|
|
2153
|
+
chat_id: "oc-group",
|
|
2154
|
+
chat_type: "group",
|
|
2155
|
+
message_type: "text",
|
|
2156
|
+
content: JSON.stringify({ text: "group sender scope" }),
|
|
2157
|
+
},
|
|
2158
|
+
};
|
|
2159
|
+
|
|
2160
|
+
await dispatchMessage({ cfg, event });
|
|
2161
|
+
|
|
2162
|
+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
2163
|
+
expect.objectContaining({
|
|
2164
|
+
peer: { kind: "group", id: "oc-group:sender:ou-scope-user" },
|
|
2165
|
+
parentPeer: null,
|
|
2166
|
+
}),
|
|
2167
|
+
);
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it("routes topic sessions and parentPeer when groupSessionScope=group_topic_sender", async () => {
|
|
2171
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2172
|
+
|
|
2173
|
+
const cfg: ClawdbotConfig = {
|
|
2174
|
+
channels: {
|
|
2175
|
+
feishu: {
|
|
2176
|
+
groups: {
|
|
2177
|
+
"oc-group": {
|
|
2178
|
+
requireMention: false,
|
|
2179
|
+
groupSessionScope: "group_topic_sender",
|
|
2180
|
+
},
|
|
2181
|
+
},
|
|
2182
|
+
},
|
|
2183
|
+
},
|
|
2184
|
+
} as ClawdbotConfig;
|
|
2185
|
+
|
|
2186
|
+
const event: FeishuMessageEvent = {
|
|
2187
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
2188
|
+
message: {
|
|
2189
|
+
message_id: "msg-scope-topic-sender",
|
|
1217
2190
|
chat_id: "oc-group",
|
|
1218
2191
|
chat_type: "group",
|
|
2192
|
+
root_id: "om_root_topic",
|
|
1219
2193
|
message_type: "text",
|
|
1220
|
-
content: JSON.stringify({ text: "
|
|
2194
|
+
content: JSON.stringify({ text: "topic sender scope" }),
|
|
1221
2195
|
},
|
|
1222
2196
|
};
|
|
1223
2197
|
|
|
@@ -1225,13 +2199,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1225
2199
|
|
|
1226
2200
|
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
|
1227
2201
|
expect.objectContaining({
|
|
1228
|
-
peer: { kind: "group", id: "oc-group:sender:ou-
|
|
1229
|
-
parentPeer:
|
|
2202
|
+
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
|
|
2203
|
+
parentPeer: { kind: "group", id: "oc-group" },
|
|
1230
2204
|
}),
|
|
1231
2205
|
);
|
|
1232
2206
|
});
|
|
1233
2207
|
|
|
1234
|
-
it("
|
|
2208
|
+
it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
|
|
1235
2209
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1236
2210
|
|
|
1237
2211
|
const cfg: ClawdbotConfig = {
|
|
@@ -1250,10 +2224,11 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1250
2224
|
const event: FeishuMessageEvent = {
|
|
1251
2225
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1252
2226
|
message: {
|
|
1253
|
-
message_id: "msg-scope-topic-
|
|
2227
|
+
message_id: "msg-scope-topic-thread-id",
|
|
1254
2228
|
chat_id: "oc-group",
|
|
1255
2229
|
chat_type: "group",
|
|
1256
2230
|
root_id: "om_root_topic",
|
|
2231
|
+
thread_id: "omt_topic_1",
|
|
1257
2232
|
message_type: "text",
|
|
1258
2233
|
content: JSON.stringify({ text: "topic sender scope" }),
|
|
1259
2234
|
},
|
|
@@ -1269,7 +2244,7 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1269
2244
|
);
|
|
1270
2245
|
});
|
|
1271
2246
|
|
|
1272
|
-
it("
|
|
2247
|
+
it("uses thread_id as the canonical topic key in Feishu topic groups", async () => {
|
|
1273
2248
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
1274
2249
|
|
|
1275
2250
|
const cfg: ClawdbotConfig = {
|
|
@@ -1278,31 +2253,51 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1278
2253
|
groups: {
|
|
1279
2254
|
"oc-group": {
|
|
1280
2255
|
requireMention: false,
|
|
1281
|
-
groupSessionScope: "
|
|
2256
|
+
groupSessionScope: "group_topic",
|
|
1282
2257
|
},
|
|
1283
2258
|
},
|
|
1284
2259
|
},
|
|
1285
2260
|
},
|
|
1286
2261
|
} as ClawdbotConfig;
|
|
1287
2262
|
|
|
1288
|
-
const
|
|
2263
|
+
const topicStarter: FeishuMessageEvent = {
|
|
1289
2264
|
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1290
2265
|
message: {
|
|
1291
|
-
message_id: "
|
|
2266
|
+
message_id: "om_topic_starter_message",
|
|
1292
2267
|
chat_id: "oc-group",
|
|
1293
|
-
chat_type: "
|
|
1294
|
-
root_id: "
|
|
2268
|
+
chat_type: "topic_group",
|
|
2269
|
+
root_id: "omt_topic_1",
|
|
2270
|
+
message_type: "text",
|
|
2271
|
+
content: JSON.stringify({ text: "topic starter" }),
|
|
2272
|
+
},
|
|
2273
|
+
};
|
|
2274
|
+
const topicReply: FeishuMessageEvent = {
|
|
2275
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
2276
|
+
message: {
|
|
2277
|
+
message_id: "om_topic_reply_message",
|
|
2278
|
+
chat_id: "oc-group",
|
|
2279
|
+
chat_type: "topic_group",
|
|
2280
|
+
root_id: "om_topic_starter_message",
|
|
1295
2281
|
thread_id: "omt_topic_1",
|
|
1296
2282
|
message_type: "text",
|
|
1297
|
-
content: JSON.stringify({ text: "topic
|
|
2283
|
+
content: JSON.stringify({ text: "topic reply" }),
|
|
1298
2284
|
},
|
|
1299
2285
|
};
|
|
1300
2286
|
|
|
1301
|
-
await dispatchMessage({ cfg, event });
|
|
2287
|
+
await dispatchMessage({ cfg, event: topicStarter });
|
|
2288
|
+
await dispatchMessage({ cfg, event: topicReply });
|
|
1302
2289
|
|
|
1303
|
-
expect(mockResolveAgentRoute).
|
|
2290
|
+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
2291
|
+
1,
|
|
1304
2292
|
expect.objectContaining({
|
|
1305
|
-
peer: { kind: "group", id: "oc-group:topic:
|
|
2293
|
+
peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
|
|
2294
|
+
parentPeer: { kind: "group", id: "oc-group" },
|
|
2295
|
+
}),
|
|
2296
|
+
);
|
|
2297
|
+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
|
2298
|
+
2,
|
|
2299
|
+
expect.objectContaining({
|
|
2300
|
+
peer: { kind: "group", id: "oc-group:topic:omt_topic_1" },
|
|
1306
2301
|
parentPeer: { kind: "group", id: "oc-group" },
|
|
1307
2302
|
}),
|
|
1308
2303
|
);
|
|
@@ -1709,280 +2704,241 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
1709
2704
|
);
|
|
1710
2705
|
});
|
|
1711
2706
|
|
|
1712
|
-
it("
|
|
2707
|
+
it("bootstraps topic thread context only for a new thread session", async () => {
|
|
1713
2708
|
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2709
|
+
mockGetMessageFeishu.mockResolvedValue({
|
|
2710
|
+
messageId: "om_topic_root",
|
|
2711
|
+
chatId: "oc-group",
|
|
2712
|
+
content: "root starter",
|
|
2713
|
+
contentType: "text",
|
|
2714
|
+
threadId: "omt_topic_1",
|
|
2715
|
+
});
|
|
2716
|
+
mockListFeishuThreadMessages.mockResolvedValue([
|
|
2717
|
+
{
|
|
2718
|
+
messageId: "om_bot_reply",
|
|
2719
|
+
senderId: "app_1",
|
|
2720
|
+
senderType: "app",
|
|
2721
|
+
content: "assistant reply",
|
|
2722
|
+
contentType: "text",
|
|
2723
|
+
createTime: 1710000000000,
|
|
2724
|
+
},
|
|
2725
|
+
{
|
|
2726
|
+
messageId: "om_follow_up",
|
|
2727
|
+
senderId: "ou-topic-user",
|
|
2728
|
+
senderType: "user",
|
|
2729
|
+
content: "follow-up question",
|
|
2730
|
+
contentType: "text",
|
|
2731
|
+
createTime: 1710000001000,
|
|
2732
|
+
},
|
|
2733
|
+
]);
|
|
1714
2734
|
|
|
1715
2735
|
const cfg: ClawdbotConfig = {
|
|
1716
2736
|
channels: {
|
|
1717
2737
|
feishu: {
|
|
1718
|
-
|
|
2738
|
+
groups: {
|
|
2739
|
+
"oc-group": {
|
|
2740
|
+
requireMention: false,
|
|
2741
|
+
groupSessionScope: "group_topic",
|
|
2742
|
+
},
|
|
2743
|
+
},
|
|
1719
2744
|
},
|
|
1720
2745
|
},
|
|
1721
2746
|
} as ClawdbotConfig;
|
|
1722
2747
|
|
|
1723
2748
|
const event: FeishuMessageEvent = {
|
|
1724
|
-
sender: {
|
|
1725
|
-
sender_id: {
|
|
1726
|
-
open_id: "ou-image-dedup",
|
|
1727
|
-
},
|
|
1728
|
-
},
|
|
2749
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1729
2750
|
message: {
|
|
1730
|
-
message_id: "
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
}),
|
|
2751
|
+
message_id: "om_topic_followup_existing_session",
|
|
2752
|
+
root_id: "om_topic_root",
|
|
2753
|
+
chat_id: "oc-group",
|
|
2754
|
+
chat_type: "group",
|
|
2755
|
+
message_type: "text",
|
|
2756
|
+
content: JSON.stringify({ text: "current turn" }),
|
|
1737
2757
|
},
|
|
1738
2758
|
};
|
|
1739
2759
|
|
|
1740
|
-
await
|
|
1741
|
-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
1742
|
-
});
|
|
1743
|
-
});
|
|
1744
|
-
|
|
1745
|
-
describe("toMessageResourceType", () => {
|
|
1746
|
-
it("maps image to image", () => {
|
|
1747
|
-
expect(toMessageResourceType("image")).toBe("image");
|
|
1748
|
-
});
|
|
1749
|
-
|
|
1750
|
-
it("maps audio to file", () => {
|
|
1751
|
-
expect(toMessageResourceType("audio")).toBe("file");
|
|
1752
|
-
});
|
|
1753
|
-
|
|
1754
|
-
it("maps video/file/sticker to file", () => {
|
|
1755
|
-
expect(toMessageResourceType("video")).toBe("file");
|
|
1756
|
-
expect(toMessageResourceType("file")).toBe("file");
|
|
1757
|
-
expect(toMessageResourceType("sticker")).toBe("file");
|
|
1758
|
-
});
|
|
1759
|
-
});
|
|
1760
|
-
|
|
1761
|
-
describe("resolveBroadcastAgents", () => {
|
|
1762
|
-
it("returns agent list when broadcast config has the peerId", () => {
|
|
1763
|
-
const cfg = { broadcast: { oc_group123: ["susan", "main"] } } as unknown as ClawdbotConfig;
|
|
1764
|
-
expect(resolveBroadcastAgents(cfg, "oc_group123")).toEqual(["susan", "main"]);
|
|
1765
|
-
});
|
|
1766
|
-
|
|
1767
|
-
it("returns null when no broadcast config", () => {
|
|
1768
|
-
const cfg = {} as ClawdbotConfig;
|
|
1769
|
-
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1770
|
-
});
|
|
1771
|
-
|
|
1772
|
-
it("returns null when peerId not in broadcast", () => {
|
|
1773
|
-
const cfg = { broadcast: { oc_other: ["susan"] } } as unknown as ClawdbotConfig;
|
|
1774
|
-
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1775
|
-
});
|
|
1776
|
-
|
|
1777
|
-
it("returns null when agent list is empty", () => {
|
|
1778
|
-
const cfg = { broadcast: { oc_group123: [] } } as unknown as ClawdbotConfig;
|
|
1779
|
-
expect(resolveBroadcastAgents(cfg, "oc_group123")).toBeNull();
|
|
1780
|
-
});
|
|
1781
|
-
});
|
|
2760
|
+
await dispatchMessage({ cfg, event });
|
|
1782
2761
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2762
|
+
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
|
|
2763
|
+
storePath: "/tmp/feishu-sessions.json",
|
|
2764
|
+
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
|
2765
|
+
});
|
|
2766
|
+
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
|
|
2767
|
+
expect.objectContaining({
|
|
2768
|
+
rootMessageId: "om_topic_root",
|
|
2769
|
+
}),
|
|
1787
2770
|
);
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
"susan",
|
|
1796
|
-
),
|
|
1797
|
-
).toBe("agent:susan:feishu:group:oc_group123:sender:ou_user1");
|
|
1798
|
-
});
|
|
1799
|
-
|
|
1800
|
-
it("returns base key unchanged when prefix does not match", () => {
|
|
1801
|
-
expect(buildBroadcastSessionKey("custom:key:format", "main", "susan")).toBe(
|
|
1802
|
-
"custom:key:format",
|
|
2771
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
2772
|
+
expect.objectContaining({
|
|
2773
|
+
ThreadStarterBody: "root starter",
|
|
2774
|
+
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
|
|
2775
|
+
ThreadLabel: "Feishu thread in oc-group",
|
|
2776
|
+
MessageThreadId: "om_topic_root",
|
|
2777
|
+
}),
|
|
1803
2778
|
);
|
|
1804
2779
|
});
|
|
1805
|
-
});
|
|
1806
|
-
|
|
1807
|
-
describe("broadcast dispatch", () => {
|
|
1808
|
-
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
|
|
1809
|
-
const mockDispatchReplyFromConfig = vi
|
|
1810
|
-
.fn()
|
|
1811
|
-
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
|
|
1812
|
-
const mockWithReplyDispatcher = vi.fn(
|
|
1813
|
-
async ({
|
|
1814
|
-
dispatcher,
|
|
1815
|
-
run,
|
|
1816
|
-
onSettled,
|
|
1817
|
-
}: Parameters<PluginRuntime["channel"]["reply"]["withReplyDispatcher"]>[0]) => {
|
|
1818
|
-
try {
|
|
1819
|
-
return await run();
|
|
1820
|
-
} finally {
|
|
1821
|
-
dispatcher.markComplete();
|
|
1822
|
-
try {
|
|
1823
|
-
await dispatcher.waitForIdle();
|
|
1824
|
-
} finally {
|
|
1825
|
-
await onSettled?.();
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
},
|
|
1829
|
-
);
|
|
1830
|
-
const mockShouldComputeCommandAuthorized = vi.fn(() => false);
|
|
1831
|
-
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
|
1832
|
-
path: "/tmp/inbound-clip.mp4",
|
|
1833
|
-
contentType: "video/mp4",
|
|
1834
|
-
});
|
|
1835
2780
|
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
agentId: "main",
|
|
1840
|
-
channel: "feishu",
|
|
1841
|
-
accountId: "default",
|
|
1842
|
-
sessionKey: "agent:main:feishu:group:oc-broadcast-group",
|
|
1843
|
-
mainSessionKey: "agent:main:main",
|
|
1844
|
-
matchedBy: "default",
|
|
1845
|
-
});
|
|
1846
|
-
mockCreateFeishuClient.mockReturnValue({
|
|
1847
|
-
contact: {
|
|
1848
|
-
user: {
|
|
1849
|
-
get: vi.fn().mockResolvedValue({ data: { user: { name: "Sender" } } }),
|
|
1850
|
-
},
|
|
1851
|
-
},
|
|
1852
|
-
});
|
|
1853
|
-
setFeishuRuntime({
|
|
1854
|
-
system: {
|
|
1855
|
-
enqueueSystemEvent: vi.fn(),
|
|
1856
|
-
},
|
|
1857
|
-
channel: {
|
|
1858
|
-
routing: {
|
|
1859
|
-
resolveAgentRoute: mockResolveAgentRoute,
|
|
1860
|
-
},
|
|
1861
|
-
reply: {
|
|
1862
|
-
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
|
|
1863
|
-
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
|
|
1864
|
-
finalizeInboundContext: mockFinalizeInboundContext,
|
|
1865
|
-
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
|
|
1866
|
-
withReplyDispatcher: mockWithReplyDispatcher,
|
|
1867
|
-
},
|
|
1868
|
-
commands: {
|
|
1869
|
-
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
|
|
1870
|
-
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
|
1871
|
-
},
|
|
1872
|
-
media: {
|
|
1873
|
-
saveMediaBuffer: mockSaveMediaBuffer,
|
|
1874
|
-
},
|
|
1875
|
-
pairing: {
|
|
1876
|
-
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
|
1877
|
-
upsertPairingRequest: vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false }),
|
|
1878
|
-
buildPairingReply: vi.fn(() => "Pairing response"),
|
|
1879
|
-
},
|
|
1880
|
-
},
|
|
1881
|
-
media: {
|
|
1882
|
-
detectMime: vi.fn(async () => "application/octet-stream"),
|
|
1883
|
-
},
|
|
1884
|
-
} as unknown as PluginRuntime);
|
|
1885
|
-
});
|
|
2781
|
+
it("skips topic thread bootstrap when the thread session already exists", async () => {
|
|
2782
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2783
|
+
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
|
|
1886
2784
|
|
|
1887
|
-
it("dispatches to all broadcast agents when bot is mentioned", async () => {
|
|
1888
2785
|
const cfg: ClawdbotConfig = {
|
|
1889
|
-
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
1890
|
-
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
1891
2786
|
channels: {
|
|
1892
2787
|
feishu: {
|
|
1893
2788
|
groups: {
|
|
1894
|
-
"oc-
|
|
1895
|
-
requireMention:
|
|
2789
|
+
"oc-group": {
|
|
2790
|
+
requireMention: false,
|
|
2791
|
+
groupSessionScope: "group_topic",
|
|
1896
2792
|
},
|
|
1897
2793
|
},
|
|
1898
2794
|
},
|
|
1899
2795
|
},
|
|
1900
|
-
} as
|
|
2796
|
+
} as ClawdbotConfig;
|
|
1901
2797
|
|
|
1902
2798
|
const event: FeishuMessageEvent = {
|
|
1903
|
-
sender: { sender_id: { open_id: "ou-
|
|
2799
|
+
sender: { sender_id: { open_id: "ou-topic-user" } },
|
|
1904
2800
|
message: {
|
|
1905
|
-
message_id: "
|
|
1906
|
-
|
|
2801
|
+
message_id: "om_topic_followup",
|
|
2802
|
+
root_id: "om_topic_root",
|
|
2803
|
+
chat_id: "oc-group",
|
|
1907
2804
|
chat_type: "group",
|
|
1908
2805
|
message_type: "text",
|
|
1909
|
-
content: JSON.stringify({ text: "
|
|
1910
|
-
mentions: [
|
|
1911
|
-
{ key: "@_user_1", id: { open_id: "bot-open-id" }, name: "Bot", tenant_key: "" },
|
|
1912
|
-
],
|
|
2806
|
+
content: JSON.stringify({ text: "current turn" }),
|
|
1913
2807
|
},
|
|
1914
2808
|
};
|
|
1915
2809
|
|
|
1916
|
-
await
|
|
1917
|
-
cfg,
|
|
1918
|
-
event,
|
|
1919
|
-
botOpenId: "bot-open-id",
|
|
1920
|
-
runtime: createRuntimeEnv(),
|
|
1921
|
-
});
|
|
1922
|
-
|
|
1923
|
-
// Both agents should get dispatched
|
|
1924
|
-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
|
1925
|
-
|
|
1926
|
-
// Verify session keys for both agents
|
|
1927
|
-
const sessionKeys = mockFinalizeInboundContext.mock.calls.map(
|
|
1928
|
-
(call: unknown[]) => (call[0] as { SessionKey: string }).SessionKey,
|
|
1929
|
-
);
|
|
1930
|
-
expect(sessionKeys).toContain("agent:susan:feishu:group:oc-broadcast-group");
|
|
1931
|
-
expect(sessionKeys).toContain("agent:main:feishu:group:oc-broadcast-group");
|
|
2810
|
+
await dispatchMessage({ cfg, event });
|
|
1932
2811
|
|
|
1933
|
-
|
|
1934
|
-
expect(
|
|
1935
|
-
expect(
|
|
1936
|
-
expect.objectContaining({
|
|
2812
|
+
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
|
|
2813
|
+
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
|
|
2814
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
2815
|
+
expect.objectContaining({
|
|
2816
|
+
ThreadStarterBody: undefined,
|
|
2817
|
+
ThreadHistoryBody: undefined,
|
|
2818
|
+
ThreadLabel: "Feishu thread in oc-group",
|
|
2819
|
+
MessageThreadId: "om_topic_root",
|
|
2820
|
+
}),
|
|
1937
2821
|
);
|
|
1938
2822
|
});
|
|
1939
2823
|
|
|
1940
|
-
it("
|
|
2824
|
+
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
|
|
2825
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2826
|
+
mockGetMessageFeishu.mockResolvedValue({
|
|
2827
|
+
messageId: "om_topic_root",
|
|
2828
|
+
chatId: "oc-group",
|
|
2829
|
+
content: "root starter",
|
|
2830
|
+
contentType: "text",
|
|
2831
|
+
threadId: "omt_topic_1",
|
|
2832
|
+
});
|
|
2833
|
+
mockListFeishuThreadMessages.mockResolvedValue([
|
|
2834
|
+
{
|
|
2835
|
+
messageId: "om_bot_reply",
|
|
2836
|
+
senderId: "app_1",
|
|
2837
|
+
senderType: "app",
|
|
2838
|
+
content: "assistant reply",
|
|
2839
|
+
contentType: "text",
|
|
2840
|
+
createTime: 1710000000000,
|
|
2841
|
+
},
|
|
2842
|
+
{
|
|
2843
|
+
messageId: "om_follow_up",
|
|
2844
|
+
senderId: "user_topic_1",
|
|
2845
|
+
senderType: "user",
|
|
2846
|
+
content: "follow-up question",
|
|
2847
|
+
contentType: "text",
|
|
2848
|
+
createTime: 1710000001000,
|
|
2849
|
+
},
|
|
2850
|
+
]);
|
|
2851
|
+
|
|
1941
2852
|
const cfg: ClawdbotConfig = {
|
|
1942
|
-
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
1943
|
-
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
1944
2853
|
channels: {
|
|
1945
2854
|
feishu: {
|
|
1946
2855
|
groups: {
|
|
1947
|
-
"oc-
|
|
1948
|
-
requireMention:
|
|
2856
|
+
"oc-group": {
|
|
2857
|
+
requireMention: false,
|
|
2858
|
+
groupSessionScope: "group_topic_sender",
|
|
1949
2859
|
},
|
|
1950
2860
|
},
|
|
1951
2861
|
},
|
|
1952
2862
|
},
|
|
1953
|
-
} as
|
|
2863
|
+
} as ClawdbotConfig;
|
|
1954
2864
|
|
|
1955
2865
|
const event: FeishuMessageEvent = {
|
|
1956
|
-
sender: {
|
|
2866
|
+
sender: {
|
|
2867
|
+
sender_id: {
|
|
2868
|
+
open_id: "ou-topic-user",
|
|
2869
|
+
user_id: "user_topic_1",
|
|
2870
|
+
},
|
|
2871
|
+
},
|
|
1957
2872
|
message: {
|
|
1958
|
-
message_id: "
|
|
1959
|
-
|
|
2873
|
+
message_id: "om_topic_followup_mixed_ids",
|
|
2874
|
+
root_id: "om_topic_root",
|
|
2875
|
+
chat_id: "oc-group",
|
|
1960
2876
|
chat_type: "group",
|
|
1961
2877
|
message_type: "text",
|
|
1962
|
-
content: JSON.stringify({ text: "
|
|
2878
|
+
content: JSON.stringify({ text: "current turn" }),
|
|
1963
2879
|
},
|
|
1964
2880
|
};
|
|
1965
2881
|
|
|
1966
|
-
await
|
|
1967
|
-
cfg,
|
|
1968
|
-
event,
|
|
1969
|
-
runtime: createRuntimeEnv(),
|
|
1970
|
-
});
|
|
2882
|
+
await dispatchMessage({ cfg, event });
|
|
1971
2883
|
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
2884
|
+
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
2885
|
+
expect.objectContaining({
|
|
2886
|
+
ThreadStarterBody: "root starter",
|
|
2887
|
+
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
|
|
2888
|
+
ThreadLabel: "Feishu thread in oc-group",
|
|
2889
|
+
MessageThreadId: "om_topic_root",
|
|
2890
|
+
}),
|
|
2891
|
+
);
|
|
1977
2892
|
});
|
|
1978
2893
|
|
|
1979
|
-
it("
|
|
2894
|
+
it("filters topic bootstrap context to allowlisted group senders", async () => {
|
|
2895
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2896
|
+
mockGetMessageFeishu.mockResolvedValue({
|
|
2897
|
+
messageId: "om_topic_root",
|
|
2898
|
+
chatId: "oc-group",
|
|
2899
|
+
senderId: "ou-blocked",
|
|
2900
|
+
senderType: "user",
|
|
2901
|
+
content: "blocked root starter",
|
|
2902
|
+
contentType: "text",
|
|
2903
|
+
threadId: "omt_topic_1",
|
|
2904
|
+
});
|
|
2905
|
+
mockListFeishuThreadMessages.mockResolvedValue([
|
|
2906
|
+
{
|
|
2907
|
+
messageId: "om_blocked_reply",
|
|
2908
|
+
senderId: "ou-blocked",
|
|
2909
|
+
senderType: "user",
|
|
2910
|
+
content: "blocked follow-up",
|
|
2911
|
+
contentType: "text",
|
|
2912
|
+
createTime: 1710000000000,
|
|
2913
|
+
},
|
|
2914
|
+
{
|
|
2915
|
+
messageId: "om_bot_reply",
|
|
2916
|
+
senderId: "app_1",
|
|
2917
|
+
senderType: "app",
|
|
2918
|
+
content: "assistant reply",
|
|
2919
|
+
contentType: "text",
|
|
2920
|
+
createTime: 1710000001000,
|
|
2921
|
+
},
|
|
2922
|
+
{
|
|
2923
|
+
messageId: "om_allowed_reply",
|
|
2924
|
+
senderId: "ou-allowed",
|
|
2925
|
+
senderType: "user",
|
|
2926
|
+
content: "allowed follow-up",
|
|
2927
|
+
contentType: "text",
|
|
2928
|
+
createTime: 1710000002000,
|
|
2929
|
+
},
|
|
2930
|
+
]);
|
|
2931
|
+
|
|
1980
2932
|
const cfg: ClawdbotConfig = {
|
|
1981
2933
|
channels: {
|
|
1982
2934
|
feishu: {
|
|
2935
|
+
groupPolicy: "open",
|
|
2936
|
+
groupSenderAllowFrom: ["ou-allowed"],
|
|
2937
|
+
contextVisibility: "allowlist",
|
|
1983
2938
|
groups: {
|
|
1984
|
-
"oc-
|
|
2939
|
+
"oc-group": {
|
|
1985
2940
|
requireMention: false,
|
|
2941
|
+
groupSessionScope: "group_topic",
|
|
1986
2942
|
},
|
|
1987
2943
|
},
|
|
1988
2944
|
},
|
|
@@ -1990,118 +2946,95 @@ describe("broadcast dispatch", () => {
|
|
|
1990
2946
|
} as ClawdbotConfig;
|
|
1991
2947
|
|
|
1992
2948
|
const event: FeishuMessageEvent = {
|
|
1993
|
-
sender: { sender_id: { open_id: "ou-
|
|
2949
|
+
sender: { sender_id: { open_id: "ou-allowed" } },
|
|
1994
2950
|
message: {
|
|
1995
|
-
message_id: "
|
|
1996
|
-
|
|
2951
|
+
message_id: "om_topic_followup_allowlisted",
|
|
2952
|
+
root_id: "om_topic_root",
|
|
2953
|
+
thread_id: "omt_topic_1",
|
|
2954
|
+
chat_id: "oc-group",
|
|
1997
2955
|
chat_type: "group",
|
|
1998
2956
|
message_type: "text",
|
|
1999
|
-
content: JSON.stringify({ text: "
|
|
2957
|
+
content: JSON.stringify({ text: "current turn" }),
|
|
2000
2958
|
},
|
|
2001
2959
|
};
|
|
2002
2960
|
|
|
2003
|
-
await
|
|
2004
|
-
cfg,
|
|
2005
|
-
event,
|
|
2006
|
-
runtime: createRuntimeEnv(),
|
|
2007
|
-
});
|
|
2961
|
+
await dispatchMessage({ cfg, event });
|
|
2008
2962
|
|
|
2009
|
-
// Single dispatch (no broadcast)
|
|
2010
|
-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
2011
|
-
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledTimes(1);
|
|
2012
2963
|
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
|
2013
2964
|
expect.objectContaining({
|
|
2014
|
-
|
|
2965
|
+
ThreadStarterBody: "assistant reply",
|
|
2966
|
+
ThreadHistoryBody: "assistant reply\n\nallowed follow-up",
|
|
2015
2967
|
}),
|
|
2016
2968
|
);
|
|
2017
2969
|
});
|
|
2018
2970
|
|
|
2019
|
-
it("
|
|
2971
|
+
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
|
2972
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
2973
|
+
|
|
2020
2974
|
const cfg: ClawdbotConfig = {
|
|
2021
|
-
broadcast: { "oc-broadcast-group": ["susan", "main"] },
|
|
2022
|
-
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
2023
2975
|
channels: {
|
|
2024
2976
|
feishu: {
|
|
2025
|
-
|
|
2026
|
-
"oc-broadcast-group": {
|
|
2027
|
-
requireMention: false,
|
|
2028
|
-
},
|
|
2029
|
-
},
|
|
2977
|
+
dmPolicy: "open",
|
|
2030
2978
|
},
|
|
2031
2979
|
},
|
|
2032
|
-
} as
|
|
2980
|
+
} as ClawdbotConfig;
|
|
2033
2981
|
|
|
2034
2982
|
const event: FeishuMessageEvent = {
|
|
2035
|
-
sender: {
|
|
2983
|
+
sender: {
|
|
2984
|
+
sender_id: {
|
|
2985
|
+
open_id: "ou-image-dedup",
|
|
2986
|
+
},
|
|
2987
|
+
},
|
|
2036
2988
|
message: {
|
|
2037
|
-
message_id: "msg-
|
|
2038
|
-
chat_id: "oc-
|
|
2039
|
-
chat_type: "
|
|
2040
|
-
message_type: "
|
|
2041
|
-
content: JSON.stringify({
|
|
2989
|
+
message_id: "msg-image-dedup",
|
|
2990
|
+
chat_id: "oc-dm",
|
|
2991
|
+
chat_type: "p2p",
|
|
2992
|
+
message_type: "image",
|
|
2993
|
+
content: JSON.stringify({
|
|
2994
|
+
image_key: "img_dedup_payload",
|
|
2995
|
+
}),
|
|
2042
2996
|
},
|
|
2043
2997
|
};
|
|
2044
2998
|
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
cfg,
|
|
2048
|
-
event,
|
|
2049
|
-
runtime: createRuntimeEnv(),
|
|
2050
|
-
accountId: "account-A",
|
|
2051
|
-
});
|
|
2052
|
-
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(2);
|
|
2053
|
-
|
|
2054
|
-
mockDispatchReplyFromConfig.mockClear();
|
|
2055
|
-
mockFinalizeInboundContext.mockClear();
|
|
2056
|
-
|
|
2057
|
-
// Second account: same message ID, different account.
|
|
2058
|
-
// Per-account dedup passes (different namespace), but cross-account
|
|
2059
|
-
// broadcast dedup blocks dispatch.
|
|
2060
|
-
await handleFeishuMessage({
|
|
2061
|
-
cfg,
|
|
2062
|
-
event,
|
|
2063
|
-
runtime: createRuntimeEnv(),
|
|
2064
|
-
accountId: "account-B",
|
|
2065
|
-
});
|
|
2066
|
-
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
2999
|
+
await Promise.all([dispatchMessage({ cfg, event }), dispatchMessage({ cfg, event })]);
|
|
3000
|
+
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
|
2067
3001
|
});
|
|
2068
3002
|
|
|
2069
|
-
it("skips
|
|
3003
|
+
it("skips empty-text messages with no media to prevent blank user turns in session (#74634)", async () => {
|
|
3004
|
+
// Feishu can deliver { "text": "" } events (empty-text or media-stripped
|
|
3005
|
+
// messages). Writing blank user content to the session causes downstream
|
|
3006
|
+
// LLM providers such as MiniMax to reject requests with "messages must not
|
|
3007
|
+
// be empty". The handler should drop such events before queuing a reply.
|
|
3008
|
+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
|
3009
|
+
|
|
2070
3010
|
const cfg: ClawdbotConfig = {
|
|
2071
|
-
broadcast: { "oc-broadcast-group": ["susan", "unknown-agent"] },
|
|
2072
|
-
agents: { list: [{ id: "main" }, { id: "susan" }] },
|
|
2073
3011
|
channels: {
|
|
2074
3012
|
feishu: {
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
requireMention: false,
|
|
2078
|
-
},
|
|
2079
|
-
},
|
|
3013
|
+
dmPolicy: "open",
|
|
3014
|
+
allowFrom: ["*"],
|
|
2080
3015
|
},
|
|
2081
3016
|
},
|
|
2082
|
-
} as
|
|
3017
|
+
} as ClawdbotConfig;
|
|
2083
3018
|
|
|
2084
3019
|
const event: FeishuMessageEvent = {
|
|
2085
|
-
sender: {
|
|
3020
|
+
sender: {
|
|
3021
|
+
sender_id: {
|
|
3022
|
+
open_id: "ou-empty-text-sender",
|
|
3023
|
+
},
|
|
3024
|
+
},
|
|
2086
3025
|
message: {
|
|
2087
|
-
message_id: "msg-
|
|
2088
|
-
chat_id: "oc-
|
|
2089
|
-
chat_type: "
|
|
3026
|
+
message_id: "msg-empty-text-74634",
|
|
3027
|
+
chat_id: "oc-dm",
|
|
3028
|
+
chat_type: "p2p",
|
|
2090
3029
|
message_type: "text",
|
|
2091
|
-
|
|
3030
|
+
// Feishu encodes empty text as {"text":""}
|
|
3031
|
+
content: JSON.stringify({ text: "" }),
|
|
2092
3032
|
},
|
|
2093
3033
|
};
|
|
2094
3034
|
|
|
2095
|
-
await
|
|
2096
|
-
cfg,
|
|
2097
|
-
event,
|
|
2098
|
-
runtime: createRuntimeEnv(),
|
|
2099
|
-
});
|
|
3035
|
+
await dispatchMessage({ cfg, event });
|
|
2100
3036
|
|
|
2101
|
-
//
|
|
2102
|
-
expect(mockDispatchReplyFromConfig).
|
|
2103
|
-
const sessionKey = (mockFinalizeInboundContext.mock.calls[0]?.[0] as { SessionKey: string })
|
|
2104
|
-
.SessionKey;
|
|
2105
|
-
expect(sessionKey).toBe("agent:susan:feishu:group:oc-broadcast-group");
|
|
3037
|
+
// No reply should be dispatched: empty message is silently skipped
|
|
3038
|
+
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
|
2106
3039
|
});
|
|
2107
3040
|
});
|