@soimy/dingtalk 3.5.3 → 3.6.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 (39) hide show
  1. package/README.md +4 -1
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +153 -5
  4. package/package.json +1 -1
  5. package/src/auth.ts +5 -2
  6. package/src/card/card-markdown-image-reroute.ts +106 -0
  7. package/src/card/card-run-registry.ts +54 -1
  8. package/src/card/card-stop-handler.ts +10 -20
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/statusline-renderer.ts +94 -0
  11. package/src/card-draft-controller.ts +245 -52
  12. package/src/card-service.ts +408 -23
  13. package/src/channel.ts +24 -1083
  14. package/src/config-schema.ts +21 -1
  15. package/src/config.ts +139 -66
  16. package/src/device-registration.ts +245 -0
  17. package/src/gateway/channel-gateway.ts +637 -0
  18. package/src/inbound-handler.ts +1276 -975
  19. package/src/media-utils.ts +6 -0
  20. package/src/message-context-store.ts +183 -85
  21. package/src/message-utils.ts +124 -16
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +174 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/onboarding.ts +333 -235
  26. package/src/path-utils.ts +49 -0
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/reply-strategy-card.ts +373 -64
  29. package/src/reply-strategy-markdown.ts +1 -1
  30. package/src/reply-strategy-types.ts +93 -0
  31. package/src/reply-strategy-with-reaction.ts +1 -1
  32. package/src/reply-strategy.ts +14 -72
  33. package/src/run-usage-store.ts +59 -0
  34. package/src/secret-input.ts +216 -0
  35. package/src/send-service.ts +115 -3
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +30 -5
  39. package/src/types.ts +48 -157
