@huo15/dingtalk-connector-pro 1.0.4 → 1.0.7
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/README.en.md +106 -384
- package/README.md +14 -18
- package/dist/index.js +17 -0
- package/dist/openclaw.plugin.json +498 -0
- package/dist/package.json +91 -0
- package/dist/src/channel.js +415 -0
- package/dist/src/config/accounts.js +182 -0
- package/dist/src/config/schema.js +135 -0
- package/dist/src/core/connection.js +561 -0
- package/dist/src/core/message-handler.js +1422 -0
- package/dist/src/core/provider.js +59 -0
- package/dist/src/core/state.js +49 -0
- package/dist/src/directory.js +53 -0
- package/dist/src/docs.js +209 -0
- package/dist/src/gateway-methods.js +360 -0
- package/dist/src/onboarding.js +337 -0
- package/dist/src/policy.js +15 -0
- package/dist/src/probe.js +144 -0
- package/dist/src/reply-dispatcher.js +435 -0
- package/dist/src/runtime.js +26 -0
- package/dist/src/sdk/helpers.js +237 -0
- package/dist/src/sdk/types.js +13 -0
- package/dist/src/secret-input.js +13 -0
- package/dist/src/services/media/audio.js +40 -0
- package/dist/src/services/media/chunk-upload.js +211 -0
- package/dist/src/services/media/common.js +120 -0
- package/dist/src/services/media/file.js +54 -0
- package/dist/src/services/media/image.js +59 -0
- package/dist/src/services/media/index.js +9 -0
- package/dist/src/services/media/video.js +133 -0
- package/dist/src/services/media.js +889 -0
- package/dist/src/services/messaging/card.js +234 -0
- package/dist/src/services/messaging/index.js +8 -0
- package/dist/src/services/messaging/send.js +85 -0
- package/dist/src/services/messaging.js +680 -0
- package/dist/src/targets.js +38 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/agent.js +55 -0
- package/dist/src/utils/async.js +40 -0
- package/dist/src/utils/constants.js +24 -0
- package/dist/src/utils/http-client.js +33 -0
- package/dist/src/utils/index.js +7 -0
- package/dist/src/utils/logger.js +76 -0
- package/dist/src/utils/session.js +95 -0
- package/dist/src/utils/token.js +71 -0
- package/dist/src/utils/utils-legacy.js +393 -0
- package/index.ts +3 -3
- package/openclaw.plugin.json +1 -1
- package/package.json +16 -5
- package/src/channel.js +415 -0
- package/src/channel.ts +12 -12
- package/src/config/accounts.js +182 -0
- package/src/config/accounts.ts +2 -2
- package/src/config/schema.js +135 -0
- package/src/config/schema.ts +2 -2
- package/src/core/connection.js +561 -0
- package/src/core/connection.ts +2 -2
- package/src/core/message-handler.js +1422 -0
- package/src/core/message-handler.ts +12 -12
- package/src/core/provider.js +59 -0
- package/src/core/provider.ts +4 -4
- package/src/core/state.js +49 -0
- package/src/directory.js +53 -0
- package/src/directory.ts +2 -2
- package/src/docs.js +209 -0
- package/src/docs.ts +3 -3
- package/src/gateway-methods.js +360 -0
- package/src/gateway-methods.ts +5 -5
- package/src/onboarding.js +337 -0
- package/src/onboarding.ts +4 -4
- package/src/policy.js +15 -0
- package/src/policy.ts +1 -1
- package/src/probe.js +144 -0
- package/src/probe.ts +2 -2
- package/src/reply-dispatcher.js +435 -0
- package/src/reply-dispatcher.ts +9 -9
- package/src/runtime.js +26 -0
- package/src/sdk/helpers.js +237 -0
- package/src/sdk/helpers.ts +1 -1
- package/src/sdk/types.js +13 -0
- package/src/secret-input.js +13 -0
- package/src/secret-input.ts +1 -1
- package/src/services/media/audio.js +40 -0
- package/src/services/media/audio.ts +2 -2
- package/src/services/media/chunk-upload.js +211 -0
- package/src/services/media/chunk-upload.ts +2 -2
- package/src/services/media/common.js +120 -0
- package/src/services/media/common.ts +3 -3
- package/src/services/media/file.js +54 -0
- package/src/services/media/file.ts +2 -2
- package/src/services/media/image.js +59 -0
- package/src/services/media/image.ts +2 -2
- package/src/services/media/index.js +9 -0
- package/src/services/media/index.ts +6 -6
- package/src/services/media/video.js +133 -0
- package/src/services/media/video.ts +2 -2
- package/src/services/media.js +889 -0
- package/src/services/media.ts +12 -12
- package/src/services/messaging/card.js +234 -0
- package/src/services/messaging/card.ts +3 -3
- package/src/services/messaging/index.js +8 -0
- package/src/services/messaging/index.ts +3 -3
- package/src/services/messaging/send.js +85 -0
- package/src/services/messaging/send.ts +3 -3
- package/src/services/messaging.js +680 -0
- package/src/services/messaging.ts +8 -8
- package/src/targets.js +38 -0
- package/src/targets.ts +1 -1
- package/src/types/index.js +1 -0
- package/src/types/index.ts +1 -1
- package/src/utils/agent.js +55 -0
- package/src/utils/async.js +40 -0
- package/src/utils/constants.js +24 -0
- package/src/utils/http-client.js +33 -0
- package/src/utils/http-client.ts +1 -1
- package/src/utils/index.js +7 -0
- package/src/utils/index.ts +4 -4
- package/src/utils/logger.js +76 -0
- package/src/utils/session.js +95 -0
- package/src/utils/session.ts +1 -1
- package/src/utils/token.js +71 -0
- package/src/utils/token.ts +2 -2
- package/src/utils/utils-legacy.js +393 -0
- package/src/utils/utils-legacy.ts +8 -8
- package/CHANGELOG.md +0 -485
- package/SKILL.md +0 -40
- package/_meta.json +0 -4
- package/docs/AGENT_ROUTING.md +0 -335
- package/docs/DEAP_AGENT_GUIDE.en.md +0 -115
- package/docs/DEAP_AGENT_GUIDE.md +0 -115
- package/docs/images/dingtalk.svg +0 -1
- package/docs/images/image-1.png +0 -0
- package/docs/images/image-2.png +0 -0
- package/docs/images/image-3.png +0 -0
- package/docs/images/image-4.png +0 -0
- package/docs/images/image-5.png +0 -0
- package/docs/images/image-6.png +0 -0
- package/docs/images/image-7.png +0 -0
- package/install-beta.sh +0 -438
- package/install-npm.sh +0 -167
- package/src/hooks/init.ts +0 -16
- package/tsconfig.json +0 -20
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { resolveDingtalkAccount } from "./config/accounts.js";
|
|
2
|
+
export function resolveDingtalkGroupToolPolicy(params) {
|
|
3
|
+
const { cfg, groupId, accountId } = params;
|
|
4
|
+
const account = resolveDingtalkAccount({ cfg, accountId });
|
|
5
|
+
const dingtalkCfg = account.config;
|
|
6
|
+
// Check group-specific policy first
|
|
7
|
+
if (groupId) {
|
|
8
|
+
const groupConfig = dingtalkCfg?.groups?.[groupId];
|
|
9
|
+
if (groupConfig?.tools) {
|
|
10
|
+
return groupConfig.tools;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
// Fall back to account-level default (allow all)
|
|
14
|
+
return { allow: ["*"] };
|
|
15
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { raceWithTimeoutAndAbort } from "./utils/async.js";
|
|
2
|
+
/** LRU Cache for probe results to reduce repeated health-check calls. */
|
|
3
|
+
class LRUCache {
|
|
4
|
+
cache = new Map();
|
|
5
|
+
maxSize;
|
|
6
|
+
constructor(maxSize) {
|
|
7
|
+
this.maxSize = maxSize;
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
const value = this.cache.get(key);
|
|
11
|
+
if (value !== undefined) {
|
|
12
|
+
// 重新插入以更新访问顺序
|
|
13
|
+
this.cache.delete(key);
|
|
14
|
+
this.cache.set(key, value);
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
set(key, value) {
|
|
19
|
+
// 如果已存在,先删除(更新顺序)
|
|
20
|
+
if (this.cache.has(key)) {
|
|
21
|
+
this.cache.delete(key);
|
|
22
|
+
}
|
|
23
|
+
this.cache.set(key, value);
|
|
24
|
+
// 超过大小限制时删除最旧的(最少使用的)
|
|
25
|
+
if (this.cache.size > this.maxSize) {
|
|
26
|
+
const oldest = this.cache.keys().next().value;
|
|
27
|
+
if (oldest !== undefined) {
|
|
28
|
+
this.cache.delete(oldest);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
clear() {
|
|
33
|
+
this.cache.clear();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const probeCache = new LRUCache(64);
|
|
37
|
+
const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
38
|
+
const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
|
|
39
|
+
export const DINGTALK_PROBE_REQUEST_TIMEOUT_MS = 10_000;
|
|
40
|
+
function setCachedProbeResult(cacheKey, result, ttlMs) {
|
|
41
|
+
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
export async function probeDingtalk(creds, options = {}) {
|
|
45
|
+
if (!creds?.clientId || !creds?.clientSecret) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: "missing credentials (clientId, clientSecret)",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (options.abortSignal?.aborted) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
clientId: creds.clientId,
|
|
55
|
+
error: "probe aborted",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const timeoutMs = options.timeoutMs ?? DINGTALK_PROBE_REQUEST_TIMEOUT_MS;
|
|
59
|
+
// Return cached result if still valid.
|
|
60
|
+
const cacheKey = creds.accountId ?? `${creds.clientId}:${creds.clientSecret.slice(0, 8)}`;
|
|
61
|
+
const cached = probeCache.get(cacheKey);
|
|
62
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
63
|
+
return cached.result;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
// Get access token
|
|
67
|
+
const tokenResponse = await raceWithTimeoutAndAbort(fetch("https://api.dingtalk.com/v1.0/oauth2/accessToken", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
appKey: creds.clientId,
|
|
72
|
+
appSecret: creds.clientSecret,
|
|
73
|
+
}),
|
|
74
|
+
}), { timeoutMs, abortSignal: options.abortSignal });
|
|
75
|
+
if (tokenResponse.status === "aborted") {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
clientId: creds.clientId,
|
|
79
|
+
error: "probe aborted",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (tokenResponse.status === "timeout") {
|
|
83
|
+
return setCachedProbeResult(cacheKey, {
|
|
84
|
+
ok: false,
|
|
85
|
+
clientId: creds.clientId,
|
|
86
|
+
error: `probe timed out after ${timeoutMs}ms`,
|
|
87
|
+
}, PROBE_ERROR_TTL_MS);
|
|
88
|
+
}
|
|
89
|
+
const tokenData = await tokenResponse.value.json();
|
|
90
|
+
if (!tokenData.accessToken) {
|
|
91
|
+
return setCachedProbeResult(cacheKey, {
|
|
92
|
+
ok: false,
|
|
93
|
+
clientId: creds.clientId,
|
|
94
|
+
error: "failed to get access token",
|
|
95
|
+
}, PROBE_ERROR_TTL_MS);
|
|
96
|
+
}
|
|
97
|
+
// Get bot info
|
|
98
|
+
const botResponse = await raceWithTimeoutAndAbort(fetch(`https://api.dingtalk.com/v1.0/contact/users/me`, {
|
|
99
|
+
method: "GET",
|
|
100
|
+
headers: {
|
|
101
|
+
"x-acs-dingtalk-access-token": tokenData.accessToken,
|
|
102
|
+
"Content-Type": "application/json",
|
|
103
|
+
},
|
|
104
|
+
}), { timeoutMs, abortSignal: options.abortSignal });
|
|
105
|
+
if (botResponse.status === "aborted") {
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
clientId: creds.clientId,
|
|
109
|
+
error: "probe aborted",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (botResponse.status === "timeout") {
|
|
113
|
+
return setCachedProbeResult(cacheKey, {
|
|
114
|
+
ok: false,
|
|
115
|
+
clientId: creds.clientId,
|
|
116
|
+
error: `probe timed out after ${timeoutMs}ms`,
|
|
117
|
+
}, PROBE_ERROR_TTL_MS);
|
|
118
|
+
}
|
|
119
|
+
const botData = await botResponse.value.json();
|
|
120
|
+
if (botData.errcode && botData.errcode !== 0) {
|
|
121
|
+
return setCachedProbeResult(cacheKey, {
|
|
122
|
+
ok: false,
|
|
123
|
+
clientId: creds.clientId,
|
|
124
|
+
error: `API error: ${botData.errmsg || `code ${botData.errcode}`}`,
|
|
125
|
+
}, PROBE_ERROR_TTL_MS);
|
|
126
|
+
}
|
|
127
|
+
return setCachedProbeResult(cacheKey, {
|
|
128
|
+
ok: true,
|
|
129
|
+
clientId: creds.clientId,
|
|
130
|
+
botName: botData.nick,
|
|
131
|
+
}, PROBE_SUCCESS_TTL_MS);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
return setCachedProbeResult(cacheKey, {
|
|
135
|
+
ok: false,
|
|
136
|
+
clientId: creds.clientId,
|
|
137
|
+
error: err instanceof Error ? err.message : String(err),
|
|
138
|
+
}, PROBE_ERROR_TTL_MS);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Clear the probe cache (for testing). */
|
|
142
|
+
export function clearProbeCache() {
|
|
143
|
+
probeCache.clear();
|
|
144
|
+
}
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// ✅ 动态导入 channel-runtime 模块
|
|
2
|
+
const channelRuntimeModule = await import("openclaw/plugin-sdk/channel-runtime");
|
|
3
|
+
const { createReplyPrefixOptions, createTypingCallbacks, logTypingFailure, } = channelRuntimeModule;
|
|
4
|
+
import { createLoggerFromConfig } from "./utils/logger.js";
|
|
5
|
+
import { resolveDingtalkAccount } from "./config/accounts.js";
|
|
6
|
+
import { getDingtalkRuntime } from "./runtime.js";
|
|
7
|
+
import { createAICardForTarget, finishAICard, streamAICard, } from "./services/messaging/card.js";
|
|
8
|
+
import { sendMessage } from "./services/messaging.js";
|
|
9
|
+
import { getOapiAccessToken } from "./utils/token.js";
|
|
10
|
+
import { processLocalImages, processVideoMarkers, processAudioMarkers, processFileMarkers, } from "./services/media/index.js";
|
|
11
|
+
export function createDingtalkReplyDispatcher(params) {
|
|
12
|
+
const core = getDingtalkRuntime();
|
|
13
|
+
const { cfg, agentId, conversationId, senderId, isDirect, accountId, sessionWebhook, asyncMode = false, preCreatedCard, } = params;
|
|
14
|
+
const account = resolveDingtalkAccount({ cfg, accountId });
|
|
15
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
16
|
+
cfg,
|
|
17
|
+
agentId,
|
|
18
|
+
channel: "dingtalk-connector",
|
|
19
|
+
accountId,
|
|
20
|
+
});
|
|
21
|
+
// ✅ 读取 debug 配置
|
|
22
|
+
const log = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
|
|
23
|
+
// AI Card 状态管理
|
|
24
|
+
let currentCardTarget = null;
|
|
25
|
+
let accumulatedText = "";
|
|
26
|
+
const deliveredFinalTexts = new Set();
|
|
27
|
+
// 异步模式:累积完整响应
|
|
28
|
+
let asyncModeFullResponse = "";
|
|
29
|
+
// ✅ 节流控制:避免频繁调用钉钉 API 导致 QPS 限流
|
|
30
|
+
let lastUpdateTime = 0;
|
|
31
|
+
const updateInterval = 500; // 最小更新间隔 500ms(钉钉 QPS 限制:40 次/秒,保守起见设为 0.5 秒)
|
|
32
|
+
// ✅ 错误兜底:防止重复发送错误消息
|
|
33
|
+
const deliveredErrorTypes = new Set();
|
|
34
|
+
let lastErrorTime = 0;
|
|
35
|
+
const ERROR_COOLDOWN = 60000; // 错误消息冷却时间 1 分钟
|
|
36
|
+
// ============ 错误兜底函数 ============
|
|
37
|
+
/**
|
|
38
|
+
* 发送兜底错误消息,确保用户始终能收到反馈
|
|
39
|
+
*/
|
|
40
|
+
const sendFallbackErrorMessage = async (errorType, originalError, forceSend = false) => {
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const errorKey = `${errorType}:${conversationId}:${senderId}`;
|
|
43
|
+
// 防止重复发送相同类型的错误消息
|
|
44
|
+
if (!forceSend && deliveredErrorTypes.has(errorKey)) {
|
|
45
|
+
log.debug(`[DingTalk][Fallback] 跳过重复错误消息:${errorType}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// 冷却时间控制
|
|
49
|
+
if (!forceSend && now - lastErrorTime < ERROR_COOLDOWN) {
|
|
50
|
+
log.debug(`[DingTalk][Fallback] 冷却时间内,跳过错误消息`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const errorMessages = {
|
|
54
|
+
mediaProcess: '⚠️ 媒体文件处理失败,已发送文字回复',
|
|
55
|
+
sendMessage: '⚠️ 消息发送失败,请稍后重试',
|
|
56
|
+
unknown: '⚠️ 抱歉,处理您的请求时出错,请稍后重试',
|
|
57
|
+
};
|
|
58
|
+
const errorMessage = errorMessages[errorType];
|
|
59
|
+
log.warn(`[DingTalk][Fallback] ${errorMessage}, error: ${originalError}`);
|
|
60
|
+
try {
|
|
61
|
+
await sendMessage(account.config, sessionWebhook, errorMessage, {
|
|
62
|
+
useMarkdown: false,
|
|
63
|
+
log: params.runtime.log,
|
|
64
|
+
});
|
|
65
|
+
deliveredErrorTypes.add(errorKey);
|
|
66
|
+
lastErrorTime = now;
|
|
67
|
+
log.info(`[DingTalk][Fallback] ✅ 错误消息发送成功`);
|
|
68
|
+
}
|
|
69
|
+
catch (fallbackErr) {
|
|
70
|
+
log.error(`[DingTalk][Fallback] ❌ 错误消息发送失败:${fallbackErr.message}`);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
// 打字指示器回调(钉钉暂不支持,预留接口)
|
|
74
|
+
const typingCallbacks = createTypingCallbacks({
|
|
75
|
+
start: async () => {
|
|
76
|
+
// 钉钉暂不支持打字指示器
|
|
77
|
+
},
|
|
78
|
+
stop: async () => {
|
|
79
|
+
// 钉钉暂不支持打字指示器
|
|
80
|
+
},
|
|
81
|
+
onStartError: (err) => logTypingFailure({
|
|
82
|
+
log: (message) => params.runtime.log?.(message),
|
|
83
|
+
channel: "dingtalk-connector",
|
|
84
|
+
action: "start",
|
|
85
|
+
error: err,
|
|
86
|
+
}),
|
|
87
|
+
onStopError: (err) => logTypingFailure({
|
|
88
|
+
log: (message) => params.runtime.log?.(message),
|
|
89
|
+
channel: "dingtalk-connector",
|
|
90
|
+
action: "stop",
|
|
91
|
+
error: err,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "dingtalk-connector", accountId, { fallbackLimit: 4000 });
|
|
95
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "dingtalk-connector");
|
|
96
|
+
// 流式 AI Card 支持
|
|
97
|
+
const streamingEnabled = account.config?.streaming !== false;
|
|
98
|
+
// 用 Promise 保存 AI Card 的创建过程,避免 final 消息到达时轮询等待
|
|
99
|
+
let cardCreationPromise = null;
|
|
100
|
+
const startStreaming = () => {
|
|
101
|
+
// 如果已经有创建中的 Promise,直接复用,避免并发创建
|
|
102
|
+
if (cardCreationPromise) {
|
|
103
|
+
return cardCreationPromise;
|
|
104
|
+
}
|
|
105
|
+
// 如果 AI Card 已存在,直接返回已完成的 Promise
|
|
106
|
+
if (currentCardTarget) {
|
|
107
|
+
return Promise.resolve();
|
|
108
|
+
}
|
|
109
|
+
cardCreationPromise = (async () => {
|
|
110
|
+
// 异步模式下禁用流式 AI Card
|
|
111
|
+
if (asyncMode) {
|
|
112
|
+
log.info(`[DingTalk][startStreaming] 异步模式,跳过 AI Card 创建`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!streamingEnabled) {
|
|
116
|
+
log.info(`[DingTalk][startStreaming] 流式功能被禁用,跳过 AI Card 创建`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// 若队列繁忙时已预先创建了 Card(显示排队 ACK 文案),直接复用,无需新建
|
|
120
|
+
// 这样用户看到的是同一条消息从 ACK 文案更新为最终结果,而不是多出一条消息
|
|
121
|
+
if (preCreatedCard) {
|
|
122
|
+
log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
|
|
123
|
+
currentCardTarget = preCreatedCard;
|
|
124
|
+
accumulatedText = "";
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
|
|
128
|
+
try {
|
|
129
|
+
const target = isDirect
|
|
130
|
+
? { type: 'user', userId: senderId }
|
|
131
|
+
: { type: 'group', openConversationId: conversationId };
|
|
132
|
+
log.info(`[DingTalk][startStreaming] 目标:${JSON.stringify(target)}`);
|
|
133
|
+
const card = await createAICardForTarget(account.config, target, log);
|
|
134
|
+
currentCardTarget = card;
|
|
135
|
+
accumulatedText = "";
|
|
136
|
+
if (card) {
|
|
137
|
+
log.info(`[DingTalk][startStreaming] ✅ AI Card 创建成功`);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
log.warn(`[DingTalk][startStreaming] AI Card 创建返回 null,静默降级到普通消息模式`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
log.error(`[DingTalk][startStreaming] ❌ AI Card 创建失败:${error?.message || String(error)},静默降级到普通消息模式`);
|
|
145
|
+
currentCardTarget = null;
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
// 创建完成后清空 Promise,允许下次重新创建
|
|
149
|
+
cardCreationPromise = null;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
return cardCreationPromise;
|
|
153
|
+
};
|
|
154
|
+
const closeStreaming = async () => {
|
|
155
|
+
// 立即捕获并清空,防止并发调用重复执行(竞争条件保护)
|
|
156
|
+
// closeStreaming 可能被 onIdle 和 onError 同时触发,若不在此处清空,
|
|
157
|
+
// 第一次调用的 finally 块会将 currentCardTarget 置 null,
|
|
158
|
+
// 导致第二次调用的 finishAICard 收到 null 参数而崩溃
|
|
159
|
+
const cardSnapshot = currentCardTarget;
|
|
160
|
+
if (!cardSnapshot) {
|
|
161
|
+
log.info(`[DingTalk][closeStreaming] 无 AI Card,跳过关闭`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
currentCardTarget = null;
|
|
165
|
+
log.info(`[DingTalk][closeStreaming] 开始关闭 AI Card...`);
|
|
166
|
+
try {
|
|
167
|
+
// 处理媒体标记
|
|
168
|
+
let finalText = accumulatedText;
|
|
169
|
+
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
170
|
+
if (!finalText.trim()) {
|
|
171
|
+
finalText = '✅ 任务执行完成(无文本输出)';
|
|
172
|
+
log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案`);
|
|
173
|
+
}
|
|
174
|
+
// 获取 oapiToken 用于媒体处理
|
|
175
|
+
const oapiToken = await getOapiAccessToken(account.config);
|
|
176
|
+
// ✅ 构建正确的 target(单聊用 senderId,群聊用 conversationId)
|
|
177
|
+
const target = isDirect
|
|
178
|
+
? { type: 'user', userId: senderId }
|
|
179
|
+
: { type: 'group', openConversationId: conversationId };
|
|
180
|
+
log.info(`[DingTalk][closeStreaming] 开始处理媒体文件,target=${JSON.stringify(target)}`);
|
|
181
|
+
if (oapiToken) {
|
|
182
|
+
// 处理本地图片
|
|
183
|
+
finalText = await processLocalImages(finalText, oapiToken, log);
|
|
184
|
+
// ✅ 先处理 Markdown 标记格式的媒体文件
|
|
185
|
+
finalText = await processVideoMarkers(finalText, '', account.config, oapiToken, log, true, // ✅ 使用主动 API 模式
|
|
186
|
+
target);
|
|
187
|
+
finalText = await processAudioMarkers(finalText, '', account.config, oapiToken, log, true, // ✅ 使用主动 API 模式
|
|
188
|
+
target);
|
|
189
|
+
finalText = await processFileMarkers(finalText, '', account.config, oapiToken, log, true, // ✅ 使用主动 API 模式
|
|
190
|
+
target);
|
|
191
|
+
// ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
|
|
192
|
+
log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
|
|
193
|
+
const { processRawMediaPaths } = await import('./services/media');
|
|
194
|
+
finalText = await processRawMediaPaths(finalText, account.config, oapiToken, log, target);
|
|
195
|
+
log.info(`[DingTalk][closeStreaming] processRawMediaPaths 处理完成`);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
log.warn(`[DingTalk][closeStreaming] oapiToken 为空,跳过媒体处理`);
|
|
199
|
+
}
|
|
200
|
+
log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
|
|
201
|
+
await finishAICard(cardSnapshot, finalText, account.config, log);
|
|
202
|
+
log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
|
|
206
|
+
// ✅ 媒体处理或关闭失败时,降级发送普通消息
|
|
207
|
+
await sendFallbackErrorMessage('mediaProcess', error?.message || String(error));
|
|
208
|
+
// 尝试用普通消息发送累积的文本
|
|
209
|
+
if (accumulatedText.trim()) {
|
|
210
|
+
try {
|
|
211
|
+
log.info(`[DingTalk][closeStreaming] 降级发送普通消息`);
|
|
212
|
+
await sendMessage(account.config, sessionWebhook, accumulatedText, {
|
|
213
|
+
useMarkdown: true,
|
|
214
|
+
log: params.runtime.log,
|
|
215
|
+
});
|
|
216
|
+
log.info(`[DingTalk][closeStreaming] ✅ 降级发送成功`);
|
|
217
|
+
}
|
|
218
|
+
catch (sendErr) {
|
|
219
|
+
log.error(`[DingTalk][closeStreaming] ❌ 降级发送失败:${sendErr.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
// currentCardTarget 已在函数开头清空,此处只需重置累积文本
|
|
225
|
+
accumulatedText = "";
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
|
229
|
+
...prefixOptions,
|
|
230
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
231
|
+
onReplyStart: () => {
|
|
232
|
+
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
233
|
+
// 每次 onReplyStart 都是全新的回复周期,清空去重集合
|
|
234
|
+
deliveredFinalTexts.clear();
|
|
235
|
+
if (streamingEnabled) {
|
|
236
|
+
// fire-and-forget:提前创建 AI Card,onPartialReply 会等待创建完成
|
|
237
|
+
void startStreaming();
|
|
238
|
+
}
|
|
239
|
+
typingCallbacks.onActive?.();
|
|
240
|
+
},
|
|
241
|
+
deliver: async (payload, info) => {
|
|
242
|
+
let text = payload.text ?? "";
|
|
243
|
+
log.info(`[DingTalk][deliver] 被调用:kind=${info?.kind}, textLength=${text.length}, hasText=${Boolean(text.trim())}`);
|
|
244
|
+
// ✅ 在 final 响应时,先处理裸露的文件路径
|
|
245
|
+
if (info?.kind === "final" && text.trim()) {
|
|
246
|
+
const target = isDirect
|
|
247
|
+
? { type: 'user', userId: senderId }
|
|
248
|
+
: { type: 'group', openConversationId: conversationId };
|
|
249
|
+
try {
|
|
250
|
+
const oapiToken = await getOapiAccessToken(account.config);
|
|
251
|
+
if (oapiToken) {
|
|
252
|
+
log.info(`[DingTalk][deliver] 检测到 final 响应,准备处理裸露文件路径`);
|
|
253
|
+
const { processRawMediaPaths } = await import('./services/media');
|
|
254
|
+
text = await processRawMediaPaths(text, account.config, oapiToken, log, target);
|
|
255
|
+
log.info(`[DingTalk][deliver] 裸露文件路径处理完成`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
log.error(`[DingTalk][deliver] 处理裸露文件路径失败:${err.message}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const hasText = Boolean(text.trim());
|
|
263
|
+
const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
264
|
+
// ✅ 如果是 final 响应且没有文本,使用默认提示文案
|
|
265
|
+
if (info?.kind === "final" && !hasText) {
|
|
266
|
+
text = '✅ 任务执行完成(无文本输出)';
|
|
267
|
+
log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案`);
|
|
268
|
+
}
|
|
269
|
+
const shouldDeliverText = Boolean(text.trim()) && !skipTextForDuplicateFinal;
|
|
270
|
+
if (!shouldDeliverText) {
|
|
271
|
+
log.info(`[DingTalk][deliver] 跳过发送:hasText=${hasText}, skipTextForDuplicateFinal=${skipTextForDuplicateFinal}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// 异步模式:只累积响应,不发送
|
|
275
|
+
if (asyncMode) {
|
|
276
|
+
log.info(`[DingTalk][deliver] 异步模式,累积响应`);
|
|
277
|
+
asyncModeFullResponse = text;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// block 消息:Agent 的中间 status update
|
|
281
|
+
// 追加到同一张流式 AI Card 里(delta 模式),不单独创建新卡片
|
|
282
|
+
// 如果流式 AI Card 未启用,直接丢弃 block(不发送)
|
|
283
|
+
if (info?.kind === "block") {
|
|
284
|
+
if (!streamingEnabled) {
|
|
285
|
+
log.info(`[DingTalk][deliver] block 消息,流式未启用,丢弃`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
log.info(`[DingTalk][deliver] block 消息,追加到流式 AI Card,文本长度=${text.length}`);
|
|
289
|
+
// 确保 AI Card 已创建(startStreaming 内部会复用已有的 cardCreationPromise)
|
|
290
|
+
await startStreaming();
|
|
291
|
+
// AI Card 已就绪,用 streamAICard 更新内容(仅展示当前 block 文本,不累积到 accumulatedText)
|
|
292
|
+
// accumulatedText 专门给 onPartialReply 的流式更新使用,block 不能污染它
|
|
293
|
+
if (currentCardTarget) {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
296
|
+
try {
|
|
297
|
+
await streamAICard(currentCardTarget, text, false, account.config, log);
|
|
298
|
+
lastUpdateTime = now;
|
|
299
|
+
log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
|
|
300
|
+
}
|
|
301
|
+
catch (streamErr) {
|
|
302
|
+
log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
log.warn(`[DingTalk][deliver] block 消息:AI Card 创建失败,丢弃该 block`);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// 流式模式的 final 处理
|
|
312
|
+
if (info?.kind === "final" && streamingEnabled) {
|
|
313
|
+
log.info(`[DingTalk][deliver] final 响应,流式模式`);
|
|
314
|
+
// await startStreaming() 确保 AI Card 创建完成后再处理 final
|
|
315
|
+
await startStreaming();
|
|
316
|
+
if (currentCardTarget) {
|
|
317
|
+
// 直接用 final 的 text 覆盖 accumulatedText,确保 closeStreaming 用最终内容关闭卡片
|
|
318
|
+
// 不能追加,因为 final text 本身就是完整的最终回复
|
|
319
|
+
accumulatedText = text;
|
|
320
|
+
log.info(`[DingTalk][deliver] 调用 closeStreaming 完成 AI Card`);
|
|
321
|
+
await closeStreaming();
|
|
322
|
+
deliveredFinalTexts.add(text);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
log.warn(`[DingTalk][deliver] ⚠️ AI Card 创建失败,降级到非流式发送`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// 流式模式但没有 card target:降级到非流式发送
|
|
330
|
+
// 或者非流式模式:使用普通消息发送
|
|
331
|
+
if (info?.kind === "final") {
|
|
332
|
+
log.info(`[DingTalk][deliver] 降级到非流式发送,文本长度=${text.length}`);
|
|
333
|
+
try {
|
|
334
|
+
for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) {
|
|
335
|
+
await sendMessage(account.config, sessionWebhook, chunk, {
|
|
336
|
+
useMarkdown: true,
|
|
337
|
+
log: params.runtime.log,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
log.info(`[DingTalk][deliver] ✅ 非流式发送成功`);
|
|
341
|
+
deliveredFinalTexts.add(text);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
log.error(`[DingTalk][deliver] ❌ 非流式发送失败:${error.message}`);
|
|
345
|
+
params.runtime.error?.(`dingtalk[${account.accountId}]: non-streaming delivery failed: ${String(error)}`);
|
|
346
|
+
// ✅ 发送兜底错误消息
|
|
347
|
+
await sendFallbackErrorMessage('sendMessage', error.message);
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
onError: async (error, info) => {
|
|
353
|
+
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${String(error)}`);
|
|
354
|
+
params.runtime.error?.(`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`);
|
|
355
|
+
await closeStreaming();
|
|
356
|
+
typingCallbacks.onIdle?.();
|
|
357
|
+
},
|
|
358
|
+
onIdle: async () => {
|
|
359
|
+
log.info(`[DingTalk][onIdle] 回复空闲,关闭 AI Card`);
|
|
360
|
+
typingCallbacks.onIdle?.();
|
|
361
|
+
await closeStreaming();
|
|
362
|
+
},
|
|
363
|
+
onCleanup: () => {
|
|
364
|
+
log.info(`[DingTalk][onCleanup] 清理回调`);
|
|
365
|
+
typingCallbacks.onCleanup?.();
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
// 构建完整的 replyOptions:replyOptions 只包含 onReplyStart、onTypingController、onTypingCleanup
|
|
369
|
+
// deliver、onError、onIdle、onCleanup 等回调已经在 createReplyDispatcherWithTyping 的参数中定义
|
|
370
|
+
return {
|
|
371
|
+
dispatcher,
|
|
372
|
+
replyOptions: {
|
|
373
|
+
...replyOptions, // ✅ 包含 onReplyStart、onTypingController、onTypingCleanup
|
|
374
|
+
onModelSelected,
|
|
375
|
+
...(streamingEnabled && {
|
|
376
|
+
onPartialReply: async (payload) => {
|
|
377
|
+
log.info(`[DingTalk][onPartialReply] 被调用,payload.text=${payload.text ? payload.text.length : 'null'}`);
|
|
378
|
+
if (!payload.text) {
|
|
379
|
+
log.debug(`[DingTalk][onPartialReply] 空文本,跳过`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
log.debug(`[DingTalk][onPartialReply] 收到部分响应,文本长度=${payload.text.length}`);
|
|
383
|
+
// 异步模式下禁用流式更新
|
|
384
|
+
if (asyncMode) {
|
|
385
|
+
log.debug(`[DingTalk][onPartialReply] 异步模式,累积响应`);
|
|
386
|
+
asyncModeFullResponse = payload.text;
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// await startStreaming() 确保 AI Card 创建完成后再更新
|
|
390
|
+
// startStreaming 内部会复用已有的 cardCreationPromise,不会重复创建
|
|
391
|
+
await startStreaming();
|
|
392
|
+
if (currentCardTarget) {
|
|
393
|
+
accumulatedText = payload.text;
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
396
|
+
const { FILE_MARKER_PATTERN, VIDEO_MARKER_PATTERN, AUDIO_MARKER_PATTERN } = await import('./services/media/common.js');
|
|
397
|
+
const displayContent = accumulatedText
|
|
398
|
+
.replace(FILE_MARKER_PATTERN, '')
|
|
399
|
+
.replace(VIDEO_MARKER_PATTERN, '')
|
|
400
|
+
.replace(AUDIO_MARKER_PATTERN, '')
|
|
401
|
+
.trim();
|
|
402
|
+
log.debug(`[DingTalk][onPartialReply] 更新 AI Card,显示文本长度=${displayContent.length}`);
|
|
403
|
+
try {
|
|
404
|
+
await streamAICard(currentCardTarget, displayContent, false, account.config, log);
|
|
405
|
+
lastUpdateTime = now;
|
|
406
|
+
log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
// 安全检查:确保 code 存在且为字符串
|
|
410
|
+
const errorCode = err.response?.data?.code;
|
|
411
|
+
if (err.response?.status === 403 && typeof errorCode === 'string' && errorCode.includes('QpsLimit')) {
|
|
412
|
+
// QPS 限流,跳过本次更新
|
|
413
|
+
log.warn(`[DingTalk][AICard] QPS 限流,跳过本次更新`);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
log.error(`[DingTalk][onPartialReply] ❌ AI Card 更新失败:${err.message}`);
|
|
417
|
+
// ✅ 发送兜底错误消息,但不抛出异常,避免中断后续处理
|
|
418
|
+
await sendFallbackErrorMessage('sendMessage', err.message);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
log.debug(`[DingTalk][onPartialReply] 节流控制,跳过本次更新(距离上次更新 ${now - lastUpdateTime}ms)`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
log.warn(`[DingTalk][onPartialReply] ⚠️ AI Card 不存在,跳过更新`);
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
}),
|
|
431
|
+
},
|
|
432
|
+
markDispatchIdle,
|
|
433
|
+
getAsyncModeResponse: () => asyncModeFullResponse,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自实现的运行时存储工厂,避免依赖特定版本 openclaw 是否导出 createPluginRuntimeStore。
|
|
3
|
+
* 旧版 openclaw 没有导出该函数,直接 import 会导致 TypeError,因此在此处内联实现。
|
|
4
|
+
*/
|
|
5
|
+
function createRuntimeStore(errorMessage) {
|
|
6
|
+
let runtimeValue = null;
|
|
7
|
+
return {
|
|
8
|
+
setRuntime: (next) => {
|
|
9
|
+
runtimeValue = next;
|
|
10
|
+
},
|
|
11
|
+
clearRuntime: () => {
|
|
12
|
+
runtimeValue = null;
|
|
13
|
+
},
|
|
14
|
+
tryGetRuntime: () => {
|
|
15
|
+
return runtimeValue;
|
|
16
|
+
},
|
|
17
|
+
getRuntime: () => {
|
|
18
|
+
if (runtimeValue === null) {
|
|
19
|
+
throw new Error(errorMessage);
|
|
20
|
+
}
|
|
21
|
+
return runtimeValue;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const { setRuntime: setDingtalkRuntime, getRuntime: getDingtalkRuntime } = createRuntimeStore("DingTalk runtime not initialized");
|
|
26
|
+
export { getDingtalkRuntime, setDingtalkRuntime };
|