@youdu/openclaw-youdu 2026.5.9 → 2026.7.3

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/dist/index.esm.js CHANGED
@@ -17,16 +17,20 @@ const plugin = {
17
17
  register(api) {
18
18
  setYouDuRuntime(api.runtime);
19
19
  api.registerChannel({ plugin: youduPlugin });
20
+ /** 有度频道系统指令 — 条件→动作格式,便于 LLM 精确遵循 */
21
+ const YOUDU_SYSTEM_CONTEXT_HINTS = [
22
+ "## 有度频道输出约束",
23
+ "",
24
+ "以下规则仅在当前频道(有度)生效:",
25
+ "",
26
+ "- 需要向用户发送图片/视频/语音/文件 → 必须使用 `MEDIA:` 指令输出;若不确定用法,调用 Skill 工具执行 `youdu-send-media`",
27
+ "- 需要生成图片或思维导图交付给用户 → 输出格式限 jpg / png / jpeg 三种之一",
28
+ "- 需要使用 /:awesome、/:happy 这类 emoji 快捷指令 → 调用 Skill 工具执行 `chat-emoji-conventions` 查询可用列表",
29
+ ];
20
30
  api.on("before_prompt_build", (_event, ctx) => {
21
- if (ctx?.channelId !== CHANNEL_ID) {
31
+ if (ctx?.channelId !== CHANNEL_ID)
22
32
  return;
23
- }
24
- return {
25
- appendSystemContext: [
26
- "重要:涉及发送图片/视频/语音/文件给用户时,请务必使用 `MEDIA:` 指令。详见 youdu-send-media 这个 skill(技能)。",
27
- "重要: 若涉及需要生成图片(或思维导图)交付给用户时,生成图片的格式为jpg, png, jpeg中的一种",
28
- ].join("\n"),
29
- };
33
+ return { appendSystemContext: YOUDU_SYSTEM_CONTEXT_HINTS.join("\n") };
30
34
  });
31
35
  },
32
36
  };
@@ -13,8 +13,16 @@ export declare const TEXT_CHUNK_LIMIT = 4000;
13
13
  export declare const WS_HEARTBEAT_INTERVAL_MS = 30000;
14
14
  /** WebSocket 最大重连次数 */
15
15
  export declare const WS_MAX_RECONNECT_ATTEMPTS = 10;
16
- /** 消息处理总超时时间(毫秒) */
16
+ /** 消息处理总超时时间(毫秒,被两级超时机制取代,保留兼容) */
17
17
  export declare const MESSAGE_PROCESS_TIMEOUT_MS: number;
18
+ /** 第一级超时:警告阈值(毫秒)- 10 分钟,发送提示后任务继续 */
19
+ export declare const WARNING_TIMEOUT_MS: number;
20
+ /** 第二级超时:中止阈值(毫秒)- 20 分钟,发送提示后强制中断 */
21
+ export declare const ABORT_TIMEOUT_MS: number;
22
+ /** 超时警告提示 */
23
+ export declare const WARNING_MESSAGE = "\u5904\u7406\u65F6\u95F4\u8F83\u957F\uFF0C\u8BF7\u7A0D\u7B49\uFF0C\u6B63\u5728\u751F\u6210\u6700\u7EC8\u7ED3\u679C...";
24
+ /** 超时中止提示 */
25
+ export declare const ABORT_MESSAGE = "\u5904\u7406\u65F6\u95F4\u8D85\u65F6\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5";
18
26
  /** "思考中"流式消息占位内容 */
19
27
  export declare const THINKING_MESSAGE = "<think></think>";
20
28
  /** 消息状态清理间隔(毫秒) */
package/dist/src/const.js CHANGED
@@ -16,8 +16,14 @@ const TEXT_CHUNK_LIMIT = 4000;
16
16
  const WS_HEARTBEAT_INTERVAL_MS = 30000;
17
17
  /** WebSocket 最大重连次数 */
18
18
  const WS_MAX_RECONNECT_ATTEMPTS = 10;
19
- /** 消息处理总超时时间(毫秒) */
20
- const MESSAGE_PROCESS_TIMEOUT_MS = 5 * 60 * 1000;
19
+ /** 第一级超时:警告阈值(毫秒)- 10 分钟,发送提示后任务继续 */
20
+ const WARNING_TIMEOUT_MS = 10 * 60 * 1000;
21
+ /** 第二级超时:中止阈值(毫秒)- 20 分钟,发送提示后强制中断 */
22
+ const ABORT_TIMEOUT_MS = 20 * 60 * 1000;
23
+ /** 超时警告提示 */
24
+ const WARNING_MESSAGE = '处理时间较长,请稍等,正在生成最终结果...';
25
+ /** 超时中止提示 */
26
+ const ABORT_MESSAGE = '处理时间超时,请稍后重试';
21
27
  /** "思考中"流式消息占位内容 */
22
28
  const THINKING_MESSAGE = '<think></think>';
23
29
  /** 消息状态清理间隔(毫秒) */
@@ -29,4 +35,4 @@ const MEDIA_DOCUMENT_PLACEHOLDER = "<media:document>";
29
35
  /** 最大的媒体传输大小 */
30
36
  const DEFAULT_MEDIA_MAX_SIZE = 12 * 1024 * 1024; // 12MB
31
37
 