@@ -0,0 +1,85 @@
1
+ import { sendMessage } from "../send-service";
2
+ import type { DingTalkConfig, Logger } from "../types";
3
+
4
+ const MAX_QUESTION_LENGTH = 80;
5
+ const LEADING_MENTIONS_RE = /^(?:@\S+\s+)*/u;
6
+
7
+ /**
8
+ * Strip leading `@mention` tokens from inbound text. Used by both the abort and
9
+ * BTW bypass branches in `inbound-handler.ts` so that command detection works
10
+ * uniformly in DM and group chats.
11
+ */
12
+ export function stripLeadingMentions(text: string): string {
13
+ return text.replace(LEADING_MENTIONS_RE, "");
14
+ }
15
+
16
+ export function buildBtwBlockquote(senderName: string, rawQuestion: string): string {
17
+ const stripped = stripLeadingMentions(rawQuestion);
18
+ // Iterate by Unicode code points (not UTF-16 code units) so emoji /
19
+ // surrogate pairs aren't sliced in half at the truncation boundary.
20
+ const codePoints = Array.from(stripped);
21
+ const truncated =
22
+ codePoints.length > MAX_QUESTION_LENGTH
23
+ ? `${codePoints.slice(0, MAX_QUESTION_LENGTH).join("")}…`
24
+ : stripped;
25
+ const senderPrefix = senderName ? `${senderName}: ` : "";
26
+ return `> ${senderPrefix}${truncated}\n\n`;
27
+ }
28
+
29
+ export interface DeliverBtwReplyArgs {
30
+ config: DingTalkConfig;
31
+ sessionWebhook: string | undefined;
32
+ conversationId: string;
33
+ to: string;
34
+ senderName: string;
35
+ rawQuestion: string;
36
+ replyText: string;
37
+ log: Logger | undefined;
38
+ accountId?: string;
39
+ storePath?: string;
40
+ }
41
+
42
+ /**
43
+ * Deliver a BTW reply through the unified `sendMessage` entry point.
44
+ *
45
+ * BTW is a special inbound trigger, but the *outbound* reply is still a regular
46
+ * markdown/text message and must inherit the standard send-service semantics:
47
+ * persistence into the message context store, delivery metadata tracking, and
48
+ * the single `{ ok, error, ... }` contract. We pass `forceMarkdown: true` so
49
+ * that `sendMessage` skips the card branch even when the channel is configured
50
+ * for card mode — BTW must never create or touch an AI Card (see CLAUDE.md
51
+ * anti-pattern: "Do not create multiple active AI Cards for the same
52
+ * `accountId:conversationId`").
53
+ *
54
+ * When `sessionWebhook` is present `sendMessage` internally dispatches via
55
+ * `sendBySession`; otherwise it falls back to the proactive text/markdown API.
56
+ * Either way the caller sees the same return shape, and failures propagate as
57
+ * `{ ok: false }` instead of being silently swallowed.
58
+ */
59
+ export async function deliverBtwReply(
60
+ args: DeliverBtwReplyArgs,
61
+ ): Promise<{ ok: boolean; error?: string }> {
62
+ const blockquote = buildBtwBlockquote(args.senderName, args.rawQuestion);
63
+ const fullText = `${blockquote}${args.replyText}`;
64
+
65
+ try {
66
+ const result = await sendMessage(args.config, args.to, fullText, {
67
+ log: args.log,
68
+ accountId: args.accountId,
69
+ storePath: args.storePath,
70
+ conversationId: args.conversationId,
71
+ sessionWebhook: args.sessionWebhook,
72
+ forceMarkdown: true,
73
+ });
74
+ if (!result.ok) {
75
+ args.log?.warn?.(
76
+ `[DingTalk] BTW reply delivery returned not-ok: ${result.error ?? "unknown"}`,
77
+ );
78
+ }
79
+ return { ok: result.ok, error: result.error };
80
+ } catch (err) {
81
+ const error = err instanceof Error ? err.message : String(err);
82
+ args.log?.warn?.(`[DingTalk] BTW reply delivery threw: ${error}`);
83
+ return { ok: false, error };
84
+ }
85
+ }
@@ -0,0 +1,174 @@
1
+ import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
2
+ import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract";
3
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
4
+ import { readStringParam } from "openclaw/plugin-sdk/param-readers";
5
+ import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
6
+ import { getConfig, stripTargetPrefix } from "../config";
7
+ import { getLogger } from "../logger-context";
8
+ import { resolveOriginalPeerId } from "../peer-id-registry";
9
+ import { hasConfiguredSecretInput } from "../secret-input";
10
+ import { sendMedia, sendMessage } from "../send-service";
11
+ import { parseBooleanLike } from "../utils";
12
+
13
+ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
14
+ return parseBooleanLike(params[key]);
15
+ }
16
+
17
+ function readSharedAudioAsVoiceParam(params: Record<string, unknown>): boolean {
18
+ const sharedValue = readBooleanLikeParam(params, "audioAsVoice");
19
+ if (sharedValue !== undefined) {
20
+ return sharedValue;
21
+ }
22
+ return readBooleanLikeParam(params, "asVoice") === true;
23
+ }
24
+
25
+ function resolveConversationIdFromSessionKey(sessionKey?: string | null): string | undefined {
26
+ const trimmed = sessionKey?.trim();
27
+ if (!trimmed) {
28
+ return undefined;
29
+ }
30
+
31
+ const markers = [":group:", ":channel:", ":direct:"] as const;
32
+ const marker = markers.find((candidate) => trimmed.includes(candidate));
33
+ if (!marker) {
34
+ return undefined;
35
+ }
36
+
37
+ const suffix = trimmed.slice(trimmed.indexOf(marker) + marker.length);
38
+ const threadMarker = ":topic:";
39
+ const threadIndex = suffix.indexOf(threadMarker);
40
+ const conversationId = (threadIndex >= 0 ? suffix.slice(0, threadIndex) : suffix).trim();
41
+ return conversationId || undefined;
42
+ }
43
+
44
+ function inferCardOwnerIdFromTarget(target: string): string | undefined {
45
+ const trimmed = target.trim();
46
+ if (!trimmed || trimmed.startsWith("cid")) {
47
+ return undefined;
48
+ }
49
+ return trimmed;
50
+ }
51
+
52
+ function describeDingTalkMessageTool(cfg: OpenClawConfig) {
53
+ const config = getConfig(cfg);
54
+ const configured = Boolean(config.clientId && hasConfiguredSecretInput(config.clientSecret));
55
+ if (!configured && !(config.accounts && Object.keys(config.accounts).length > 0)) {
56
+ return { actions: [], capabilities: [], schema: null };
57
+ }
58
+ const hasCardMode =
59
+ config.messageType === "card" ||
60
+ (config.accounts && Object.values(config.accounts).some((account) => account?.messageType === "card"));
61
+ return {
62
+ actions: ["send"] as const,
63
+ capabilities: hasCardMode ? (["cards"] as const) : [],
64
+ schema: null,
65
+ };
66
+ }
67
+
68
+ export function createDingTalkMessageActions(): ChannelMessageActionAdapter {
69
+ return {
70
+ describeMessageTool: ({ cfg }) => describeDingTalkMessageTool(cfg),
71
+ supportsAction: ({ action }) => action === "send",
72
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
73
+ handleAction: async ({ action, params, cfg, accountId, dryRun, mediaLocalRoots, sessionKey }) => {
74
+ if (action !== "send") {
75
+ throw new Error(`Action ${action} is not supported for provider dingtalk.`);
76
+ }
77
+
78
+ const to = readStringParam(params, "to", { required: true });
79
+ const mediaInput =
80
+ readStringParam(params, "media", { trim: false }) ??
81
+ readStringParam(params, "path", { trim: false }) ??
82
+ readStringParam(params, "filePath", { trim: false }) ??
83
+ readStringParam(params, "mediaUrl", { trim: false });
84
+
85
+ const hasMedia = Boolean(mediaInput && mediaInput.trim());
86
+ const caption = readStringParam(params, "caption", { allowEmpty: true }) ?? "";
87
+ let message =
88
+ readStringParam(params, "message", {
89
+ required: !hasMedia,
90
+ allowEmpty: true,
91
+ }) ?? "";
92
+
93
+ if (!message.trim() && caption.trim()) {
94
+ message = caption;
95
+ }
96
+
97
+ const asVoice = readSharedAudioAsVoiceParam(params);
98
+ const requestedMediaType = readStringParam(params, "mediaType") as
99
+ | "image"
100
+ | "voice"
101
+ | "video"
102
+ | "file"
103
+ | undefined;
104
+
105
+ const target = resolveOriginalPeerId(stripTargetPrefix(to).targetId);
106
+
107
+ if (dryRun) {
108
+ return jsonResult({
109
+ ok: true,
110
+ dryRun: true,
111
+ to: target,
112
+ hasMedia,
113
+ asVoice,
114
+ });
115
+ }
116
+
117
+ const log = getLogger();
118
+ const config = getConfig(cfg, accountId ?? undefined);
119
+
120
+ if (hasMedia && mediaInput) {
121
+ const conversationId = resolveConversationIdFromSessionKey(sessionKey) ?? target;
122
+ const expectedCardOwnerId =
123
+ readStringParam(params, "expectedCardOwnerId") ?? inferCardOwnerIdFromTarget(target);
124
+ const result = await sendMedia(config, target, mediaInput, {
125
+ log,
126
+ accountId: accountId ?? undefined,
127
+ conversationId,
128
+ mediaType: requestedMediaType ?? undefined,
129
+ audioAsVoice: asVoice,
130
+ mediaLocalRoots: mediaLocalRoots ? [...mediaLocalRoots] : undefined,
131
+ expectedCardOwnerId: expectedCardOwnerId ?? undefined,
132
+ });
133
+
134
+ if (!result.ok) {
135
+ throw new Error(result.error || "send media failed");
136
+ }
137
+
138
+ return jsonResult({
139
+ ok: true,
140
+ to: target,
141
+ messageId: result.messageId ?? null,
142
+ result: result.data ?? null,
143
+ });
144
+ }
145
+
146
+ if (asVoice) {
147
+ throw new Error(
148
+ "DingTalk send with asVoice requires media/path/filePath/mediaUrl pointing to an audio file.",
149
+ );
150
+ }
151
+
152
+ if (!message.trim()) {
153
+ throw new Error("send requires message when media is not provided");
154
+ }
155
+
156
+ const result = await sendMessage(config, target, message, {
157
+ log,
158
+ accountId: accountId ?? undefined,
159
+ });
160
+
161
+ if (!result.ok) {
162
+ throw new Error(result.error || "send message failed");
163
+ }
164
+
165
+ const data = result.data as any;
166
+ return jsonResult({
167
+ ok: true,
168
+ to: target,
169
+ messageId: data?.processQueryKey || data?.messageId || null,
170
+ result: data ?? null,
171
+ });
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,158 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getConfig } from "../config";
3
+ import { getLogger } from "../logger-context";
4
+ import { getDingTalkRuntime } from "../runtime";
5
+ import { sendMedia, sendMessage } from "../send-service";
6
+ import { normalizeResolvedDingTalkTarget } from "../targeting/target-directory-adapter";
7
+ import type { DingTalkChannelPlugin } from "../types";
8
+ import { formatDingTalkErrorPayloadLog, parseBooleanLike } from "../utils";
9
+
10
+ function readBooleanLikeParam(params: Record<string, unknown>, key: string): boolean | undefined {
11
+ return parseBooleanLike(params[key]);
12
+ }
13
+
14
+ function readSharedAudioAsVoiceParam(params: Record<string, unknown>): boolean {
15
+ const sharedValue = readBooleanLikeParam(params, "audioAsVoice");
16
+ if (sharedValue !== undefined) {
17
+ return sharedValue;
18
+ }
19
+ return readBooleanLikeParam(params, "asVoice") === true;
20
+ }
21
+
22
+ export function createDingTalkOutbound(): NonNullable<DingTalkChannelPlugin["outbound"]> {
23
+ return {
24
+ deliveryMode: "direct",
25
+ resolveTarget: ({ to }: any) => {
26
+ const trimmed = to?.trim();
27
+ if (!trimmed) {
28
+ return {
29
+ ok: false as const,
30
+ error: new Error("DingTalk message requires --to <conversationId>"),
31
+ };
32
+ }
33
+ return { ok: true as const, to: normalizeResolvedDingTalkTarget(trimmed) };
34
+ },
35
+ sendText: async ({ cfg, to, text, accountId, log }: any) => {
36
+ const config = getConfig(cfg, accountId);
37
+ const runtime = getDingTalkRuntime();
38
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
39
+ agentId: accountId,
40
+ });
41
+ const effectiveLog = getLogger(accountId) || log;
42
+ try {
43
+ const result = await sendMessage(config, to, text, {
44
+ log: effectiveLog,
45
+ accountId,
46
+ storePath,
47
+ conversationId: to,
48
+ });
49
+ effectiveLog?.debug?.(`[DingTalk] sendText: "${text}" result: ${JSON.stringify(result)}`);
50
+ if (!result.ok) {
51
+ throw new Error(result.error || "sendText failed");
52
+ }
53
+ const data = result.data as any;
54
+ const messageId = String(data?.processQueryKey || data?.messageId || randomUUID());
55
+ const meta =
56
+ result.data || result.tracking
57
+ ? {
58
+ ...(result.data ? { data: result.data as unknown as Record<string, unknown> } : {}),
59
+ ...(result.tracking ? { tracking: result.tracking } : {}),
60
+ }
61
+ : undefined;
62
+ return {
63
+ channel: "dingtalk",
64
+ messageId,
65
+ meta,
66
+ };
67
+ } catch (err: any) {
68
+ if (err?.response?.data !== undefined) {
69
+ effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendText", err.response.data));
70
+ }
71
+ throw new Error(
72
+ typeof err?.response?.data === "string"
73
+ ? err.response.data
74
+ : err?.message || "sendText failed",
75
+ { cause: err },
76
+ );
77
+ }
78
+ },
79
+ sendMedia: async ({
80
+ cfg,
81
+ to,
82
+ mediaPath,
83
+ filePath,
84
+ mediaUrl,
85
+ mediaType: providedMediaType,
86
+ audioAsVoice,
87
+ asVoice,
88
+ accountId,
89
+ mediaLocalRoots,
90
+ log,
91
+ expectedCardOwnerId,
92
+ }: any) => {
93
+ const config = getConfig(cfg, accountId);
94
+ const runtime = getDingTalkRuntime();
95
+ const storePath = runtime.channel.session.resolveStorePath(cfg.session?.store, {
96
+ agentId: accountId,
97
+ });
98
+ const effectiveLog = getLogger(accountId) || log;
99
+ if (!config.clientId) {
100
+ throw new Error("DingTalk not configured");
101
+ }
102
+
103
+ const rawMediaPath = mediaPath || filePath || mediaUrl;
104
+ if (!rawMediaPath) {
105
+ throw new Error(
106
+ `mediaPath, filePath, or mediaUrl is required. Received: ${JSON.stringify({
107
+ to,
108
+ mediaPath,
109
+ filePath,
110
+ mediaUrl,
111
+ })}`,
112
+ );
113
+ }
114
+
115
+ const requestedMediaType = typeof providedMediaType === "string"
116
+ ? (providedMediaType as "image" | "voice" | "video" | "file")
117
+ : undefined;
118
+
119
+ try {
120
+ const result = await sendMedia(config, to, rawMediaPath, {
121
+ log: effectiveLog,
122
+ accountId,
123
+ storePath,
124
+ conversationId: to,
125
+ mediaType: requestedMediaType,
126
+ audioAsVoice: readSharedAudioAsVoiceParam({ audioAsVoice, asVoice }),
127
+ mediaLocalRoots,
128
+ expectedCardOwnerId,
129
+ });
130
+ effectiveLog?.debug?.(`[DingTalk] sendMedia result: ${JSON.stringify(result)}`);
131
+ if (!result.ok) {
132
+ throw new Error(result.error || "sendMedia failed");
133
+ }
134
+ const data = result.data;
135
+ const messageId = String(
136
+ result.messageId || data?.processQueryKey || data?.messageId || randomUUID(),
137
+ );
138
+ return {
139
+ channel: "dingtalk",
140
+ messageId,
141
+ meta: result.data
142
+ ? { data: result.data as unknown as Record<string, unknown> }
143
+ : undefined,
144
+ };
145
+ } catch (err: any) {
146
+ if (err?.response?.data !== undefined) {
147
+ effectiveLog?.error?.(formatDingTalkErrorPayloadLog("outbound.sendMedia", err.response.data));
148
+ }
149
+ throw new Error(
150
+ typeof err?.response?.data === "string"
151
+ ? err.response.data
152
+ : err?.message || "sendMedia failed",
153
+ { cause: err },
154
+ );
155
+ }
156
+ },
157
+ };
158
+ }