@soimy/dingtalk 3.3.0 → 3.4.0

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/src/channel.ts CHANGED
@@ -1,9 +1,6 @@
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";
3
+ import type { ChannelMessageActionAdapter, OpenClawConfig } from "openclaw/plugin-sdk";
7
4
  import * as pluginSdk from "openclaw/plugin-sdk";
8
5
  import { getAccessToken } from "./auth";
9
6
  import { analyzeCardCallback } from "./card-callback-service";
@@ -18,23 +15,24 @@ import {
18
15
  getConfig,
19
16
  isConfigured,
20
17
  mergeAccountWithDefaults,
18
+ resolveGroupConfig,
21
19
  resolveRelativePath,
22
20
  stripTargetPrefix,
23
21
  } from "./config";
24
22
  import { DingTalkConfigSchema } from "./config-schema.js";
25
23
  import { ConnectionManager } from "./connection-manager";
26
24
  import { isMessageProcessed, markMessageProcessed } from "./dedup";
25
+ import {
26
+ isFeedbackLearningAutoApplyEnabled,
27
+ isFeedbackLearningEnabled,
28
+ recordExplicitFeedbackLearning,
29
+ } from "./feedback-learning-service";
27
30
  import { handleDingTalkMessage } from "./inbound-handler";
28
31
  import { getLogger } from "./logger-context";
29
32
  import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
30
33
  import { dingtalkOnboardingAdapter } from "./onboarding.js";
31
34
  import { resolveOriginalPeerId, preloadPeerIdsFromSessions } from "./peer-id-registry";
32
35
  import { getDingTalkRuntime } from "./runtime";
33
- import {
34
- isFeedbackLearningAutoApplyEnabled,
35
- isFeedbackLearningEnabled,
36
- recordExplicitFeedbackLearning,
37
- } from "./feedback-learning-service";
38
36
  import {
39
37
  sendMessage,
40
38
  sendProactiveMedia,
@@ -42,6 +40,12 @@ import {
42
40
  sendBySession,
43
41
  uploadMedia,
44
42
  } from "./send-service";
43
+ import {
44
+ listDingTalkDirectoryGroups,
45
+ listDingTalkDirectoryUsers,
46
+ normalizeResolvedDingTalkTarget,
47
+ } from "./targeting/target-directory-adapter";
48
+ import { looksLikeDingTalkTargetId, normalizeDingTalkTarget } from "./targeting/target-input";
45
49
  import type {
46
50
  DingTalkInboundMessage,
47
51
  GatewayStartContext,
@@ -93,7 +97,11 @@ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefin
93
97
  if (typeof endpointConfig === "string") {
94
98
  return endpointConfig;
95
99
  }
96
- if (endpointConfig && typeof endpointConfig === "object" && typeof endpointConfig.endpoint === "string") {
100
+ if (
101
+ endpointConfig &&
102
+ typeof endpointConfig === "object" &&
103
+ typeof endpointConfig.endpoint === "string"
104
+ ) {
97
105
  return endpointConfig.endpoint;
98
106
  }
99
107
  return undefined;
@@ -101,7 +109,10 @@ function getInstrumentedEndpoint(client: InstrumentedDWClient): string | undefin
101
109
 
102
110
  function instrumentConnectionStages(client: DWClient): void {
103
111
  const instrumented = client as unknown as InstrumentedDWClient;
104
- if (typeof instrumented.getEndpoint !== "function" || typeof instrumented._connect !== "function") {
112
+ if (
113
+ typeof instrumented.getEndpoint !== "function" ||
114
+ typeof instrumented._connect !== "function"
115
+ ) {
105
116
  return;
106
117
  }
107
118
 
@@ -339,9 +350,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
339
350
  const config = getConfig(cfg);
340
351
  const id = accountId || "default";
341
352
  const account = config.accounts?.[id];
342
- const resolvedConfig = account
343
- ? mergeAccountWithDefaults(config, account)
344
- : config;
353
+ const resolvedConfig = account ? mergeAccountWithDefaults(config, account) : config;
345
354
  const configured = Boolean(resolvedConfig.clientId && resolvedConfig.clientSecret);
346
355
  return {
347
356
  accountId: id,
@@ -372,7 +381,16 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
372
381
  }),
373
382
  },
374
383
  groups: {
375
- resolveRequireMention: ({ cfg }: any): boolean => getConfig(cfg).groupPolicy !== "open",
384
+ resolveRequireMention: ({ cfg, groupId }: any): boolean => {
385
+ const config = getConfig(cfg);
386
+ if (groupId) {
387
+ const groupCfg = resolveGroupConfig(config, groupId);
388
+ if (groupCfg?.requireMention !== undefined) {
389
+ return groupCfg.requireMention;
390
+ }
391
+ }
392
+ return config.groupPolicy !== "open";
393
+ },
376
394
  resolveGroupIntroHint: ({ groupId, groupChannel }: any): string | undefined => {
377
395
  const parts = [`conversationId=${groupId}`];
378
396
  if (groupChannel) {
@@ -382,12 +400,20 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
382
400
  },
383
401
  },
384
402
  messaging: {
385
- normalizeTarget: (raw: string) => (raw ? raw.replace(/^(dingtalk|dd|ding):/i, "") : undefined),
403
+ normalizeTarget: (raw: string) => (raw ? normalizeDingTalkTarget(raw) : undefined),
386
404
  targetResolver: {
387
- looksLikeId: (id: string): boolean => /^[\w+\-/=]+$/.test(id),
388
- hint: "<conversationId>",
405
+ looksLikeId: (raw: string, normalized?: string): boolean =>
406
+ looksLikeDingTalkTargetId(raw, normalized),
407
+ hint: "<displayName|conversationId|user:staffId|user:+861...>",
389
408
  },
390
409
  },
410
+ directory: {
411
+ self: async () => null,
412
+ listGroups: async (params) => listDingTalkDirectoryGroups(params),
413
+ listGroupsLive: async (params) => listDingTalkDirectoryGroups(params),
414
+ listPeers: async (params) => listDingTalkDirectoryUsers(params),
415
+ listPeersLive: async (params) => listDingTalkDirectoryUsers(params),
416
+ },
391
417
  actions: dingtalkMessageActions,
392
418
  outbound: {
393
419
  deliveryMode: "direct" as const,
@@ -399,9 +425,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
399
425
  error: new Error("DingTalk message requires --to <conversationId>"),
400
426
  };
401
427
  }
402
- const { targetId } = stripTargetPrefix(trimmed);
403
- const resolved = resolveOriginalPeerId(targetId);
404
- return { ok: true as const, to: resolved };
428
+ return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
405
429
  },
406
430
  sendText: async ({ cfg, to, text, accountId, log }: any) => {
407
431
  const config = getConfig(cfg, accountId);
@@ -490,12 +514,17 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
490
514
  preparedMedia = await prepareMediaInput(rawMediaPath, log, config.mediaUrlAllowlist);
491
515
  } catch (err: any) {
492
516
  if (err?.response?.data !== undefined) {
493
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data));
517
+ log?.error?.(
518
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.prepare", err.response.data),
519
+ );
494
520
  }
495
521
  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
- });
522
+ throw new Error(
523
+ `remote media preparation failed: ${errorCode}${err?.message || "unknown error"}`,
524
+ {
525
+ cause: err,
526
+ },
527
+ );
499
528
  }
