@soimy/dingtalk 3.3.0 → 3.4.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.
Files changed (36) hide show
  1. package/README.md +141 -12
  2. package/index.ts +71 -66
  3. package/package.json +6 -5
  4. package/src/access-control.ts +65 -0
  5. package/src/ack-reaction/dynamic-ack-reaction-controller.ts +271 -0
  6. package/src/ack-reaction/dynamic-ack-reaction-events.ts +123 -0
  7. package/src/ack-reaction/dynamic-ack-reaction-progress.ts +59 -0
  8. package/src/ack-reaction-classifier.ts +17 -4
  9. package/src/ack-reaction-service.ts +66 -19
  10. package/src/attachment-text-extractor.ts +2 -1
  11. package/src/card-service.ts +145 -257
  12. package/src/channel.ts +106 -47
  13. package/src/config-schema.ts +28 -6
  14. package/src/config.ts +30 -6
  15. package/src/connection-manager.ts +16 -5
  16. package/src/inbound-handler.ts +694 -520
  17. package/src/media-utils.ts +99 -36
  18. package/src/message-context-store.ts +787 -0
  19. package/src/message-utils.ts +221 -42
  20. package/src/messaging/quoted-context.ts +269 -0
  21. package/src/messaging/quoted-ref.ts +97 -0
  22. package/src/onboarding.ts +381 -269
  23. package/src/reply-strategy-card.ts +225 -0
  24. package/src/reply-strategy-markdown.ts +55 -0
  25. package/src/reply-strategy-with-reaction.ts +190 -0
  26. package/src/reply-strategy.ts +72 -0
  27. package/src/runtime.ts +5 -7
  28. package/src/send-service.ts +164 -62
  29. package/src/targeting/agent-name-matcher.ts +148 -0
  30. package/src/targeting/agent-routing.ts +181 -0
  31. package/src/targeting/target-directory-adapter.ts +152 -0
  32. package/src/targeting/target-directory-store.ts +396 -0
  33. package/src/targeting/target-input.ts +62 -0
  34. package/src/types.ts +124 -21
  35. package/src/quote-journal.ts +0 -242
  36. package/src/quoted-msg-cache.ts +0 -226
package/src/channel.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { DWClient, TOPIC_CARD, TOPIC_ROBOT } from "dingtalk-stream";
3
- import type {
4
- ChannelMessageActionAdapter,
5
- OpenClawConfig,
6
- } from "openclaw/plugin-sdk";
7
- import * as pluginSdk from "openclaw/plugin-sdk";
3
+ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
4
+ import { buildChannelConfigSchema, type OpenClawConfig } from "openclaw/plugin-sdk/core";
5
+ import { jsonResult } from "openclaw/plugin-sdk/telegram-core";
6
+ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
7
+ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
8
8
  import { getAccessToken } from "./auth";
9
9
  import { analyzeCardCallback } from "./card-callback-service";
