@youdu/openclaw-youdu 2026.9.16 → 2026.10.9

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.md CHANGED
@@ -91,6 +91,8 @@ openclaw gateway restart
91
91
  | `channels.youdu.groups.<群ID或群名>.requireMention` | 该群是否必须 @ 机器人 | true / false | true |
92
92
  | `channels.youdu.groups."*".requireMention` | 所有群的默认 @ 要求 | true / false | true |
93
93
  | `channels.youdu.historyLimit` | 未被 @ 的群消息保留条数(作为下次被 @ 时的上下文);**不配置即默认 50**,`0` 关闭 | 数字(≥0) | 50 |
94
+ | `channels.youdu.historyMediaLimit` | 被 @ 时最多补带几张群历史**图片**(作为本轮附件交给模型,进 vision 输入);只取 30 分钟内的,`0` 关闭 | 数字(≥0) | 4 |
95
+ | `channels.youdu.historyFileLimit` | 被 @ 时最多补带几个群历史**文件**(含语音/视频)作为本轮附件;`0` 关闭 | 数字(≥0) | 2 |
94
96
  | `channels.youdu.defaultAccount` | 默认账号 ID | 任意账号 ID | — |
95
97
  | `channels.youdu.accounts` | 多账号配置 | 账号 ID → 账号级字段的映射 | — |
96
98
 
@@ -137,10 +139,16 @@ openclaw gateway restart
137
139
  - 送进模型前会剥掉消息里的 `@账号(显示名)` 包装,避免模型看到 @ 前缀。
138
140
  - **未被 @ 的消息不会进入会话**:不研究、不下载附件、不发"思考中"占位消息。它们只按群进入一个
139
141
  **内存中的 pending 窗口**(**默认每个群保留最近 50 条,无需配置**;`channels.youdu.historyLimit` 可调,
140
- `0` 关闭;窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会以
141
- `[Chat messages since your last reply - for context]` 的形式随当次请求一起注入,让机器人知道群里刚才说了什么。
142
+ `0` 关闭;窗口在每次回复结束、以及进程重启后清空)。下一次被 @ 时,这些消息会作为结构化历史
143
+ (OpenClaw 的 `InboundHistory`)随当次请求一起注入,让机器人知道群里刚才说了什么。
144
+ - **窗口里的文件/图片会在被 @ 时补带**(只记事实、不提前下载):被 @ 前 30 分钟内的图片最多补
145
+ `channels.youdu.historyMediaLimit` 张(默认 4)、文件/语音/视频最多补 `channels.youdu.historyFileLimit`
146
+ 个(默认 2),都作为**本轮附件**交给模型(图片进 vision 输入,文件作为附件)。
147
+ 超出时间窗口或下载失败的媒体只保留文本历史,不会拖垮这一轮回复。
148
+ (`requireMention: false` 的群不产生这个窗口——它每条消息都会触发一轮,会话自身就带上下文。)
142
149
  - 窗口大小按 `账号级 historyLimit` → `channels.youdu.historyLimit` → `messages.groupChat.historyLimit`
143
150
  (OpenClaw 全局群聊配置)→ 内置默认 50 依次取值,四层都不配就是 50;多账号模式下该值在通道级共享。
151
+ 媒体上限按 `账号级` → `通道级` → 默认(图片 4、文件 2)取值。
144
152
  - 判定能力缺失时(认证应答没有 `gid`,也没有配置 `mentionPatterns`)**宁可不回**:会记录一条错误日志并丢弃
145
153
  该消息,而不会退化成"群里全回"。
146
154
  - 想让某个群恢复"全部回复",在该群配置里显式关闭即可(键可以写群 ID,也可以写群名):