32
- export { CHANNEL_ID, DEFAULT_DM_POLICY, DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, MEDIA_DOCUMENT_PLACEHOLDER, MEDIA_IMAGE_PLACEHOLDER, MESSAGE_PROCESS_TIMEOUT_MS, MESSAGE_STATE_CLEANUP_INTERVAL_MS, PORT, TEXT_CHUNK_LIMIT, THINKING_MESSAGE, UPLOAD_MEDIA_URL, WEBSOCKET_SCHEME, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_URL };
38
+ export { ABORT_MESSAGE, ABORT_TIMEOUT_MS, CHANNEL_ID, DEFAULT_DM_POLICY, DEFAULT_MEDIA_MAX_SIZE, DOWNLOAD_MEDIA_URL, HTTP_SCHEME, MEDIA_DOCUMENT_PLACEHOLDER, MEDIA_IMAGE_PLACEHOLDER, MESSAGE_STATE_CLEANUP_INTERVAL_MS, PORT, TEXT_CHUNK_LIMIT, THINKING_MESSAGE, UPLOAD_MEDIA_URL, WARNING_MESSAGE, WARNING_TIMEOUT_MS, WEBSOCKET_SCHEME, WS_HEARTBEAT_INTERVAL_MS, WS_MAX_RECONNECT_ATTEMPTS, WS_URL };
@@ -0,0 +1,219 @@
1
+ import type { Youdu } from './media.js';
2
+ /** text[] 数组中的单个元素 — 支持 txt / em / img 混排 */
3
+ export interface YouduTextElement {
4
+ txt?: string;
5
+ em?: string;
6
+ img?: {
7
+ id: string;
8
+ name: string;
9
+ size?: number;
10
+ width?: number;
11
+ height?: number;
12
+ isGif?: boolean;
13
+ };
14
+ }
15
+ /** 引用消息体(被回复的那条消息),raw.decoded.reference */
16
+ export interface YouduReferenceBody {
17
+ msgId: number;
18
+ sender: number;
19
+ senderName: string;
20
+ sessionId: string;
21
+ text: YouduTextElement[];
22
+ time: number;
23
+ }
24
+ /** raw.decoded 顶层结构(同时覆盖合并消息和引用消息的字段) */
25
+ export interface YouduDecodedBody {
26
+ msgType: number;
27
+ atUser?: number[];
28
+ /** 引用消息(被回复的那条消息),msgType=10 时存在 */
29
+ reference?: YouduReferenceBody;
30
+ /** 消息内容数组,支持 txt / em / img 混排 */
31
+ text?: YouduTextElement[];
32
+ /** 合并消息(msgType=12)的聊天记录列表 */
33
+ list?: MergedMessageListItem[];
34
+ /** 合并消息标题 */
35
+ title?: string;
36
+ id?: string;
37
+ name?: string;
38
+ size?: number;
39
+ isDraft?: boolean | null;
40
+ [key: string]: unknown;
41
+ }
42
+ /** 合并消息中 list 的原始条目 */
43
+ export interface MergedMessageListItem {
44
+ sender: number;
45
+ senderName: string;
46
+ sessType: number;
47
+ sendTime: number;
48
+ content: string;
49
+ [key: string]: unknown;
50
+ }
51
+ /** 合并消息中子消息解码后的内容体 */
52
+ export interface MergedSubMessageContent {
53
+ text?: Array<{
54
+ txt?: string;
55
+ em?: string;
56
+ img?: {
57
+ id: string;
58
+ name: string;
59
+ size?: number;
60
+ width?: number;
61
+ height?: number;
62
+ isGif?: boolean;
63
+ };
64
+ }>;
65
+ msgType: number;
66
+ isDraft?: boolean;
67
+ id?: string;
68
+ name?: string;
69
+ size?: number;
70
+ isFolder?: boolean;
71
+ expired?: boolean;
72
+ isTemp?: boolean;
73
+ [key: string]: unknown;
74
+ }
75
+ /** 解析后的单条子消息(合并消息内部使用) */
76
+ export interface ParsedSubMessage {
77
+ text: string;
78
+ media: Youdu.InboundMediaAttachment[];
79
+ sender: number;
80
+ senderName: string;
81
+ sendTime: number;
82
+ }
83
+ /** 合并消息解析结果(内部使用) */
84
+ export interface ParsedMergedResult {
85
+ textParts: string[];
86
+ mediaAttachments: Youdu.InboundMediaAttachment[];
87
+ subMessages: ParsedSubMessage[];
88
+ subMessageCount: number;
89
+ }
90
+ /** parseDecodedBody 的解析结果 */
91
+ export interface ParsedDecodedMessage {
92
+ originalText: string;
93
+ originalMedia: Youdu.InboundMediaAttachment[];
94
+ reference?: {
95
+ text: string;
96
+ media: Youdu.InboundMediaAttachment[];
97
+ sender: number;
98
+ senderName: string;
99
+ time: number;
100
+ };
101
+ }
102
+ /** prepareMessageBody() 的统一返回类型 */
103
+ export interface PreparedMessageBody {
104
+ /** AI 可读的纯文本(可能已包含引用上下文或合并消息格式) */
105
+ text: string;
106
+ /** 需要下载并传给 AI 的媒体附件列表 */
107
+ media: Youdu.InboundMediaAttachment[];
108
+ }
109
+ /** 根据文件名后缀推断 content-type */
110
+ export declare function inferContentTypeFromName(name: string): string | undefined;
111
+ /** 将 Unix 毫秒时间戳格式化为本地日期时间字符串 */
112
+ export declare function formatTimestamp(ms: number): string;
113
+ /** 格式化文件大小为人类可读形式 */
114
+ export declare function formatFileSize(bytes: number): string;
115
+ /**
116
+ * 解析 text[] 数组,提取纯文本和媒体附件。
117
+ *
118
+ * text[] 中的元素类型:
119
+ * - txt: 纯文本内容,直接拼接
120
+ * - em: 表情代码,格式化为 "[表情: /:happy]"
121
+ * - img: 图片,生成 "[图片: xxx.png]" 占位文本,同时提取为附件
122
+ */
123
+ export declare function parseTextElements(elements: YouduTextElement[]): {
124
+ text: string;
125
+ media: Youdu.InboundMediaAttachment[];
126
+ };
127
+ /**
128
+ * 解析合并消息(msgType === 12)。
129
+ *
130
+ * 解析策略:逐条遍历 list[],对每条子消息:
131
+ * 1. base64 解码 content
132
+ * 2. 遍历 text[] 数组(通用容器,可混排 txt/em/img)
133
+ * 3. 文件类型(msgType=3)单独处理
134
+ */
135
+ export declare function parseMergedMessage(body: {
136
+ msgType?: unknown;
137
+ list?: unknown;
138
+ title?: unknown;
139
+ [key: string]: unknown;
140
+ }): ParsedMergedResult | null;
141
+ /**
142
+ * 解析 raw.decoded 为结构化消息(仅用于引用消息 msgType=10)。
143
+ *
144
+ * 同时解析 reference(被引用内容)和 text(原文),
145
+ * 注意:不对 msgType=0 做特殊处理。
146
+ */
147
+ export declare function parseDecodedBody(decoded: YouduDecodedBody): ParsedDecodedMessage;
148
+ /**
149
+ * 格式化合并消息为 AI 可读的文本。
150
+ */
151
+ export declare function formatMergedText(result: ParsedMergedResult, title?: string): string;
152
+ /**
153
+ * 构建 AI 可读的引用消息上下文文本。
154
+ *
155
+ * 当存在引用消息时,生成以下格式:
156
+ * ```text
157
+ * 用户回复了一条消息,请结合以下上下文理解用户意图:
158
+ *
159
+ * 被回复的消息(发送者: 6hhhuhh,时间: 2026-06-14 10:58:05):
160
+ * <引用消息内容>
161
+ *
162
+ * 用户当前消息(发送者: coco):
163
+ * <用户回复内容>
164
+ *
165
+ * 请结合上述对话上下文理解用户的意图,并给出恰当的回复。
166
+ * ```
167
+ */
168
+ export declare function formatMessageWithReference(parsed: ParsedDecodedMessage, currentUserName: string): {
169
+ text: string;
170
+ allMedia: Youdu.InboundMediaAttachment[];
171
+ };
172
+ /**
173
+ * 消息体类型(prepareMessageBody 的输入参数)
174
+ */
175
+ export interface YouDuMessageBody {
176
+ msgId?: string;
177
+ msgType?: string;
178
+ chatType?: 'single' | 'group';
179
+ chatId?: string;
180
+ from?: {
181
+ userId?: string;
182
+ account?: string;
183
+ name?: string;
184
+ };
185
+ createTime?: number;
186
+ text?: {
187
+ content?: string;
188
+ };
189
+ image?: Youdu.MediaBody;
190
+ file?: Youdu.MediaBody;
191
+ audio?: Youdu.MediaBody;
192
+ video?: Youdu.MediaBody;
193
+ media?: Array<Youdu.MediaBody & {
194
+ kind?: string;
195
+ }>;
196
+ raw?: {
197
+ decoded?: YouduDecodedBody;
198
+ };
199
+ [key: string]: any;
200
+ }
201
+ /**
202
+ * 统一消息准备入口。
203
+ *
204
+ * 根据消息体内容自动判断消息类型并完成解析,返回 AI 可消费的
205
+ * 纯文本 + 媒体附件列表。调用方无需关心底层消息类型。
206
+ *
207
+ * 处理流程:
208
+ * 1. 从 body 顶层字段提取直接媒体和文本(保持原有逻辑)
209
+ * 2. 检查 raw.decoded:
210
+ * - msgType=12 → 合并消息解析,覆盖 text 和 media
211
+ * - msgType=10 → 引用消息解析,覆盖 text 和 media
212
+ * - 其他 msgType(含 msgType=0)→ 不做特殊处理
213
+ * 3. 合并顶层媒体和 decoded 解析出的媒体
214
+ *
215
+ * @param body 消息体(WsFrame.body)
216
+ * @param currentUserName 当前消息发送者名称(引用消息格式化时需要)
217
+ * @returns 统一结果 { text, media }
218
+ */
219
+ export declare function prepareMessageBody(body: YouDuMessageBody, currentUserName?: string): PreparedMessageBody;
@@ -0,0 +1,395 @@
1
+ import { extractInboundMedia, inferInboundMessageText } from './media.js';
2
+
3
+ /*
4
+ * Licensed under the MIT License.
5
+ *
6
+ * 有度消息统一解析器
7
+ *
8
+ * 所有消息内容的解析与格式化在此文件中集中管理。
9
+ * 入口函数 prepareMessageBody() 根据消息体类型自动分发,
10
+ * 返回统一的 { text, media } 结果,调用方无需关心底层消息类型。
11
+ *
12
+ * 支持的消息类型:
13
+ * - msgType=12(合并消息/聊天记录转发)
14
+ * - msgType=10(引用消息/回复)
15
+ * - msgType=1/3/其他(图片/文件/普通消息等,走 body 顶层字段提取)
16
+ *
17
+ * 普通文本消息(msgType=0)不在此做特殊处理,
18
+ * 直接走 body.text.content,与原有逻辑保持一致。
19
+ */
20
+ // ============================================================================
21
+ // 2. 共享工具函数
22
+ // ============================================================================
23
+ /** 根据文件名后缀推断 content-type */
24
+ function inferContentTypeFromName(name) {
25
+ const ext = name.split('.').pop()?.toLowerCase();
26
+ const map = {
27
+ png: 'image/png',
28
+ jpg: 'image/jpeg',
29
+ jpeg: 'image/jpeg',
30
+ gif: 'image/gif',
31
+ webp: 'image/webp',
32
+ bmp: 'image/bmp',
33
+ svg: 'image/svg+xml',
34
+ };
35
+ return map[ext ?? ''];
36
+ }
37
+ /** 将 Unix 毫秒时间戳格式化为本地日期时间字符串 */
38
+ function formatTimestamp(ms) {
39
+ if (!ms)
40
+ return '';
41
+ try {
42
+ const d = new Date(ms);
43
+ const pad = (n) => String(n).padStart(2, '0');
44
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
45
+ }
46
+ catch {
47
+ return String(ms);
48
+ }
49
+ }
50
+ /** 格式化文件大小为人类可读形式 */
51
+ function formatFileSize(bytes) {
52
+ if (bytes < 1024) {
53
+ return `${bytes}B`;
54
+ }
55
+ if (bytes < 1024 * 1024) {
56
+ return `${(bytes / 1024).toFixed(1)}KB`;
57
+ }
58
+ return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
59
+ }
60
+ // ============================================================================
61
+ // 3. 底层解析函数
62
+ // ============================================================================
63
+ /**
64
+ * 解析 text[] 数组,提取纯文本和媒体附件。
65
+ *
66
+ * text[] 中的元素类型:
67
+ * - txt: 纯文本内容,直接拼接
68
+ * - em: 表情代码,格式化为 "[表情: /:happy]"
69
+ * - img: 图片,生成 "[图片: xxx.png]" 占位文本,同时提取为附件
70
+ */
71
+ function parseTextElements(elements) {
72
+ const textParts = [];
73
+ const media = [];
74
+ for (const entry of elements) {
75
+ if (!entry || typeof entry !== 'object') {
76
+ continue;
77
+ }
78
+ // 文本段
79
+ if (typeof entry.txt === 'string' && entry.txt) {
80
+ textParts.push(entry.txt);
81
+ continue;
82
+ }
83
+ // 表情段
84
+ if (typeof entry.em === 'string' && entry.em) {
85
+ textParts.push(`[表情: ${entry.em}]`);
86
+ continue;
87
+ }
88
+ // 图片段
89
+ if (entry.img && typeof entry.img.id === 'string' && entry.img.id) {
90
+ const imgName = typeof entry.img.name === 'string' ? entry.img.name : '图片';
91
+ textParts.push(`[图片: ${imgName}]`);
92
+ media.push({
93
+ kind: 'image',
94
+ mediaId: entry.img.id,
95
+ name: imgName,
96
+ size: typeof entry.img.size === 'number' ? entry.img.size : undefined,
97
+ width: typeof entry.img.width === 'number' ? entry.img.width : undefined,
98
+ height: typeof entry.img.height === 'number' ? entry.img.height : undefined,
99
+ contentType: inferContentTypeFromName(imgName),
100
+ });
101
+ continue;
102
+ }
103
+ }
104
+ return { text: textParts.join(''), media };
105
+ }
106
+ /**
107
+ * 安全解码 base64 字符串,失败时返回 null。
108
+ *
109
+ * 编码兼容:先尝试 UTF-8 解码。若 JSON.parse 校验失败,再尝试 GBK 编码
110
+ *(有度早期版本的部分消息使用 GBK 编码的中文文本)。
111
+ */
112
+ function safeBase64Decode(encoded) {
113
+ if (!encoded || typeof encoded !== 'string') {
114
+ return null;
115
+ }
116
+ if (!/^[A-Za-z0-9+/=]*$/.test(encoded)) {
117
+ return null;
118
+ }
119
+ try {
120
+ const buf = Buffer.from(encoded, 'base64');
121
+ if (buf.length === 0) {
122
+ return null;
123
+ }
124
+ // 1) 尝试 UTF-8 解码
125
+ const utf8Str = buf.toString('utf-8');
126
+ if (utf8Str) {
127
+ try {
128
+ JSON.parse(utf8Str);
129
+ return utf8Str;
130
+ }
131
+ catch {
132
+ // 不是合法 JSON → 可能是 GBK 编码
133
+ }
134
+ }
135
+ // 2) GBK 回退
136
+ try {
137
+ const gbkStr = new TextDecoder('gbk').decode(buf);
138
+ if (gbkStr) {
139
+ JSON.parse(gbkStr);
140
+ return gbkStr;
141
+ }
142
+ }
143
+ catch {
144
+ // GBK 也失败
145
+ }
146
+ return null;
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ // ============================================================================
153
+ // 4. 合并消息解析(msgType=12)
154
+ // ============================================================================
155
+ /** 合并消息的 msgType 值 */
156
+ const MERGED_MESSAGE_MSG_TYPE = 12;
157
+ /**
158
+ * 解析合并消息(msgType === 12)。
159
+ *
160
+ * 解析策略:逐条遍历 list[],对每条子消息:
161
+ * 1. base64 解码 content
162
+ * 2. 遍历 text[] 数组(通用容器,可混排 txt/em/img)
163
+ * 3. 文件类型(msgType=3)单独处理
164
+ */
165
+ function parseMergedMessage(body) {
166
+ if (body?.msgType !== MERGED_MESSAGE_MSG_TYPE) {
167
+ return null;
168
+ }
169
+ const rawList = body.list;
170
+ if (!Array.isArray(rawList) || rawList.length === 0) {
171
+ return null;
172
+ }
173
+ const result = {
174
+ textParts: [],
175
+ mediaAttachments: [],
176
+ subMessages: [],
177
+ subMessageCount: rawList.length,
178
+ };
179
+ for (let i = 0; i < rawList.length; i++) {
180
+ const item = rawList[i];
181
+ const sender = typeof item.sender === 'number' ? item.sender : 0;
182
+ const senderName = (typeof item.senderName === 'string' ? item.senderName : '').trim();
183
+ const sendTime = typeof item.sendTime === 'number' ? item.sendTime : 0;
184
+ const encodedContent = typeof item.content === 'string' ? item.content : '';
185
+ // base64 解码
186
+ const decodedStr = safeBase64Decode(encodedContent);
187
+ if (decodedStr === null) {
188
+ continue;
189
+ }
190
+ // JSON 解析
191
+ let decoded;
192
+ try {
193
+ decoded = JSON.parse(decodedStr);
194
+ }
195
+ catch {
196
+ // 非 JSON 文本,作为纯文本展示
197
+ const textLine = decodedStr;
198
+ result.textParts.push(textLine);
199
+ result.subMessages.push({ text: textLine, media: [], sender, senderName, sendTime });
200
+ continue;
201
+ }
202
+ const subTextParts = [];
203
+ const subMedia = [];
204
+ // 分支 A:有 text[] 数组(msgType=0 文本/表情/混排 或 msgType=1 图片)
205
+ if (Array.isArray(decoded.text) && decoded.text.length > 0) {
206
+ const parsed = parseTextElements(decoded.text);
207
+ subTextParts.push(parsed.text);
208
+ subMedia.push(...parsed.media);
209
+ }
210
+ // 分支 B:文件类型(msgType=3,无 text[],顶层 id/name/size)
211
+ const subMsgType = typeof decoded.msgType === 'number' ? decoded.msgType : -1;
212
+ if (subMsgType === 3 && typeof decoded.id === 'string' && decoded.id) {
213
+ const fileName = typeof decoded.name === 'string' ? decoded.name : '文件';
214
+ const fileSize = typeof decoded.size === 'number' ? decoded.size : undefined;
215
+ const sizeLabel = fileSize !== undefined ? ` (${formatFileSize(fileSize)})` : '';
216
+ subTextParts.push(`[文件: ${fileName}]${sizeLabel}`);
217
+ subMedia.push({
218
+ kind: 'file',
219
+ mediaId: decoded.id,
220
+ name: fileName,
221
+ size: fileSize,
222
+ contentType: inferContentTypeFromName(fileName),
223
+ });
224
+ }
225
+ // 分支 C:完全未知的 msgType,兜底
226
+ if (subTextParts.length === 0 && subMedia.length === 0) {
227
+ subTextParts.push(JSON.stringify(decoded));
228
+ }
229
+ const lineText = subTextParts.join('');
230
+ result.textParts.push(lineText);
231
+ result.mediaAttachments.push(...subMedia);
232
+ result.subMessages.push({
233
+ text: lineText,
234
+ media: subMedia,
235
+ sender,
236
+ senderName,
237
+ sendTime,
238
+ });
239
+ }
240
+ if (result.subMessages.length === 0) {
241
+ return null;
242
+ }
243
+ result.subMessageCount = result.subMessages.length;
244
+ return result;
245
+ }
246
+ // ============================================================================
247
+ // 5. 引用消息解析(msgType=10)
248
+ // ============================================================================
249
+ /**
250
+ * 解析 raw.decoded 为结构化消息(仅用于引用消息 msgType=10)。
251
+ *
252
+ * 同时解析 reference(被引用内容)和 text(原文),
253
+ * 注意:不对 msgType=0 做特殊处理。
254
+ */
255
+ function parseDecodedBody(decoded) {
256
+ const original = parseTextElements(decoded.text ?? []);
257
+ let reference;
258
+ if (decoded.reference) {
259
+ const refContent = parseTextElements(decoded.reference.text);
260
+ reference = {
261
+ text: refContent.text,
262
+ media: refContent.media,
263
+ sender: decoded.reference.sender,
264
+ senderName: decoded.reference.senderName,
265
+ time: decoded.reference.time,
266
+ };
267
+ }
268
+ return {
269
+ originalText: original.text,
270
+ originalMedia: original.media,
271
+ reference,
272
+ };
273
+ }
274
+ // ============================================================================
275
+ // 6. 格式化函数
276
+ // ============================================================================
277
+ /**
278
+ * 格式化合并消息为 AI 可读的文本。
279
+ */
280
+ function formatMergedText(result, title) {
281
+ const header = `聊天记录: ${title ?? ''}`;
282
+ const lines = result.subMessages.map((msg) => {
283
+ const timeStr = formatTimestamp(msg.sendTime);
284
+ const senderLabel = msg.senderName
285
+ ? `${msg.sender}(${msg.senderName})`
286
+ : String(msg.sender);
287
+ return `[${timeStr}] ${senderLabel}: ${msg.text}`;
288
+ });
289
+ const body = [header, ...lines].join('\n');
290
+ return [
291
+ '用户分享了一段聊天记录。请先完整阅读聊天记录,然后对聊天记录中的对话内容做简要总结,最后主动询问用户的意图或需要你帮忙完成什么任务。',
292
+ '',
293
+ body,
294
+ ].join('\n');
295
+ }
296
+ /**
297
+ * 构建 AI 可读的引用消息上下文文本。
298
+ *
299
+ * 当存在引用消息时,生成以下格式:
300
+ * ```text
301
+ * 用户回复了一条消息,请结合以下上下文理解用户意图:
302
+ *
303
+ * 被回复的消息(发送者: 6hhhuhh,时间: 2026-06-14 10:58:05):
304
+ * <引用消息内容>
305
+ *
306
+ * 用户当前消息(发送者: coco):
307
+ * <用户回复内容>
308
+ *
309
+ * 请结合上述对话上下文理解用户的意图,并给出恰当的回复。
310
+ * ```
311
+ */
312
+ function formatMessageWithReference(parsed, currentUserName) {
313
+ if (!parsed.reference) {
314
+ return {
315
+ text: parsed.originalText || '',
316
+ allMedia: parsed.originalMedia,
317
+ };
318
+ }
319
+ const ref = parsed.reference;
320
+ const refSenderLabel = ref.senderName || String(ref.sender);
321
+ const refTimeStr = formatTimestamp(ref.time);
322
+ const userName = currentUserName || '未知用户';
323
+ const text = [
324
+ '用户回复了一条消息,请结合以下上下文理解用户意图:',
325
+ '',
326
+ `被回复的消息(发送者: ${refSenderLabel},时间: ${refTimeStr}):`,
327
+ ref.text || '(空消息)',
328
+ '',
329
+ `用户当前消息(发送者: ${userName}):`,
330
+ parsed.originalText || '(空消息)',
331
+ '',
332
+ '请结合上述对话上下文理解用户的意图,并给出恰当的回复。',
333
+ ].join('\n');
334
+ return {
335
+ text,
336
+ allMedia: [...parsed.originalMedia, ...ref.media],
337
+ };
338
+ }
339
+ /**
340
+ * 统一消息准备入口。
341
+ *
342
+ * 根据消息体内容自动判断消息类型并完成解析,返回 AI 可消费的
343
+ * 纯文本 + 媒体附件列表。调用方无需关心底层消息类型。
344
+ *
345
+ * 处理流程:
346
+ * 1. 从 body 顶层字段提取直接媒体和文本(保持原有逻辑)
347
+ * 2. 检查 raw.decoded:
348
+ * - msgType=12 → 合并消息解析,覆盖 text 和 media
349
+ * - msgType=10 → 引用消息解析,覆盖 text 和 media
350
+ * - 其他 msgType(含 msgType=0)→ 不做特殊处理
351
+ * 3. 合并顶层媒体和 decoded 解析出的媒体
352
+ *
353
+ * @param body 消息体(WsFrame.body)
354
+ * @param currentUserName 当前消息发送者名称(引用消息格式化时需要)
355
+ * @returns 统一结果 { text, media }
356
+ */
357
+ function prepareMessageBody(body, currentUserName) {
358
+ // Step 1: 提取顶层媒体和基础文本(保持原有逻辑)
359
+ let inboundMedia = extractInboundMedia(body);
360
+ let text = inferInboundMessageText(body);
361
+ // Step 2: 检查 raw.decoded,处理特殊消息类型
362
+ const rawDecoded = body.raw?.decoded;
363
+ if (rawDecoded) {
364
+ // 合并消息(msgType=12)
365
+ if (rawDecoded.msgType === MERGED_MESSAGE_MSG_TYPE && Array.isArray(rawDecoded.list)) {
366
+ const mergedResult = parseMergedMessage(rawDecoded);
367
+ if (mergedResult) {
368
+ text = formatMergedText(mergedResult, rawDecoded.title);
369
+ if (mergedResult.mediaAttachments.length > 0) {
370
+ inboundMedia = mergedResult.mediaAttachments;
371
+ }
372
+ }
373
+ }
374
+ // 引用消息(msgType=10)
375
+ else if (rawDecoded.msgType === 10) {
376
+ const parsed = parseDecodedBody(rawDecoded);
377
+ if (parsed.reference) {
378
+ const userName = currentUserName || body.from?.name || body.from?.account || '';
379
+ const formatted = formatMessageWithReference(parsed, userName);
380
+ text = formatted.text;
381
+ if (formatted.allMedia.length > 0) {
382
+ inboundMedia = formatted.allMedia;
383
+ }
384
+ }
385
+ else if (parsed.originalText && !text) {
386
+ // 有 decoded.text 但没有 reference,兜底使用解析出的文本
387
+ text = parsed.originalText;
388
+ }
389
+ }
390
+ }
391
+ // Step 3: 返回统一结果
392
+ return { text, media: inboundMedia };
393
+ }
394
+
395
+ export { formatFileSize, formatMergedText, formatMessageWithReference, formatTimestamp, inferContentTypeFromName, parseDecodedBody, parseMergedMessage, parseTextElements, prepareMessageBody };
@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
2
  import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
3
3
  import { ResolvedYouDuAccount } from './utils.js';
4
4
  import { type Youdu } from './media.js';
5
+ import { type YouduDecodedBody } from './message-prepare.js';
5
6
  /**
6
7
  * 消息体类型(待用户完善)
7
8
  */
@@ -27,6 +28,10 @@ export interface YouDuMessageBody {
27
28
  media?: Array<Youdu.MediaBody & {
28
29
  kind?: string;
29
30
  }>;
31
+ /** 消息原始解码数据。raw.decoded 包含合并消息/引用消息等结构 */
32
+ raw?: {
33
+ decoded?: YouduDecodedBody;
34
+ };
30
35
  [key: string]: any;
31
36
  }
32
37
  /**
@@ -1,14 +1,15 @@
1
1
  import { WSClient } from './sdk/ws-client.js';
2
2
  import { getYouDuRuntime } from './runtime.js';
3
3
  import { resolveYouDuAccount } from './utils.js';
4
- import { WS_MAX_RECONNECT_ATTEMPTS, WS_HEARTBEAT_INTERVAL_MS, PORT, THINKING_MESSAGE, MESSAGE_PROCESS_TIMEOUT_MS, CHANNEL_ID, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER } from './const.js';
4
+ import { WS_MAX_RECONNECT_ATTEMPTS, WS_HEARTBEAT_INTERVAL_MS, PORT, THINKING_MESSAGE, ABORT_TIMEOUT_MS, ABORT_MESSAGE, WARNING_TIMEOUT_MS, WARNING_MESSAGE, CHANNEL_ID, MEDIA_IMAGE_PLACEHOLDER, MEDIA_DOCUMENT_PLACEHOLDER } from './const.js';
5
5
  import { buildYouDuTlsConnectOptions } from './tls.js';
6
6
  import { cleanupAccount, startMessageStateCleanup, setYouDuWebSocket, stopMessageStateCleanup, rememberDirectChatTarget, setMessageState, deleteMessageState } from './state-manager.js';
7
7
  import { checkDmPolicy } from './dm-policy.js';
8
8
  import { checkGroupPolicy } from './group-policy.js';
9
- import { extractInboundMedia, inferInboundMessageText, downloadInboundMedia, uploadAndSendMedia, resolveYouDuMediaLocalRoots } from './media.js';
9
+ import { downloadInboundMedia, uploadAndSendMedia, resolveYouDuMediaLocalRoots } from './media.js';
10
10
  import { sendYouDuReply } from './message-sender.js';
11
11
  import { generateReqId } from './sdk/ws-utils.js';
12
+ import { prepareMessageBody } from './message-prepare.js';
12
13
 
13
14
  /*
14
15
  * Licensed under the MIT License.
@@ -88,12 +89,13 @@ async function processYouDuMessage(params) {
88
89
  if (chatType === "direct" && chatId && youduAccount) {
89
90
  rememberDirectChatTarget(account.accountId, youduAccount, chatId);
90
91
  }
91
- let text = '';
92
- const inboundMedia = extractInboundMedia(body);
93
- text = inferInboundMessageText(body);
92
+ // 统一消息解析入口:处理合并消息(msgType=12)、引用消息(msgType=10)和普通消息
93
+ const currentUserName = body.from.name || body.from.account;
94
+ const prepared = prepareMessageBody(body, currentUserName);
95
+ const text = prepared.text;
96
+ const inboundMedia = prepared.media;
94
97
  // 如果消息和附件都为空,跳过处理
95
98
  if (!text && inboundMedia.length === 0) {
96
- runtime.log?.('[youdu][plugin] Skipping empty message');
97
99
  return;
98
100
  }
99
101
  // 群组策略检查 (群聊)
@@ -153,7 +155,7 @@ async function processYouDuMessage(params) {
153
155
  // 构建上下文并路由到 OpenClaw 核心处理
154
156
  const ctxPayload = buildMessageContext(frame, account, config, text, mediaList);
155
157
  try {
156
- await processWithTimeout(routeAndDispatchMessage({
158
+ await processWithTimeoutGuard(() => routeAndDispatchMessage({
157
159
  ctxPayload,
158
160
  config,
159
161
  account,
@@ -162,23 +164,40 @@ async function processYouDuMessage(params) {
162
164
  state,
163
165
  runtime,
164
166
  onCleanup: cleanupState,
165
- }), MESSAGE_PROCESS_TIMEOUT_MS);
167
+ }), {
168
+ warningMs: WARNING_TIMEOUT_MS,
169
+ abortMs: ABORT_TIMEOUT_MS,
170
+ onWarning: async () => {
171
+ runtime.log?.(`[youdu] Task running for ${WARNING_TIMEOUT_MS / 1000 / 60} min, sending warning`);
172
+ await sendYouDuReply({ wsClient, frame, text: WARNING_MESSAGE, runtime });
173
+ },
174
+ onAbort: async () => {
175
+ runtime.log?.(`[youdu] Task running for ${ABORT_TIMEOUT_MS / 1000 / 60} min, aborting`);
176
+ state.timedOut = true;
177
+ await sendYouDuReply({ wsClient, frame, text: ABORT_MESSAGE, runtime });
178
+ },
179
+ });
166
180
  }
167
181
  catch (err) {
168
- runtime.error?.(`[youdu][plugin] Message processing failed: ${String(err)}`);
169
- // 确保 thinking 流被关闭
170
- try {
171
- if (shouldSendThinking) {
172
- await sendYouDuReply({
173
- wsClient,
174
- frame,
175
- text: '处理消息时出现异常,请稍后重试。',
176
- runtime,
177
- });
178
- }
182
+ // 超时中止错误已在 onAbort 中发送提示,不再重复发送
183
+ if (state.timedOut) {
184
+ runtime.error?.(`[youdu][plugin] Message processing timed out after ${ABORT_TIMEOUT_MS / 1000 / 60} min`);
179
185
  }
180
- catch (finishErr) {
181
- runtime.error?.(`[youdu] Failed to finish thinking stream on error: ${String(finishErr)}`);
186
+ else {
187
+ runtime.error?.(`[youdu][plugin] Message processing failed: ${String(err)}`);
188
+ try {
189
+ if (shouldSendThinking) {
190
+ await sendYouDuReply({
191
+ wsClient,
192
+ frame,
193
+ text: '处理消息时出现异常,请稍后重试。',
194
+ runtime,
195
+ });
196
+ }
197
+ }
198
+ catch (finishErr) {
199
+ runtime.error?.(`[youdu] Failed to finish thinking stream on error: ${String(finishErr)}`);
200
+ }
182
201
  }
183
202
  cleanupState();
184
203
  }
@@ -203,6 +222,9 @@ async function routeAndDispatchMessage(params) {
203
222
  cfg: config,
204
223
  dispatcherOptions: {
205
224
  deliver: async (payload, info) => {
225
+ // 超时中止后不再处理后续交付
226
+ if (state.timedOut)
227
+ return;
206
228
  state.deliverCalled = true;
207
229
  // 累积文本
208
230
  if (payload.text) {
@@ -275,6 +297,9 @@ function buildMediaErrorSummary(mediaUrl, errMsg) {
275
297
  }
276
298
  async function finishThinkingStream(ctx) {
277
299
  const { wsClient, frame, state, runtime } = ctx;
300
+ // 超时中止后不再发送最终回复
301
+ if (state.timedOut)
302
+ return;
278
303
  let finishText = state.accumulatedText;
279
304
  if (finishText) {
280
305
  await sendYouDuReply({ wsClient, frame, text: finishText, runtime });
@@ -307,13 +332,53 @@ async function finishThinkingStream(ctx) {
307
332
  }
308
333
  }
309
334
  /**
310
- * 带超时的 Promise 处理
335
+ * 两级超时保护
336
+ *
337
+ * 第一级(warningMs):发送提示消息,任务继续执行
338
+ * 第二级(abortMs):发送中止消息,强制中断任务
311
339
  */
312
- async function processWithTimeout(promise, timeoutMs) {
313
- return Promise.race([
314
- promise,
315
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout after ${timeoutMs}ms`)), timeoutMs)),
316
- ]);
340
+ async function processWithTimeoutGuard(task, options) {
341
+ return new Promise((resolve, reject) => {
342
+ let finished = false;
343
+ const warningTimer = setTimeout(async () => {
344
+ if (finished)
345
+ return;
346
+ try {
347
+ await options.onWarning();
348
+ }
349
+ catch {
350
+ // 警告发送失败不影响主流程
351
+ }
352
+ }, options.warningMs);
353
+ const abortTimer = setTimeout(async () => {
354
+ if (finished)
355
+ return;
356
+ finished = true;
357
+ clearTimeout(warningTimer);
358
+ try {
359
+ await options.onAbort();
360
+ }
361
+ catch {
362
+ // 中止消息发送失败仍要 reject
363
+ }
364
+ reject(new Error('Task aborted after timeout'));
365
+ }, options.abortMs);
366
+ task().then((result) => {
367
+ if (finished)
368
+ return;
369
+ finished = true;
370
+ clearTimeout(warningTimer);
371
+ clearTimeout(abortTimer);
372
+ resolve(result);
373
+ }, (error) => {
374
+ if (finished)
375
+ return;
376
+ finished = true;
377
+ clearTimeout(warningTimer);
378
+ clearTimeout(abortTimer);
379
+ reject(error);
380
+ });
381
+ });
317
382
  }
318
383
  /**
319
384
  * 创建 SDK Logger 适配器
@@ -97,9 +97,7 @@ class WsConnectionManager extends EventEmitter {
97
97
  let frame;
98
98
  try {
99
99
  frame = JSON.parse(raw);
100
- if (!frame.headers?.reqId.startsWith("ping")) {
101
- this.logger.info(`[WS] <<< Received message (${raw.length} chars): ${raw}`);
102
- }
100
+ this.logger.info(`[WS] <<< Received message (${raw.length} chars): ${raw}`);
103
101
  }
104
102
  catch {
105
103
  frame = {
@@ -137,6 +135,9 @@ class WsConnectionManager extends EventEmitter {
137
135
  this.ws.on("ping", () => {
138
136
  this.ws?.pong();
139
137
  });
138
+ this.ws.on("pong", () => {
139
+ this.missedPongCount = 0;
140
+ });
140
141
  this.logger.info("[WS] Event handlers registered");
141
142
  }
142
143
  sendAuth(forceSubscribe = false) {
@@ -255,17 +256,16 @@ class WsConnectionManager extends EventEmitter {
255
256
  }
256
257
  sendHeartbeat() {
257
258
  if (this.missedPongCount >= this.maxMissedPong) {
258
- this.logger.warn(`No heartbeat ack received for ${this.missedPongCount} consecutive pings`);
259
+ this.logger.warn(`No heartbeat pong received for ${this.missedPongCount} consecutive pings`);
259
260
  this.stopHeartbeat();
260
261
  this.ws?.terminate();
261
262
  return;
262
263
  }
263
264
  this.missedPongCount++;
264
265
  try {
265
- this.send({
266
- cmd: WsCmd.HEARTBEAT,
267
- headers: { reqId: generateReqId(WsCmd.HEARTBEAT), token: this.token },
268
- });
266
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
267
+ this.ws.ping();
268
+ }
269
269
  }
270
270
  catch (error) {
271
271
  this.logger.error("Failed to send heartbeat:", error.message);
@@ -13,6 +13,8 @@ export interface MessageState {
13
13
  hasMediaFailed?: boolean;
14
14
  deliverCalled?: boolean;
15
15
  mediaErrorSummary?: string;
16
+ /** 标记是否已超时中止,true 时不再发送流式更新和最终回复 */
17
+ timedOut?: boolean;
16
18
  }
17
19
  /**
18
20
  * 设置账户的 WebSocket 客户端
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdu/openclaw-youdu",
3
- "version": "2026.5.9",
3
+ "version": "2026.7.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.esm.js",
6
6
  "module": "dist/index.esm.js",