10
10
  import {
@@ -18,23 +18,24 @@ import {
18
18
  getConfig,
19
19
  isConfigured,
20
20
  mergeAccountWithDefaults,
21
+ resolveGroupConfig,
21
22
  resolveRelativePath,
22
23
  stripTargetPrefix,
23
24
  } from "./config";
24
25
  import { DingTalkConfigSchema } from "./config-schema.js";
25
26
  import { ConnectionManager } from "./connection-manager";
26
27
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
27
- import { handleDingTalkMessage } from "./inbound-handler";
28
- import { getLogger } from "./logger-context";
29
- import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
30
- import { dingtalkOnboardingAdapter } from "./onboarding.js";
31
- import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
32
- import { getDingTalkRuntime } from "./runtime";
33
28
  import {
34
29
  isFeedbackLearningAutoApplyEnabled,
35
30
  isFeedbackLearningEnabled,
36
31
  recordExplicitFeedbackLearning,
37
32
  } from "./feedback-learning-service";
33
+ import { handleDingTalkMessage } from "./inbound-handler";
34
+ import { getLogger } from "./logger-context";
35
+ import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
36
+ import { dingtalkSetupAdapter, dingtalkSetupWizard } from "./onboarding.js";
37
+ import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
38
+ import { getDingTalkRuntime } from "./runtime";
38
39
  import {
39
40
  sendMessage,
40
41
  sendProactiveMedia,
@@ -42,6 +43,12 @@ import {
42
43
  sendBySession,
43
44
  uploadMedia,
44
45
  } from "./send-service";
46
+ import {
47
+ listDingTalkDirectoryGroups,
48
+ listDingTalkDirectoryUsers,
49
+ normalizeResolvedDingTalkTarget,
50
+ } from "./targeting/target-directory-adapter";
51
+ import { looksLikeDingTalkTargetId, normalizeDingTalkTarget } from "./targeting/target-input";
45
52
  import type {
46
53
  DingTalkInboundMessage,
47
54
  GatewayStartContext,
@@ -93,7 +100,11 @@ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefin
93
100
  if (typeof endpointConfig === "string") {
94
101
  return endpointConfig;
95
102
  }
96
- if (endpointConfig && typeof endpointConfig === "object" && typeof endpointConfig.endpoint === "string") {
103
+ if (
104
+ endpointConfig &&
105
+ typeof endpointConfig === "object" &&
106
+ typeof endpointConfig.endpoint === "string"
107
+ ) {
97
108
  return endpointConfig.endpoint;
98
109
  }
99
110
  return undefined;
@@ -101,7 +112,10 @@ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefin
101
112
 
102
113
  function instrumentConnectionStages(client: DWClient): void {
103
114
  const instrumented = client as unknown as InstrumentedDWClient;
104
- if (typeof instrumented.getEndpoint !== "function" || typeof instrumented._connect !== "function") {
115
+ if (
116
+ typeof instrumented.getEndpoint !== "function" ||
117
+ typeof instrumented._connect !== "function"
118
+ ) {
105
119
  return;
106
120
  }
107
121
 
@@ -195,26 +209,46 @@ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boo
195
209
  return undefined;
196
210
  }
197
211
 
212
+ function describeDingTalkMessageTool(cfg: OpenClawConfig): {
213
+ actions: readonly ["send"] | readonly [];
214
+ capabilities: readonly ["cards"] | readonly [];
215
+ schema: null;
216
+ } {
217
+ const config = getConfig(cfg);
218
+ const configured = Boolean(config.clientId && config.clientSecret);
219
+ if (!configured && !(config.accounts && Object.keys(config.accounts).length > 0)) {
220
+ return { actions: [], capabilities: [], schema: null };
221
+ }
222
+ const hasCardMode =
223
+ config.messageType === "card" ||
224
+ (config.accounts && Object.values(config.accounts).some((a) => a?.messageType === "card"));
225
+ return {
226
+ actions: ["send"] as const,
227
+ capabilities: hasCardMode ? (["cards"] as const) : [],
228
+ schema: null,
229
+ };
230
+ }
231
+
198
232
  const dingtalkMessageActions: ChannelMessageActionAdapter = {
199
- listActions: () => ["send"],
233
+ describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
200
234
  supportsAction: ({ action }) => action === "send",
201
- extractToolSend: ({ args }) => pluginSdk.extractToolSend(args, "sendMessage"),
202
- handleAction: async ({ action, params, cfg, accountId, dryRun }) => {
235
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
236
+ handleAction: async ({ action, params, cfg, accountId, dryRun, mediaLocalRoots }) => {
203
237
  if (action !== "send") {
204
238
  throw new Error(`Action ${action} is not supported for provider dingtalk.`);
205
239
  }
206
240
 
207
- const to = pluginSdk.readStringParam(params, "to", { required: true });
241
+ const to = readStringParam(params, "to", { required: true });
208
242
  const mediaInput =
209
- pluginSdk.readStringParam(params, "media", { trim: false }) ??
210
- pluginSdk.readStringParam(params, "path", { trim: false }) ??
211
- pluginSdk.readStringParam(params, "filePath", { trim: false }) ??
212
- pluginSdk.readStringParam(params, "mediaUrl", { trim: false });
243
+ readStringParam(params, "media", { trim: false }) ??
244
+ readStringParam(params, "path", { trim: false }) ??
245
+ readStringParam(params, "filePath", { trim: false }) ??
246
+ readStringParam(params, "mediaUrl", { trim: false });
213
247
 
214
248
  const hasMedia = Boolean(mediaInput && mediaInput.trim());
215
- const caption = pluginSdk.readStringParam(params, "caption", { allowEmpty: true }) ?? "";
249
+ const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
216
250
  let message =
217
- pluginSdk.readStringParam(params, "message", {
251
+ readStringParam(params, "message", {
218
252
  required: !hasMedia,
219
253
  allowEmpty: true,
220
254
  }) ?? "";
@@ -224,12 +258,12 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
224
258
  }
225
259
 
226
260
  const asVoice = readBooleanLikeParam(params, "asVoice") === true;
227
- const requestedMediaType = pluginSdk.readStringParam(params, "mediaType");
261
+ const requestedMediaType = readStringParam(params, "mediaType");
228
262
 
229
263
  const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
230
264
 
231
265
  if (dryRun) {
232
- return pluginSdk.jsonResult({
266
+ return jsonResult({
233
267
  ok: true,
234
268
  dryRun: true,
235
269
  to: target,
@@ -256,13 +290,14 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
256
290
  const result = await sendProactiveMedia(config, target, mediaPath, mediaType, {
257
291
  log,
258
292
  accountId: accountId ?? undefined,
293
+ mediaLocalRoots: mediaLocalRoots ? [...mediaLocalRoots] : undefined,
259
294
  });
260
295
 
261
296
  if (!result.ok) {
262
297
  throw new Error(result.error || "send media failed");
263
298
  }
264
299
 
265
- return pluginSdk.jsonResult({
300
+ return jsonResult({
266
301
  ok: true,
267
302
  to: target,
268
303
  mediaType,
@@ -294,7 +329,7 @@ const dingtalkMessageActions: ChannelMessageActionAdapter = {
294
329
  }
295
330
 
296
331
  const data = result.data as any;
297
- return pluginSdk.jsonResult({
332
+ return jsonResult({
298
333
  ok: true,
299
334
  to: target,
300
335
  messageId: data?.processQueryKey || data?.messageId || null,
@@ -311,12 +346,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
311
346
  id: "dingtalk",
312
347
  label: "DingTalk",
313
348
  selectionLabel: "DingTalk (钉钉)",
314
- docsPath: "/channels/dingtalk",
349
+ docsPath: "https://github.com/soimy/openclaw-channel-dingtalk",
315
350
  blurb: "钉钉企业内部机器人,使用 Stream 模式,无需公网 IP。",
316
351
  aliases: ["dd", "ding"],
317
352
  },
318
- configSchema: pluginSdk.buildChannelConfigSchema(DingTalkConfigSchema),
319
- onboarding: dingtalkOnboardingAdapter,
353
+ configSchema: buildChannelConfigSchema(DingTalkConfigSchema),
354
+ setup: dingtalkSetupAdapter,
355
+ setupWizard: dingtalkSetupWizard,
320
356
  capabilities: {
321
357
  chatTypes: ["direct", "group"] as Array<"direct" | "group">,
322
358
  reactions: false,
@@ -339,9 +375,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
339
375
  const config = getConfig(cfg);
340
376
  const id = accountId || "default";
341
377
  const account = config.accounts?.[id];
342
- const resolvedConfig = account
343
- ? mergeAccountWithDefaults(config, account)
344
- : config;
378
+ const resolvedConfig = account ? mergeAccountWithDefaults(config, account) : config;
345
379
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
346
380
  return {
347
381
  accountId: id,
@@ -372,7 +406,16 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
372
406
  }),
373
407
  },
374
408
  groups: {
375
- resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== "open",
409
+ resolveRequireMention: ({ cfg, groupId }: any): boolean => {
410
+ const config = getConfig(cfg);
411
+ if (groupId) {
412
+ const groupCfg = resolveGroupConfig(config, groupId);
413
+ if (groupCfg?.requireMention !== undefined) {
414
+ return groupCfg.requireMention;
415
+ }
416
+ }
417
+ return config.groupPolicy !== "open";
418
+ },
376
419
  resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
377
420
  const parts = [`conversationId=${groupId}`];
378
421
  if (groupChannel) {
@@ -382,12 +425,20 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
382
425
  },
383
426
  },
384
427
  messaging: {
385
- normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, "") : undefined),
428
+ normalizeTarget: (raw: string) => (raw ? normalizeDingTalkTarget(raw) : undefined),
386
429
  targetResolver: {
387
- looksLikeId: (id: string): boolean => /^[\w+\-/=]+$/.test(id),
388
- hint: "<conversationId>",
430
+ looksLikeId: (raw: string, normalized?: string): boolean =>
431
+ looksLikeDingTalkTargetId(raw, normalized),
432
+ hint: "<displayName|conversationId|user:staffId|user:+861...>",
389
433
  },
390
434
  },
435
+ directory: {
436
+ self: async () => null,
437
+ listGroups: async (params) => listDingTalkDirectoryGroups(params),
438
+ listGroupsLive: async (params) => listDingTalkDirectoryGroups(params),
439
+ listPeers: async (params) => listDingTalkDirectoryUsers(params),
440
+ listPeersLive: async (params) => listDingTalkDirectoryUsers(params),
441
+ },
391
442
  actions: dingtalkMessageActions,
392
443
  outbound: {
393
444
  deliveryMode: "direct" as const,
@@ -399,9 +450,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
399
450
  error: new Error("DingTalk message requires --to <conversationId>"),
400
451
  };
401
452
  }
402
- const { targetId } = stripTargetPrefix(trimmed);
403
- const resolved = resolveOriginalPeerId(targetId);
404
- return { ok: true as const, to: resolved };
453
+ return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
405
454
  },
406
455
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
407
456
  const config = getConfig(cfg, accountId);
@@ -455,6 +504,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
455
504
  mediaType: providedMediaType,
456
505
  asVoice,
457
506
  accountId,
507
+ mediaLocalRoots,
458
508
  log,
459
509
  }: any) => {
460
510
  const config = getConfig(cfg, accountId);
@@ -490,12 +540,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
490
540
  preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
491
541
  } catch (err: any) {
492
542
  if (err?.response?.data !== undefined) {
493
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
543
+ log?.error?.(
544
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data),
545
+ );
494
546
  }
495
547
  const errorCode = typeof err?.code === "string" ? `[${err.code}] ` : "";
496
- throw new Error(`remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`, {
497
- cause: err,
498
- });
548
+ throw new Error(
549
+ `remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`,
550
+ {
551
+ cause: err,
552
+ },
553
+ );
499
554
  }
500
555
 
501
556
  const actualMediaPath = preparedMedia.cleanup
@@ -518,10 +573,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
518
573
  accountId,
519
574
  storePath,
520
575
  conversationId: to,
576
+ mediaLocalRoots,
521
577
  });
522
578
  } catch (err: any) {
523
579
  if (err?.response?.data !== undefined) {
524
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
580
+ log?.error?.(
581
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data),
582
+ );
525
583
  }
526
584
  throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
527
585
  cause: err,
@@ -657,6 +715,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
657
715
  if (!dedupKey) {
658
716
  ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
659
717
  stats.noMessageId += 1;
718
+ acknowledge();
660
719
  await handleDingTalkMessage({
661
720
  cfg,
662
721
  accountId: account.accountId,
@@ -666,7 +725,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
666
725
  dingtalkConfig: config,
667
726
  });
668
727
  stats.processed += 1;
669
- acknowledge();
670
728
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
671
729
  logInboundCounters(ctx.log, account.accountId, "periodic");
672
730
  }
@@ -693,11 +751,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
693
751
  `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
694
752
  );
695
753
  stats.inflightSkipped += 1;
754
+ acknowledge();
696
755
  logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
697
756
  return;
698
757
  }
699
758
  }
700
759
 
760
+ acknowledge();
701
761
  processingDedupKeys.set(dedupKey, Date.now());
702
762
  try {
703
763
  await handleDingTalkMessage({
@@ -710,7 +770,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
710
770
  });
711
771
  stats.processed += 1;
712
772
  markMessageProcessed(dedupKey);
713
- acknowledge();
714
773
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
715
774
  logInboundCounters(ctx.log, account.accountId, "periodic");
716
775
  }
@@ -1,5 +1,11 @@
1
1
  import { z } from "zod";
2
- import { DEFAULT_JOURNAL_TTL_DAYS } from "./quote-journal";
2
+ import { DEFAULT_MESSAGE_CONTEXT_TTL_DAYS } from "./message-context-store";
3
+
4
+ const AckReactionSchema = z.union([
5
+ z.literal(""),
6
+ z.enum(["off", "emoji", "kaomoji"]),
7
+ z.string().min(1),
8
+ ]);
3
9
 
4
10
  const DingTalkAccountConfigShape = {
5
11
  /** Account name (optional display name) */
@@ -26,18 +32,24 @@ const DingTalkAccountConfigShape = {
26
32
  /** Direct message policy: open, pairing, or allowlist */
27
33
  dmPolicy: z.enum(["open", "pairing", "allowlist"]).optional().default("open"),
28
34
 
29
- /** Group message policy: open or allowlist */
30
- groupPolicy: z.enum(["open", "allowlist"]).optional().default("open"),
35
+ /** Group message policy: open, allowlist, or disabled */
36
+ groupPolicy: z.enum(["open", "allowlist", "disabled"]).optional().default("open"),
31
37
 
32
38
  /** List of allowed user IDs for allowlist policy */
33
39
  allowFrom: z.array(z.string()).optional(),
34
40
 
41
+ /** List of allowed user IDs for group allowlist policy */
42
+ groupAllowFrom: z.array(z.string()).optional(),
43
+
44
+ /** Default disabled. Enabling "all" allows learned displayName lookup but may misroute on stale/duplicate names and is available to all callers until upstream exposes requester authz context. */
45
+ displayNameResolution: z.enum(["disabled", "all"]).optional().default("disabled"),
46
+
35
47
  mediaUrlAllowlist: z.array(z.string()).optional(),
36
48
 
37
- /** Official OpenClaw ackReaction entry for processing feedback; empty string disables it */
38
- ackReaction: z.string().optional(),
49
+ /** Native ack reaction mode: off, emoji, or kaomoji */
50
+ ackReaction: AckReactionSchema.optional(),
39
51
 
40
- journalTTLDays: z.number().int().min(1).optional().default(DEFAULT_JOURNAL_TTL_DAYS),
52
+ journalTTLDays: z.number().int().min(1).optional().default(DEFAULT_MESSAGE_CONTEXT_TTL_DAYS),
41
53
  /** Enable debug logging */
42
54
  debug: z.boolean().optional().default(false),
43
55
 
@@ -62,6 +74,8 @@ const DingTalkAccountConfigShape = {
62
74
  z.string(),
63
75
  z.object({
64
76
  systemPrompt: z.string().optional(),
77
+ requireMention: z.boolean().optional(),
78
+ groupAllowFrom: z.array(z.string()).optional(),
65
79
  }),
66
80
  )
67
81
  .optional(),
@@ -128,6 +142,14 @@ const DingTalkAccountConfigShape = {
128
142
 
129
143
  /** @deprecated Use learningNoteTtlMs */
130
144
  feedbackLearningNoteTtlMs: z.number().int().min(60_000).optional(),
145
+
146
+ /** Whether to convert markdown tables to plain text for better rendering on some clients (default: true) */
147
+ convertMarkdownTables: z.boolean().optional().default(true),
148
+
149
+ /** @mention the sender after card finalization in group chats.
150
+ * Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
151
+ * Leave empty or omit to disable. */
152
+ cardAtSender: z.string().optional(),
131
153
  } as const;
132
154
 
133
155
  const DingTalkAccountConfigSchema = z.object(DingTalkAccountConfigShape);
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as os from "node:os";
2
2
  import * as path from "node:path";
3
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
4
  import type { DingTalkConfig } from "./types";
5
5
 
6
6
  const WINDOWS_ROOT_DIRECTORIES = new Set([
@@ -145,7 +145,7 @@ export const resolveUserPath = resolveRelativePath;
145
145
  export function resolveGroupConfig(
146
146
  cfg: DingTalkConfig,
147
147
  groupId: string,
148
- ): { systemPrompt?: string } | undefined {
148
+ ): { systemPrompt?: string; requireMention?: boolean; groupAllowFrom?: string[] } | undefined {
149
149
  // Group config supports exact match first, then wildcard fallback.
150
150
  const groups = cfg.groups;
151
151
  if (!groups) {
@@ -169,6 +169,30 @@ function resolveAgentIdentityEmoji(cfg: OpenClawConfig, agentId?: string | null)
169
169
  return emoji || undefined;
170
170
  }
171
171
 
172
+ function normalizeAckReactionValue(value: unknown): string | undefined {
173
+ if (typeof value !== "string") {
174
+ return undefined;
175
+ }
176
+ const trimmed = value.trim();
177
+ if (!trimmed) {
178
+ return "";
179
+ }
180
+ const normalized = trimmed.toLowerCase();
181
+ if (normalized === "off") {
182
+ return "off";
183
+ }
184
+ if (normalized === "emoji") {
185
+ return "emoji";
186
+ }
187
+ if (normalized === "kaomoji") {
188
+ return "kaomoji";
189
+ }
190
+ if (trimmed === "🤔思考中") {
191
+ return "emoji";
192
+ }
193
+ return trimmed;
194
+ }
195
+
172
196
  export function resolveAckReactionSetting(params: {
173
197
  cfg: OpenClawConfig;
174
198
  accountId?: string | null;
@@ -182,18 +206,18 @@ export function resolveAckReactionSetting(params: {
182
206
  : undefined;
183
207
 
184
208
  if (hasOwn(accountConfig, "ackReaction")) {
185
- return typeof accountConfig.ackReaction === "string" ? accountConfig.ackReaction.trim() : "";
209
+ return normalizeAckReactionValue(accountConfig.ackReaction);
186
210
  }
187
211
  if (hasOwn(dingtalk, "ackReaction")) {
188
- return typeof dingtalk.ackReaction === "string" ? dingtalk.ackReaction.trim() : "";
212
+ return normalizeAckReactionValue(dingtalk.ackReaction);
189
213
  }
190
214
 
191
215
  const messages = (params.cfg as any)?.messages;
192
216
  if (hasOwn(messages, "ackReaction")) {
193
- return typeof messages.ackReaction === "string" ? messages.ackReaction.trim() : "";
217
+ return normalizeAckReactionValue(messages.ackReaction);
194
218
  }
195
219
 
196
- return resolveAgentIdentityEmoji(params.cfg, params.agentId);
220
+ return resolveAgentIdentityEmoji(params.cfg, params.agentId) || "👀";
197
221
  }
198
222
 
199
223
  /**
@@ -112,6 +112,18 @@ export class ConnectionManager {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * Resolve all pending waitForStop() callers without performing full stop().
117
+ * Called by stop() and terminal FAILED states where the manager will never
118
+ * reconnect again, so startAccount must be allowed to exit.
119
+ */
120
+ private resolveStopWaiters(): void {
121
+ for (const resolve of this.stopPromiseResolvers) {
122
+ resolve();
123
+ }
124
+ this.stopPromiseResolvers = [];
125
+ }
126
+
115
127
  private logRuntimeCounters(reason: string): void {
116
128
  const c = this.runtimeCounters;
117
129
  this.log?.info?.(
@@ -722,6 +734,7 @@ export class ConnectionManager {
722
734
  this.notifyStateChange(
723
735
  `Max consecutive deadline timeouts (${ConnectionManager.MAX_CONSECUTIVE_DEADLINE_TIMEOUTS}) reached`,
724
736
  );
737
+ this.resolveStopWaiters();
725
738
  return;
726
739
  }
727
740
 
@@ -755,6 +768,7 @@ export class ConnectionManager {
755
768
  this.consecutiveUnhealthyChecks = 0;
756
769
  this.reconnectDeadline = undefined;
757
770
  this.notifyStateChange(`Max runtime reconnect cycles (${maxCycles}) reached`);
771
+ this.resolveStopWaiters();
758
772
  return;
759
773
  }
760
774
 
@@ -822,10 +836,7 @@ export class ConnectionManager {
822
836
  this.log?.info?.(`[${this.accountId}] Connection manager stopped`);
823
837
 
824
838
  // Resolve all pending waitForStop() promises
825
- for (const resolve of this.stopPromiseResolvers) {
826
- resolve();
827
- }
828
- this.stopPromiseResolvers = [];
839
+ this.resolveStopWaiters();
829
840
  }
830
841
 
831
842
  /**
@@ -835,7 +846,7 @@ export class ConnectionManager {
835
846
  * Safe to call concurrently; all pending callers are resolved when stop() is called.
836
847
  */
837
848
  public waitForStop(): Promise<void> {
838
- if (this.stopped) {
849
+ if (this.stopped || this.state === ConnectionStateEnum.FAILED) {
839
850
  return Promise.resolve();
840
851
  }
841
852
  return new Promise<void>((resolve) => {