@jeik/dingtalk-connector 0.8.21-fix1
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/CHANGELOG.md +686 -0
- package/LICENSE +21 -0
- package/README.en.md +181 -0
- package/README.md +221 -0
- package/bin/dingtalk-connector.js +858 -0
- package/bin/wizard-config.mjs +110 -0
- package/dist/accounts-BAzdqkAV.mjs +268 -0
- package/dist/accounts-BQptOmgB.mjs +2 -0
- package/dist/chunk-upload-BBQgGtcZ.mjs +193 -0
- package/dist/chunk-upload-DaLXXZH3.mjs +2 -0
- package/dist/common-C8pYKU_y.mjs +2 -0
- package/dist/common-Dt9n6fQN.mjs +101 -0
- package/dist/connection-DHHFFNQJ.mjs +423 -0
- package/dist/entry-bundled.d.mts +16 -0
- package/dist/entry-bundled.mjs +31 -0
- package/dist/game-xiyou-CqHt-6Q1.mjs +4271 -0
- package/dist/gateway-methods-C4tcgI7P.mjs +771 -0
- package/dist/gateway-methods-Ci31A3vg.mjs +2 -0
- package/dist/http-client-CpnJHB89.mjs +2 -0
- package/dist/http-client-DFWZgO1n.mjs +33 -0
- package/dist/index.d.mts +193 -0
- package/dist/index.mjs +45 -0
- package/dist/logger-BmJkQkm1.mjs +2 -0
- package/dist/logger-mZ9OSbmD.mjs +58 -0
- package/dist/media-C_SVin7s.mjs +2 -0
- package/dist/media-cz72EVS3.mjs +509 -0
- package/dist/message-handler-DESzFFDc.mjs +1971 -0
- package/dist/messaging-B6l1sRvX.mjs +1044 -0
- package/dist/runtime-DUgpo5zC.mjs +1422 -0
- package/dist/session-DJ4jYqPv.mjs +114 -0
- package/dist/utils-Bjh4r_qS.mjs +4 -0
- package/dist/utils-CIfI_3Jh.mjs +63 -0
- package/dist/utils-legacy-CALCPP1t.mjs +230 -0
- package/dist/utils-legacy-CFYDBM4r.mjs +3 -0
- package/docs/DEAP_AGENT_GUIDE.en.md +115 -0
- package/docs/DEAP_AGENT_GUIDE.md +115 -0
- package/docs/DINGTALK_MANUAL_SETUP.md +50 -0
- package/docs/MULTI_AGENT_SETUP.md +306 -0
- package/docs/RELEASE_NOTES_V0.7.10.md +40 -0
- package/docs/RELEASE_NOTES_V0.7.2.md +143 -0
- package/docs/RELEASE_NOTES_V0.7.3.md +149 -0
- package/docs/RELEASE_NOTES_V0.7.4.md +206 -0
- package/docs/RELEASE_NOTES_V0.7.5.md +267 -0
- package/docs/RELEASE_NOTES_V0.7.6.md +219 -0
- package/docs/RELEASE_NOTES_V0.7.7.md +122 -0
- package/docs/RELEASE_NOTES_V0.7.8.md +101 -0
- package/docs/RELEASE_NOTES_V0.7.9.md +65 -0
- package/docs/RELEASE_NOTES_V0.8.0.md +53 -0
- package/docs/RELEASE_NOTES_V0.8.1.md +47 -0
- package/docs/RELEASE_NOTES_V0.8.10.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.11.md +51 -0
- package/docs/RELEASE_NOTES_V0.8.12.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.13-beta.0.md +69 -0
- package/docs/RELEASE_NOTES_V0.8.13.md +62 -0
- package/docs/RELEASE_NOTES_V0.8.14.md +86 -0
- package/docs/RELEASE_NOTES_V0.8.16.md +40 -0
- package/docs/RELEASE_NOTES_V0.8.17.md +87 -0
- package/docs/RELEASE_NOTES_V0.8.18.md +64 -0
- package/docs/RELEASE_NOTES_V0.8.19.md +62 -0
- package/docs/RELEASE_NOTES_V0.8.2.md +55 -0
- package/docs/RELEASE_NOTES_V0.8.20.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.3.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.4.md +45 -0
- package/docs/RELEASE_NOTES_V0.8.7.md +49 -0
- package/docs/RELEASE_NOTES_V0.8.8.md +63 -0
- package/docs/RELEASE_NOTES_V0.8.9.md +81 -0
- package/docs/RELEASE_NOTES_v0.7.0.md +142 -0
- package/docs/RELEASE_NOTES_v0.7.1.md +74 -0
- package/docs/TROUBLESHOOTING.md +122 -0
- package/index.ts +77 -0
- package/openclaw.plugin.json +551 -0
- package/package.json +147 -0
- package/skills/dingtalk-channel-rules/SKILL.md +91 -0
- package/skills/dingtalk-troubleshoot/SKILL.md +93 -0
- package/skills/dws-cli/SKILL.md +129 -0
- package/skills/dws-cli/references/error-codes.md +95 -0
- package/skills/dws-cli/references/field-rules.md +105 -0
- package/skills/dws-cli/references/global-reference.md +104 -0
- package/skills/dws-cli/references/intent-guide.md +114 -0
- package/skills/dws-cli/references/products/aitable.md +452 -0
- package/skills/dws-cli/references/products/attendance.md +93 -0
- package/skills/dws-cli/references/products/calendar.md +217 -0
- package/skills/dws-cli/references/products/chat.md +292 -0
- package/skills/dws-cli/references/products/contact.md +108 -0
- package/skills/dws-cli/references/products/ding.md +57 -0
- package/skills/dws-cli/references/products/report.md +162 -0
- package/skills/dws-cli/references/products/simple.md +128 -0
- package/skills/dws-cli/references/products/todo.md +138 -0
- package/skills/dws-cli/references/products/workbench.md +39 -0
- package/skills/dws-cli/references/recovery-guide.md +94 -0
- package/src/channel.ts +588 -0
- package/src/config/accounts.ts +242 -0
- package/src/config/schema.ts +180 -0
- package/src/core/connection.ts +741 -0
- package/src/core/message-handler.ts +1788 -0
- package/src/core/provider.ts +111 -0
- package/src/core/state.ts +54 -0
- package/src/device-auth-config.ts +14 -0
- package/src/device-auth.ts +197 -0
- package/src/directory.ts +95 -0
- package/src/docs.ts +293 -0
- package/src/game-xiyou/achievement-engine.ts +252 -0
- package/src/game-xiyou/bounty-system.ts +315 -0
- package/src/game-xiyou/commands.ts +223 -0
- package/src/game-xiyou/drop-engine.ts +241 -0
- package/src/game-xiyou/encounter-system.ts +135 -0
- package/src/game-xiyou/escape-engine.ts +164 -0
- package/src/game-xiyou/exp-calculator.ts +139 -0
- package/src/game-xiyou/index.ts +479 -0
- package/src/game-xiyou/level-system.ts +91 -0
- package/src/game-xiyou/monster-pool.ts +180 -0
- package/src/game-xiyou/pity-counter.ts +114 -0
- package/src/game-xiyou/random-event-engine.ts +648 -0
- package/src/game-xiyou/renderer.ts +679 -0
- package/src/game-xiyou/storage.ts +218 -0
- package/src/game-xiyou/treasure-system.ts +105 -0
- package/src/game-xiyou/types.ts +582 -0
- package/src/game-xiyou/uid-resolver.ts +49 -0
- package/src/gateway-methods.ts +740 -0
- package/src/onboarding.ts +553 -0
- package/src/policy.ts +32 -0
- package/src/probe.ts +210 -0
- package/src/reply-dispatcher.ts +874 -0
- package/src/runtime.ts +32 -0
- package/src/sdk/helpers.ts +322 -0
- package/src/sdk/types.ts +519 -0
- package/src/secret-input.ts +19 -0
- package/src/services/media/audio.ts +54 -0
- package/src/services/media/chunk-upload.ts +296 -0
- package/src/services/media/common.ts +155 -0
- package/src/services/media/file.ts +75 -0
- package/src/services/media/image.ts +81 -0
- package/src/services/media/index.ts +10 -0
- package/src/services/media/video.ts +162 -0
- package/src/services/media.ts +1143 -0
- package/src/services/messaging/card.ts +604 -0
- package/src/services/messaging/index.ts +18 -0
- package/src/services/messaging/mentions.ts +267 -0
- package/src/services/messaging/send.ts +141 -0
- package/src/services/messaging.ts +1191 -0
- package/src/services/reply-markers.ts +55 -0
- package/src/targets.ts +45 -0
- package/src/types/index.ts +59 -0
- package/src/types/pdf-parse.d.ts +3 -0
- package/src/utils/agent.ts +63 -0
- package/src/utils/async.ts +51 -0
- package/src/utils/constants.ts +27 -0
- package/src/utils/http-client.ts +38 -0
- package/src/utils/index.ts +8 -0
- package/src/utils/logger.ts +78 -0
- package/src/utils/session.ts +147 -0
- package/src/utils/token.ts +93 -0
- package/src/utils/utils-legacy.ts +454 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
// 类型定义
|
|
2
|
+
interface ClawdbotConfig {
|
|
3
|
+
[key: string]: any;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface RuntimeEnv {
|
|
7
|
+
log?: (...args: any[]) => void;
|
|
8
|
+
error?: (...args: any[]) => void;
|
|
9
|
+
warn?: (...args: any[]) => void;
|
|
10
|
+
debug?: (...args: any[]) => void;
|
|
11
|
+
info?: (...args: any[]) => void;
|
|
12
|
+
[key: string]: any;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ReplyPayload {
|
|
16
|
+
text?: string;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ✅ 动态导入 channel-runtime 模块
|
|
21
|
+
const channelRuntimeModule = await import("openclaw/plugin-sdk/channel-runtime") as any;
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
createReplyPrefixOptions,
|
|
25
|
+
createTypingCallbacks,
|
|
26
|
+
logTypingFailure,
|
|
27
|
+
} = channelRuntimeModule;
|
|
28
|
+
|
|
29
|
+
import { createLoggerFromConfig } from "./utils/logger.ts";
|
|
30
|
+
import { CHANNEL_ID } from "./channel.ts";
|
|
31
|
+
import { resolveDingtalkAccount } from "./config/accounts.ts";
|
|
32
|
+
import { getDingtalkRuntime } from "./runtime.ts";
|
|
33
|
+
import type { DingtalkConfig } from "./types/index.ts";
|
|
34
|
+
import {
|
|
35
|
+
createAICardForTarget,
|
|
36
|
+
finishAICard,
|
|
37
|
+
streamAICard,
|
|
38
|
+
isQpsLimitError,
|
|
39
|
+
registerActiveCard,
|
|
40
|
+
unregisterActiveCard,
|
|
41
|
+
type AICardInstance,
|
|
42
|
+
type AICardTarget,
|
|
43
|
+
} from "./services/messaging/card.ts";
|
|
44
|
+
import { sendMessage, sendTextMessage, sendMarkdownMessage } from "./services/messaging.ts";
|
|
45
|
+
import { getOapiAccessToken } from "./utils/token.ts";
|
|
46
|
+
import {
|
|
47
|
+
processLocalImages,
|
|
48
|
+
processVideoMarkers,
|
|
49
|
+
processAudioMarkers,
|
|
50
|
+
uploadAndReplaceFileMarkers,
|
|
51
|
+
} from "./services/media/index.ts";
|
|
52
|
+
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
|
53
|
+
import { PROCESS_TAG, FINAL_TAG, extractFinal, finalClean, displayClean } from "./services/reply-markers.ts";
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
export type CreateDingtalkReplyDispatcherParams = {
|
|
57
|
+
cfg: ClawdbotConfig;
|
|
58
|
+
agentId: string;
|
|
59
|
+
runtime: RuntimeEnv;
|
|
60
|
+
conversationId: string;
|
|
61
|
+
senderId: string;
|
|
62
|
+
isDirect: boolean;
|
|
63
|
+
accountId?: string;
|
|
64
|
+
messageCreateTimeMs?: number;
|
|
65
|
+
sessionWebhook: string;
|
|
66
|
+
asyncMode?: boolean;
|
|
67
|
+
/** 队列繁忙时预先创建的 AI Card,startStreaming 时直接复用而非新建 */
|
|
68
|
+
preCreatedCard?: AICardInstance;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatcherParams) {
|
|
72
|
+
const core = getDingtalkRuntime();
|
|
73
|
+
const {
|
|
74
|
+
cfg,
|
|
75
|
+
agentId,
|
|
76
|
+
conversationId,
|
|
77
|
+
senderId,
|
|
78
|
+
isDirect,
|
|
79
|
+
accountId,
|
|
80
|
+
sessionWebhook,
|
|
81
|
+
asyncMode = false,
|
|
82
|
+
preCreatedCard,
|
|
83
|
+
} = params;
|
|
84
|
+
|
|
85
|
+
const account = resolveDingtalkAccount({ cfg, accountId });
|
|
86
|
+
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
87
|
+
cfg,
|
|
88
|
+
agentId,
|
|
89
|
+
channel: CHANNEL_ID,
|
|
90
|
+
accountId,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ✅ 读取 debug 配置
|
|
94
|
+
const log = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
|
|
95
|
+
|
|
96
|
+
// AI Card 状态管理
|
|
97
|
+
let currentCardTarget: AICardTarget | null = null;
|
|
98
|
+
let accumulatedText = "";
|
|
99
|
+
const deliveredFinalTexts = new Set<string>();
|
|
100
|
+
// 防止 startStreaming 在 closeStreaming 之后重新创建新卡片(会导致多余 AI Card)
|
|
101
|
+
let sessionClosed = false;
|
|
102
|
+
|
|
103
|
+
// 异步模式:累积完整响应
|
|
104
|
+
let asyncModeFullResponse = "";
|
|
105
|
+
|
|
106
|
+
// 工具输出累积(用于写入卡片的 cardToolVar)
|
|
107
|
+
let accumulatedToolOutput = "";
|
|
108
|
+
|
|
109
|
+
// ===== 回复标记 / 最终答案认定 =====
|
|
110
|
+
// finalMarkedText:见到 [-final-] 后捕获其后内容(marker 模式的权威最终答案)。
|
|
111
|
+
// lastAnswerText:最近一段非 reasoning 的正式答案(无标记兜底,靠 openclaw 的 isReasoning 标签)。
|
|
112
|
+
let finalMarkedText: string | null = null;
|
|
113
|
+
let lastAnswerText = "";
|
|
114
|
+
// 仅用于日志去重:每轮回复对"检测到过程/最终标记"各只打一次。
|
|
115
|
+
let processMarkerLogged = false;
|
|
116
|
+
let finalMarkerLogged = false;
|
|
117
|
+
|
|
118
|
+
// 观察每段到达的文本,更新两个认定源,并在首次检测到标记时打日志(用户无感,仅日志可见)。
|
|
119
|
+
const observeReply = (raw: string | undefined, isReasoning: boolean | undefined) => {
|
|
120
|
+
const text = raw ?? "";
|
|
121
|
+
if (!text) return;
|
|
122
|
+
if (!processMarkerLogged && text.includes(PROCESS_TAG)) {
|
|
123
|
+
processMarkerLogged = true;
|
|
124
|
+
log.info(`[DingTalk][marker] 检测到过程标记 ${PROCESS_TAG}(已剥离,不展示给用户)`);
|
|
125
|
+
}
|
|
126
|
+
const fin = extractFinal(text);
|
|
127
|
+
if (fin !== null) {
|
|
128
|
+
if (!finalMarkerLogged) {
|
|
129
|
+
finalMarkerLogged = true;
|
|
130
|
+
log.info(`[DingTalk][marker] 检测到最终标记 ${FINAL_TAG}(已剥离,以其后内容为最终答案)`);
|
|
131
|
+
}
|
|
132
|
+
finalMarkedText = fin; // 取最后一个 [-final-] 之后内容
|
|
133
|
+
}
|
|
134
|
+
if (!isReasoning && text.trim()) lastAnswerText = text; // 非思考过程 = 正式答案
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// 选定本轮最终答案:优先 marker,其次最近的非 reasoning 答案,最后退回 accumulatedText。
|
|
138
|
+
const pickFinalText = (): string => finalMarkedText ?? (lastAnswerText || accumulatedText);
|
|
139
|
+
|
|
140
|
+
// 对最终答案套用 prompt-rewriter 的固定模板(跑 reply_payload_sending 钩子)。失败不阻断投递。
|
|
141
|
+
const applyReplyTemplate = async (text: string): Promise<string> => {
|
|
142
|
+
try {
|
|
143
|
+
const runner = getGlobalHookRunner?.();
|
|
144
|
+
if (!runner?.hasHooks?.("reply_payload_sending")) return text;
|
|
145
|
+
const res = await runner.runReplyPayloadSending(
|
|
146
|
+
{ payload: { text }, kind: "final", channel: CHANNEL_ID } as any,
|
|
147
|
+
{ channelId: CHANNEL_ID, accountId, conversationId, senderId } as any,
|
|
148
|
+
);
|
|
149
|
+
if (res?.cancel) return text;
|
|
150
|
+
const out = res?.payload?.text;
|
|
151
|
+
return typeof out === "string" && out ? out : text;
|
|
152
|
+
} catch (e: any) {
|
|
153
|
+
log.warn(`[DingTalk] 套用回复模板失败(忽略):${e?.message || String(e)}`);
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// ===== 养成系统: 通过 onCommandOutput 监听 dws 命令执行 =====
|
|
159
|
+
// 记录当前回复周期内 onCommandOutput 回调检测到的 dws 产品名(如 "aitable"、"calendar"),
|
|
160
|
+
// 在 closeStreaming 时用于触发降妖逻辑,每轮结束后清空。
|
|
161
|
+
const detectedDwsProducts = new Set<string>();
|
|
162
|
+
// 匹配 shell 命令中的 dws 子命令(如 `dws aitable list`),提取产品名用于养成系统掉落判定。
|
|
163
|
+
const DWS_PRODUCT_PATTERN = /\bdws\s+(aitable|calendar|chat|contact|todo|approval|attendance|report|ding|workbench|devdoc)\b/;
|
|
164
|
+
|
|
165
|
+
// ✅ 节流控制:避免频繁调用钉钉 API 导致 QPS 限流
|
|
166
|
+
// 全局令牌桶限流器已在 streamAICard 内部实现(card.ts),此处的 updateInterval
|
|
167
|
+
// 作为单实例级别的前置过滤,减少不必要的 streamAICard 调用
|
|
168
|
+
let lastUpdateTime = 0;
|
|
169
|
+
const updateInterval = 800; // 最小更新间隔 800ms(配合 card.ts 全局限流器,降低单实例发送频率)
|
|
170
|
+
|
|
171
|
+
// ✅ 错误兜底:防止重复发送错误消息
|
|
172
|
+
const deliveredErrorTypes = new Set<string>();
|
|
173
|
+
let lastErrorTime = 0;
|
|
174
|
+
const ERROR_COOLDOWN = 60000; // 错误消息冷却时间 1 分钟
|
|
175
|
+
|
|
176
|
+
// ============ 错误兜底函数 ============
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 发送兜底错误消息,确保用户始终能收到反馈
|
|
180
|
+
*/
|
|
181
|
+
const sendFallbackErrorMessage = async (
|
|
182
|
+
errorType: 'mediaProcess' | 'sendMessage' | 'unknown',
|
|
183
|
+
originalError?: string,
|
|
184
|
+
forceSend: boolean = false
|
|
185
|
+
) => {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const errorKey = `${errorType}:${conversationId}:${senderId}`;
|
|
188
|
+
|
|
189
|
+
// 防止重复发送相同类型的错误消息
|
|
190
|
+
if (!forceSend && deliveredErrorTypes.has(errorKey)) {
|
|
191
|
+
log.debug(`[DingTalk][Fallback] 跳过重复错误消息:${errorType}`);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 冷却时间控制
|
|
196
|
+
if (!forceSend && now - lastErrorTime < ERROR_COOLDOWN) {
|
|
197
|
+
log.debug(`[DingTalk][Fallback] 冷却时间内,跳过错误消息`);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const errorMessages = {
|
|
202
|
+
mediaProcess: '⚠️ 媒体文件处理失败,已发送文字回复',
|
|
203
|
+
sendMessage: '⚠️ 消息发送失败,请稍后重试',
|
|
204
|
+
unknown: '⚠️ 抱歉,处理您的请求时出错,请稍后重试',
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const errorMessage = errorMessages[errorType];
|
|
208
|
+
log.warn(`[DingTalk][Fallback] ${errorMessage}, error: ${originalError}`);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
await sendMessage(
|
|
212
|
+
account.config as DingtalkConfig,
|
|
213
|
+
sessionWebhook,
|
|
214
|
+
errorMessage,
|
|
215
|
+
{
|
|
216
|
+
useMarkdown: false,
|
|
217
|
+
log: params.runtime.log,
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
deliveredErrorTypes.add(errorKey);
|
|
221
|
+
lastErrorTime = now;
|
|
222
|
+
log.info(`[DingTalk][Fallback] ✅ 错误消息发送成功`);
|
|
223
|
+
} catch (fallbackErr: any) {
|
|
224
|
+
log.error(`[DingTalk][Fallback] ❌ 错误消息发送失败:${fallbackErr.message}`);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// 打字指示器回调(钉钉暂不支持,预留接口)
|
|
229
|
+
const typingCallbacks = createTypingCallbacks({
|
|
230
|
+
start: async () => {
|
|
231
|
+
// 钉钉暂不支持打字指示器
|
|
232
|
+
},
|
|
233
|
+
stop: async () => {
|
|
234
|
+
// 钉钉暂不支持打字指示器
|
|
235
|
+
},
|
|
236
|
+
onStartError: (err: any) =>
|
|
237
|
+
logTypingFailure({
|
|
238
|
+
log: (message: any) => params.runtime.log?.(message),
|
|
239
|
+
channel: CHANNEL_ID,
|
|
240
|
+
action: "start",
|
|
241
|
+
error: err,
|
|
242
|
+
}),
|
|
243
|
+
onStopError: (err: any) =>
|
|
244
|
+
logTypingFailure({
|
|
245
|
+
log: (message: any) => params.runtime.log?.(message),
|
|
246
|
+
channel: CHANNEL_ID,
|
|
247
|
+
action: "stop",
|
|
248
|
+
error: err,
|
|
249
|
+
}),
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit(
|
|
253
|
+
cfg,
|
|
254
|
+
CHANNEL_ID,
|
|
255
|
+
accountId,
|
|
256
|
+
{ fallbackLimit: 4000 }
|
|
257
|
+
);
|
|
258
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, CHANNEL_ID);
|
|
259
|
+
|
|
260
|
+
// ✅ 群聊回复模式:当 groupReplyMode 为 text/markdown 时,群聊禁用 AI Card
|
|
261
|
+
const groupReplyMode = (account.config as any)?.groupReplyMode || 'aicard';
|
|
262
|
+
const isTextMode = !isDirect && (groupReplyMode === 'text' || groupReplyMode === 'markdown');
|
|
263
|
+
if (isTextMode) {
|
|
264
|
+
log.info(`[DingTalk] 群聊回复模式: ${groupReplyMode},禁用 AI Card,使用 ${groupReplyMode} 发送`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 流式 AI Card 支持(text/markdown 模式强制禁用流式)
|
|
268
|
+
const streamingEnabled = !isTextMode && (account.config as any)?.streaming !== false;
|
|
269
|
+
// 用 Promise 保存 AI Card 的创建过程,避免 final 消息到达时轮询等待
|
|
270
|
+
let cardCreationPromise: Promise<void> | null = null;
|
|
271
|
+
|
|
272
|
+
const startStreaming = (): Promise<void> => {
|
|
273
|
+
// 如果已经有创建中的 Promise,直接复用,避免并发创建
|
|
274
|
+
if (cardCreationPromise) {
|
|
275
|
+
return cardCreationPromise;
|
|
276
|
+
}
|
|
277
|
+
// 如果 AI Card 已存在,直接返回已完成的 Promise
|
|
278
|
+
if (currentCardTarget) {
|
|
279
|
+
return Promise.resolve();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
cardCreationPromise = (async () => {
|
|
283
|
+
// 异步模式下禁用流式 AI Card
|
|
284
|
+
if (asyncMode) {
|
|
285
|
+
log.info(`[DingTalk][startStreaming] 异步模式,跳过 AI Card 创建`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (!streamingEnabled) {
|
|
289
|
+
log.info(`[DingTalk][startStreaming] 流式功能被禁用,跳过 AI Card 创建`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
// 本次对话会话已关闭(closeStreaming 已执行),禁止重新创建 AI Card。
|
|
293
|
+
// 防止 humanDelay 延迟的 block 在 final 交付后触发 startStreaming 创建多余卡片。
|
|
294
|
+
if (sessionClosed) {
|
|
295
|
+
log.info(`[DingTalk][startStreaming] 会话已关闭,跳过 AI Card 创建`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 若队列繁忙时已预先创建了 Card(显示排队 ACK 文案),直接复用,无需新建
|
|
300
|
+
// 这样用户看到的是同一条消息从 ACK 文案更新为最终结果,而不是多出一条消息
|
|
301
|
+
if (preCreatedCard) {
|
|
302
|
+
log.info(`[DingTalk][startStreaming] 复用预创建 AI Card,cardInstanceId=${preCreatedCard.cardInstanceId}`);
|
|
303
|
+
currentCardTarget = preCreatedCard as any;
|
|
304
|
+
accumulatedText = "";
|
|
305
|
+
// preCreatedCard 路径也要注册,确保 outbound.sendText 拦截器能找到此卡片
|
|
306
|
+
if (!isDirect) {
|
|
307
|
+
registerActiveCard(conversationId, preCreatedCard);
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
log.info(`[DingTalk][startStreaming] 开始创建 AI Card...`);
|
|
313
|
+
|
|
314
|
+
try {
|
|
315
|
+
const target: AICardTarget = isDirect
|
|
316
|
+
? { type: 'user', userId: senderId }
|
|
317
|
+
: { type: 'group', openConversationId: conversationId };
|
|
318
|
+
|
|
319
|
+
log.info(`[DingTalk][startStreaming] 目标:${JSON.stringify(target)}`);
|
|
320
|
+
|
|
321
|
+
const card = await createAICardForTarget(
|
|
322
|
+
account.config as DingtalkConfig,
|
|
323
|
+
target,
|
|
324
|
+
log
|
|
325
|
+
);
|
|
326
|
+
currentCardTarget = card as any;
|
|
327
|
+
accumulatedText = "";
|
|
328
|
+
|
|
329
|
+
if (card) {
|
|
330
|
+
// 注册到全局注册表,让 outbound.sendText(AI 的 message 工具)
|
|
331
|
+
// 能感知到当前会话有活跃 AI Card,并将消息路由到卡片更新而非独立气泡
|
|
332
|
+
if (!isDirect) {
|
|
333
|
+
registerActiveCard(conversationId, card);
|
|
334
|
+
}
|
|
335
|
+
log.info(`[DingTalk][startStreaming] ✅ AI Card 创建成功`);
|
|
336
|
+
} else {
|
|
337
|
+
log.warn(`[DingTalk][startStreaming] AI Card 创建返回 null,静默降级到普通消息模式`);
|
|
338
|
+
}
|
|
339
|
+
} catch (error: any) {
|
|
340
|
+
log.error(`[DingTalk][startStreaming] ❌ AI Card 创建失败:${error?.message || String(error)},静默降级到普通消息模式`);
|
|
341
|
+
currentCardTarget = null;
|
|
342
|
+
} finally {
|
|
343
|
+
// 创建完成后清空 Promise,允许下次重新创建
|
|
344
|
+
cardCreationPromise = null;
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
|
|
348
|
+
return cardCreationPromise;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const closeStreaming: () => Promise<void> = async () => {
|
|
352
|
+
// 立即捕获并清空,防止并发调用重复执行(竞争条件保护)
|
|
353
|
+
// closeStreaming 可能被 onIdle 和 onError 同时触发,若不在此处清空,
|
|
354
|
+
// 第一次调用的 finally 块会将 currentCardTarget 置 null,
|
|
355
|
+
// 导致第二次调用的 finishAICard 收到 null 参数而崩溃
|
|
356
|
+
const cardSnapshot = currentCardTarget;
|
|
357
|
+
if (!cardSnapshot) {
|
|
358
|
+
log.info(`[DingTalk][closeStreaming] 无 AI Card,跳过关闭`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
currentCardTarget = null;
|
|
362
|
+
sessionClosed = true;
|
|
363
|
+
// 从全局注册表中移除,确保关闭后 outbound.sendText 不再向此 Card 路由
|
|
364
|
+
if (!isDirect) {
|
|
365
|
+
unregisterActiveCard(conversationId);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
log.info(`[DingTalk][closeStreaming] 开始关闭 AI Card...`);
|
|
369
|
+
|
|
370
|
+
try {
|
|
371
|
+
// 选定最终答案:marker 优先 → 最近非 reasoning 答案 → accumulatedText 兜底,并剥离尾标记
|
|
372
|
+
let finalText = finalClean(pickFinalText());
|
|
373
|
+
log.info(
|
|
374
|
+
`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : (lastAnswerText ? "非reasoning答案" : "accumulatedText兜底")},长度=${finalText.length}`
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
378
|
+
if (!finalText.trim()) {
|
|
379
|
+
finalText = '✅ 任务执行完成(无文本输出)';
|
|
380
|
+
log.info(`[DingTalk][closeStreaming] 累积文本为空,使用默认提示文案`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// 获取 oapiToken 用于媒体处理
|
|
384
|
+
const oapiToken = await getOapiAccessToken(account.config as DingtalkConfig);
|
|
385
|
+
|
|
386
|
+
// ✅ 构建正确的 target(单聊用 senderId,群聊用 conversationId)
|
|
387
|
+
const target: AICardTarget = isDirect
|
|
388
|
+
? { type: 'user', userId: senderId }
|
|
389
|
+
: { type: 'group', openConversationId: conversationId };
|
|
390
|
+
|
|
391
|
+
log.info(`[DingTalk][closeStreaming] 开始处理媒体文件,target=${JSON.stringify(target)}`);
|
|
392
|
+
|
|
393
|
+
if (oapiToken) {
|
|
394
|
+
// 处理本地图片
|
|
395
|
+
finalText = await processLocalImages(finalText, oapiToken, log);
|
|
396
|
+
|
|
397
|
+
// ✅ 先处理 Markdown 标记格式的媒体文件
|
|
398
|
+
finalText = await processVideoMarkers(
|
|
399
|
+
finalText,
|
|
400
|
+
'',
|
|
401
|
+
account.config as DingtalkConfig,
|
|
402
|
+
oapiToken,
|
|
403
|
+
log,
|
|
404
|
+
true, // ✅ 使用主动 API 模式
|
|
405
|
+
target
|
|
406
|
+
);
|
|
407
|
+
finalText = await processAudioMarkers(
|
|
408
|
+
finalText,
|
|
409
|
+
'',
|
|
410
|
+
account.config as DingtalkConfig,
|
|
411
|
+
oapiToken,
|
|
412
|
+
log,
|
|
413
|
+
true, // ✅ 使用主动 API 模式
|
|
414
|
+
target
|
|
415
|
+
);
|
|
416
|
+
finalText = await uploadAndReplaceFileMarkers(
|
|
417
|
+
finalText,
|
|
418
|
+
'',
|
|
419
|
+
account.config as DingtalkConfig,
|
|
420
|
+
oapiToken,
|
|
421
|
+
log,
|
|
422
|
+
true, // ✅ 使用主动 API 模式
|
|
423
|
+
target
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// ✅ 处理裸露的本地文件路径(绕过 OpenClaw SDK 的 bug)
|
|
427
|
+
log.info(`[DingTalk][closeStreaming] 准备调用 processRawMediaPaths`);
|
|
428
|
+
const { processRawMediaPaths } = await import('./services/media');
|
|
429
|
+
finalText = await processRawMediaPaths(
|
|
430
|
+
finalText,
|
|
431
|
+
account.config as DingtalkConfig,
|
|
432
|
+
oapiToken,
|
|
433
|
+
log,
|
|
434
|
+
target
|
|
435
|
+
);
|
|
436
|
+
log.info(`[DingTalk][closeStreaming] processRawMediaPaths 处理完成`);
|
|
437
|
+
} else {
|
|
438
|
+
log.warn(`[DingTalk][closeStreaming] oapiToken 为空,跳过媒体处理`);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// ===== 养成系统:基于 onCommandOutput 检测到的 dws 产品触发降妖 =====
|
|
442
|
+
// 优先使用 onCommandOutput 监听到的产品(精准),兜底用正则匹配回复文本
|
|
443
|
+
try {
|
|
444
|
+
const productsToProcess = new Set<string>(detectedDwsProducts);
|
|
445
|
+
|
|
446
|
+
// 兜底:如果 onCommandOutput 没捕获到,尝试从回复文本中正则匹配
|
|
447
|
+
if (productsToProcess.size === 0) {
|
|
448
|
+
const dwsProductMatch = finalText.match(/(?:^|\n)\s*(?:>?\s*)?(?:`\s*)?dws\s+(aitable|calendar|chat|contact|todo|approval|attendance|report|ding|workbench|devdoc)\b/m);
|
|
449
|
+
if (dwsProductMatch && !finalText.includes('command not found: dws') && !finalText.includes('请先执行 dws login')) {
|
|
450
|
+
productsToProcess.add(dwsProductMatch[1]);
|
|
451
|
+
log.info(`[DingTalk][closeStreaming] 养成系统:正则兜底匹配到产品=${dwsProductMatch[1]}`);
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
log.info(`[DingTalk][closeStreaming] 养成系统:onCommandOutput 监听到 ${productsToProcess.size} 个 dws 产品: ${[...productsToProcess].join(', ')}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (productsToProcess.size > 0) {
|
|
458
|
+
const { GamificationEngine } = await import('./game-xiyou/index.ts');
|
|
459
|
+
const engine = GamificationEngine.getInstanceForUser(senderId);
|
|
460
|
+
if (engine.isEnabled()) {
|
|
461
|
+
// 一次任务只触发一次降妖,取第一个产品作为代表
|
|
462
|
+
const primaryProduct = [...productsToProcess][0];
|
|
463
|
+
const allProducts = [...productsToProcess].join('+');
|
|
464
|
+
const gamificationBlock = engine.onDwsCommandResult(primaryProduct, true, `dws ${allProducts}`);
|
|
465
|
+
if (gamificationBlock) {
|
|
466
|
+
finalText += '\n' + gamificationBlock;
|
|
467
|
+
log.info(`[DingTalk][closeStreaming] ✅ 养成系统渲染已追加,主产品=${primaryProduct},涉及产品=${allProducts}`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 清空本轮检测记录
|
|
473
|
+
detectedDwsProducts.clear();
|
|
474
|
+
} catch (gamErr: any) {
|
|
475
|
+
log.warn(`[DingTalk][closeStreaming] 养成系统处理失败(不影响主流程): ${gamErr?.message || gamErr}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 套用 prompt-rewriter 的固定回复模板(只对最终答案)
|
|
479
|
+
finalText = await applyReplyTemplate(finalText);
|
|
480
|
+
|
|
481
|
+
log.info(`[DingTalk][closeStreaming] 准备调用 finishAICard,文本长度=${finalText.length}`);
|
|
482
|
+
log.debug(`[DingTalk][closeStreaming] 最终发送内容长度=${finalText.length}`);
|
|
483
|
+
await finishAICard(
|
|
484
|
+
cardSnapshot as any,
|
|
485
|
+
finalText,
|
|
486
|
+
account.config as DingtalkConfig,
|
|
487
|
+
log
|
|
488
|
+
);
|
|
489
|
+
log.info(`[DingTalk][closeStreaming] ✅ AI Card 关闭成功`);
|
|
490
|
+
} catch (error: any) {
|
|
491
|
+
log.error(`[DingTalk][closeStreaming] ❌ AI Card 关闭失败:${error?.message || String(error)}`);
|
|
492
|
+
// ✅ 媒体处理或关闭失败时,降级发送普通消息
|
|
493
|
+
await sendFallbackErrorMessage('mediaProcess', error?.message || String(error));
|
|
494
|
+
|
|
495
|
+
// 尝试用普通消息发送累积的文本(剥离尾标记防泄漏)
|
|
496
|
+
const fallbackText = finalClean(finalMarkedText ?? accumulatedText);
|
|
497
|
+
if (fallbackText.trim()) {
|
|
498
|
+
try {
|
|
499
|
+
log.info(`[DingTalk][closeStreaming] 降级发送普通消息`);
|
|
500
|
+
await sendMessage(
|
|
501
|
+
account.config as DingtalkConfig,
|
|
502
|
+
sessionWebhook,
|
|
503
|
+
fallbackText,
|
|
504
|
+
{
|
|
505
|
+
useMarkdown: true,
|
|
506
|
+
log: params.runtime.log,
|
|
507
|
+
}
|
|
508
|
+
);
|
|
509
|
+
log.info(`[DingTalk][closeStreaming] ✅ 降级发送成功`);
|
|
510
|
+
} catch (sendErr: any) {
|
|
511
|
+
log.error(`[DingTalk][closeStreaming] ❌ 降级发送失败:${sendErr.message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
} finally {
|
|
515
|
+
// currentCardTarget 已在函数开头清空,此处只需重置累积文本
|
|
516
|
+
accumulatedText = "";
|
|
517
|
+
}
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
521
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
522
|
+
...prefixOptions,
|
|
523
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
524
|
+
onReplyStart: () => {
|
|
525
|
+
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
526
|
+
// 每次 onReplyStart 都是全新的回复周期,清空去重集合 + 标记认定状态
|
|
527
|
+
deliveredFinalTexts.clear();
|
|
528
|
+
finalMarkedText = null;
|
|
529
|
+
lastAnswerText = "";
|
|
530
|
+
processMarkerLogged = false;
|
|
531
|
+
finalMarkerLogged = false;
|
|
532
|
+
if (streamingEnabled) {
|
|
533
|
+
// fire-and-forget:提前创建 AI Card,onPartialReply 会等待创建完成
|
|
534
|
+
void startStreaming();
|
|
535
|
+
}
|
|
536
|
+
typingCallbacks.onActive?.();
|
|
537
|
+
},
|
|
538
|
+
deliver: async (payload, info) => {
|
|
539
|
+
let text = payload.text ?? "";
|
|
540
|
+
|
|
541
|
+
log.info(`[DingTalk][deliver] 被调用:kind=${info?.kind}, textLength=${text.length}, hasText=${Boolean(text.trim())}`);
|
|
542
|
+
log.debug(`[DingTalk][deliver] payload keys=${Object.keys(payload).join(',')}, info.kind=${info?.kind}`);
|
|
543
|
+
|
|
544
|
+
// 观察标记:更新最终答案认定(marker / 非 reasoning 兜底)
|
|
545
|
+
observeReply(payload.text, (payload as any).isReasoning);
|
|
546
|
+
|
|
547
|
+
// ✅ 在 final 响应时,先处理裸露的文件路径
|
|
548
|
+
if (info?.kind === "final" && text.trim()) {
|
|
549
|
+
const target: AICardTarget = isDirect
|
|
550
|
+
? { type: 'user', userId: senderId }
|
|
551
|
+
: { type: 'group', openConversationId: conversationId };
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const oapiToken = await getOapiAccessToken(account.config as DingtalkConfig);
|
|
555
|
+
if (oapiToken) {
|
|
556
|
+
log.info(`[DingTalk][deliver] 检测到 final 响应,准备处理裸露文件路径`);
|
|
557
|
+
const { processRawMediaPaths } = await import('./services/media');
|
|
558
|
+
text = await processRawMediaPaths(
|
|
559
|
+
text,
|
|
560
|
+
account.config as DingtalkConfig,
|
|
561
|
+
oapiToken,
|
|
562
|
+
log,
|
|
563
|
+
target
|
|
564
|
+
);
|
|
565
|
+
log.info(`[DingTalk][deliver] 裸露文件路径处理完成`);
|
|
566
|
+
}
|
|
567
|
+
} catch (err: any) {
|
|
568
|
+
log.error(`[DingTalk][deliver] 处理裸露文件路径失败:${err.message}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const hasText = Boolean(text.trim());
|
|
573
|
+
const skipTextForDuplicateFinal =
|
|
574
|
+
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
575
|
+
|
|
576
|
+
// ✅ 如果是 final 响应且没有文本,使用默认提示文案
|
|
577
|
+
if (info?.kind === "final" && !hasText) {
|
|
578
|
+
text = '✅ 任务执行完成(无文本输出)';
|
|
579
|
+
log.info(`[DingTalk][deliver] final 响应无文本,使用默认提示文案`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const shouldDeliverText = Boolean(text.trim()) && !skipTextForDuplicateFinal;
|
|
583
|
+
|
|
584
|
+
if (!shouldDeliverText) {
|
|
585
|
+
log.info(`[DingTalk][deliver] 跳过发送:hasText=${hasText}, skipTextForDuplicateFinal=${skipTextForDuplicateFinal}`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// 异步模式:只累积响应,不发送(剥离标记防泄漏;固定模板由消费方按需套)
|
|
590
|
+
if (asyncMode) {
|
|
591
|
+
log.info(`[DingTalk][deliver] 异步模式,累积响应`);
|
|
592
|
+
asyncModeFullResponse = finalClean(finalMarkedText ?? text);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// block 消息:Agent 的中间 status update
|
|
597
|
+
// 追加到同一张流式 AI Card 里(delta 模式),不单独创建新卡片
|
|
598
|
+
// 如果流式 AI Card 未启用,直接丢弃 block(不发送)
|
|
599
|
+
if (info?.kind === "block") {
|
|
600
|
+
if (!streamingEnabled) {
|
|
601
|
+
log.info(`[DingTalk][deliver] block 消息,流式未启用,丢弃`);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
log.info(`[DingTalk][deliver] block 消息,追加到流式 AI Card,文本长度=${text.length}`);
|
|
605
|
+
// 确保 AI Card 已创建(startStreaming 内部会复用已有的 cardCreationPromise)
|
|
606
|
+
await startStreaming();
|
|
607
|
+
// AI Card 已就绪,用 streamAICard 更新内容(仅展示当前 block 文本,不累积到 accumulatedText)
|
|
608
|
+
// accumulatedText 专门给 onPartialReply 的流式更新使用,block 不能污染它
|
|
609
|
+
if (currentCardTarget) {
|
|
610
|
+
// 若 onPartialReply 已开始流式传输最终文本(accumulatedText 非空),
|
|
611
|
+
// 则跳过 block 更新,避免因 humanDelay 延迟交付的旧状态消息覆盖正在流式中的最终回复内容。
|
|
612
|
+
// (humanDelay 会在 block 之间插入 800-2500ms 延迟,导致 block 在 final 流式开始后才到达)
|
|
613
|
+
if (accumulatedText) {
|
|
614
|
+
log.info(`[DingTalk][deliver] block 消息:最终回复已在流式中(${accumulatedText.length}字),跳过以防覆盖流式内容`);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const now = Date.now();
|
|
618
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
619
|
+
// ✅ 乐观更新:防止并发回调在 await 期间通过节流检查
|
|
620
|
+
lastUpdateTime = now;
|
|
621
|
+
try {
|
|
622
|
+
await streamAICard(
|
|
623
|
+
currentCardTarget as any,
|
|
624
|
+
displayClean(text),
|
|
625
|
+
false,
|
|
626
|
+
account.config as DingtalkConfig,
|
|
627
|
+
log
|
|
628
|
+
);
|
|
629
|
+
log.info(`[DingTalk][deliver] ✅ block 更新到 AI Card 成功`);
|
|
630
|
+
} catch (streamErr: any) {
|
|
631
|
+
log.error(`[DingTalk][deliver] ❌ block 更新 AI Card 失败:${streamErr.message}`);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
} else {
|
|
635
|
+
log.warn(`[DingTalk][deliver] block 消息:AI Card 创建失败,丢弃该 block`);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 流式模式的 final 处理
|
|
641
|
+
if (info?.kind === "final" && streamingEnabled) {
|
|
642
|
+
log.info(`[DingTalk][deliver] final 响应,流式模式`);
|
|
643
|
+
// await startStreaming() 确保 AI Card 创建完成后再处理 final
|
|
644
|
+
await startStreaming();
|
|
645
|
+
|
|
646
|
+
if (currentCardTarget) {
|
|
647
|
+
// 多轮 Agent 模式:每轮 final 仅更新 accumulatedText,不调用 streamAICard
|
|
648
|
+
// 卡片内容由 onPartialReply 实时流式更新;此处调用 streamAICard 会用旧轮次文本
|
|
649
|
+
// 覆盖 onPartialReply 已写入的新内容,导致卡片在完成后快速倒放中间状态。
|
|
650
|
+
// onIdle → closeStreaming() → finishAICard() 是唯一的卡片最终确认路径。
|
|
651
|
+
accumulatedText = text;
|
|
652
|
+
log.info(`[DingTalk][deliver] 多轮 Agent 模式:仅更新 accumulatedText(len=${text.length}),不触发卡片更新`);
|
|
653
|
+
deliveredFinalTexts.add(text);
|
|
654
|
+
return;
|
|
655
|
+
} else {
|
|
656
|
+
log.warn(`[DingTalk][deliver] ⚠️ AI Card 创建失败,降级到非流式发送`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// 流式模式但没有 card target:降级到非流式发送
|
|
661
|
+
// 或者非流式模式:使用普通消息发送
|
|
662
|
+
if (info?.kind === "final") {
|
|
663
|
+
// 非流式最终发送:选定最终答案(marker 优先)+ 剥离尾标记 + 套固定模板
|
|
664
|
+
text = await applyReplyTemplate(finalClean(finalMarkedText ?? text));
|
|
665
|
+
log.info(`[DingTalk][deliver] 降级到非流式发送,文本长度=${text.length}, isTextMode=${isTextMode}, groupReplyMode=${groupReplyMode}`);
|
|
666
|
+
try {
|
|
667
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
668
|
+
text,
|
|
669
|
+
textChunkLimit,
|
|
670
|
+
chunkMode
|
|
671
|
+
)) {
|
|
672
|
+
if (isTextMode) {
|
|
673
|
+
if (groupReplyMode === 'markdown') {
|
|
674
|
+
await sendMarkdownMessage(
|
|
675
|
+
account.config as DingtalkConfig,
|
|
676
|
+
sessionWebhook,
|
|
677
|
+
chunk.split('\n')[0]?.replace(/^[#*\s\->]+/, '').slice(0, 20) || 'Message',
|
|
678
|
+
chunk,
|
|
679
|
+
{ cfg, detectBareAliases: true },
|
|
680
|
+
);
|
|
681
|
+
} else {
|
|
682
|
+
await sendTextMessage(
|
|
683
|
+
account.config as DingtalkConfig,
|
|
684
|
+
sessionWebhook,
|
|
685
|
+
chunk,
|
|
686
|
+
{ cfg, detectBareAliases: true },
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
} else {
|
|
690
|
+
await sendMessage(
|
|
691
|
+
account.config as DingtalkConfig,
|
|
692
|
+
sessionWebhook,
|
|
693
|
+
chunk,
|
|
694
|
+
{
|
|
695
|
+
useMarkdown: true,
|
|
696
|
+
log: params.runtime.log,
|
|
697
|
+
cfg,
|
|
698
|
+
detectBareAliases: true,
|
|
699
|
+
}
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
log.info(`[DingTalk][deliver] ✅ 非流式发送成功`);
|
|
704
|
+
deliveredFinalTexts.add(text);
|
|
705
|
+
} catch (error: any) {
|
|
706
|
+
log.error(`[DingTalk][deliver] ❌ 非流式发送失败:${error.message}`);
|
|
707
|
+
params.runtime.error?.(
|
|
708
|
+
`dingtalk[${account.accountId}]: non-streaming delivery failed: ${String(error)}`
|
|
709
|
+
);
|
|
710
|
+
// ✅ 发送兜底错误消息
|
|
711
|
+
await sendFallbackErrorMessage('sendMessage', error.message);
|
|
712
|
+
}
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
},
|
|
716
|
+
onError: async (error, info) => {
|
|
717
|
+
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${String(error)}`);
|
|
718
|
+
params.runtime.error?.(
|
|
719
|
+
`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`
|
|
720
|
+
);
|
|
721
|
+
await closeStreaming();
|
|
722
|
+
typingCallbacks.onIdle?.();
|
|
723
|
+
},
|
|
724
|
+
onIdle: async () => {
|
|
725
|
+
log.info(`[DingTalk][onIdle] 回复空闲,关闭 AI Card`);
|
|
726
|
+
typingCallbacks.onIdle?.();
|
|
727
|
+
await closeStreaming();
|
|
728
|
+
},
|
|
729
|
+
onCleanup: () => {
|
|
730
|
+
log.info(`[DingTalk][onCleanup] 清理回调`);
|
|
731
|
+
typingCallbacks.onCleanup?.();
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
// 构建完整的 replyOptions:replyOptions 只包含 onReplyStart、onTypingController、onTypingCleanup
|
|
736
|
+
// deliver、onError、onIdle、onCleanup 等回调已经在 createReplyDispatcherWithTyping 的参数中定义
|
|
737
|
+
return {
|
|
738
|
+
dispatcher,
|
|
739
|
+
replyOptions: {
|
|
740
|
+
...replyOptions, // ✅ 包含 onReplyStart、onTypingController、onTypingCleanup
|
|
741
|
+
onModelSelected,
|
|
742
|
+
...(streamingEnabled && {
|
|
743
|
+
onPartialReply: async (payload: ReplyPayload) => {
|
|
744
|
+
log.info(`[DingTalk][onPartialReply] 被调用,payload.text=${payload.text ? payload.text.length : 'null'}`);
|
|
745
|
+
log.debug(`[DingTalk][onPartialReply] textLength=${payload.text?.length ?? 0}`);
|
|
746
|
+
if (!payload.text) {
|
|
747
|
+
log.debug(`[DingTalk][onPartialReply] 空文本,跳过`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
log.debug(`[DingTalk][onPartialReply] 收到部分响应,文本长度=${payload.text.length}`);
|
|
752
|
+
|
|
753
|
+
// 观察标记:更新最终答案认定(marker / 非 reasoning 兜底)
|
|
754
|
+
observeReply(payload.text, (payload as any).isReasoning);
|
|
755
|
+
|
|
756
|
+
// 异步模式下禁用流式更新(剥离标记防泄漏)
|
|
757
|
+
if (asyncMode) {
|
|
758
|
+
log.debug(`[DingTalk][onPartialReply] 异步模式,累积响应`);
|
|
759
|
+
asyncModeFullResponse = finalClean(finalMarkedText ?? payload.text);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// await startStreaming() 确保 AI Card 创建完成后再更新
|
|
764
|
+
// startStreaming 内部会复用已有的 cardCreationPromise,不会重复创建
|
|
765
|
+
await startStreaming();
|
|
766
|
+
|
|
767
|
+
if (currentCardTarget) {
|
|
768
|
+
accumulatedText = payload.text;
|
|
769
|
+
|
|
770
|
+
const now = Date.now();
|
|
771
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
772
|
+
const { FILE_MARKER_PATTERN, VIDEO_MARKER_PATTERN, AUDIO_MARKER_PATTERN } = await import('./services/media/common.ts');
|
|
773
|
+
// 见到 [-final-] 后卡片只展示最终答案;否则展示当前文本(去尾部完整/半截标记,正文不动)。
|
|
774
|
+
const displaySource = finalMarkedText ?? accumulatedText;
|
|
775
|
+
const displayContent = displayClean(displaySource)
|
|
776
|
+
.replace(FILE_MARKER_PATTERN, '')
|
|
777
|
+
.replace(VIDEO_MARKER_PATTERN, '')
|
|
778
|
+
.replace(AUDIO_MARKER_PATTERN, '')
|
|
779
|
+
.trim();
|
|
780
|
+
|
|
781
|
+
log.debug(`[DingTalk][onPartialReply] 更新 AI Card,显示文本长度=${displayContent.length}`);
|
|
782
|
+
|
|
783
|
+
// ✅ 乐观更新:在发起 HTTP 请求前立即更新 lastUpdateTime,
|
|
784
|
+
// 防止并发的 onPartialReply 回调在 await 期间通过节流检查,
|
|
785
|
+
// 导致多个请求同时打到同一张卡片触发服务端 403 并发保护
|
|
786
|
+
lastUpdateTime = now;
|
|
787
|
+
try {
|
|
788
|
+
await streamAICard(
|
|
789
|
+
currentCardTarget as any,
|
|
790
|
+
displayContent,
|
|
791
|
+
false,
|
|
792
|
+
account.config as DingtalkConfig,
|
|
793
|
+
log,
|
|
794
|
+
(account.config as DingtalkConfig)?.cardContentVar as string || "msgContent"
|
|
795
|
+
);
|
|
796
|
+
log.debug(`[DingTalk][onPartialReply] ✅ AI Card 更新成功`);
|
|
797
|
+
} catch (err: any) {
|
|
798
|
+
// QPS 限流是瞬时错误:streamAICard 内部已自动退避+重试,
|
|
799
|
+
// 退避期过后下一次 partial 更新会把 AI Card 内容覆盖补齐,
|
|
800
|
+
// 因此不应把 QPS 限流展示为用户可见的「消息发送失败」提示,
|
|
801
|
+
// 否则用户会同时看到正常的 AI Card 回复和一条误报错误。
|
|
802
|
+
// 真正无法恢复的错误(finalize 仍失败)会在 closeStreaming
|
|
803
|
+
// 的降级路径里通过 sendFallbackErrorMessage 兜底。
|
|
804
|
+
if (isQpsLimitError(err)) {
|
|
805
|
+
log.warn(
|
|
806
|
+
`[DingTalk][onPartialReply] AI Card 流式更新遇到 QPS 限流,已在内部退避重试;本次跳过,等待下一次 partial 更新补齐内容`,
|
|
807
|
+
);
|
|
808
|
+
} else {
|
|
809
|
+
log.error(`[DingTalk][onPartialReply] ❌ AI Card 更新失败:${err.message}`);
|
|
810
|
+
await sendFallbackErrorMessage('sendMessage', err.message);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
} else {
|
|
814
|
+
log.debug(`[DingTalk][onPartialReply] 节流控制,跳过本次更新(距离上次更新 ${now - lastUpdateTime}ms)`);
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
log.warn(`[DingTalk][onPartialReply] ⚠️ AI Card 不存在,跳过更新`);
|
|
818
|
+
}
|
|
819
|
+
},
|
|
820
|
+
}),
|
|
821
|
+
// ===== 养成系统:监听 dws 命令执行 =====
|
|
822
|
+
onCommandOutput: (payload: {
|
|
823
|
+
itemId?: string;
|
|
824
|
+
phase?: string;
|
|
825
|
+
title?: string;
|
|
826
|
+
toolCallId?: string;
|
|
827
|
+
name?: string;
|
|
828
|
+
output?: string;
|
|
829
|
+
status?: string;
|
|
830
|
+
exitCode?: number | null;
|
|
831
|
+
durationMs?: number;
|
|
832
|
+
cwd?: string;
|
|
833
|
+
}) => {
|
|
834
|
+
const commandText = payload.title || payload.name || '';
|
|
835
|
+
const dwsMatch = commandText.match(DWS_PRODUCT_PATTERN) || payload.output?.match(DWS_PRODUCT_PATTERN);
|
|
836
|
+
if (dwsMatch) {
|
|
837
|
+
const product = dwsMatch[1];
|
|
838
|
+
// 只记录成功执行的命令(exitCode 为 0 或 phase 不是 end 时还不知道结果)
|
|
839
|
+
const isFailure = payload.phase === 'end' && payload.exitCode !== null && payload.exitCode !== 0;
|
|
840
|
+
if (!isFailure) {
|
|
841
|
+
detectedDwsProducts.add(product);
|
|
842
|
+
log.info(`[DingTalk][onCommandOutput] 检测到 dws 产品: ${product},phase=${payload.phase}, exitCode=${payload.exitCode}`);
|
|
843
|
+
} else {
|
|
844
|
+
log.info(`[DingTalk][onCommandOutput] dws 命令执行失败,跳过: ${product},exitCode=${payload.exitCode}`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// 工具输出写入 AI Card 卡片变量(cardToolVar)
|
|
849
|
+
const toolVar = (account.config as DingtalkConfig)?.cardToolVar as string;
|
|
850
|
+
if (toolVar && payload.output && currentCardTarget) {
|
|
851
|
+
accumulatedToolOutput = payload.output;
|
|
852
|
+
const now = Date.now();
|
|
853
|
+
if (now - lastUpdateTime >= updateInterval) {
|
|
854
|
+
lastUpdateTime = now;
|
|
855
|
+
void streamAICard(
|
|
856
|
+
currentCardTarget as any,
|
|
857
|
+
payload.output,
|
|
858
|
+
false,
|
|
859
|
+
account.config as DingtalkConfig,
|
|
860
|
+
log,
|
|
861
|
+
toolVar
|
|
862
|
+
).then(() => {
|
|
863
|
+
log.debug(`[DingTalk][onCommandOutput] ✅ 工具输出写入 AI Card(${toolVar})`);
|
|
864
|
+
}).catch((err: any) => {
|
|
865
|
+
log.error(`[DingTalk][onCommandOutput] ❌ 工具输出写入失败:${err.message}`);
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
markDispatchIdle,
|
|
872
|
+
getAsyncModeResponse: () => asyncModeFullResponse,
|
|
873
|
+
};
|
|
874
|
+
}
|