500
529
 
501
530
  const actualMediaPath = preparedMedia.cleanup
@@ -521,7 +550,9 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
521
550
  });
522
551
  } catch (err: any) {
523
552
  if (err?.response?.data !== undefined) {
524
- log?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data));
553
+ log?.error?.(
554
+ formatDingTalkErrorPayloadLog("outbound.sendMedia.send", err.response.data),
555
+ );
525
556
  }
526
557
  throw new Error(`proactive media send failed: ${err?.message || "unknown error"}`, {
527
558
  cause: err,
@@ -657,6 +688,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
657
688
  if (!dedupKey) {
658
689
  ctx.log?.warn?.(`[${account.accountId}] No message ID available for deduplication`);
659
690
  stats.noMessageId += 1;
691
+ acknowledge();
660
692
  await handleDingTalkMessage({
661
693
  cfg,
662
694
  accountId: account.accountId,
@@ -666,7 +698,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
666
698
  dingtalkConfig: config,
667
699
  });
668
700
  stats.processed += 1;
669
- acknowledge();
670
701
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
671
702
  logInboundCounters(ctx.log, account.accountId, "periodic");
672
703
  }
@@ -693,11 +724,13 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
693
724
  `[${account.accountId}] Skipping in-flight duplicate message: ${dedupKey}`,
694
725
  );
695
726
  stats.inflightSkipped += 1;
727
+ acknowledge();
696
728
  logInboundCounters(ctx.log, account.accountId, "inflight-skipped");
697
729
  return;
698
730
  }
699
731
  }
700
732
 
733
+ acknowledge();
701
734
  processingDedupKeys.set(dedupKey, Date.now());
702
735
  try {
703
736
  await handleDingTalkMessage({
@@ -710,7 +743,6 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
710
743
  });
711
744
  stats.processed += 1;
712
745
  markMessageProcessed(dedupKey);
713
- acknowledge();
714
746
  if (stats.received % INBOUND_COUNTER_LOG_EVERY === 0) {
715
747
  logInboundCounters(ctx.log, account.accountId, "periodic");
716
748
  }
@@ -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
@@ -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
  /**