@@ -0,0 +1,144 @@
1
+ /**
2
+ * 群聊 pending 历史窗口:未被 @ 的群消息在这里排队,等下一次被 @ 时作为上下文交给模型。
3
+ *
4
+ * 两条通道,各管一件事:
5
+ * - **文本历史**走宿主的 `InboundHistory`:宿主渲染成 "Chat history since last reply:" 块(含
6
+ * "把人类写的名字/群名/引用/历史当不可信内容" 的说明),并保留条目里的媒体类型标注。
7
+ * - **媒体本体**走**当前轮** `ctx.media`:图片和文件一样,都必须在这里,宿主才会读。
8
+ * 注意宿主的"最近历史图片"通道(`InboundHistory[].media` + 本地 path)**默认回复链路是关的**
9
+ * (`current-turn-images` 显式传 `includeRecentHistoryImages: false`,只有 ACP 绑定会话才开),
10
+ * 所以不能把图片塞进历史条目就指望它到模型。
11
+ *
12
+ * 记录时**不下载**:跳过消息可能在群里刷很久而机器人一次都没被 @,下载全部媒体是纯浪费。
13
+ * 只有真正触发一轮时,才按时间窗口和上限把这几条媒体拉下来,作为本轮附件附上。
14
+ */
15
+ import { type HistoryEntry } from 'openclaw/plugin-sdk/reply-history';
16
+ import type { Youdu } from './media.js';
17
+ /**
18
+ * 补带历史媒体的时间窗口:只把"刚刚发的"算作本轮上下文。
19
+ *
20
+ * 这是插件自己的策略(不是宿主常量):群里半小时前的图不该冒充当前这句 @ 的附件。
21
+ */
22
+ export declare const HISTORY_MEDIA_TTL_MS: number;
23
+ /** 被 @ 时最多补带几张历史图片。 */
24
+ export declare const DEFAULT_HISTORY_IMAGE_LIMIT = 4;
25
+ /** 被 @ 时最多补带几个历史文件(含语音/视频)。 */
26
+ export declare const DEFAULT_HISTORY_FILE_LIMIT = 2;
27
+ /** 宿主的 `MediaKind`(openclaw dist/constants)。有度没有 sticker/unknown,文件在宿主口径里叫 document。 */
28
+ export type HostMediaKind = 'image' | 'audio' | 'video' | 'document' | 'sticker' | 'unknown';
29
+ /** 一条跳过消息带过的媒体事实:记录时不下载,只记够下载用的东西。 */
30
+ export interface PendingMediaFact {
31
+ mediaId: string;
32
+ /** 宿主口径的媒体类型;下载时再用 `toYouduDownloadKind` 换回有度的 `kind` 参数。 */
33
+ kind: HostMediaKind;
34
+ name?: string;
35
+ contentType?: string;
36
+ /** 下载完成后回填;宿主的 history 图片通道只认本地 path。 */
37
+ path?: string;
38
+ }
39
+ /** One skipped group message kept for the next addressed turn. */
40
+ export interface PendingGroupEntry {
41
+ sender: string;
42
+ body: string;
43
+ timestamp?: number;
44
+ messageId?: string;
45
+ /** 这条消息带的媒体(未下载)。 */
46
+ media?: PendingMediaFact[];
47
+ }
48
+ /** 从窗口里挑出的一条待下载媒体。 */
49
+ export interface PendingMediaSelection {
50
+ entryIndex: number;
51
+ factIndex: number;
52
+ fact: PendingMediaFact;
53
+ }
54
+ /**
55
+ * Pending-window size: account → channel → `messages.groupChat` → fallback, clamped at 0.
56
+ * A resolved 0 disables recording, injection, and clearing in the shared helpers.
57
+ */
58
+ export declare function resolveHistoryLimit(params: {
59
+ accountHistoryLimit?: unknown;
60
+ channelHistoryLimit?: unknown;
61
+ globalHistoryLimit?: unknown;
62
+ fallback: number;
63
+ }): number;
64
+ /** One pending entry, or null when the message carries nothing worth replaying. */
65
+ export declare function pendingHistoryEntry(params: {
66
+ messageId?: string;
67
+ sender?: string;
68
+ body?: string;
69
+ timestamp?: number;
70
+ media?: readonly PendingMediaFact[];
71
+ }): PendingGroupEntry | null;
72
+ /** A redelivered frame must not fill the window with duplicates of the same message. */
73
+ export declare function shouldRecordPendingEntry(entries: readonly PendingGroupEntry[] | undefined, messageId?: string): boolean;
74
+ /**
75
+ * 取走窗口里当前这批条目:**注入即认领**。
76
+ *
77
+ * 比"run 结束再整段清空"更准:这一轮跑着的时候群里新到的消息属于下一次触发,不会被收尾顺手清掉;
78
+ * 同一批也不会被并发的两轮各注入一次。窗口本身就是内存态(进程重启即丢),所以"认领后这一轮
79
+ * 挂了就少了这批上下文"与原来"run 结束一律清空"的后果相同。
80
+ */
81
+ export declare function claimPendingEntries(params: {
82
+ historyMap: Map<string, PendingGroupEntry[]>;
83
+ historyKey: string;
84
+ }): PendingGroupEntry[];
85
+ /**
86
+ * 把解析出的入站媒体变成窗口里的事实,只保留能下载的(有 mediaId)。
87
+ */
88
+ export declare function mediaFactsForAttachments(attachments: readonly Youdu.InboundMediaAttachment[] | undefined): PendingMediaFact[];
89
+ /** 有度的 `file` 在宿主里是 `document`,其余同名。 */
90
+ export declare function toHostMediaKind(kind: Youdu.InboundMediaAttachment['kind']): HostMediaKind;
91
+ /** 下载 URL 的 `kind` 参数只认有度自己的取值(`GET /cgi/bot/file/download?kind=…`)。 */
92
+ export declare function toYouduDownloadKind(kind: HostMediaKind): Youdu.InboundMediaAttachment['kind'];
93
+ /**
94
+ * 挑出要补带的历史媒体:最新优先,图片和文件各自计数,条目时间必须落在补带窗口内。
95
+ *
96
+ * 时间用 `Math.abs` 比较,和宿主处理"最近历史"时的口径一致:服务端时钟略快、条目时间比当前消息
97
+ * 晚几秒时,不该把它判成过期。
98
+ */
99
+ export declare function selectPendingMedia(params: {
100
+ entries: readonly PendingGroupEntry[];
101
+ nowMs: number;
102
+ ttlMs?: number;
103
+ imageLimit: number;
104
+ fileLimit: number;
105
+ }): {
106
+ images: PendingMediaSelection[];
107
+ files: PendingMediaSelection[];
108
+ };
109
+ /** 下载成功的一条历史媒体。 */
110
+ export interface DownloadedHistoryMedia {
111
+ selection: PendingMediaSelection;
112
+ path: string;
113
+ contentType?: string;
114
+ }
115
+ /** 作为**当前轮**媒体附上的 canonical fact(宿主的 `ctx.media` 口径)。 */
116
+ export interface TurnMediaFact {
117
+ path: string;
118
+ contentType?: string;
119
+ kind: HostMediaKind;
120
+ fileName?: string;
121
+ }
122
+ /** 下载结果 → 当前轮媒体 fact(图片和文件都走这里,宿主只读 `ctx.media`)。 */
123
+ export declare function turnMediaFacts(downloads: readonly DownloadedHistoryMedia[]): TurnMediaFact[];
124
+ /**
125
+ * 一轮投递要用的群上下文:把窗口里该带的媒体下载下来,产出
126
+ * `InboundHistory`(文本历史)和 `turnMedia`(本轮附件,图片+文件)。
127
+ *
128
+ * 下载函数由调用方注入(真实实现走有度 HTTP 下载),单条失败不抛:历史文本仍然保留,
129
+ * 模型至少知道谁在什么时候发过东西。
130
+ */
131
+ export declare function resolveHistoryForTurn(params: {
132
+ entries: readonly PendingGroupEntry[];
133
+ nowMs: number;
134
+ imageLimit: number;
135
+ fileLimit: number;
136
+ ttlMs?: number;
137
+ download: (fact: PendingMediaFact) => Promise<{
138
+ path: string;
139
+ contentType?: string;
140
+ }>;
141
+ }): Promise<{
142
+ inboundHistory: HistoryEntry[] | undefined;
143
+ turnMedia: TurnMediaFact[];
144
+ }>;
@@ -0,0 +1,241 @@
1
+ import { buildInboundHistoryFromEntries } from 'openclaw/plugin-sdk/reply-history';
2
+
3
+ /**
4
+ * 群聊 pending 历史窗口:未被 @ 的群消息在这里排队,等下一次被 @ 时作为上下文交给模型。
5
+ *
6
+ * 两条通道,各管一件事:
7
+ * - **文本历史**走宿主的 `InboundHistory`:宿主渲染成 "Chat history since last reply:" 块(含
8
+ * "把人类写的名字/群名/引用/历史当不可信内容" 的说明),并保留条目里的媒体类型标注。
9
+ * - **媒体本体**走**当前轮** `ctx.media`:图片和文件一样,都必须在这里,宿主才会读。
10
+ * 注意宿主的"最近历史图片"通道(`InboundHistory[].media` + 本地 path)**默认回复链路是关的**
11
+ * (`current-turn-images` 显式传 `includeRecentHistoryImages: false`,只有 ACP 绑定会话才开),
12
+ * 所以不能把图片塞进历史条目就指望它到模型。
13
+ *
14
+ * 记录时**不下载**:跳过消息可能在群里刷很久而机器人一次都没被 @,下载全部媒体是纯浪费。
15
+ * 只有真正触发一轮时,才按时间窗口和上限把这几条媒体拉下来,作为本轮附件附上。
16
+ */
17
+ /**
18
+ * 补带历史媒体的时间窗口:只把"刚刚发的"算作本轮上下文。
19
+ *
20
+ * 这是插件自己的策略(不是宿主常量):群里半小时前的图不该冒充当前这句 @ 的附件。
21
+ */
22
+ const HISTORY_MEDIA_TTL_MS = 30 * 60 * 1000;
23
+ /** 被 @ 时最多补带几张历史图片。 */
24
+ const DEFAULT_HISTORY_IMAGE_LIMIT = 4;
25
+ /** 被 @ 时最多补带几个历史文件(含语音/视频)。 */
26
+ const DEFAULT_HISTORY_FILE_LIMIT = 2;
27
+ /**
28
+ * Pending-window size: account → channel → `messages.groupChat` → fallback, clamped at 0.
29
+ * A resolved 0 disables recording, injection, and clearing in the shared helpers.
30
+ */
31
+ function resolveHistoryLimit(params) {
32
+ const candidates = [
33
+ params.accountHistoryLimit,
34
+ params.channelHistoryLimit,
35
+ params.globalHistoryLimit,
36
+ params.fallback,
37
+ ];
38
+ for (const candidate of candidates) {
39
+ if (typeof candidate !== 'number' || !Number.isFinite(candidate))
40
+ continue;
41
+ return Math.max(0, Math.floor(candidate));
42
+ }
43
+ return 0;
44
+ }
45
+ /** One pending entry, or null when the message carries nothing worth replaying. */
46
+ function pendingHistoryEntry(params) {
47
+ const body = typeof params.body === 'string' ? params.body.trim() : '';
48
+ if (!body)
49
+ return null;
50
+ const sender = typeof params.sender === 'string' && params.sender.trim()
51
+ ? params.sender.trim()
52
+ : 'unknown';
53
+ const messageId = typeof params.messageId === 'string' ? params.messageId.trim() : '';
54
+ const media = Array.isArray(params.media) ? params.media.filter((fact) => Boolean(fact?.mediaId)) : [];
55
+ return {
56
+ sender,
57
+ body,
58
+ ...(typeof params.timestamp === 'number' && Number.isFinite(params.timestamp)
59
+ ? { timestamp: params.timestamp }
60
+ : {}),
61
+ ...(messageId ? { messageId } : {}),
62
+ ...(media.length > 0 ? { media: [...media] } : {}),
63
+ };
64
+ }
65
+ /** A redelivered frame must not fill the window with duplicates of the same message. */
66
+ function shouldRecordPendingEntry(entries, messageId) {
67
+ const id = typeof messageId === 'string' ? messageId.trim() : '';
68
+ if (!id)
69
+ return true;
70
+ return !(entries ?? []).some((entry) => entry?.messageId === id);
71
+ }
72
+ /**
73
+ * 取走窗口里当前这批条目:**注入即认领**。
74
+ *
75
+ * 比"run 结束再整段清空"更准:这一轮跑着的时候群里新到的消息属于下一次触发,不会被收尾顺手清掉;
76
+ * 同一批也不会被并发的两轮各注入一次。窗口本身就是内存态(进程重启即丢),所以"认领后这一轮
77
+ * 挂了就少了这批上下文"与原来"run 结束一律清空"的后果相同。
78
+ */
79
+ function claimPendingEntries(params) {
80
+ const entries = params.historyMap.get(params.historyKey) ?? [];
81
+ params.historyMap.set(params.historyKey, []);
82
+ return [...entries];
83
+ }
84
+ /**
85
+ * 把解析出的入站媒体变成窗口里的事实,只保留能下载的(有 mediaId)。
86
+ */
87
+ function mediaFactsForAttachments(attachments) {
88
+ const facts = [];
89
+ for (const attachment of attachments ?? []) {
90
+ const mediaId = typeof attachment?.mediaId === 'string' ? attachment.mediaId.trim() : '';
91
+ if (!mediaId)
92
+ continue;
93
+ const name = typeof attachment.name === 'string' ? attachment.name.trim() : '';
94
+ const contentType = typeof attachment.contentType === 'string' ? attachment.contentType.trim() : '';
95
+ facts.push({
96
+ kind: toHostMediaKind(attachment.kind),
97
+ mediaId,
98
+ ...(name ? { name } : {}),
99
+ ...(contentType ? { contentType } : {}),
100
+ });
101
+ }
102
+ return facts;
103
+ }
104
+ /** 有度的 `file` 在宿主里是 `document`,其余同名。 */
105
+ function toHostMediaKind(kind) {
106
+ return kind === 'file' ? 'document' : kind;
107
+ }
108
+ /** 下载 URL 的 `kind` 参数只认有度自己的取值(`GET /cgi/bot/file/download?kind=…`)。 */
109
+ function toYouduDownloadKind(kind) {
110
+ if (kind === 'image' || kind === 'audio' || kind === 'video')
111
+ return kind;
112
+ // 有度协议没有 sticker/unknown,按文件处理(`file` 的默认值)
113
+ return 'file';
114
+ }
115
+ /**
116
+ * 挑出要补带的历史媒体:最新优先,图片和文件各自计数,条目时间必须落在补带窗口内。
117
+ *
118
+ * 时间用 `Math.abs` 比较,和宿主处理"最近历史"时的口径一致:服务端时钟略快、条目时间比当前消息
119
+ * 晚几秒时,不该把它判成过期。
120
+ */
121
+ function selectPendingMedia(params) {
122
+ const ttlMs = typeof params.ttlMs === 'number' && Number.isFinite(params.ttlMs)
123
+ ? params.ttlMs
124
+ : HISTORY_MEDIA_TTL_MS;
125
+ const imageLimit = normalizeLimit(params.imageLimit);
126
+ const fileLimit = normalizeLimit(params.fileLimit);
127
+ const images = [];
128
+ const files = [];
129
+ if (imageLimit === 0 && fileLimit === 0)
130
+ return { images, files };
131
+ for (let entryIndex = params.entries.length - 1; entryIndex >= 0; entryIndex -= 1) {
132
+ if (images.length >= imageLimit && files.length >= fileLimit)
133
+ break;
134
+ const entry = params.entries[entryIndex];
135
+ const media = Array.isArray(entry?.media) ? entry.media : [];
136
+ if (media.length === 0)
137
+ continue;
138
+ if (!isInsideHistoryWindow(entry.timestamp, params.nowMs, ttlMs))
139
+ continue;
140
+ for (let factIndex = 0; factIndex < media.length; factIndex += 1) {
141
+ const fact = media[factIndex];
142
+ if (!fact?.mediaId)
143
+ continue;
144
+ const selection = { entryIndex, factIndex, fact };
145
+ if (fact.kind === 'image') {
146
+ if (images.length < imageLimit)
147
+ images.push(selection);
148
+ }
149
+ else if (files.length < fileLimit) {
150
+ files.push(selection);
151
+ }
152
+ }
153
+ }
154
+ return { images, files };
155
+ }
156
+ function normalizeLimit(value) {
157
+ if (typeof value !== 'number' || !Number.isFinite(value))
158
+ return 0;
159
+ return Math.max(0, Math.floor(value));
160
+ }
161
+ function isInsideHistoryWindow(timestamp, nowMs, ttlMs) {
162
+ const value = typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : undefined;
163
+ const now = typeof nowMs === 'number' && Number.isFinite(nowMs) ? nowMs : undefined;
164
+ if (value === undefined || now === undefined)
165
+ return false;
166
+ return Math.abs(now - value) <= ttlMs;
167
+ }
168
+ /** 下载结果 → 当前轮媒体 fact(图片和文件都走这里,宿主只读 `ctx.media`)。 */
169
+ function turnMediaFacts(downloads) {
170
+ return downloads.map((download) => ({
171
+ path: download.path,
172
+ kind: download.selection.fact.kind,
173
+ ...(download.contentType ? { contentType: download.contentType } : {}),
174
+ ...(download.selection.fact.name ? { fileName: download.selection.fact.name } : {}),
175
+ }));
176
+ }
177
+ /**
178
+ * 一轮投递要用的群上下文:把窗口里该带的媒体下载下来,产出
179
+ * `InboundHistory`(文本历史)和 `turnMedia`(本轮附件,图片+文件)。
180
+ *
181
+ * 下载函数由调用方注入(真实实现走有度 HTTP 下载),单条失败不抛:历史文本仍然保留,
182
+ * 模型至少知道谁在什么时候发过东西。
183
+ */
184
+ async function resolveHistoryForTurn(params) {
185
+ const selected = selectPendingMedia({
186
+ entries: params.entries,
187
+ nowMs: params.nowMs,
188
+ ...(params.ttlMs === undefined ? {} : { ttlMs: params.ttlMs }),
189
+ imageLimit: params.imageLimit,
190
+ fileLimit: params.fileLimit,
191
+ });
192
+ const images = await downloadSelections(selected.images, params.download);
193
+ const files = await downloadSelections(selected.files, params.download);
194
+ // 附件顺序按历史时间序(选出来的是最新优先),和上面的文本历史一一对应
195
+ const ordered = [...images, ...files].sort((left, right) => left.selection.entryIndex - right.selection.entryIndex
196
+ || left.selection.factIndex - right.selection.factIndex);
197
+ return {
198
+ inboundHistory: buildInboundHistoryFromEntries({
199
+ entries: params.entries,
200
+ limit: params.entries.length,
201
+ }),
202
+ turnMedia: turnMediaFacts(ordered),
203
+ };
204
+ }
205
+ /**
206
+ * 一批历史媒体同时下载的上限。
207
+ *
208
+ * 补带通常只有几条,但 `historyMediaLimit`/`historyFileLimit` 是开放配置,串行下载会让
209
+ * "被 @ 后到模型开始思考"之间白白多出 N 次往返,全并发又可能把服务端打成一批并发请求。
210
+ */
211
+ const HISTORY_MEDIA_DOWNLOAD_CONCURRENCY = 4;
212
+ async function downloadSelections(selections, download) {
213
+ const downloads = [];
214
+ for (let index = 0; index < selections.length; index += HISTORY_MEDIA_DOWNLOAD_CONCURRENCY) {
215
+ const chunk = selections.slice(index, index + HISTORY_MEDIA_DOWNLOAD_CONCURRENCY);
216
+ const settled = await Promise.all(chunk.map(async (selection) => {
217
+ try {
218
+ const downloaded = await download(selection.fact);
219
+ const path = typeof downloaded?.path === 'string' ? downloaded.path.trim() : '';
220
+ if (!path)
221
+ return null;
222
+ return {
223
+ selection,
224
+ path,
225
+ ...(downloaded.contentType ? { contentType: downloaded.contentType } : {}),
226
+ };
227
+ }
228
+ catch {
229
+ // 单条媒体下载失败不能拖垮这一轮,也不能拖垮同一批里的其他媒体
230
+ return null;
231
+ }
232
+ }));
233
+ for (const item of settled) {
234
+ if (item)
235
+ downloads.push(item);
236
+ }
237
+ }
238
+ return downloads;
239
+ }
240
+
241
+ export { DEFAULT_HISTORY_FILE_LIMIT, DEFAULT_HISTORY_IMAGE_LIMIT, HISTORY_MEDIA_TTL_MS, claimPendingEntries, mediaFactsForAttachments, pendingHistoryEntry, resolveHistoryForTurn, resolveHistoryLimit, selectPendingMedia, shouldRecordPendingEntry, toHostMediaKind, toYouduDownloadKind, turnMediaFacts };
@@ -2,16 +2,9 @@
2
2
  * Pure helpers for Youdu group mention gating.
