@workclaw/openclaw-workchat 1.0.0 → 1.0.3

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.
@@ -1,530 +1,530 @@
1
- import {
2
- resolveSenderCommandAuthorizationWithRuntime,
3
- resolveDirectDmAuthorizationOutcome,
4
- } from "openclaw/plugin-sdk/command-auth";
5
- import type { PluginRuntime } from "openclaw/plugin-sdk";
6
-
7
- import { notifyAgentCreatedStatus } from "../api/api.js";
8
- import type { WorkChatApiOptions } from "../api/api.js";
9
- import {
10
- listIndexedWorkChatAccountIds,
11
- loadWorkChatAccount,
12
- registerWorkChatAccountId,
13
- saveWorkChatAccount,
14
- triggerWorkChatChannelReload,
15
- pruneStaleWorkChatAccounts,
16
- removeWorkChatAccountBinding,
17
- } from "../auth/accounts.js";
18
- import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
19
- import { readFrameworkAllowFromList } from "../auth/pairing.js";
20
- import { logger } from "../util/logger.js";
21
- import { redactBody } from "../util/redact.js";
22
-
23
- import { isDebugMode } from "./debug-mode.js";
24
- import { sendWorkChatErrorNotice } from "./error-notice.js";
25
- import type { ParseWorkChatFrameResult } from "./inbound.js";
26
- import { workChatMessageToMsgContext } from "./inbound.js";
27
- import { handleSlashCommand } from "./slash-commands.js";
28
-
29
- // workchat_lazy_outbound_hooks: hooks are not needed for registration or QR binding.
30
- let workchatOutboundHooksPromise: Promise<typeof import("./outbound-hooks.js")> | null = null;
31
- function loadWorkChatOutboundHooks() {
32
- workchatOutboundHooksPromise ??= import("./outbound-hooks.js");
33
- return workchatOutboundHooksPromise;
34
- }
35
-
36
- async function applyWorkChatMessageSendingHook(params: {
37
- to: string;
38
- text: string;
39
- accountId?: string;
40
- }): Promise<{ cancelled: boolean; text: string }> {
41
- try {
42
- const hooks = await loadWorkChatOutboundHooks();
43
- return hooks.applyWorkChatMessageSendingHook(params);
44
- } catch (error) {
45
- logger.warn(`message_sending hook load error, proceeding with send: ${String(error)}`);
46
- return { cancelled: false, text: params.text };
47
- }
48
- }
49
-
50
- function emitWorkChatMessageSent(params: {
51
- to: string;
52
- content: string;
53
- success: boolean;
54
- error?: string;
55
- accountId?: string;
56
- }): void {
57
- void loadWorkChatOutboundHooks()
58
- .then((hooks) => hooks.emitWorkChatMessageSent(params))
59
- .catch((error) => logger.warn(`message_sent hook load error: ${String(error)}`));
60
- }
61
-
62
- /** Dependencies for processOneFrame, injected by the monitor loop. */
63
- export type ProcessFrameDeps = {
64
- accountId: string;
65
- account: ResolvedWorkChatAccount;
66
- config: import("openclaw/plugin-sdk").OpenClawConfig;
67
- channelRuntime: PluginRuntime["channel"];
68
- apiOpts: WorkChatApiOptions;
69
- log: (msg: string) => void;
70
- errLog: (m: string) => void;
71
- };
72
-
73
- /** Persist agent/user ids discovered from AGENT_CREATED / INIT_AGENT events. */
74
- async function handleAgentLifecycleEvent(
75
- frameType: "agent_created" | "init_agent",
76
- result: ParseWorkChatFrameResult,
77
- deps: ProcessFrameDeps,
78
- ): Promise<void> {
79
- const eventData = result.eventData;
80
- if (!eventData) return;
81
-
82
- const agentId = String(eventData.id ?? eventData.agentId ?? "");
83
- if (!agentId) {
84
- logger.warn(`${frameType}: missing agent id, ignoring`);
85
- return;
86
- }
87
- const userId = String(eventData.futureId ?? eventData.userId ?? "");
88
-
89
- logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
90
- deps.log(`${frameType}: agentId=${agentId}`);
91
-
92
- // Rebind detection must happen BEFORE pruning empties the index (see the
93
- // matching note in the binding listener): a rebind always registers under a
94
- // FRESH agent-scoped key so the gateway restarts it — the QR login flow
95
- // force-stops the channel with a manual-stop mark, and a reused account id
96
- // would stay stopped after the config reload.
97
- const isRebind = listWorkChatAccountIdsCount() > 0;
98
-
99
- // Rebind semantics: one active agent per host. Before persisting the new
100
- // binding, remove every registered account bound to a different agent
101
- // (a re-scan creates a new platform agent; the old one is deleted there).
102
- try {
103
- const removed = await pruneStaleWorkChatAccounts(agentId);
104
- if (removed.length > 0) {
105
- deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
106
- }
107
- } catch (err) {
108
- logger.error(`rebind cleanup failed: ${String(err)}`);
109
- }
110
-
111
- // The bound agent becomes the account: `default` for the primary install,
112
- // or a fresh agent-scoped key on rebind.
113
- const { completeWorkChatBinding } = await import("../auth/login-qr.js");
114
- const accountId = frameType === "init_agent"
115
- ? (isRebind ? `agent-${agentId}` : "default")
116
- : deps.accountId;
117
-
118
- try {
119
- saveWorkChatAccount(accountId, {
120
- agentId,
121
- ...(userId ? { userId } : {}),
122
- });
123
- registerWorkChatAccountId(accountId);
124
- // Bind the account to the incoming agent for message routing. agentId
125
- // comes from the event; userId (futureId) is the WorkChat user.
126
- const { bindAgentToAccount } = await import("../auth/bind-agent.js");
127
- await bindAgentToAccount(accountId, agentId, userId);
128
- logger.info(`${frameType}: bound agent to account accountId=${accountId}`);
129
- await triggerWorkChatChannelReload();
130
- } catch (err) {
131
- logger.error(`${frameType}: failed to save account data err=${String(err)}`);
132
- }
133
-
134
- // INIT_AGENT closes the pending QR binding session (if any).
135
- if (frameType === "init_agent") {
136
- const resolved = completeWorkChatBinding({
137
- accountId,
138
- agentId,
139
- ...(userId ? { userId } : {}),
140
- });
141
- if (resolved) {
142
- deps.log(`绑定完成: agentId=${agentId}`);
143
- }
144
- }
145
-
146
- if (frameType === "agent_created") {
147
- // Ack the platform that the local side is ready (best-effort, non-fatal).
148
- void notifyAgentCreatedStatus(deps.apiOpts, agentId);
149
- }
150
- }
151
-
152
- function listWorkChatAccountIdsCount(): number {
153
- return listIndexedWorkChatAccountIds().length;
154
- }
155
-
156
- // Typing-indicator helper versions: the host renamed the subpath between
157
- // releases (2026.3.x exports openclaw/plugin-sdk/channel-runtime, 2026.8.x
158
- // exports openclaw/plugin-sdk/channel-message). Load whichever resolves —
159
- // both export createTypingCallbacks. WorkChat has no typing API, so the
160
- // callbacks are no-op; only the dispatcher lifecycle shape matters.
161
- let createTypingCallbacksPromise: Promise<((opts: unknown) => unknown) | null> | undefined;
162
- function loadCreateTypingCallbacks(): Promise<((opts: unknown) => unknown) | null> {
163
- createTypingCallbacksPromise ??= (async () => {
164
- for (const specifier of ["openclaw/plugin-sdk/channel-runtime", "openclaw/plugin-sdk/channel-message"]) {
165
- try {
166
- // Variable specifier keeps the import dynamic so both host versions
167
- // resolve at runtime instead of one failing at bundle time.
168
- const mod = (await import(specifier)) as { createTypingCallbacks?: (opts: unknown) => unknown };
169
- if (typeof mod?.createTypingCallbacks === "function") return mod.createTypingCallbacks;
170
- } catch {
171
- // subpath not present on this host — try the next
172
- }
173
- }
174
- return null;
175
- })();
176
- return createTypingCallbacksPromise;
177
- }
178
-
179
- async function createNoopTypingCallbacks(): Promise<unknown> {
180
- const createTypingCallbacks = await loadCreateTypingCallbacks();
181
- if (!createTypingCallbacks) {
182
- // Host exposes neither subpath: minimal lifecycle stub.
183
- return { start: () => {}, stop: () => {} };
184
- }
185
- return createTypingCallbacks({
186
- start: async () => {},
187
- stop: async () => {},
188
- onStartError: () => {},
189
- onStopError: () => {},
190
- keepaliveIntervalMs: 5000,
191
- });
192
- }
193
-
194
-
195
-
196
- /**
197
- * Process a single parsed inbound frame: lifecycle events are persisted,
198
- * AGENT_MESSAGE frames are authorized, routed, and dispatched into the core
199
- * reply pipeline.
200
- */
201
- export async function processOneFrame(
202
- result: ParseWorkChatFrameResult,
203
- rawFrame: string,
204
- deps: ProcessFrameDeps,
205
- ): Promise<void> {
206
- if (!deps?.channelRuntime) {
207
- logger.error(`processOneFrame: channelRuntime is undefined, skipping type=${result.type}`);
208
- return;
209
- }
210
-
211
- switch (result.type) {
212
- case "agent_created":
213
- case "init_agent":
214
- await handleAgentLifecycleEvent(result.type, result, deps);
215
- return;
216
- case "agent_updated":
217
- logger.info(`${result.type}: no local action`);
218
- return;
219
- case "agent_deleted": {
220
- // The platform deleted this agent (e.g. a re-scan replaced it): tear
221
- // down the local account binding so no dead monitor keeps running.
222
- const eventData = result.eventData;
223
- const deletedAgentId = String(eventData?.id ?? eventData?.agentId ?? "").trim();
224
- if (!deletedAgentId) {
225
- logger.warn("agent_deleted: missing agent id, ignoring");
226
- return;
227
- }
228
- const victim = listIndexedWorkChatAccountIds()
229
- .find(id => loadWorkChatAccount(id)?.agentId?.trim() === deletedAgentId);
230
- if (victim) {
231
- try {
232
- await removeWorkChatAccountBinding(victim);
233
- await triggerWorkChatChannelReload();
234
- deps.log(`agent_deleted: removed account=${victim} agent=${deletedAgentId}`);
235
- } catch (err) {
236
- logger.error(`agent_deleted: failed to remove account=${victim}: ${String(err)}`);
237
- }
238
- } else {
239
- logger.info(`agent_deleted: agent=${deletedAgentId} not bound locally, ignoring`);
240
- }
241
- return;
242
- }
243
- case "ignored":
244
- // Unhandled-but-recognized frames (TOOLS_LIST/SKILLS_LIST/SKILL_DO_REMIND/
245
- // CRON_REMIND...): log the raw kind so platform pushes are observable.
246
- logger.debug(`ignored frame kind=${result.ignoredKind ?? "(unknown)"}`);
247
- return;
248
- case "ping":
249
- case "disconnect":
250
- return;
251
- case "agent_message":
252
- break;
253
- default:
254
- return;
255
- }
256
-
257
- const message = result.message;
258
- if (!message) return;
259
- if (!message.text?.trim()) {
260
- logger.debug("inbound: empty body, ignoring");
261
- return;
262
- }
263
-
264
- const receivedAt = Date.now();
265
- const debug = isDebugMode(deps.accountId);
266
- const debugTrace: string[] = [];
267
- const debugTs: Record<string, number> = { received: receivedAt };
268
-
269
- const ctx = workChatMessageToMsgContext(message, deps.accountId);
270
- logger.info(
271
- `inbound: from=${ctx.From} to=${ctx.To} bodyLen=${ctx.Body.length} conversationId=${ctx.ConversationId ?? "(none)"}`,
272
- );
273
- logger.debug(`inbound raw frame: ${rawFrame.slice(0, 200)}`);
274
-
275
- // --- Slash commands (handled locally, never dispatched to AI) ---
276
- if (ctx.Body.trim().startsWith("/")) {
277
- const slashResult = await handleSlashCommand(ctx.Body, {
278
- account: deps.account,
279
- to: ctx.To,
280
- agentId: ctx.AgentId,
281
- log: deps.log,
282
- errLog: deps.errLog,
283
- }, receivedAt);
284
- if (slashResult.handled) {
285
- return;
286
- }
287
- }
288
-
289
- // --- Framework command authorization ---
290
- const rawBody = ctx.Body?.trim() ?? "";
291
- ctx.CommandBody = rawBody;
292
- const senderId = ctx.From;
293
-
294
- const { senderAllowedForCommands, commandAuthorized } =
295
- await resolveSenderCommandAuthorizationWithRuntime({
296
- cfg: deps.config,
297
- rawBody,
298
- isGroup: false,
299
- dmPolicy: "pairing",
300
- configuredAllowFrom: [],
301
- configuredGroupAllowFrom: [],
302
- senderId,
303
- isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
304
- /** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */
305
- readAllowFromStore: async () => {
306
- const fromStore = readFrameworkAllowFromList(deps.accountId);
307
- if (fromStore.length > 0) return fromStore;
308
- const uid = loadWorkChatAccount(deps.accountId)?.userId?.trim();
309
- return uid ? [uid] : [];
310
- },
311
- runtime: deps.channelRuntime.commands,
312
- });
313
-
314
- const directDmOutcome = resolveDirectDmAuthorizationOutcome({
315
- isGroup: false,
316
- dmPolicy: "pairing",
317
- senderAllowedForCommands,
318
- });
319
-
320
- if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
321
- logger.info(
322
- `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
323
- );
324
- return;
325
- }
326
-
327
- ctx.CommandAuthorized = commandAuthorized;
328
- logger.debug(
329
- `authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
330
- );
331
-
332
- if (debug) {
333
- debugTrace.push(
334
- "── 鉴权 & 路由 ──",
335
- `│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
336
- );
337
- }
338
-
339
- const route = deps.channelRuntime.routing.resolveAgentRoute({
340
- cfg: deps.config,
341
- channel: "openclaw-workchat",
342
- accountId: deps.accountId,
343
- peer: { kind: "direct", id: ctx.To },
344
- });
345
- logger.debug(
346
- `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
347
- );
348
- if (!route.agentId) {
349
- logger.error(
350
- `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
351
- );
352
- }
353
-
354
- if (debug) {
355
- debugTrace.push(`│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`);
356
- debugTs.preDispatch = Date.now();
357
- }
358
- // Propagate the resolved session key into ctx so dispatchReplyFromConfig uses
359
- // the correct session (matching the dmScope from config) instead of falling back.
360
- ctx.SessionKey = route.sessionKey;
361
- const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
362
- agentId: route.agentId,
363
- });
364
- const finalized = deps.channelRuntime.reply.finalizeInboundContext(
365
- ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0],
366
- );
367
-
368
- logger.info(
369
- `inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length}`,
370
- );
371
- logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`);
372
-
373
- await deps.channelRuntime.session.recordInboundSession({
374
- storePath,
375
- sessionKey: route.sessionKey,
376
- ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
377
- updateLastRoute: {
378
- sessionKey: route.mainSessionKey,
379
- channel: "openclaw-workchat",
380
- to: ctx.To,
381
- accountId: deps.accountId,
382
- },
383
- onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
384
- });
385
- logger.debug(`recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`);
386
-
387
- const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
388
-
389
- const typingCallbacks = await createNoopTypingCallbacks();
390
-
391
- /** Delivery records populated synchronously at deliver() entry, safe to read in finally. */
392
- const debugDeliveries: Array<{ textLen: number; preview: string; ts: number }> = [];
393
-
394
- const { dispatcher, replyOptions, markDispatchIdle } =
395
- deps.channelRuntime.reply.createReplyDispatcherWithTyping({
396
- humanDelay,
397
- // Shape comes from a host-version-dependent loader; the local SDK's
398
- // TypingCallbacks type does not apply cleanly across versions.
399
- typingCallbacks: typingCallbacks as any,
400
- deliver: async (payload) => {
401
- // Lazy-load send + markdown filter to keep registration / QR binding import-light.
402
- const [{ replyWorkChatMessage }, { StreamingMarkdownFilter }] = await Promise.all([
403
- import("./send.js"),
404
- import("./markdown-filter.js"),
405
- ]);
406
- const rawText = String(payload.text ?? "");
407
- const f = new StreamingMarkdownFilter();
408
- let text = f.feed(rawText) + f.flush();
409
-
410
- logger.info(`outbound: to=${ctx.To} textLen=${text.length}`);
411
-
412
- if (debug) {
413
- debugDeliveries.push({
414
- textLen: text.length,
415
- preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
416
- ts: Date.now(),
417
- });
418
- }
419
-
420
- const sendingResult = await applyWorkChatMessageSendingHook({
421
- to: ctx.To,
422
- text,
423
- accountId: deps.accountId,
424
- });
425
- if (sendingResult.cancelled) {
426
- logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
427
- return;
428
- }
429
- text = sendingResult.text;
430
-
431
- try {
432
- const result = await replyWorkChatMessage({
433
- account: {
434
- ...deps.account,
435
- agentId: ctx.AgentId ?? deps.account.agentId,
436
- userId: ctx.From || deps.account.userId,
437
- },
438
- to: ctx.To,
439
- text,
440
- replyToMessageId: ctx.MessageSid,
441
- conversationId: ctx.ConversationId,
442
- agentId: ctx.AgentId,
443
- last: true,
444
- });
445
- emitWorkChatMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
446
- logger.info(`outbound: reply sent OK to=${ctx.To} messageId=${result.messageId}`);
447
- } catch (err) {
448
- emitWorkChatMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId });
449
- logger.error(`outbound: FAILED to=${ctx.To} err=${String(err)}`);
450
- throw err;
451
- }
452
- },
453
- onError: (err, info) => {
454
- deps.errLog(`workchat reply ${info.kind}: ${String(err)}`);
455
- const errMsg = err instanceof Error ? err.message : String(err);
456
- const notice = `⚠️ 消息发送失败:${errMsg}`;
457
- void sendWorkChatErrorNotice({
458
- account: deps.account,
459
- to: ctx.To,
460
- message: notice,
461
- agentId: ctx.AgentId,
462
- errLog: deps.errLog,
463
- });
464
- },
465
- });
466
-
467
- logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);
468
- try {
469
- await deps.channelRuntime.reply.withReplyDispatcher({
470
- dispatcher,
471
- run: () =>
472
- deps.channelRuntime.reply.dispatchReplyFromConfig({
473
- ctx: finalized,
474
- cfg: deps.config,
475
- dispatcher,
476
- replyOptions: { ...replyOptions, disableBlockStreaming: true },
477
- }),
478
- });
479
- logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
480
- } catch (err) {
481
- logger.error(
482
- `dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`,
483
- );
484
- throw err;
485
- } finally {
486
- markDispatchIdle();
487
-
488
- if (debug) {
489
- const dispatchDoneAt = Date.now();
490
- const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
491
- const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
492
- const totalTime = dispatchDoneAt - receivedAt;
493
-
494
- if (debugDeliveries.length > 0) {
495
- debugTrace.push("── 回复 ──");
496
- for (const d of debugDeliveries) {
497
- debugTrace.push(`│ textLen=${d.textLen}`, `│ text="${d.preview}"`);
498
- }
499
- const firstTs = debugDeliveries[0].ts;
500
- debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`);
501
- } else {
502
- debugTrace.push("── 回复 ──", "│ (deliver未捕获)");
503
- }
504
-
505
- debugTrace.push(
506
- "── 耗时 ──",
507
- `├ 入站处理(auth+route): ${inboundProcessMs}ms`,
508
- `├ AI生成+回复: ${aiMs}ms`,
509
- `├ 总耗时: ${totalTime}ms`,
510
- );
511
-
512
- const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
513
- logger.info(`debug-timing: sending to=${ctx.To}`);
514
- void import("./send.js").then(({ pushWorkChatMessage }) =>
515
- pushWorkChatMessage({
516
- account: {
517
- ...deps.account,
518
- agentId: ctx.AgentId ?? deps.account.agentId,
519
- userId: ctx.From || deps.account.userId,
520
- },
521
- to: ctx.To,
522
- text: timingText,
523
- agentId: ctx.AgentId,
524
- }),
525
- ).catch((debugErr) => {
526
- logger.error(`debug-timing: send FAILED err=${String(debugErr)}`);
527
- });
528
- }
529
- }
530
- }
1
+ import {
2
+ resolveSenderCommandAuthorizationWithRuntime,
3
+ resolveDirectDmAuthorizationOutcome,
4
+ } from "openclaw/plugin-sdk/command-auth";
5
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
6
+
7
+ import { notifyAgentCreatedStatus } from "../api/api.js";
8
+ import type { WorkChatApiOptions } from "../api/api.js";
9
+ import {
10
+ listIndexedWorkChatAccountIds,
11
+ loadWorkChatAccount,
12
+ registerWorkChatAccountId,
13
+ saveWorkChatAccount,
14
+ triggerWorkChatChannelReload,
15
+ pruneStaleWorkChatAccounts,
16
+ removeWorkChatAccountBinding,
17
+ } from "../auth/accounts.js";
18
+ import type { ResolvedWorkChatAccount } from "../auth/accounts.js";
19
+ import { readFrameworkAllowFromList } from "../auth/pairing.js";
20
+ import { logger } from "../util/logger.js";
21
+ import { redactBody } from "../util/redact.js";
22
+
23
+ import { isDebugMode } from "./debug-mode.js";
24
+ import { sendWorkChatErrorNotice } from "./error-notice.js";
25
+ import type { ParseWorkChatFrameResult } from "./inbound.js";
26
+ import { workChatMessageToMsgContext } from "./inbound.js";
27
+ import { handleSlashCommand } from "./slash-commands.js";
28
+
29
+ // workchat_lazy_outbound_hooks: hooks are not needed for registration or QR binding.
30
+ let workchatOutboundHooksPromise: Promise<typeof import("./outbound-hooks.js")> | null = null;
31
+ function loadWorkChatOutboundHooks() {
32
+ workchatOutboundHooksPromise ??= import("./outbound-hooks.js");
33
+ return workchatOutboundHooksPromise;
34
+ }
35
+
36
+ async function applyWorkChatMessageSendingHook(params: {
37
+ to: string;
38
+ text: string;
39
+ accountId?: string;
40
+ }): Promise<{ cancelled: boolean; text: string }> {
41
+ try {
42
+ const hooks = await loadWorkChatOutboundHooks();
43
+ return hooks.applyWorkChatMessageSendingHook(params);
44
+ } catch (error) {
45
+ logger.warn(`message_sending hook load error, proceeding with send: ${String(error)}`);
46
+ return { cancelled: false, text: params.text };
47
+ }
48
+ }
49
+
50
+ function emitWorkChatMessageSent(params: {
51
+ to: string;
52
+ content: string;
53
+ success: boolean;
54
+ error?: string;
55
+ accountId?: string;
56
+ }): void {
57
+ void loadWorkChatOutboundHooks()
58
+ .then((hooks) => hooks.emitWorkChatMessageSent(params))
59
+ .catch((error) => logger.warn(`message_sent hook load error: ${String(error)}`));
60
+ }
61
+
62
+ /** Dependencies for processOneFrame, injected by the monitor loop. */
63
+ export type ProcessFrameDeps = {
64
+ accountId: string;
65
+ account: ResolvedWorkChatAccount;
66
+ config: import("openclaw/plugin-sdk").OpenClawConfig;
67
+ channelRuntime: PluginRuntime["channel"];
68
+ apiOpts: WorkChatApiOptions;
69
+ log: (msg: string) => void;
70
+ errLog: (m: string) => void;
71
+ };
72
+
73
+ /** Persist agent/user ids discovered from AGENT_CREATED / INIT_AGENT events. */
74
+ async function handleAgentLifecycleEvent(
75
+ frameType: "agent_created" | "init_agent",
76
+ result: ParseWorkChatFrameResult,
77
+ deps: ProcessFrameDeps,
78
+ ): Promise<void> {
79
+ const eventData = result.eventData;
80
+ if (!eventData) return;
81
+
82
+ const agentId = String(eventData.id ?? eventData.agentId ?? "");
83
+ if (!agentId) {
84
+ logger.warn(`${frameType}: missing agent id, ignoring`);
85
+ return;
86
+ }
87
+ const userId = String(eventData.futureId ?? eventData.userId ?? "");
88
+
89
+ logger.info(`${frameType}: agentId=${agentId} userId=${userId ? "present" : "missing"}`);
90
+ deps.log(`${frameType}: agentId=${agentId}`);
91
+
92
+ // Rebind detection must happen BEFORE pruning empties the index (see the
93
+ // matching note in the binding listener): a rebind always registers under a
94
+ // FRESH agent-scoped key so the gateway restarts it — the QR login flow
95
+ // force-stops the channel with a manual-stop mark, and a reused account id
96
+ // would stay stopped after the config reload.
97
+ const isRebind = listWorkChatAccountIdsCount() > 0;
98
+
99
+ // Rebind semantics: one active agent per host. Before persisting the new
100
+ // binding, remove every registered account bound to a different agent
101
+ // (a re-scan creates a new platform agent; the old one is deleted there).
102
+ try {
103
+ const removed = await pruneStaleWorkChatAccounts(agentId);
104
+ if (removed.length > 0) {
105
+ deps.log(`rebind: removed stale account(s): ${removed.join(", ")}`);
106
+ }
107
+ } catch (err) {
108
+ logger.error(`rebind cleanup failed: ${String(err)}`);
109
+ }
110
+
111
+ // The bound agent becomes the account: `default` for the primary install,
112
+ // or a fresh agent-scoped key on rebind.
113
+ const { completeWorkChatBinding } = await import("../auth/login-qr.js");
114
+ const accountId = frameType === "init_agent"
115
+ ? (isRebind ? `agent-${agentId}` : "default")
116
+ : deps.accountId;
117
+
118
+ try {
119
+ saveWorkChatAccount(accountId, {
120
+ agentId,
121
+ ...(userId ? { userId } : {}),
122
+ });
123
+ registerWorkChatAccountId(accountId);
124
+ // Bind the account to the incoming agent for message routing. agentId
125
+ // comes from the event; userId (futureId) is the WorkChat user.
126
+ const { bindAgentToAccount } = await import("../auth/bind-agent.js");
127
+ await bindAgentToAccount(accountId, agentId, userId);
128
+ logger.info(`${frameType}: bound agent to account accountId=${accountId}`);
129
+ await triggerWorkChatChannelReload();
130
+ } catch (err) {
131
+ logger.error(`${frameType}: failed to save account data err=${String(err)}`);
132
+ }
133
+
134
+ // INIT_AGENT closes the pending QR binding session (if any).
135
+ if (frameType === "init_agent") {
136
+ const resolved = completeWorkChatBinding({
137
+ accountId,
138
+ agentId,
139
+ ...(userId ? { userId } : {}),
140
+ });
141
+ if (resolved) {
142
+ deps.log(`绑定完成: agentId=${agentId}`);
143
+ }
144
+ }
145
+
146
+ if (frameType === "agent_created") {
147
+ // Ack the platform that the local side is ready (best-effort, non-fatal).
148
+ void notifyAgentCreatedStatus(deps.apiOpts, agentId);
149
+ }
150
+ }
151
+
152
+ function listWorkChatAccountIdsCount(): number {
153
+ return listIndexedWorkChatAccountIds().length;
154
+ }
155
+
156
+ // Typing-indicator helper versions: the host renamed the subpath between
157
+ // releases (2026.3.x exports openclaw/plugin-sdk/channel-runtime, 2026.8.x
158
+ // exports openclaw/plugin-sdk/channel-message). Load whichever resolves —
159
+ // both export createTypingCallbacks. WorkChat has no typing API, so the
160
+ // callbacks are no-op; only the dispatcher lifecycle shape matters.
161
+ let createTypingCallbacksPromise: Promise<((opts: unknown) => unknown) | null> | undefined;
162
+ function loadCreateTypingCallbacks(): Promise<((opts: unknown) => unknown) | null> {
163
+ createTypingCallbacksPromise ??= (async () => {
164
+ for (const specifier of ["openclaw/plugin-sdk/channel-runtime", "openclaw/plugin-sdk/channel-message"]) {
165
+ try {
166
+ // Variable specifier keeps the import dynamic so both host versions
167
+ // resolve at runtime instead of one failing at bundle time.
168
+ const mod = (await import(specifier)) as { createTypingCallbacks?: (opts: unknown) => unknown };
169
+ if (typeof mod?.createTypingCallbacks === "function") return mod.createTypingCallbacks;
170
+ } catch {
171
+ // subpath not present on this host — try the next
172
+ }
173
+ }
174
+ return null;
175
+ })();
176
+ return createTypingCallbacksPromise;
177
+ }
178
+
179
+ async function createNoopTypingCallbacks(): Promise<unknown> {
180
+ const createTypingCallbacks = await loadCreateTypingCallbacks();
181
+ if (!createTypingCallbacks) {
182
+ // Host exposes neither subpath: minimal lifecycle stub.
183
+ return { start: () => {}, stop: () => {} };
184
+ }
185
+ return createTypingCallbacks({
186
+ start: async () => {},
187
+ stop: async () => {},
188
+ onStartError: () => {},
189
+ onStopError: () => {},
190
+ keepaliveIntervalMs: 5000,
191
+ });
192
+ }
193
+
194
+
195
+
196
+ /**
197
+ * Process a single parsed inbound frame: lifecycle events are persisted,
198
+ * AGENT_MESSAGE frames are authorized, routed, and dispatched into the core
199
+ * reply pipeline.
200
+ */
201
+ export async function processOneFrame(
202
+ result: ParseWorkChatFrameResult,
203
+ rawFrame: string,
204
+ deps: ProcessFrameDeps,
205
+ ): Promise<void> {
206
+ if (!deps?.channelRuntime) {
207
+ logger.error(`processOneFrame: channelRuntime is undefined, skipping type=${result.type}`);
208
+ return;
209
+ }
210
+
211
+ switch (result.type) {
212
+ case "agent_created":
213
+ case "init_agent":
214
+ await handleAgentLifecycleEvent(result.type, result, deps);
215
+ return;
216
+ case "agent_updated":
217
+ logger.info(`${result.type}: no local action`);
218
+ return;
219
+ case "agent_deleted": {
220
+ // The platform deleted this agent (e.g. a re-scan replaced it): tear
221
+ // down the local account binding so no dead monitor keeps running.
222
+ const eventData = result.eventData;
223
+ const deletedAgentId = String(eventData?.id ?? eventData?.agentId ?? "").trim();
224
+ if (!deletedAgentId) {
225
+ logger.warn("agent_deleted: missing agent id, ignoring");
226
+ return;
227
+ }
228
+ const victim = listIndexedWorkChatAccountIds()
229
+ .find(id => loadWorkChatAccount(id)?.agentId?.trim() === deletedAgentId);
230
+ if (victim) {
231
+ try {
232
+ await removeWorkChatAccountBinding(victim);
233
+ await triggerWorkChatChannelReload();
234
+ deps.log(`agent_deleted: removed account=${victim} agent=${deletedAgentId}`);
235
+ } catch (err) {
236
+ logger.error(`agent_deleted: failed to remove account=${victim}: ${String(err)}`);
237
+ }
238
+ } else {
239
+ logger.info(`agent_deleted: agent=${deletedAgentId} not bound locally, ignoring`);
240
+ }
241
+ return;
242
+ }
243
+ case "ignored":
244
+ // Unhandled-but-recognized frames (TOOLS_LIST/SKILLS_LIST/SKILL_DO_REMIND/
245
+ // CRON_REMIND...): log the raw kind so platform pushes are observable.
246
+ logger.debug(`ignored frame kind=${result.ignoredKind ?? "(unknown)"}`);
247
+ return;
248
+ case "ping":
249
+ case "disconnect":
250
+ return;
251
+ case "agent_message":
252
+ break;
253
+ default:
254
+ return;
255
+ }
256
+
257
+ const message = result.message;
258
+ if (!message) return;
259
+ if (!message.text?.trim()) {
260
+ logger.debug("inbound: empty body, ignoring");
261
+ return;
262
+ }
263
+
264
+ const receivedAt = Date.now();
265
+ const debug = isDebugMode(deps.accountId);
266
+ const debugTrace: string[] = [];
267
+ const debugTs: Record<string, number> = { received: receivedAt };
268
+
269
+ const ctx = workChatMessageToMsgContext(message, deps.accountId);
270
+ logger.info(
271
+ `inbound: from=${ctx.From} to=${ctx.To} bodyLen=${ctx.Body.length} conversationId=${ctx.ConversationId ?? "(none)"}`,
272
+ );
273
+ logger.debug(`inbound raw frame: ${rawFrame.slice(0, 200)}`);
274
+
275
+ // --- Slash commands (handled locally, never dispatched to AI) ---
276
+ if (ctx.Body.trim().startsWith("/")) {
277
+ const slashResult = await handleSlashCommand(ctx.Body, {
278
+ account: deps.account,
279
+ to: ctx.To,
280
+ agentId: ctx.AgentId,
281
+ log: deps.log,
282
+ errLog: deps.errLog,
283
+ }, receivedAt);
284
+ if (slashResult.handled) {
285
+ return;
286
+ }
287
+ }
288
+
289
+ // --- Framework command authorization ---
290
+ const rawBody = ctx.Body?.trim() ?? "";
291
+ ctx.CommandBody = rawBody;
292
+ const senderId = ctx.From;
293
+
294
+ const { senderAllowedForCommands, commandAuthorized } =
295
+ await resolveSenderCommandAuthorizationWithRuntime({
296
+ cfg: deps.config,
297
+ rawBody,
298
+ isGroup: false,
299
+ dmPolicy: "pairing",
300
+ configuredAllowFrom: [],
301
+ configuredGroupAllowFrom: [],
302
+ senderId,
303
+ isSenderAllowed: (id: string, list: string[]) => list.length === 0 || list.includes(id),
304
+ /** Pairing: framework credentials `*-allowFrom.json`, with account `userId` fallback for legacy installs. */
305
+ readAllowFromStore: async () => {
306
+ const fromStore = readFrameworkAllowFromList(deps.accountId);
307
+ if (fromStore.length > 0) return fromStore;
308
+ const uid = loadWorkChatAccount(deps.accountId)?.userId?.trim();
309
+ return uid ? [uid] : [];
310
+ },
311
+ runtime: deps.channelRuntime.commands,
312
+ });
313
+
314
+ const directDmOutcome = resolveDirectDmAuthorizationOutcome({
315
+ isGroup: false,
316
+ dmPolicy: "pairing",
317
+ senderAllowedForCommands,
318
+ });
319
+
320
+ if (directDmOutcome === "disabled" || directDmOutcome === "unauthorized") {
321
+ logger.info(
322
+ `authorization: dropping message from=${senderId} outcome=${directDmOutcome}`,
323
+ );
324
+ return;
325
+ }
326
+
327
+ ctx.CommandAuthorized = commandAuthorized;
328
+ logger.debug(
329
+ `authorization: senderId=${senderId} commandAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
330
+ );
331
+
332
+ if (debug) {
333
+ debugTrace.push(
334
+ "── 鉴权 & 路由 ──",
335
+ `│ auth: cmdAuthorized=${String(commandAuthorized)} senderAllowed=${String(senderAllowedForCommands)}`,
336
+ );
337
+ }
338
+
339
+ const route = deps.channelRuntime.routing.resolveAgentRoute({
340
+ cfg: deps.config,
341
+ channel: "openclaw-workchat",
342
+ accountId: deps.accountId,
343
+ peer: { kind: "direct", id: ctx.To },
344
+ });
345
+ logger.debug(
346
+ `resolveAgentRoute: agentId=${route.agentId ?? "(none)"} sessionKey=${route.sessionKey ?? "(none)"} mainSessionKey=${route.mainSessionKey ?? "(none)"}`,
347
+ );
348
+ if (!route.agentId) {
349
+ logger.error(
350
+ `resolveAgentRoute: no agentId resolved for peer=${ctx.To} accountId=${deps.accountId} — message will not be dispatched`,
351
+ );
352
+ }
353
+
354
+ if (debug) {
355
+ debugTrace.push(`│ route: agent=${route.agentId ?? "none"} session=${route.sessionKey ?? "none"}`);
356
+ debugTs.preDispatch = Date.now();
357
+ }
358
+ // Propagate the resolved session key into ctx so dispatchReplyFromConfig uses
359
+ // the correct session (matching the dmScope from config) instead of falling back.
360
+ ctx.SessionKey = route.sessionKey;
361
+ const storePath = deps.channelRuntime.session.resolveStorePath(deps.config.session?.store, {
362
+ agentId: route.agentId,
363
+ });
364
+ const finalized = deps.channelRuntime.reply.finalizeInboundContext(
365
+ ctx as Parameters<typeof deps.channelRuntime.reply.finalizeInboundContext>[0],
366
+ );
367
+
368
+ logger.info(
369
+ `inbound: from=${finalized.From} to=${finalized.To} bodyLen=${(finalized.Body ?? "").length}`,
370
+ );
371
+ logger.debug(`inbound context: ${redactBody(JSON.stringify(finalized))}`);
372
+
373
+ await deps.channelRuntime.session.recordInboundSession({
374
+ storePath,
375
+ sessionKey: route.sessionKey,
376
+ ctx: finalized as Parameters<typeof deps.channelRuntime.session.recordInboundSession>[0]["ctx"],
377
+ updateLastRoute: {
378
+ sessionKey: route.mainSessionKey,
379
+ channel: "openclaw-workchat",
380
+ to: ctx.To,
381
+ accountId: deps.accountId,
382
+ },
383
+ onRecordError: (err) => deps.errLog(`recordInboundSession: ${String(err)}`),
384
+ });
385
+ logger.debug(`recordInboundSession: done storePath=${storePath} sessionKey=${route.sessionKey ?? "(none)"}`);
386
+
387
+ const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
388
+
389
+ const typingCallbacks = await createNoopTypingCallbacks();
390
+
391
+ /** Delivery records populated synchronously at deliver() entry, safe to read in finally. */
392
+ const debugDeliveries: Array<{ textLen: number; preview: string; ts: number }> = [];
393
+
394
+ const { dispatcher, replyOptions, markDispatchIdle } =
395
+ deps.channelRuntime.reply.createReplyDispatcherWithTyping({
396
+ humanDelay,
397
+ // Shape comes from a host-version-dependent loader; the local SDK's
398
+ // TypingCallbacks type does not apply cleanly across versions.
399
+ typingCallbacks: typingCallbacks as any,
400
+ deliver: async (payload) => {
401
+ // Lazy-load send + markdown filter to keep registration / QR binding import-light.
402
+ const [{ replyWorkChatMessage }, { StreamingMarkdownFilter }] = await Promise.all([
403
+ import("./send.js"),
404
+ import("./markdown-filter.js"),
405
+ ]);
406
+ const rawText = String(payload.text ?? "");
407
+ const f = new StreamingMarkdownFilter();
408
+ let text = f.feed(rawText) + f.flush();
409
+
410
+ logger.info(`outbound: to=${ctx.To} textLen=${text.length}`);
411
+
412
+ if (debug) {
413
+ debugDeliveries.push({
414
+ textLen: text.length,
415
+ preview: `${text.slice(0, 60)}${text.length > 60 ? "…" : ""}`,
416
+ ts: Date.now(),
417
+ });
418
+ }
419
+
420
+ const sendingResult = await applyWorkChatMessageSendingHook({
421
+ to: ctx.To,
422
+ text,
423
+ accountId: deps.accountId,
424
+ });
425
+ if (sendingResult.cancelled) {
426
+ logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
427
+ return;
428
+ }
429
+ text = sendingResult.text;
430
+
431
+ try {
432
+ const result = await replyWorkChatMessage({
433
+ account: {
434
+ ...deps.account,
435
+ agentId: ctx.AgentId ?? deps.account.agentId,
436
+ userId: ctx.From || deps.account.userId,
437
+ },
438
+ to: ctx.To,
439
+ text,
440
+ replyToMessageId: ctx.MessageSid,
441
+ conversationId: ctx.ConversationId,
442
+ agentId: ctx.AgentId,
443
+ last: true,
444
+ });
445
+ emitWorkChatMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
446
+ logger.info(`outbound: reply sent OK to=${ctx.To} messageId=${result.messageId}`);
447
+ } catch (err) {
448
+ emitWorkChatMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId });
449
+ logger.error(`outbound: FAILED to=${ctx.To} err=${String(err)}`);
450
+ throw err;
451
+ }
452
+ },
453
+ onError: (err, info) => {
454
+ deps.errLog(`workchat reply ${info.kind}: ${String(err)}`);
455
+ const errMsg = err instanceof Error ? err.message : String(err);
456
+ const notice = `⚠️ 消息发送失败:${errMsg}`;
457
+ void sendWorkChatErrorNotice({
458
+ account: deps.account,
459
+ to: ctx.To,
460
+ message: notice,
461
+ agentId: ctx.AgentId,
462
+ errLog: deps.errLog,
463
+ });
464
+ },
465
+ });
466
+
467
+ logger.debug(`dispatchReplyFromConfig: starting agentId=${route.agentId ?? "(none)"}`);
468
+ try {
469
+ await deps.channelRuntime.reply.withReplyDispatcher({
470
+ dispatcher,
471
+ run: () =>
472
+ deps.channelRuntime.reply.dispatchReplyFromConfig({
473
+ ctx: finalized,
474
+ cfg: deps.config,
475
+ dispatcher,
476
+ replyOptions: { ...replyOptions, disableBlockStreaming: true },
477
+ }),
478
+ });
479
+ logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
480
+ } catch (err) {
481
+ logger.error(
482
+ `dispatchReplyFromConfig: error agentId=${route.agentId ?? "(none)"} err=${String(err)}`,
483
+ );
484
+ throw err;
485
+ } finally {
486
+ markDispatchIdle();
487
+
488
+ if (debug) {
489
+ const dispatchDoneAt = Date.now();
490
+ const inboundProcessMs = (debugTs.preDispatch ?? receivedAt) - receivedAt;
491
+ const aiMs = dispatchDoneAt - (debugTs.preDispatch ?? receivedAt);
492
+ const totalTime = dispatchDoneAt - receivedAt;
493
+
494
+ if (debugDeliveries.length > 0) {
495
+ debugTrace.push("── 回复 ──");
496
+ for (const d of debugDeliveries) {
497
+ debugTrace.push(`│ textLen=${d.textLen}`, `│ text="${d.preview}"`);
498
+ }
499
+ const firstTs = debugDeliveries[0].ts;
500
+ debugTrace.push(`│ deliver耗时: ${dispatchDoneAt - firstTs}ms`);
501
+ } else {
502
+ debugTrace.push("── 回复 ──", "│ (deliver未捕获)");
503
+ }
504
+
505
+ debugTrace.push(
506
+ "── 耗时 ──",
507
+ `├ 入站处理(auth+route): ${inboundProcessMs}ms`,
508
+ `├ AI生成+回复: ${aiMs}ms`,
509
+ `├ 总耗时: ${totalTime}ms`,
510
+ );
511
+
512
+ const timingText = `⏱ Debug 全链路\n${debugTrace.join("\n")}`;
513
+ logger.info(`debug-timing: sending to=${ctx.To}`);
514
+ void import("./send.js").then(({ pushWorkChatMessage }) =>
515
+ pushWorkChatMessage({
516
+ account: {
517
+ ...deps.account,
518
+ agentId: ctx.AgentId ?? deps.account.agentId,
519
+ userId: ctx.From || deps.account.userId,
520
+ },
521
+ to: ctx.To,
522
+ text: timingText,
523
+ agentId: ctx.AgentId,
524
+ }),
525
+ ).catch((debugErr) => {
526
+ logger.error(`debug-timing: send FAILED err=${String(debugErr)}`);
527
+ });
528
+ }
529
+ }
530
+ }