@soimy/dingtalk 3.5.3 → 3.6.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/index.ts +7 -0
- package/openclaw.plugin.json +104 -0
- package/package.json +1 -1
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-template.ts +14 -3
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +245 -52
- package/src/card-service.ts +368 -8
- package/src/channel.ts +19 -1081
- package/src/config-schema.ts +19 -0
- package/src/config.ts +117 -1
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +147 -24
- package/src/media-utils.ts +6 -0
- package/src/message-utils.ts +124 -16
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/onboarding.ts +321 -232
- package/src/platform/channel-status.ts +81 -0
- package/src/reply-strategy-card.ts +373 -64
- package/src/reply-strategy-markdown.ts +1 -1
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -72
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +115 -3
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/types.ts +23 -147
package/src/config-schema.ts
CHANGED
|
@@ -156,6 +156,25 @@ const DingTalkAccountConfigShape = {
|
|
|
156
156
|
* Set to a non-empty string (e.g. "✅ 回复完成") to enable — the value is used as the message text.
|
|
157
157
|
* Leave empty or omit to disable. */
|
|
158
158
|
cardAtSender: z.string().optional(),
|
|
159
|
+
|
|
160
|
+
/** Status line visibility toggles for the AI card footer. */
|
|
161
|
+
cardStatusLine: z
|
|
162
|
+
.object({
|
|
163
|
+
/** Show model name. */
|
|
164
|
+
model: z.boolean().optional().default(true),
|
|
165
|
+
/** Show thinking effort level. */
|
|
166
|
+
effort: z.boolean().optional().default(true),
|
|
167
|
+
/** Show agent display name. */
|
|
168
|
+
agent: z.boolean().optional().default(true),
|
|
169
|
+
/** Show task elapsed time. */
|
|
170
|
+
taskTime: z.boolean().optional().default(false),
|
|
171
|
+
/** Show token usage summary (input/output/cache). */
|
|
172
|
+
tokens: z.boolean().optional().default(false),
|
|
173
|
+
/** Show DingTalk API call count. */
|
|
174
|
+
dapiUsage: z.boolean().optional().default(false),
|
|
175
|
+
})
|
|
176
|
+
.optional()
|
|
177
|
+
.default({ model: true, effort: true, agent: true, taskTime: false, tokens: false, dapiUsage: false }),
|
|
159
178
|
} as const;
|
|
160
179
|
|
|
161
180
|
const DingTalkAccountConfigSchema = z.object(DingTalkAccountConfigShape);
|
package/src/config.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as os from "node:os";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
4
|
-
import type { DingTalkConfig } from "./types";
|
|
4
|
+
import type { DingTalkChannelConfig, DingTalkConfig } from "./types";
|
|
5
5
|
|
|
6
6
|
const WINDOWS_ROOT_DIRECTORIES = new Set([
|
|
7
7
|
"Users",
|
|
@@ -272,3 +272,119 @@ export function stripTargetPrefix(target: string): { targetId: string; isExplici
|
|
|
272
272
|
}
|
|
273
273
|
return { targetId: target, isExplicitUser: false };
|
|
274
274
|
}
|
|
275
|
+
|
|
276
|
+
// ============ Onboarding Helper Functions ============
|
|
277
|
+
|
|
278
|
+
const DEFAULT_ACCOUNT_ID = "default";
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* List all DingTalk account IDs from config
|
|
283
|
+
*/
|
|
284
|
+
export function listDingTalkAccountIds(cfg: OpenClawConfig): string[] {
|
|
285
|
+
const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
|
|
286
|
+
if (!dingtalk) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const accountIds: string[] = [];
|
|
291
|
+
|
|
292
|
+
if (dingtalk.clientId || dingtalk.clientSecret) {
|
|
293
|
+
accountIds.push(DEFAULT_ACCOUNT_ID);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (dingtalk.accounts) {
|
|
297
|
+
accountIds.push(...Object.keys(dingtalk.accounts));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return accountIds;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolved DingTalk account with configuration status
|
|
305
|
+
*/
|
|
306
|
+
export interface ResolvedDingTalkAccount extends DingTalkConfig {
|
|
307
|
+
accountId: string;
|
|
308
|
+
configured: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve a specific DingTalk account configuration
|
|
313
|
+
*/
|
|
314
|
+
export function resolveDingTalkAccount(
|
|
315
|
+
cfg: OpenClawConfig,
|
|
316
|
+
accountId?: string | null,
|
|
317
|
+
): ResolvedDingTalkAccount {
|
|
318
|
+
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
319
|
+
const dingtalk = cfg.channels?.dingtalk as DingTalkChannelConfig | undefined;
|
|
320
|
+
|
|
321
|
+
if (id === DEFAULT_ACCOUNT_ID) {
|
|
322
|
+
const rawConfig: DingTalkConfig = {
|
|
323
|
+
clientId: dingtalk?.clientId ?? "",
|
|
324
|
+
clientSecret: dingtalk?.clientSecret ?? "",
|
|
325
|
+
name: dingtalk?.name,
|
|
326
|
+
enabled: dingtalk?.enabled,
|
|
327
|
+
dmPolicy: dingtalk?.dmPolicy,
|
|
328
|
+
groupPolicy: dingtalk?.groupPolicy,
|
|
329
|
+
allowFrom: dingtalk?.allowFrom,
|
|
330
|
+
groupAllowFrom: dingtalk?.groupAllowFrom,
|
|
331
|
+
displayNameResolution: dingtalk?.displayNameResolution,
|
|
332
|
+
contextVisibility: dingtalk?.contextVisibility,
|
|
333
|
+
journalTTLDays: dingtalk?.journalTTLDays,
|
|
334
|
+
ackReaction: dingtalk?.ackReaction,
|
|
335
|
+
debug: dingtalk?.debug,
|
|
336
|
+
messageType: dingtalk?.messageType,
|
|
337
|
+
cardTemplateId: dingtalk?.cardTemplateId,
|
|
338
|
+
cardTemplateKey: dingtalk?.cardTemplateKey,
|
|
339
|
+
groups: dingtalk?.groups,
|
|
340
|
+
accounts: dingtalk?.accounts,
|
|
341
|
+
maxConnectionAttempts: dingtalk?.maxConnectionAttempts,
|
|
342
|
+
initialReconnectDelay: dingtalk?.initialReconnectDelay,
|
|
343
|
+
maxReconnectDelay: dingtalk?.maxReconnectDelay,
|
|
344
|
+
reconnectJitter: dingtalk?.reconnectJitter,
|
|
345
|
+
maxReconnectCycles: dingtalk?.maxReconnectCycles,
|
|
346
|
+
reconnectDeadlineMs: dingtalk?.reconnectDeadlineMs,
|
|
347
|
+
useConnectionManager: dingtalk?.useConnectionManager,
|
|
348
|
+
mediaMaxMb: dingtalk?.mediaMaxMb,
|
|
349
|
+
keepAlive: dingtalk?.keepAlive,
|
|
350
|
+
bypassProxyForSend: dingtalk?.bypassProxyForSend,
|
|
351
|
+
proactivePermissionHint: dingtalk?.proactivePermissionHint,
|
|
352
|
+
cardStreamingMode: dingtalk?.cardStreamingMode,
|
|
353
|
+
cardRealTimeStream: dingtalk?.cardRealTimeStream,
|
|
354
|
+
cardStreamInterval: dingtalk?.cardStreamInterval,
|
|
355
|
+
aicardDegradeMs: dingtalk?.aicardDegradeMs,
|
|
356
|
+
learningEnabled: dingtalk?.learningEnabled,
|
|
357
|
+
learningAutoApply: dingtalk?.learningAutoApply,
|
|
358
|
+
learningNoteTtlMs: dingtalk?.learningNoteTtlMs,
|
|
359
|
+
convertMarkdownTables: dingtalk?.convertMarkdownTables,
|
|
360
|
+
cardAtSender: dingtalk?.cardAtSender,
|
|
361
|
+
};
|
|
362
|
+
const config = stripRemovedLegacyFields(rawConfig);
|
|
363
|
+
return {
|
|
364
|
+
...config,
|
|
365
|
+
accountId: id,
|
|
366
|
+
configured: Boolean(config.clientId && config.clientSecret),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const accountConfig = dingtalk?.accounts?.[id];
|
|
371
|
+
if (accountConfig) {
|
|
372
|
+
const merged = mergeAccountWithDefaults(
|
|
373
|
+
dingtalk as DingTalkConfig,
|
|
374
|
+
accountConfig,
|
|
375
|
+
);
|
|
376
|
+
const publicMerged = stripRemovedLegacyFields(merged);
|
|
377
|
+
return {
|
|
378
|
+
...publicMerged,
|
|
379
|
+
accountId: id,
|
|
380
|
+
configured: Boolean(merged.clientId && merged.clientSecret),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
clientId: "",
|
|
386
|
+
clientSecret: "",
|
|
387
|
+
accountId: id,
|
|
388
|
+
configured: false,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import httpClient from "./http-client.js";
|
|
3
|
+
|
|
4
|
+
// ── Constants ──────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const REGISTRATION_BASE_URL = "https://oapi.dingtalk.com";
|
|
7
|
+
const REGISTRATION_SOURCE = "openClaw";
|
|
8
|
+
const RETRY_WINDOW_MS = 120_000; // 2 minutes for transient errors
|
|
9
|
+
|
|
10
|
+
// ── Types ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export class RegistrationError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "RegistrationError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RegistrationResult {
|
|
20
|
+
clientId: string;
|
|
21
|
+
clientSecret: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface BeginResult {
|
|
25
|
+
deviceCode: string;
|
|
26
|
+
verificationUrl: string;
|
|
27
|
+
expiresIn: number;
|
|
28
|
+
interval: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PollStatus = "WAITING" | "SUCCESS" | "FAIL" | "EXPIRED";
|
|
32
|
+
|
|
33
|
+
interface PollResult {
|
|
34
|
+
status: PollStatus;
|
|
35
|
+
clientId?: string;
|
|
36
|
+
clientSecret?: string;
|
|
37
|
+
failReason?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Internal helpers ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function asString(value: unknown): string {
|
|
43
|
+
return typeof value === "string" ? value : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function apiPost(
|
|
47
|
+
path: string,
|
|
48
|
+
payload: Record<string, unknown>,
|
|
49
|
+
): Promise<Record<string, unknown>> {
|
|
50
|
+
const url = `${REGISTRATION_BASE_URL}${path}`;
|
|
51
|
+
const resp = await httpClient.post(url, payload, { timeout: 15_000 });
|
|
52
|
+
const data = resp.data as Record<string, unknown>;
|
|
53
|
+
const errcode = data.errcode;
|
|
54
|
+
if (errcode !== undefined && errcode !== 0) {
|
|
55
|
+
const errmsg = asString(data.errmsg) || "unknown error";
|
|
56
|
+
throw new RegistrationError(`API error [${path}]: ${errmsg} (errcode=${typeof errcode === "number" ? errcode : asString(errcode)})`);
|
|
57
|
+
}
|
|
58
|
+
return data;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Step 1: init → nonce ───────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
async function initRegistration(): Promise<string> {
|
|
64
|
+
const data = await apiPost("/app/registration/init", { source: REGISTRATION_SOURCE });
|
|
65
|
+
const nonce = asString(data.nonce).trim();
|
|
66
|
+
if (!nonce) {
|
|
67
|
+
throw new RegistrationError("init response missing nonce");
|
|
68
|
+
}
|
|
69
|
+
return nonce;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Step 2: begin → deviceCode + verificationUrl ───────────────────────────
|
|
73
|
+
|
|
74
|
+
async function beginRegistration(nonce: string): Promise<BeginResult> {
|
|
75
|
+
const data = await apiPost("/app/registration/begin", { nonce });
|
|
76
|
+
const deviceCode = asString(data.device_code).trim();
|
|
77
|
+
const verificationUrl = asString(data.verification_uri_complete).trim();
|
|
78
|
+
if (!deviceCode) {
|
|
79
|
+
throw new RegistrationError("begin response missing device_code");
|
|
80
|
+
}
|
|
81
|
+
if (!verificationUrl) {
|
|
82
|
+
throw new RegistrationError("begin response missing verification_uri_complete");
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
deviceCode,
|
|
86
|
+
verificationUrl,
|
|
87
|
+
expiresIn: Number(data.expires_in ?? 7200) || 7200,
|
|
88
|
+
interval: Math.max(Number(data.interval ?? 3) || 3, 2),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Step 3: poll ───────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
async function pollRegistration(deviceCode: string): Promise<PollResult> {
|
|
95
|
+
const data = await apiPost("/app/registration/poll", { device_code: deviceCode });
|
|
96
|
+
const raw = asString(data.status).trim().toUpperCase();
|
|
97
|
+
const status: PollStatus = ["WAITING", "SUCCESS", "FAIL", "EXPIRED"].includes(raw)
|
|
98
|
+
? (raw as PollStatus)
|
|
99
|
+
: "FAIL";
|
|
100
|
+
return {
|
|
101
|
+
status,
|
|
102
|
+
clientId: asString(data.client_id).trim() || undefined,
|
|
103
|
+
clientSecret: asString(data.client_secret).trim() || undefined,
|
|
104
|
+
failReason: asString(data.fail_reason).trim() || undefined,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
export interface DeviceRegistrationSession {
|
|
111
|
+
verificationUrl: string;
|
|
112
|
+
waitForResult: (options?: {
|
|
113
|
+
onWaiting?: () => void;
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
}) => Promise<RegistrationResult>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function beginDeviceRegistration(): Promise<DeviceRegistrationSession> {
|
|
119
|
+
const nonce = await initRegistration();
|
|
120
|
+
const { deviceCode, verificationUrl, expiresIn, interval } = await beginRegistration(nonce);
|
|
121
|
+
|
|
122
|
+
const waitForResult = async (options?: {
|
|
123
|
+
onWaiting?: () => void;
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
}): Promise<RegistrationResult> => {
|
|
126
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
127
|
+
let networkRetryStart = 0;
|
|
128
|
+
let statusRetryStart = 0;
|
|
129
|
+
|
|
130
|
+
const signal = options?.signal;
|
|
131
|
+
let abortHandler: (() => void) | null = null;
|
|
132
|
+
const abortPromise = signal
|
|
133
|
+
? new Promise<never>((_resolve, reject) => {
|
|
134
|
+
abortHandler = () => reject(new RegistrationError("registration cancelled"));
|
|
135
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
136
|
+
})
|
|
137
|
+
: null;
|
|
138
|
+
// Suppress unhandled rejection when abort fires outside Promise.race
|
|
139
|
+
abortPromise?.catch(() => {});
|
|
140
|
+
|
|
141
|
+
const sleep = () =>
|
|
142
|
+
new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
while (Date.now() < deadline) {
|
|
146
|
+
if (signal?.aborted) {
|
|
147
|
+
throw new RegistrationError("registration cancelled");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// AbortSignal-aware sleep
|
|
151
|
+
await (abortPromise ? Promise.race([sleep(), abortPromise]) : sleep());
|
|
152
|
+
|
|
153
|
+
// Check again after sleep — abort may have fired during sleep
|
|
154
|
+
if (signal?.aborted) {
|
|
155
|
+
throw new RegistrationError("registration cancelled");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let result: PollResult;
|
|
159
|
+
try {
|
|
160
|
+
result = await pollRegistration(deviceCode);
|
|
161
|
+
} catch {
|
|
162
|
+
if (!networkRetryStart) {
|
|
163
|
+
networkRetryStart = Date.now();
|
|
164
|
+
}
|
|
165
|
+
if (Date.now() - networkRetryStart < RETRY_WINDOW_MS) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
throw new RegistrationError("registration polling failed after retry window");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Successful poll resets network retry window
|
|
172
|
+
networkRetryStart = 0;
|
|
173
|
+
|
|
174
|
+
const { status } = result;
|
|
175
|
+
|
|
176
|
+
if (status === "WAITING") {
|
|
177
|
+
statusRetryStart = 0;
|
|
178
|
+
options?.onWaiting?.();
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (status === "SUCCESS") {
|
|
183
|
+
const clientId = result.clientId;
|
|
184
|
+
const clientSecret = result.clientSecret;
|
|
185
|
+
if (!clientId || !clientSecret) {
|
|
186
|
+
throw new RegistrationError("authorization succeeded but credentials are missing");
|
|
187
|
+
}
|
|
188
|
+
return { clientId, clientSecret };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (status === "EXPIRED") {
|
|
192
|
+
throw new RegistrationError("authorization expired, please restart registration");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// FAIL — retry within window
|
|
196
|
+
if (!statusRetryStart) {
|
|
197
|
+
statusRetryStart = Date.now();
|
|
198
|
+
}
|
|
199
|
+
if (Date.now() - statusRetryStart < RETRY_WINDOW_MS) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
throw new RegistrationError(`authorization failed: ${result.failReason ?? status}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
throw new RegistrationError("authorization timed out, please retry");
|
|
206
|
+
} finally {
|
|
207
|
+
if (abortHandler && signal) {
|
|
208
|
+
signal.removeEventListener("abort", abortHandler);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return { verificationUrl, waitForResult };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Browser helper ─────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export function openUrlInBrowser(url: string): void {
|
|
219
|
+
// Validate URL before handing to OS launcher
|
|
220
|
+
try {
|
|
221
|
+
const parsed = new URL(url);
|
|
222
|
+
if (parsed.protocol !== "https:" || !parsed.hostname.endsWith(".dingtalk.com")) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
} catch {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const platform = process.platform;
|
|
230
|
+
let bin: string;
|
|
231
|
+
let args: string[];
|
|
232
|
+
if (platform === "darwin") {
|
|
233
|
+
bin = "open";
|
|
234
|
+
args = [url];
|
|
235
|
+
} else if (platform === "win32") {
|
|
236
|
+
bin = "cmd";
|
|
237
|
+
args = ["/c", "start", "", url];
|
|
238
|
+
} else {
|
|
239
|
+
bin = "xdg-open";
|
|
240
|
+
args = [url];
|
|
241
|
+
}
|
|
242
|
+
execFile(bin, args, (err) => {
|
|
243
|
+
void err;
|
|
244
|
+
});
|
|
245
|
+
}
|