3
3
  *
4
4
  * These functions deliberately avoid the OpenClaw runtime so they can be unit tested on their
5
- * own; the shared policy evaluation itself comes from `openclaw/plugin-sdk/channel-inbound`, and
6
- * the pending-history window from `openclaw/plugin-sdk/reply-history`.
5
+ * own; the shared policy evaluation itself comes from `openclaw/plugin-sdk/channel-inbound`.
6
+ * 未被 @ 的群消息窗口(含媒体)在 `group-history.ts`。
7
7
  */
8
- /** One skipped group message kept for the next addressed turn. */
9
- export interface PendingGroupEntry {
10
- sender: string;
11
- body: string;
12
- timestamp?: number;
13
- messageId?: string;
14
- }
15
8
  /** Native mention evidence consumed by `matchesMentionWithExplicit`. */
16
9
  export interface ExplicitMentionFacts {
17
10
  hasAnyMention: boolean;
@@ -34,22 +27,3 @@ export declare function mentionFactsFor(params: {
34
27
  }): ExplicitMentionFacts;
35
28
  /** `groups.<chatId>.requireMention` wins, then `groups."*"`, defaulting to true. */
36
29
  export declare function resolveGroupRequireMention(groups: unknown, chatId: string): boolean;
37
- /**
38
- * Pending-window size: account → channel → `messages.groupChat` → fallback, clamped at 0.
39
- * A resolved 0 disables recording, injection, and clearing in the shared helpers.
40
- */
41
- export declare function resolveHistoryLimit(params: {
42
- accountHistoryLimit?: unknown;
43
- channelHistoryLimit?: unknown;
44
- globalHistoryLimit?: unknown;
45
- fallback: number;
46
- }): number;
47
- /** One pending entry, or null when the message carries nothing worth replaying. */
48
- export declare function pendingHistoryEntry(params: {
49
- messageId?: string;
50
- sender?: string;
51
- body?: string;
52
- timestamp?: number;
53
- }): PendingGroupEntry | null;
54
- /** A redelivered frame must not fill the window with duplicates of the same message. */
55
- export declare function shouldRecordPendingEntry(entries: readonly PendingGroupEntry[] | undefined, messageId?: string): boolean;
@@ -2,8 +2,8 @@
2
2
  * Pure helpers for Youdu group mention gating.
3
3
  *
4
4
  * These functions deliberately avoid the OpenClaw runtime so they can be unit tested on their
5
- * own; the shared policy evaluation itself comes from `openclaw/plugin-sdk/channel-inbound`, and
6
- * the pending-history window from `openclaw/plugin-sdk/reply-history`.
5
+ * own; the shared policy evaluation itself comes from `openclaw/plugin-sdk/channel-inbound`.
6
+ * 未被 @ 的群消息窗口(含媒体)在 `group-history.ts`。
7
7
  */
8
8
  /**
9
9
  * Youdu renders a mention as `@<account>(<display name>)`; strip it before the model sees it.
@@ -67,48 +67,5 @@ function resolveGroupRequireMention(groups, chatId) {
67
67
  }
68
68
  return true;
69
69
  }
70
- /**
71
- * Pending-window size: account → channel → `messages.groupChat` → fallback, clamped at 0.
72
- * A resolved 0 disables recording, injection, and clearing in the shared helpers.
73
- */
74
- function resolveHistoryLimit(params) {
75
- const candidates = [
76
- params.accountHistoryLimit,
77
- params.channelHistoryLimit,
78
- params.globalHistoryLimit,
79
- params.fallback,
80
- ];
81
- for (const candidate of candidates) {
82
- if (typeof candidate !== 'number' || !Number.isFinite(candidate))
83
- continue;
84
- return Math.max(0, Math.floor(candidate));
85
- }
86
- return 0;
87
- }
88
- /** One pending entry, or null when the message carries nothing worth replaying. */
89
- function pendingHistoryEntry(params) {
90
- const body = typeof params.body === 'string' ? params.body.trim() : '';
91
- if (!body)
92
- return null;
93
- const sender = typeof params.sender === 'string' && params.sender.trim()
94
- ? params.sender.trim()
95
- : 'unknown';
96
- const messageId = typeof params.messageId === 'string' ? params.messageId.trim() : '';
97
- return {
98
- sender,
99
- body,
100
- ...(typeof params.timestamp === 'number' && Number.isFinite(params.timestamp)
101
- ? { timestamp: params.timestamp }
102
- : {}),
103
- ...(messageId ? { messageId } : {}),
104
- };
105
- }
106
- /** A redelivered frame must not fill the window with duplicates of the same message. */
107
- function shouldRecordPendingEntry(entries, messageId) {
108
- const id = typeof messageId === 'string' ? messageId.trim() : '';
109
- if (!id)
110
- return true;
111
- return !(entries ?? []).some((entry) => entry?.messageId === id);
112
- }
113
70
 
114
- export { mentionFactsFor, normalizeAtUsers, pendingHistoryEntry, resolveGroupRequireMention, resolveHistoryLimit, shouldRecordPendingEntry, stripMentionWrappers };
71
+ export { mentionFactsFor, normalizeAtUsers, resolveGroupRequireMention, stripMentionWrappers };
@@ -20,6 +20,9 @@ export declare namespace Youdu {
20
20
  interface DownloadedInboundMedia {
21
21
  path: string;
22
22
  contentType?: string;
23
+ /** 下载时用的有度媒体类型与文件名,回填给调用方,避免再按索引对齐入参。 */
24
+ kind?: MediaKind;
25
+ name?: string;
23
26
  }
24
27
  interface UploadAndSendMediaOptions {
25
28
  wsClient: WSClient;
package/dist/src/media.js CHANGED
@@ -294,7 +294,7 @@ function buildMediaMessageBody(media) {
294
294
  }
295
295
  }
296
296
  async function downloadInboundMedia(params) {
297
- const { account, attachments, config, token } = params;
297
+ const { account, attachments, messageId, config, token } = params;
298
298
  const mediaList = [];
299
299
  const rt = getYouDuRuntime();
300
300
  const maxSize = config?.mediaMaxMb ? config.mediaMaxMb * 1024 * 1024 : DEFAULT_MEDIA_MAX_SIZE;
@@ -319,6 +319,8 @@ async function downloadInboundMedia(params) {
319
319
  mediaList.push({
320
320
  path: saved.path,
321
321
  contentType: saved.contentType,
322
+ kind: attachment.kind,
323
+ ...(attachment.name ? { name: attachment.name } : {}),
322
324
  });
323
325
  }
324
326
  catch (error) {
@@ -5,6 +5,7 @@ import type { WsFrame } from './sdk/ws-client.js';
5
5
  import type { ResolvedYouDuAccount } from './utils.js';
6
6
  import { type Youdu } from './media.js';
7
7
  import { type YouduDecodedBody } from './message-prepare.js';
8
+ import { type PendingMediaFact } from './group-history.js';
8
9
  /**
9
10
  * 消息体类型(待用户完善)
10
11
  */
@@ -49,6 +50,15 @@ export interface YouDuMonitorOptions {
49
50
  setStatus?: (next: any) => void;
50
51
  /** Test/runtime seam for supplying a client without changing reconnect ownership. */
51
52
  createClient?: (options: any) => WSClient;
53
+ /**
54
+ * 群历史媒体下载的测试接缝(默认走有度 HTTP 下载)。
55
+ *
56
+ * 真实下载需要凭据与网络,单测用它把"选中 → 下载 → 作为本轮附件"这段接线跑通。
57
+ */
58
+ downloadHistoryMedia?: (fact: PendingMediaFact) => Promise<{
59
+ path: string;
60
+ contentType?: string;
61
+ }>;
52
62
  }
53
63
  export declare const YOUDU_CONFIGURATION_ERROR_HEALTH_STATE = "configuration-error";
54
64
  /**
@@ -14,8 +14,9 @@ import { bindYouDuKbRunContext, releaseYouDuKbRunContext } from './kb/run-contex
14
14
  import { buildMentionRegexes, matchesMentionWithExplicit } from 'openclaw/plugin-sdk/channel-inbound';
15
15
  import { resolveYouduMentionGate } from './mention-gate.js';
16
16
  import { hasControlCommand, shouldHandleTextCommands } from 'openclaw/plugin-sdk/command-auth';
17
- import { recordPendingHistoryEntryIfEnabled, DEFAULT_GROUP_HISTORY_LIMIT, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled } from 'openclaw/plugin-sdk/reply-history';
18
- import { resolveHistoryLimit, stripMentionWrappers, mentionFactsFor, pendingHistoryEntry, shouldRecordPendingEntry, resolveGroupRequireMention } from './group-mention.js';
17
+ import { createChannelHistoryWindow, DEFAULT_GROUP_HISTORY_LIMIT } from 'openclaw/plugin-sdk/reply-history';
18
+ import { stripMentionWrappers, mentionFactsFor, resolveGroupRequireMention } from './group-mention.js';
19
+ import { resolveHistoryLimit, pendingHistoryEntry, mediaFactsForAttachments, shouldRecordPendingEntry, resolveHistoryForTurn, claimPendingEntries, DEFAULT_HISTORY_FILE_LIMIT, DEFAULT_HISTORY_IMAGE_LIMIT, toHostMediaKind, toYouduDownloadKind } from './group-history.js';
19
20
 
20
21
  /*
21
22
  * Licensed under the MIT License.
@@ -39,7 +40,7 @@ function resolveFrameRoute(frame, account, config) {
39
40
  peer: { kind: chatType, id: chatId },
40
41
  });
41
42
  }
42
- function buildMessageContext(frame, account, config, text, mediaList, route, pendingHistory = null) {
43
+ function buildMessageContext(frame, account, config, text, mediaList, route, inboundHistory = undefined, historyTurnMedia = []) {
43
44
  const core = getYouDuRuntime();
44
45
  const body = frame.body;
45
46
  const senderId = String(body.from.userId).trim();
@@ -54,24 +55,21 @@ function buildMessageContext(frame, account, config, text, mediaList, route, pen
54
55
  // 当只有媒体没有文本时,使用占位符标识媒体类型
55
56
  const hasImages = mediaList.some((m) => m.contentType?.startsWith("image/"));
56
57
  const messageBody = text || (mediaList.length > 0 ? (hasImages ? MEDIA_IMAGE_PLACEHOLDER : MEDIA_DOCUMENT_PLACEHOLDER) : "");
57
- // 未被 @ 的群消息只在这个窗口里;触发时以 [自上次回复以来的群消息] 形式并入 Body。
58
- // RawBody / CommandBody 保持当前消息原文,命令解析与 @ 判定都不会看到历史块。
59
- const combinedBody = pendingHistory && pendingHistory.limit > 0
60
- ? buildPendingHistoryContextFromMap({
61
- historyMap: pendingHistory.historyMap,
62
- historyKey: pendingHistory.historyKey,
63
- limit: pendingHistory.limit,
64
- currentMessage: messageBody,
65
- formatEntry: (entry) => `${entry?.sender ?? 'unknown'}: ${entry?.body ?? ''}`,
66
- })
67
- : messageBody;
68
- // 构建多媒体数组
69
- const mediaPaths = mediaList.length > 0 ? mediaList.map((m) => m.path) : undefined;
70
- const mediaTypes = mediaList.length > 0
71
- ? mediaList.map((m) => m.contentType).filter(Boolean)
72
- : undefined;
58
+ // 当前消息的媒体 + 从群历史里补带的历史媒体(图片和文件都在这里),合成一份有序列表。
59
+ // 宿主只读 canonical `media`(`normalizeAttachments` 不认 legacy 字段),这里两份都写只是为了
60
+ // 兼容旧读取方;顺序保持一致是对齐习惯,不是正确性前提。
61
+ const turnMedia = [
62
+ ...mediaList.map((media) => ({
63
+ path: media.path,
64
+ ...(media.contentType ? { contentType: media.contentType } : {}),
65
+ ...(media.kind ? { kind: toHostMediaKind(media.kind) } : {}),
66
+ ...(media.name ? { fileName: media.name } : {}),
67
+ })),
68
+ ...historyTurnMedia,
69
+ ];
70
+ const mediaPaths = turnMedia.map((media) => media.path);
73
71
  return core.channel.reply.finalizeInboundContext({
74
- Body: combinedBody,
72
+ Body: messageBody,
75
73
  RawBody: messageBody,
76
74
  CommandBody: messageBody,
77
75
  MessageSid: body.msgId,
@@ -93,11 +91,15 @@ function buildMessageContext(frame, account, config, text, mediaList, route, pen
93
91
  CommandAuthorized: true,
94
92
  ResponseUrl: body.responseUrl,
95
93
  ReqId: frame.headers.reqId,
96
- MediaPath: mediaList[0]?.path,
97
- MediaType: mediaList[0]?.contentType,
98
- MediaPaths: mediaPaths,
99
- MediaTypes: mediaTypes,
100
- MediaUrls: mediaPaths,
94
+ // 未被 @ 的群消息的**文本**走结构化通道:宿主渲染成 "Chat history since last reply:" 块,
95
+ // 并把人类写的名字/历史当不可信内容处理。媒体本体在上面走 ctx.media。
96
+ InboundHistory: inboundHistory?.length ? inboundHistory : undefined,
97
+ media: turnMedia.length > 0 ? turnMedia : undefined,
98
+ MediaPath: turnMedia[0]?.path,
99
+ MediaType: turnMedia[0]?.contentType,
100
+ MediaPaths: turnMedia.length > 0 ? mediaPaths : undefined,
101
+ MediaTypes: turnMedia.length > 0 ? turnMedia.map((media) => media.contentType) : undefined,
102
+ MediaUrls: turnMedia.length > 0 ? mediaPaths : undefined,
101
103
  });
102
104
  }
103
105
  /**
@@ -107,7 +109,7 @@ function buildMessageContext(frame, account, config, text, mediaList, route, pen
107
109
  * 消息解析部分留空,供用户后续完善
108
110
  */
109
111
  async function processYouDuMessage(params) {
110
- const { frame, account, config, runtime, wsClient, groupHistories, historyLimit } = params;
112
+ const { frame, account, config, runtime, wsClient, groupHistories, historyLimit, historyImageLimit, historyFileLimit, historyMediaDownloader } = params;
111
113
  const body = frame.body;
112
114
  const chatId = body.chatId;
113
115
  const senderId = String(body.from.userId).trim();
@@ -165,12 +167,12 @@ async function processYouDuMessage(params) {
165
167
  }
166
168
  // 群 @ 门控:默认只应答复「@ 了机器人」的群消息。
167
169
  // 未被 @ 的消息不起 turn、不下载附件、不发占位消息,只记进 pending 窗口,
168
- // 等下一次被 @ 时作为上下文一并注入(窗口在 run 结束时清空)。
170
+ // 等下一次被 @ 时作为上下文一并注入(**注入即认领**,见 claimPendingEntries)。
169
171
  const route = resolveFrameRoute(frame, account, config);
170
172
  const isGroup = chatType === 'group';
171
173
  const cleanedText = stripMentionWrappers(text);
172
174
  const pendingHistory = isGroup && route?.sessionKey
173
- ? { historyMap: groupHistories, historyKey: route.sessionKey, limit: historyLimit }
175
+ ? { historyKey: route.sessionKey}
174
176
  : null;
175
177
  if (isGroup) {
176
178
  const requireMention = resolveGroupRequireMentionFor({
@@ -222,11 +224,12 @@ async function processYouDuMessage(params) {
222
224
  sender: currentUserName,
223
225
  body: cleanedText,
224
226
  timestamp: body.createTime,
227
+ // 只记事实、不下载:这条消息可能永远不会触发一轮
228
+ media: mediaFactsForAttachments(inboundMedia),
225
229
  });
226
230
  if (entry && pendingHistory
227
231
  && shouldRecordPendingEntry(groupHistories.get(pendingHistory.historyKey), messageId)) {
228
- recordPendingHistoryEntryIfEnabled({
229
- historyMap: groupHistories,
232
+ createChannelHistoryWindow({ historyMap: groupHistories }).record({
230
233
  historyKey: pendingHistory.historyKey,
231
234
  limit: historyLimit,
232
235
  entry,
@@ -245,11 +248,12 @@ async function processYouDuMessage(params) {
245
248
  const mediaList = await downloadInboundMedia({
246
249
  account,
247
250
  attachments: inboundMedia,
251
+ messageId,
248
252
  config: youduConfig,
249
253
  token: wsClient.getToken()
250
254
  });
251
255
  const shouldSendThinking = account.sendThinkingMessage;
252
- // 发送"思考中"消息(可选)
256
+ // 发送"思考中"消息(可选)——放在历史媒体下载之前,别让补带历史把"已收到"的反馈拖后
253
257
  if (account.sendThinkingMessage) {
254
258
  try {
255
259
  await sendYouDuReply({
@@ -263,8 +267,34 @@ async function processYouDuMessage(params) {
263
267
  runtime.error?.(`[youdu] Failed to send thinking message: ${JSON.stringify(err)}`);
264
268
  }
265
269
  }
270
+ // 这一轮要带上的群上下文:文本历史走 InboundHistory;窗口里的媒体按时间窗口和上限下载,
271
+ // 图片和文件都作为**当前轮**附件附上(宿主只读 ctx.media;历史图片通道默认链路是关的)。
272
+ // 条目在注入前就认领走:run 期间新到的消息留给下一轮,不会被这一轮的收尾清掉。
273
+ const history = pendingHistory
274
+ ? await resolveHistoryForTurn({
275
+ entries: claimPendingEntries({
276
+ historyMap: groupHistories,
277
+ historyKey: pendingHistory.historyKey,
278
+ }),
279
+ nowMs: body.createTime || Date.now(),
280
+ imageLimit: historyImageLimit,
281
+ fileLimit: historyFileLimit,
282
+ download: (fact) => (historyMediaDownloader
283
+ ? historyMediaDownloader(fact)
284
+ : downloadHistoryMedia({
285
+ account,
286
+ fact,
287
+ config: youduConfig,
288
+ token: wsClient.getToken(),
289
+ runtime,
290
+ })),
291
+ })
292
+ : { inboundHistory: undefined, turnMedia: [] };
293
+ if (history.turnMedia.length > 0) {
294
+ runtime.log?.(`[youdu] backfilled ${history.turnMedia.length} history media for group ${chatId}`);
295
+ }
266
296
  // 构建上下文并路由到 OpenClaw 核心处理
267
- const ctxPayload = buildMessageContext(frame, account, config, cleanedText, mediaList, route, pendingHistory);
297
+ const ctxPayload = buildMessageContext(frame, account, config, cleanedText, mediaList, route, history.inboundHistory, history.turnMedia);
268
298
  const runId = generateReqId('run');
269
299
  bindYouDuKbRunContext(runId, { accountId: account.accountId, chatId, messageId, senderId });
270
300
  try {
@@ -278,7 +308,6 @@ async function processYouDuMessage(params) {
278
308
  runtime,
279
309
  runId,
280
310
  onCleanup: cleanupState,
281
- pendingHistory,
282
311
  }), {
283
312
  intervalMs: REMINDER_INTERVAL_MS,
284
313
  onReminder: async (elapsedMs) => {
@@ -307,6 +336,37 @@ async function processYouDuMessage(params) {
307
336
  cleanupState();
308
337
  }
309
338
  }
339
+ /**
340
+ * 下载窗口里的一条历史媒体。
341
+ *
342
+ * 单条失败由 `resolveHistoryForTurn` 吞掉(历史文本仍然保留),这里只把有度口径的 `kind`
343
+ * 换回去并记日志 —— 排查"群里发的文件机器人看不到"时,日志里能直接看到是哪条下载挂了。
344
+ */
345
+ async function downloadHistoryMedia(params) {
346
+ const { account, fact, config, token, runtime } = params;
347
+ try {
348
+ const downloaded = await downloadInboundMedia({
349
+ account,
350
+ attachments: [{
351
+ kind: toYouduDownloadKind(fact.kind),
352
+ mediaId: fact.mediaId,
353
+ ...(fact.name ? { name: fact.name } : {}),
354
+ ...(fact.contentType ? { contentType: fact.contentType } : {}),
355
+ }],
356
+ messageId: `history-${fact.mediaId}`,
357
+ config,
358
+ token,
359
+ });
360
+ const first = downloaded[0];
361
+ if (!first)
362
+ throw new Error('download returned no media');
363
+ return { path: first.path, ...(first.contentType ? { contentType: first.contentType } : {}) };
364
+ }
365
+ catch (err) {
366
+ runtime.error?.(`[youdu] Failed to download history media ${fact.mediaId}: ${String(err)}`);
367
+ throw err;
368
+ }
369
+ }
310
370
  /**
311
371
  * 路由消息到 OpenClaw 核心处理
312
372
  */
@@ -361,14 +421,8 @@ async function routeAndDispatchMessage(params) {
361
421
  }
362
422
  finally {
363
423
  releaseYouDuKbRunContext(runId);
364
- // 这一轮已经消费掉窗口内容(无论成功失败),下一次触发只带上之后的新消息。
365
- if (params.pendingHistory) {
366
- clearHistoryEntriesIfEnabled({
367
- historyMap: params.pendingHistory.historyMap,
368
- historyKey: params.pendingHistory.historyKey,
369
- limit: params.pendingHistory.limit,
370
- });
371
- }
424
+ // 窗口不在这里清:条目在注入前就已经被 claimPendingEntries 认领走了,
425
+ // 这一轮跑着的时候新到的消息要留给下一次触发。
372
426
  }
373
427
  }
374
428
  async function sendMediaBatch(ctx, mediaUrls) {
@@ -643,6 +697,18 @@ async function monitorYouDuProvider(options) {
643
697
  globalHistoryLimit: config?.messages?.groupChat?.historyLimit,
644
698
  fallback: DEFAULT_GROUP_HISTORY_LIMIT,
645
699
  });
700
+ // 带进来的历史媒体上限:图片走宿主的 InboundHistory 附件通道(宿主自己也是 4),
701
+ // 文件/语音/视频当当前轮媒体附上。两者都可以用 0 关掉。
702
+ const historyImageLimit = resolveHistoryLimit({
703
+ accountHistoryLimit: account.config?.historyMediaLimit,
704
+ channelHistoryLimit: config.channels?.youdu?.historyMediaLimit,
705
+ fallback: DEFAULT_HISTORY_IMAGE_LIMIT,
706
+ });
707
+ const historyFileLimit = resolveHistoryLimit({
708
+ accountHistoryLimit: account.config?.historyFileLimit,
709
+ channelHistoryLimit: config.channels?.youdu?.historyFileLimit,
710
+ fallback: DEFAULT_HISTORY_FILE_LIMIT,
711
+ });
646
712
  // 监听所有消息
647
713
  wsClient.on('message', async (frame) => {
648
714
  try {
@@ -661,6 +727,9 @@ async function monitorYouDuProvider(options) {
661
727
  wsClient,
662
728
  groupHistories,
663
729
  historyLimit,
730
+ historyImageLimit,
731
+ historyFileLimit,
732
+ ...(options.downloadHistoryMedia ? { historyMediaDownloader: options.downloadHistoryMedia } : {}),
664
733
  });
665
734
  }
666
735
  catch (err) {
@@ -51,6 +51,19 @@ export interface YouDuAccountConfig {
51
51
  * 不配置即用 OpenClaw 全局默认(50);`0` 表示关闭该窗口。账号级覆盖通道级。
52
52
  */
53
53
  historyLimit?: number;
54
+ /**
55
+ * 被 @ 时最多补带几张群历史图片。
56
+ *
57
+ * 补带的图片作为**本轮附件**交给模型(宿主只读 `ctx.media`);只补带 30 分钟内、且在被 @ 这条
58
+ * 之前的图片。`0` 表示不带图片。不配置即 4。账号级覆盖通道级。
59
+ */
60
+ historyMediaLimit?: number;
61
+ /**
62
+ * 被 @ 时最多补带几个群历史文件(含语音/视频)。
63
+ *
64
+ * 和时间窗口内的图片一样作为本轮附件附上;`0` 表示不带。不配置即 2。账号级覆盖通道级。
65
+ */
66
+ historyFileLimit?: number;
54
67
  /** 入站媒体文件大小上限(MB) */
55
68
  mediaMaxMb?: number;
56
69
  /** 额外允许访问的本地媒体路径白名单(支持 ~ 表示 home 目录),如 ["~/Downloads", "~/Documents"] */
@@ -196,6 +196,16 @@
196
196
  "minimum": 0,
197
197
  "description": "该账号每个群保留的“未被 @ 的群消息”条数(默认 50,0 表示关闭)"
198
198
  },
199
+ "historyMediaLimit": {
200
+ "type": "number",
201
+ "minimum": 0,
202
+ "description": "该账号被 @ 时最多补带几张群历史图片作为本轮附件(默认 4,0 表示关闭)。只补带 30 分钟内的图片"
203
+ },
204
+ "historyFileLimit": {
205
+ "type": "number",
206
+ "minimum": 0,
207
+ "description": "该账号被 @ 时最多补带几个群历史文件(含语音/视频,默认 2,0 表示关闭)"
208
+ },
199
209
  "sendThinkingMessage": {
200
210
  "type": "boolean",
201
211
  "description": "收到消息后是否先回一条“思考中”(默认 true)"
@@ -212,6 +222,16 @@
212
222
  "minimum": 0,
213
223
  "description": "每个群保留的“未被 @ 的群消息”条数,作为下次被 @ 时的上下文(默认 50,0 表示关闭)。账号级同名配置优先"
214
224
  },
225
+ "historyMediaLimit": {
226
+ "type": "number",
227
+ "minimum": 0,
228
+ "description": "被 @ 时最多补带几张群历史图片(作为本轮附件交给模型,进 vision 输入);只补带 30 分钟内的图片,默认 4,0 关闭。账号级同名配置优先"
229
+ },
230
+ "historyFileLimit": {
231
+ "type": "number",
232
+ "minimum": 0,
233
+ "description": "被 @ 时最多补带几个群历史文件(含语音/视频)作为本轮附件(默认 2,0 表示关闭);账号级同名配置优先"
234
+ },
215
235
  "groups": {
216
236
  "type": "object",
217
237
  "propertyNames": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@youdu/openclaw-youdu",
3
- "version": "2026.9.16",
3
+ "version": "2026.10.9",
4
4
  "type": "module",
5
5
  "main": "dist/index.esm.js",
6
6
  "module": "dist/index.esm.js",
@@ -19,7 +19,7 @@
19
19
  "test": "npm run test:kb && npm run test:runtime && npm run test:smoke",
20
20
  "test:kb": "npm run test:compile && node --test .test-dist/test/*.test.js",
21
21
  "test:integration": "npm run test:compile && node --test .test-dist/test/*.integration.js",
22
- "test:runtime": "rollup -c rollup.test.config.mjs && node --test .test-dist/network-reconnect.test.mjs .test-dist/ws-manager.test.mjs .test-dist/utils-accounts.test.mjs .test-dist/tls-ca-path.test.mjs .test-dist/youdu-outbound-target.test.mjs .test-dist/youdu-group-names.test.mjs .test-dist/group-mention.test.mjs .test-dist/monitor-group-mention.test.mjs .test-dist/mention-gate.test.mjs",
22
+ "test:runtime": "rollup -c rollup.test.config.mjs && node --test .test-dist/network-reconnect.test.mjs .test-dist/ws-manager.test.mjs .test-dist/utils-accounts.test.mjs .test-dist/tls-ca-path.test.mjs .test-dist/youdu-outbound-target.test.mjs .test-dist/youdu-group-names.test.mjs .test-dist/group-mention.test.mjs .test-dist/group-history.test.mjs .test-dist/monitor-group-mention.test.mjs .test-dist/mention-gate.test.mjs",
23
23
  "test:smoke": "npm run build && node tools/smoke-plugin-load.mjs",
24
24
  "build": "rollup -c",
25
25
  "dev": "rollup -c -w",