@soimy/dingtalk 3.1.0 → 3.1.2
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/package.json +1 -1
- package/src/channel.ts +1 -0
- package/src/config-schema.ts +11 -0
- package/src/connection-manager.ts +25 -3
- package/src/inbound-handler.ts +106 -1
- package/src/onboarding.ts +33 -0
- package/src/proactive-risk-registry.ts +89 -0
- package/src/send-service.ts +79 -3
- package/src/types.ts +17 -0
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -381,6 +381,7 @@ export const dingtalkPlugin: DingTalkChannelPlugin = {
|
|
|
381
381
|
initialDelay: config.initialReconnectDelay ?? 1000,
|
|
382
382
|
maxDelay: config.maxReconnectDelay ?? 60000,
|
|
383
383
|
jitter: config.reconnectJitter ?? 0.3,
|
|
384
|
+
maxReconnectCycles: config.maxReconnectCycles,
|
|
384
385
|
onStateChange: (state: ConnectionState, error?: string) => {
|
|
385
386
|
if (stopped) {
|
|
386
387
|
return;
|
package/src/config-schema.ts
CHANGED
|
@@ -75,6 +75,17 @@ const DingTalkAccountConfigSchema = z.object({
|
|
|
75
75
|
|
|
76
76
|
/** Jitter factor for reconnection delay randomization (0-1, default: 0.3) */
|
|
77
77
|
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
78
|
+
|
|
79
|
+
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
80
|
+
maxReconnectCycles: z.number().int().min(1).optional().default(10),
|
|
81
|
+
|
|
82
|
+
proactivePermissionHint: z
|
|
83
|
+
.object({
|
|
84
|
+
enabled: z.boolean().optional().default(true),
|
|
85
|
+
cooldownHours: z.number().int().min(1).max(24 * 30).optional().default(24),
|
|
86
|
+
})
|
|
87
|
+
.optional()
|
|
88
|
+
.default({ enabled: true, cooldownHours: 24 }),
|
|
78
89
|
});
|
|
79
90
|
|
|
80
91
|
/**
|
|
@@ -37,6 +37,8 @@ export class ConnectionManager {
|
|
|
37
37
|
private static readonly HEALTH_CHECK_INTERVAL_MS = 5000;
|
|
38
38
|
private static readonly HEALTH_CHECK_GRACE_MS = 3000;
|
|
39
39
|
private static readonly HEALTH_CHECK_UNHEALTHY_THRESHOLD = 2;
|
|
40
|
+
private static readonly DEFAULT_MAX_RECONNECT_CYCLES = 10;
|
|
41
|
+
private runtimeReconnectCycles: number = 0;
|
|
40
42
|
private runtimeCounters = {
|
|
41
43
|
healthUnhealthyChecks: 0,
|
|
42
44
|
healthTriggeredReconnects: 0,
|
|
@@ -160,6 +162,9 @@ export class ConnectionManager {
|
|
|
160
162
|
|
|
161
163
|
this.log?.info?.(`[${this.accountId}] DingTalk Stream client connected successfully`);
|
|
162
164
|
|
|
165
|
+
// Reset runtime reconnect cycle counter on successful connection
|
|
166
|
+
this.runtimeReconnectCycles = 0;
|
|
167
|
+
|
|
163
168
|
return { success: true, attempt: successfulAttempt };
|
|
164
169
|
} catch (err: any) {
|
|
165
170
|
this.log?.error?.(
|
|
@@ -427,17 +432,34 @@ export class ConnectionManager {
|
|
|
427
432
|
this.log?.error?.(`[${this.accountId}] Reconnection failed: ${err.message}`);
|
|
428
433
|
this.runtimeCounters.reconnectFailures += 1;
|
|
429
434
|
this.logRuntimeCounters("reconnect-failed");
|
|
435
|
+
|
|
436
|
+
// Track runtime reconnect cycles to prevent infinite loops
|
|
437
|
+
this.runtimeReconnectCycles += 1;
|
|
438
|
+
const maxCycles = this.config.maxReconnectCycles ?? ConnectionManager.DEFAULT_MAX_RECONNECT_CYCLES;
|
|
439
|
+
|
|
440
|
+
if (this.runtimeReconnectCycles >= maxCycles) {
|
|
441
|
+
this.log?.error?.(
|
|
442
|
+
`[${this.accountId}] Max runtime reconnect cycles (${maxCycles}) reached. Giving up. ` +
|
|
443
|
+
`Please check network connectivity or restart the gateway.`,
|
|
444
|
+
);
|
|
445
|
+
this.state = ConnectionStateEnum.FAILED;
|
|
446
|
+
this.connectedAt = undefined;
|
|
447
|
+
this.consecutiveUnhealthyChecks = 0;
|
|
448
|
+
this.notifyStateChange(`Max runtime reconnect cycles (${maxCycles}) reached`);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
430
452
|
this.state = ConnectionStateEnum.FAILED;
|
|
431
453
|
this.connectedAt = undefined;
|
|
432
454
|
this.consecutiveUnhealthyChecks = 0;
|
|
433
455
|
this.notifyStateChange(err.message);
|
|
434
456
|
|
|
435
|
-
// Continue runtime recovery
|
|
436
|
-
const delay = this.calculateNextDelay(
|
|
457
|
+
// Continue runtime recovery with exponential backoff based on cycle count
|
|
458
|
+
const delay = this.calculateNextDelay(Math.min(this.runtimeReconnectCycles - 1, 6)); // Cap at ~64x initial delay
|
|
437
459
|
this.attemptCount = 0;
|
|
438
460
|
this.clearReconnectTimer();
|
|
439
461
|
this.log?.warn?.(
|
|
440
|
-
`[${this.accountId}] Reconnection cycle failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`,
|
|
462
|
+
`[${this.accountId}] Reconnection cycle ${this.runtimeReconnectCycles}/${maxCycles} failed; scheduling next reconnect in ${(delay / 1000).toFixed(2)}s`,
|
|
441
463
|
);
|
|
442
464
|
this.reconnectTimer = setTimeout(() => {
|
|
443
465
|
void this.reconnect();
|
package/src/inbound-handler.ts
CHANGED
|
@@ -16,12 +16,78 @@ import { formatGroupMembers, noteGroupMember } from "./group-members-store";
|
|
|
16
16
|
import { setCurrentLogger } from "./logger-context";
|
|
17
17
|
import { extractMessageContent } from "./message-utils";
|
|
18
18
|
import { registerPeerId } from "./peer-id-registry";
|
|
19
|
+
import {
|
|
20
|
+
clearProactiveRiskObservationsForTest,
|
|
21
|
+
getProactiveRiskObservationForAny,
|
|
22
|
+
} from "./proactive-risk-registry";
|
|
19
23
|
import { getDingTalkRuntime } from "./runtime";
|
|
20
24
|
import { sendBySession, sendMessage } from "./send-service";
|
|
21
25
|
import type { DingTalkConfig, HandleDingTalkMessageParams, MediaFile } from "./types";
|
|
22
26
|
import { AICardStatus } from "./types";
|
|
23
27
|
import { formatDingTalkErrorPayloadLog, maskSensitiveData } from "./utils";
|
|
24
28
|
|
|
29
|
+
const DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS = 24;
|
|
30
|
+
const proactiveHintLastSentAt = new Map<string, number>();
|
|
31
|
+
|
|
32
|
+
export function resetProactivePermissionHintStateForTest(): void {
|
|
33
|
+
proactiveHintLastSentAt.clear();
|
|
34
|
+
clearProactiveRiskObservationsForTest();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function shouldSendProactivePermissionHint(params: {
|
|
38
|
+
isDirect: boolean;
|
|
39
|
+
accountId: string;
|
|
40
|
+
senderId: string;
|
|
41
|
+
senderStaffId?: string;
|
|
42
|
+
config: DingTalkConfig;
|
|
43
|
+
nowMs: number;
|
|
44
|
+
}): boolean {
|
|
45
|
+
if (!params.isDirect) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const hintConfig = params.config.proactivePermissionHint;
|
|
50
|
+
if (hintConfig?.enabled === false) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const targetId = (params.senderId || "").trim();
|
|
55
|
+
if (!targetId) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const riskObservation = getProactiveRiskObservationForAny(
|
|
60
|
+
params.accountId,
|
|
61
|
+
[params.senderId, params.senderStaffId],
|
|
62
|
+
params.nowMs,
|
|
63
|
+
);
|
|
64
|
+
if (!riskObservation || riskObservation.source !== "proactive-api") {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const cooldownHours =
|
|
69
|
+
hintConfig?.cooldownHours && hintConfig.cooldownHours > 0
|
|
70
|
+
? hintConfig.cooldownHours
|
|
71
|
+
: DEFAULT_PROACTIVE_HINT_COOLDOWN_HOURS;
|
|
72
|
+
const cooldownMs = cooldownHours * 60 * 60 * 1000;
|
|
73
|
+
const key = `${params.accountId}:${targetId}`;
|
|
74
|
+
const lastSentAt = proactiveHintLastSentAt.get(key) || 0;
|
|
75
|
+
if (params.nowMs - lastSentAt < cooldownMs) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
proactiveHintLastSentAt.set(key, params.nowMs);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isUnhandledStopReasonText(value: string): boolean {
|
|
84
|
+
const normalized = value.trim();
|
|
85
|
+
if (!normalized) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
return /^Unhandled stop reason:\s*[A-Za-z0-9_-]+/i.test(normalized);
|
|
89
|
+
}
|
|
90
|
+
|
|
25
91
|
/**
|
|
26
92
|
* Download DingTalk media file via runtime media service (sandbox-compatible).
|
|
27
93
|
* Files are stored in the global media inbound directory.
|
|
@@ -143,6 +209,31 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
143
209
|
if (groupId) {
|
|
144
210
|
registerPeerId(groupId);
|
|
145
211
|
}
|
|
212
|
+
|
|
213
|
+
if (
|
|
214
|
+
shouldSendProactivePermissionHint({
|
|
215
|
+
isDirect,
|
|
216
|
+
accountId,
|
|
217
|
+
senderId,
|
|
218
|
+
senderStaffId: data.senderStaffId,
|
|
219
|
+
config: dingtalkConfig,
|
|
220
|
+
nowMs: Date.now(),
|
|
221
|
+
})
|
|
222
|
+
) {
|
|
223
|
+
try {
|
|
224
|
+
await sendBySession(
|
|
225
|
+
dingtalkConfig,
|
|
226
|
+
sessionWebhook,
|
|
227
|
+
"⚠️ 主动推送可能失败\n\n检测到该用户最近一次主动发送调用返回了权限或目标不可达错误。当前会话回复仍可正常使用,但定时/主动发送可能失败。\n\n建议:\n1) 在钉钉开放平台确认应用已申请并获得主动发送相关权限\n2) 确认目标用户属于当前企业并在应用可见范围内\n3) 使用相同账号进行一次主动发送验证并检查错误码详情",
|
|
228
|
+
{ log },
|
|
229
|
+
);
|
|
230
|
+
} catch (err: any) {
|
|
231
|
+
log?.debug?.(`[DingTalk] Failed to send proactive permission hint: ${err.message}`);
|
|
232
|
+
if (err?.response?.data !== undefined) {
|
|
233
|
+
log?.debug?.(formatDingTalkErrorPayloadLog("inbound.proactivePermissionHint", err.response.data));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
146
237
|
if (senderId) {
|
|
147
238
|
registerPeerId(senderId);
|
|
148
239
|
}
|
|
@@ -383,6 +474,11 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
383
474
|
return;
|
|
384
475
|
}
|
|
385
476
|
|
|
477
|
+
if (typeof textToSend === "string" && isUnhandledStopReasonText(textToSend)) {
|
|
478
|
+
log?.warn?.(`[DingTalk] Suppressed stop reason from outbound chat content: ${textToSend}`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
386
482
|
if (useCardMode && currentAICard && info?.kind === "final") {
|
|
387
483
|
lastCardContent = textToSend;
|
|
388
484
|
return;
|
|
@@ -468,12 +564,21 @@ export async function handleDingTalkMessage(params: HandleDingTalkMessageParams)
|
|
|
468
564
|
const hasQueuedFinalString = isNonEmptyString(queuedFinal);
|
|
469
565
|
|
|
470
566
|
if (hasLastCardContent || hasQueuedFinalString) {
|
|
471
|
-
const
|
|
567
|
+
const finalContentCandidate =
|
|
472
568
|
hasLastCardContent && typeof lastCardContent === "string"
|
|
473
569
|
? lastCardContent
|
|
474
570
|
: typeof queuedFinal === "string"
|
|
475
571
|
? queuedFinal
|
|
476
572
|
: "";
|
|
573
|
+
if (isUnhandledStopReasonText(finalContentCandidate)) {
|
|
574
|
+
log?.warn?.(
|
|
575
|
+
`[DingTalk] Suppressed stop reason from AI Card final content: ${finalContentCandidate}`,
|
|
576
|
+
);
|
|
577
|
+
currentAICard.state = AICardStatus.FINISHED;
|
|
578
|
+
currentAICard.lastUpdated = Date.now();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const finalContent = finalContentCandidate;
|
|
477
582
|
await finishAICard(currentAICard, finalContent, log);
|
|
478
583
|
} else {
|
|
479
584
|
log?.debug?.(
|
package/src/onboarding.ts
CHANGED
|
@@ -106,6 +106,9 @@ function applyAccountConfig(params: {
|
|
|
106
106
|
...(input.messageType ? { messageType: input.messageType } : {}),
|
|
107
107
|
...(input.cardTemplateId ? { cardTemplateId: input.cardTemplateId } : {}),
|
|
108
108
|
...(input.cardTemplateKey ? { cardTemplateKey: input.cardTemplateKey } : {}),
|
|
109
|
+
...(typeof input.maxReconnectCycles === "number"
|
|
110
|
+
? { maxReconnectCycles: input.maxReconnectCycles }
|
|
111
|
+
: {}),
|
|
109
112
|
};
|
|
110
113
|
|
|
111
114
|
if (useDefault) {
|
|
@@ -304,6 +307,35 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
304
307
|
initialValue: resolved.groupPolicy ?? "open",
|
|
305
308
|
});
|
|
306
309
|
|
|
310
|
+
let maxReconnectCycles: number | undefined;
|
|
311
|
+
const wantsReconnectLimits = await prompter.confirm({
|
|
312
|
+
message: "Configure runtime reconnect cycle limit? (recommended)",
|
|
313
|
+
initialValue: typeof resolved.maxReconnectCycles === "number",
|
|
314
|
+
});
|
|
315
|
+
if (wantsReconnectLimits) {
|
|
316
|
+
const parsedCycles = Number(
|
|
317
|
+
String(
|
|
318
|
+
await prompter.text({
|
|
319
|
+
message: "Max runtime reconnect cycles",
|
|
320
|
+
placeholder: "10",
|
|
321
|
+
initialValue: String(resolved.maxReconnectCycles ?? 10),
|
|
322
|
+
validate: (value) => {
|
|
323
|
+
const raw = String(value ?? "").trim();
|
|
324
|
+
const num = Number(raw);
|
|
325
|
+
if (!raw) {
|
|
326
|
+
return "Required";
|
|
327
|
+
}
|
|
328
|
+
if (!Number.isInteger(num) || num < 1) {
|
|
329
|
+
return "Must be an integer >= 1";
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
},
|
|
333
|
+
}),
|
|
334
|
+
).trim(),
|
|
335
|
+
);
|
|
336
|
+
maxReconnectCycles = Number.isInteger(parsedCycles) && parsedCycles > 0 ? parsedCycles : 10;
|
|
337
|
+
}
|
|
338
|
+
|
|
307
339
|
const next = applyAccountConfig({
|
|
308
340
|
cfg,
|
|
309
341
|
accountId,
|
|
@@ -319,6 +351,7 @@ export const dingtalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
|
319
351
|
messageType,
|
|
320
352
|
cardTemplateId,
|
|
321
353
|
cardTemplateKey,
|
|
354
|
+
maxReconnectCycles,
|
|
322
355
|
},
|
|
323
356
|
});
|
|
324
357
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
type ProactiveRiskLevel = "low" | "medium" | "high";
|
|
2
|
+
|
|
3
|
+
export interface ProactiveRiskObservation {
|
|
4
|
+
accountId: string;
|
|
5
|
+
targetId: string;
|
|
6
|
+
level: ProactiveRiskLevel;
|
|
7
|
+
reason: string;
|
|
8
|
+
source: string;
|
|
9
|
+
observedAtMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ProactiveRiskSnapshot {
|
|
13
|
+
level: ProactiveRiskLevel;
|
|
14
|
+
reason: string;
|
|
15
|
+
source: string;
|
|
16
|
+
observedAtMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
+
const store = new Map<string, ProactiveRiskSnapshot>();
|
|
21
|
+
|
|
22
|
+
function keyOf(accountId: string, targetId: string): string {
|
|
23
|
+
return `${accountId}:${targetId.trim()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function recordProactiveRiskObservation(observation: ProactiveRiskObservation): void {
|
|
27
|
+
const targetId = observation.targetId?.trim();
|
|
28
|
+
if (!observation.accountId || !targetId) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
store.set(keyOf(observation.accountId, targetId), {
|
|
33
|
+
level: observation.level,
|
|
34
|
+
reason: observation.reason,
|
|
35
|
+
source: observation.source,
|
|
36
|
+
observedAtMs: observation.observedAtMs ?? Date.now(),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getProactiveRiskObservation(
|
|
41
|
+
accountId: string,
|
|
42
|
+
targetId: string,
|
|
43
|
+
nowMs = Date.now(),
|
|
44
|
+
): ProactiveRiskSnapshot | null {
|
|
45
|
+
const target = targetId?.trim();
|
|
46
|
+
if (!accountId || !target) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const key = keyOf(accountId, target);
|
|
51
|
+
const entry = store.get(key);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (nowMs - entry.observedAtMs > DEFAULT_TTL_MS) {
|
|
56
|
+
store.delete(key);
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return entry;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getProactiveRiskObservationForAny(
|
|
63
|
+
accountId: string,
|
|
64
|
+
targetIds: Array<string | null | undefined>,
|
|
65
|
+
nowMs = Date.now(),
|
|
66
|
+
): ProactiveRiskSnapshot | null {
|
|
67
|
+
for (const candidate of targetIds) {
|
|
68
|
+
if (!candidate) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const observation = getProactiveRiskObservation(accountId, candidate, nowMs);
|
|
72
|
+
if (observation) {
|
|
73
|
+
return observation;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function deleteProactiveRiskObservation(accountId: string, targetId: string): void {
|
|
80
|
+
const target = targetId?.trim();
|
|
81
|
+
if (!accountId || !target) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
store.delete(keyOf(accountId, target));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function clearProactiveRiskObservationsForTest(): void {
|
|
88
|
+
store.clear();
|
|
89
|
+
}
|
package/src/send-service.ts
CHANGED
|
@@ -13,6 +13,11 @@ import { getLogger } from "./logger-context";
|
|
|
13
13
|
import { uploadMedia as uploadMediaUtil } from "./media-utils";
|
|
14
14
|
import { detectMarkdownAndExtractTitle } from "./message-utils";
|
|
15
15
|
import { resolveOriginalPeerId } from "./peer-id-registry";
|
|
16
|
+
import {
|
|
17
|
+
deleteProactiveRiskObservation,
|
|
18
|
+
getProactiveRiskObservation,
|
|
19
|
+
recordProactiveRiskObservation,
|
|
20
|
+
} from "./proactive-risk-registry";
|
|
16
21
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
17
22
|
import type {
|
|
18
23
|
AxiosResponse,
|
|
@@ -26,6 +31,38 @@ import { AICardStatus } from "./types";
|
|
|
26
31
|
|
|
27
32
|
export { detectMediaTypeFromExtension } from "./media-utils";
|
|
28
33
|
|
|
34
|
+
function extractErrorCodeFromResponseData(data: unknown): string | null {
|
|
35
|
+
if (!data || typeof data !== "object") {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const payload = data as Record<string, unknown>;
|
|
40
|
+
const code = payload.code;
|
|
41
|
+
if (typeof code === "string" && code.trim()) {
|
|
42
|
+
return code.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const subCode = payload.subCode;
|
|
46
|
+
if (typeof subCode === "string" && subCode.trim()) {
|
|
47
|
+
return subCode.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isProactivePermissionOrScopeError(code: string | null): boolean {
|
|
54
|
+
if (!code) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return (
|
|
58
|
+
code.startsWith("Forbidden.AccessDenied") ||
|
|
59
|
+
code === "invalidParameter.userIds.invalid" ||
|
|
60
|
+
code === "invalidParameter.userIds.empty" ||
|
|
61
|
+
code === "invalidParameter.openConversationId.invalid" ||
|
|
62
|
+
code === "invalidParameter.robotCode.empty"
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
/**
|
|
30
67
|
* Wrapper to upload media with shared getAccessToken binding.
|
|
31
68
|
*/
|
|
@@ -51,6 +88,12 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
51
88
|
const { targetId, isExplicitUser } = stripTargetPrefix(target);
|
|
52
89
|
const resolvedTarget = resolveOriginalPeerId(targetId);
|
|
53
90
|
const isGroup = !isExplicitUser && resolvedTarget.startsWith("cid");
|
|
91
|
+
const proactiveRisk = options.accountId
|
|
92
|
+
? getProactiveRiskObservation(options.accountId, resolvedTarget)
|
|
93
|
+
: null;
|
|
94
|
+
const proactiveRiskTag = proactiveRisk
|
|
95
|
+
? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
|
|
96
|
+
: "";
|
|
54
97
|
|
|
55
98
|
const url = isGroup
|
|
56
99
|
? "https://api.dingtalk.com/v1.0/robot/groupMessages/send"
|
|
@@ -59,7 +102,7 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
59
102
|
const { useMarkdown, title } = detectMarkdownAndExtractTitle(text, options, "OpenClaw 提醒");
|
|
60
103
|
|
|
61
104
|
log?.debug?.(
|
|
62
|
-
`[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"`,
|
|
105
|
+
`[DingTalk] Sending proactive message to ${isGroup ? "group" : "user"} ${resolvedTarget} with title "${title}"${proactiveRiskTag}`,
|
|
63
106
|
);
|
|
64
107
|
|
|
65
108
|
// DingTalk proactive API uses message templates (sampleMarkdown / sampleText).
|
|
@@ -87,6 +130,9 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
87
130
|
data: payload,
|
|
88
131
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
89
132
|
});
|
|
133
|
+
if (options.accountId) {
|
|
134
|
+
deleteProactiveRiskObservation(options.accountId, resolvedTarget);
|
|
135
|
+
}
|
|
90
136
|
return result.data;
|
|
91
137
|
} catch (err: unknown) {
|
|
92
138
|
const maybeAxiosError = err as {
|
|
@@ -94,13 +140,23 @@ export async function sendProactiveTextOrMarkdown(
|
|
|
94
140
|
message?: string;
|
|
95
141
|
};
|
|
96
142
|
if (maybeAxiosError?.response) {
|
|
143
|
+
const errCode = extractErrorCodeFromResponseData(maybeAxiosError.response.data);
|
|
144
|
+
if (options.accountId && isProactivePermissionOrScopeError(errCode)) {
|
|
145
|
+
recordProactiveRiskObservation({
|
|
146
|
+
accountId: options.accountId,
|
|
147
|
+
targetId: resolvedTarget,
|
|
148
|
+
level: "high",
|
|
149
|
+
reason: errCode || "proactive-permission-error",
|
|
150
|
+
source: "proactive-api",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
97
153
|
const status = maybeAxiosError.response.status;
|
|
98
154
|
const statusText = maybeAxiosError.response.statusText;
|
|
99
155
|
const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
|
|
100
156
|
log?.error?.(
|
|
101
157
|
`[DingTalk] Failed to send proactive message:${statusLabel} message=${
|
|
102
158
|
maybeAxiosError.message || String(err)
|
|
103
|
-
}`,
|
|
159
|
+
}${proactiveRiskTag}`,
|
|
104
160
|
);
|
|
105
161
|
if (maybeAxiosError.response.data !== undefined) {
|
|
106
162
|
log?.error?.(
|
|
@@ -183,16 +239,36 @@ export async function sendProactiveMedia(
|
|
|
183
239
|
data: payload,
|
|
184
240
|
headers: { "x-acs-dingtalk-access-token": token, "Content-Type": "application/json" },
|
|
185
241
|
});
|
|
242
|
+
if (options.accountId) {
|
|
243
|
+
deleteProactiveRiskObservation(options.accountId, resolvedTarget);
|
|
244
|
+
}
|
|
186
245
|
|
|
187
246
|
const messageId = result.data?.processQueryKey || result.data?.messageId;
|
|
188
247
|
return { ok: true, data: result.data, messageId };
|
|
189
248
|
} catch (err: any) {
|
|
190
249
|
log?.error?.(`[DingTalk] Failed to send proactive media: ${err.message}`);
|
|
250
|
+
const normalizedTarget = resolveOriginalPeerId(stripTargetPrefix(target).targetId);
|
|
251
|
+
const proactiveRisk = options.accountId
|
|
252
|
+
? getProactiveRiskObservation(options.accountId, normalizedTarget)
|
|
253
|
+
: null;
|
|
254
|
+
const proactiveRiskTag = proactiveRisk
|
|
255
|
+
? ` proactiveRisk=${proactiveRisk.level}:${proactiveRisk.reason}`
|
|
256
|
+
: "";
|
|
191
257
|
if (axios.isAxiosError(err) && err.response) {
|
|
258
|
+
const errCode = extractErrorCodeFromResponseData(err.response.data);
|
|
259
|
+
if (options.accountId && isProactivePermissionOrScopeError(errCode)) {
|
|
260
|
+
recordProactiveRiskObservation({
|
|
261
|
+
accountId: options.accountId,
|
|
262
|
+
targetId: normalizedTarget,
|
|
263
|
+
level: "high",
|
|
264
|
+
reason: errCode || "proactive-permission-error",
|
|
265
|
+
source: "proactive-api",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
192
268
|
const status = err.response.status;
|
|
193
269
|
const statusText = err.response.statusText;
|
|
194
270
|
const statusLabel = status ? ` status=${status}${statusText ? ` ${statusText}` : ""}` : "";
|
|
195
|
-
log?.error?.(`[DingTalk] Proactive media response${statusLabel}`);
|
|
271
|
+
log?.error?.(`[DingTalk] Proactive media response${statusLabel}${proactiveRiskTag}`);
|
|
196
272
|
log?.error?.(formatDingTalkErrorPayloadLog("send.proactiveMedia", err.response.data));
|
|
197
273
|
}
|
|
198
274
|
return { ok: false, error: err.message };
|
package/src/types.ts
CHANGED
|
@@ -52,6 +52,12 @@ export interface DingTalkConfig extends OpenClawConfig {
|
|
|
52
52
|
initialReconnectDelay?: number;
|
|
53
53
|
maxReconnectDelay?: number;
|
|
54
54
|
reconnectJitter?: number;
|
|
55
|
+
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
56
|
+
maxReconnectCycles?: number;
|
|
57
|
+
proactivePermissionHint?: {
|
|
58
|
+
enabled?: boolean;
|
|
59
|
+
cooldownHours?: number;
|
|
60
|
+
};
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -79,6 +85,12 @@ export interface DingTalkChannelConfig {
|
|
|
79
85
|
initialReconnectDelay?: number;
|
|
80
86
|
maxReconnectDelay?: number;
|
|
81
87
|
reconnectJitter?: number;
|
|
88
|
+
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
89
|
+
maxReconnectCycles?: number;
|
|
90
|
+
proactivePermissionHint?: {
|
|
91
|
+
enabled?: boolean;
|
|
92
|
+
cooldownHours?: number;
|
|
93
|
+
};
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
/**
|
|
@@ -201,6 +213,7 @@ export interface SendMessageOptions {
|
|
|
201
213
|
filePath?: string;
|
|
202
214
|
mediaUrl?: string;
|
|
203
215
|
mediaType?: "image" | "voice" | "video" | "file";
|
|
216
|
+
accountId?: string;
|
|
204
217
|
}
|
|
205
218
|
|
|
206
219
|
/**
|
|
@@ -467,6 +480,8 @@ export interface ConnectionManagerConfig {
|
|
|
467
480
|
initialDelay: number;
|
|
468
481
|
maxDelay: number;
|
|
469
482
|
jitter: number;
|
|
483
|
+
/** Maximum number of runtime reconnect cycles before giving up (default: 10) */
|
|
484
|
+
maxReconnectCycles?: number;
|
|
470
485
|
/** Callback invoked when connection state changes */
|
|
471
486
|
onStateChange?: (state: ConnectionState, error?: string) => void;
|
|
472
487
|
}
|
|
@@ -551,6 +566,8 @@ export function resolveDingTalkAccount(
|
|
|
551
566
|
initialReconnectDelay: dingtalk?.initialReconnectDelay,
|
|
552
567
|
maxReconnectDelay: dingtalk?.maxReconnectDelay,
|
|
553
568
|
reconnectJitter: dingtalk?.reconnectJitter,
|
|
569
|
+
maxReconnectCycles: dingtalk?.maxReconnectCycles,
|
|
570
|
+
proactivePermissionHint: dingtalk?.proactivePermissionHint,
|
|
554
571
|
};
|
|
555
572
|
return {
|
|
556
573
|
...config,
|