openclaw-plugin-yuanbao 2.8.0 → 2.9.0

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.
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-plugin-yuanbao",
3
3
  "name": "元宝 Bot",
4
4
  "description": "Tencent YuanBao intelligent bot channel plugin",
5
- "version": "2.8.0",
5
+ "version": "2.9.0",
6
6
  "channels": [
7
7
  "yuanbao"
8
8
  ],
@@ -1,16 +1,19 @@
1
1
  import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from 'openclaw/plugin-sdk/core';
2
2
  import { formatPairingApproveHint } from 'openclaw/plugin-sdk/mattermost';
3
- import { listYuanbaoAccountIds, resolveDefaultYuanbaoAccountId, resolveYuanbaoAccount } from './accounts.js';
3
+ import { listYuanbaoAccountIds, resolveDefaultYuanbaoAccountId, resolveYuanbaoAccount, } from './accounts.js';
4
4
  import { yuanbaoConfigSchema } from './config-schema.js';
5
5
  import { yuanbaoOnboardingAdapter } from './onboarding.js';
6
6
  import { createLog } from './logger.js';
7
7
  import { yuanbaoSetupAdapter } from './setup.js';
8
- import { startYuanbaoWsGateway, getActiveWsClient } from './yuanbao-server/ws/index.js';
8
+ import { startYuanbaoWsGateway, getActiveWsClient, } from './yuanbao-server/ws/index.js';
9
9
  import { getYuanbaoRuntime } from './runtime.js';
10
- import { sendYuanbaoMessage, sendYuanbaoGroupMessage } from './message-handler/index.js';
11
- import { initOutboundQueue, destroyOutboundQueue, getOutboundQueue } from './outbound-queue.js';
10
+ import { sendYuanbaoMessage, sendYuanbaoGroupMessage, } from './message-handler/index.js';
11
+ import { initOutboundQueue, destroyOutboundQueue, getOutboundQueue, } from './outbound-queue.js';
12
12
  import { ChatType, getGroupCode, parseTarget } from './targets.js';
13
- import { buildMessageToolHints, yuanbaoMessageActions } from './message-tool/index.js';
13
+ import { buildMessageToolHints, yuanbaoMessageActions, } from './message-tool/index.js';
14
+ import { validateMediaBeforeQueue } from './media.js';
15
+ import { yuanbaoMessagingAdapter } from './messaging-adapter.js';
16
+ import { yuanbaoDirectoryAdapter } from './directory-adapter.js';
14
17
  function toChannelResult(result) {
15
18
  return {
16
19
  channel: 'yuanbao',
@@ -35,15 +38,32 @@ async function sendTextToTarget(account, target, text, wsClient) {
35
38
  account,
36
39
  config: {},
37
40
  core: {},
38
- log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
41
+ log: {
42
+ info: () => { },
43
+ warn: () => { },
44
+ error: () => { },
45
+ verbose: () => { },
46
+ },
39
47
  wsClient,
40
48
  }
41
49
  : undefined;
42
50
  const { chatType, target: targetId } = parseTarget(target, account.accountId);
43
51
  if (chatType === ChatType.GROUP) {
44
- return sendYuanbaoGroupMessage({ account, groupCode: targetId, text, fromAccount: account.botId, ctx: minCtx });
52
+ return sendYuanbaoGroupMessage({
53
+ account,
54
+ groupCode: targetId,
55
+ text,
56
+ fromAccount: account.botId,
57
+ ctx: minCtx,
58
+ });
45
59
  }
46
- return sendYuanbaoMessage({ account, toAccount: targetId, text, fromAccount: account.botId, ctx: minCtx });
60
+ return sendYuanbaoMessage({
61
+ account,
62
+ toAccount: targetId,
63
+ text,
64
+ fromAccount: account.botId,
65
+ ctx: minCtx,
66
+ });
47
67
  }
48
68
  const meta = {
49
69
  id: 'yuanbao',
@@ -57,12 +77,6 @@ const meta = {
57
77
  order: 85,
58
78
  quickstartAllowFrom: true,
59
79
  };
60
- function normalizeYuanbaoMessagingTarget(raw) {
61
- const trimmed = raw.trim();
62
- if (!trimmed)
63
- return undefined;
64
- return trimmed.replace(/^(yuanbao):/i, '').trim() || undefined;
65
- }
66
80
  export const yuanbaoPlugin = {
67
81
  id: 'yuanbao',
68
82
  meta,
@@ -117,7 +131,10 @@ export const yuanbaoPlugin = {
117
131
  tokenStatus: account.configured ? 'available' : 'missing',
118
132
  }),
119
133
  resolveAllowFrom: ({ cfg, accountId }) => {
120
- const account = resolveYuanbaoAccount({ cfg: cfg, accountId });
134
+ const account = resolveYuanbaoAccount({
135
+ cfg: cfg,
136
+ accountId,
137
+ });
121
138
  return (account.config.dm?.allowFrom ?? []).map(entry => String(entry));
122
139
  },
123
140
  formatAllowFrom: ({ allowFrom }) => allowFrom
@@ -129,7 +146,9 @@ export const yuanbaoPlugin = {
129
146
  resolveDmPolicy: ({ cfg, accountId, account }) => {
130
147
  const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
131
148
  const useAccountPath = Boolean(cfg.channels?.yuanbao?.accounts?.[resolvedAccountId]);
132
- const basePath = useAccountPath ? `channels.yuanbao.accounts.${resolvedAccountId}.` : 'channels.yuanbao.';
149
+ const basePath = useAccountPath
150
+ ? `channels.yuanbao.accounts.${resolvedAccountId}.`
151
+ : 'channels.yuanbao.';
133
152
  const policy = account.config.dm?.policy ?? 'open';
134
153
  const rawAllowFrom = (account.config.dm?.allowFrom ?? []).map(entry => String(entry));
135
154
  const allowFrom = policy === 'open' && !rawAllowFrom.includes('*')
@@ -151,13 +170,8 @@ export const yuanbaoPlugin = {
151
170
  threading: {
152
171
  resolveReplyToMode: () => 'all',
153
172
  },
154
- messaging: {
155
- normalizeTarget: normalizeYuanbaoMessagingTarget,
156
- targetResolver: {
157
- looksLikeId: raw => Boolean(raw.trim()),
158
- hint: '<userid> or group:<groupcode>',
159
- },
160
- },
173
+ messaging: yuanbaoMessagingAdapter,
174
+ directory: yuanbaoDirectoryAdapter,
161
175
  agentPrompt: {
162
176
  messageToolHints() {
163
177
  return buildMessageToolHints();
@@ -175,7 +189,9 @@ export const yuanbaoPlugin = {
175
189
  deliveryMode: 'direct',
176
190
  chunkerMode: 'markdown',
177
191
  textChunkLimit: 3000,
178
- chunker: (text, limit) => getYuanbaoRuntime()?.channel.text.chunkMarkdownText(text, limit) ?? [text],
192
+ chunker: (text, limit) => getYuanbaoRuntime()?.channel.text.chunkMarkdownText(text, limit) ?? [
193
+ text,
194
+ ],
179
195
  sendText: async (params) => {
180
196
  const { cfg, accountId, to: _to, text } = params;
181
197
  const to = _to.replace(/^yuanbao:/, '');
@@ -184,7 +200,12 @@ export const yuanbaoPlugin = {
184
200
  slog.info('sendText', { accountId, to });
185
201
  const wsClient = getActiveWsClient(account.accountId);
186
202
  if (!wsClient) {
187
- return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(`WebSocket client not connected for account ${account.accountId}`) };
203
+ return {
204
+ channel: 'yuanbao',
205
+ ok: false,
206
+ messageId: '',
207
+ error: new Error(`WebSocket client not connected for account ${account.accountId}`),
208
+ };
188
209
  }
189
210
  const queueManager = getOutboundQueue(account.accountId);
190
211
  if (queueManager) {
@@ -203,18 +224,28 @@ export const yuanbaoPlugin = {
203
224
  return toChannelResult(await sendTextToTarget(account, to, text, wsClient));
204
225
  },
205
226
  sendMedia: async (params) => {
206
- const { cfg, accountId, to: _to, mediaUrl, text, mediaLocalRoots } = params;
227
+ const { cfg, accountId, to: _to, mediaUrl, text, mediaLocalRoots, } = params;
207
228
  const to = _to.replace(/^yuanbao:/, '');
208
229
  const account = resolveYuanbaoAccount({ cfg, accountId: accountId ?? undefined });
209
230
  const slog = createLog('channel.outbound');
210
231
  const wsClient = getActiveWsClient(account.accountId);
211
232
  slog.info('sendMedia', { accountId, to, mediaUrl, text });
212
233
  if (!wsClient) {
213
- return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(`WebSocket client not connected for account ${account.accountId}`) };
234
+ return {
235
+ channel: 'yuanbao',
236
+ ok: false,
237
+ messageId: '',
238
+ error: new Error(`WebSocket client not connected for account ${account.accountId}`),
239
+ };
214
240
  }
215
241
  if (!mediaUrl) {
216
242
  return { channel: 'yuanbao', ok: true, messageId: '' };
217
243
  }
244
+ const validationError = validateMediaBeforeQueue(mediaUrl);
245
+ if (validationError) {
246
+ slog.error(`sendMedia 前置校验失败: ${validationError}`, { accountId, to, mediaUrl });
247
+ return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(validationError) };
248
+ }
218
249
  const queueManager = getOutboundQueue(account.accountId);
219
250
  if (queueManager) {
220
251
  const { chatType, target, sessionKey } = parseTarget(to, account.accountId);
@@ -232,7 +263,12 @@ export const yuanbaoPlugin = {
232
263
  await session.flush();
233
264
  return { channel: 'yuanbao', ok: true, messageId: '' };
234
265
  }
235
- return { channel: 'yuanbao', ok: false, messageId: '', error: new Error('No session found') };
266
+ return {
267
+ channel: 'yuanbao',
268
+ ok: false,
269
+ messageId: '',
270
+ error: new Error('No session found'),
271
+ };
236
272
  },
237
273
  },
238
274
  status: {
@@ -274,7 +310,11 @@ export const yuanbaoPlugin = {
274
310
  slog.debug('启动账号', account);
275
311
  if (!account.configured) {
276
312
  slog.warn('yuanbao not configured; skipping');
277
- ctx.setStatus({ accountId: account.accountId, running: false, configured: false });
313
+ ctx.setStatus({
314
+ accountId: account.accountId,
315
+ running: false,
316
+ configured: false,
317
+ });
278
318
  return;
279
319
  }
280
320
  slog.info('使用 WebSocket 模式连接');
@@ -5,8 +5,32 @@ const INSTALL_SCRIPT_TIMEOUT_MS = 5 * 60 * 1000;
5
5
  var MessageEnum;
6
6
  (function (MessageEnum) {
7
7
  MessageEnum["REPAIR_BOT_CONFIG_GUIDE"] = "\u274C \u5347\u7EA7\u5931\u8D25\uFF0C\u8BF7\u524D\u5F80 Bot \u7BA1\u7406\u9875\u9762\u4F7F\u7528\u300C\u4FEE\u590D Bot \u914D\u7F6E\u300D\u529F\u80FD\u4FEE\u590D\u3002";
8
- MessageEnum["AUTO_UPGRADE_FAILED_FALLBACK"] = "\u274C \u5347\u7EA7\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF0C\u5143\u5B9D\u521B\u5EFA\u7684 Bot \u53EF\u524D\u5F80\u300CBot \u8BBE\u7F6E\u300D\u70B9\u51FB\u300C\u66F4\u65B0\u63D2\u4EF6\u300D\u8FDB\u884C\u5347\u7EA7\u3002";
8
+ MessageEnum["AUTO_UPGRADE_FAILED_FALLBACK"] = "\u274C \u5347\u7EA7\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF0C\u5143\u5B9D\u521B\u5EFA\u7684 Bot \u53EF\u524D\u5F80\u300CBot \u8BBE\u7F6E\u300D\u70B9\u51FB\u300C\u66F4\u65B0\u63D2\u4EF6\u300D\u8FDB\u884C\u5347\u7EA7\uFF0C\u975E\u5143\u5B9D\u521B\u5EFA\u7684Bot\uFF0C\u8BF7\u524D\u5F80\u5404\u81EA\u5E73\u53F0\u624B\u52A8\u66F4\u65B0\u3002";
9
+ MessageEnum["RATE_LIMITED"] = "\u274C \u5347\u7EA7\u5931\u8D25\uFF0C\u5F53\u524D\u670D\u52A1\u7E41\u5FD9\uFF0C\u8BF7\u7A0D\u540E\u518D\u8BD5\u3002\u5143\u5B9D\u521B\u5EFA\u7684 Bot \u53EF\u524D\u5F80\u300CBot \u8BBE\u7F6E\u300D\u70B9\u51FB\u300C\u66F4\u65B0\u63D2\u4EF6\u300D\u8FDB\u884C\u5347\u7EA7\u3002";
9
10
  })(MessageEnum || (MessageEnum = {}));
11
+ async function verifyVersionAfterFailedCommand(params) {
12
+ const { currentVersion, targetVersion, commandResult, commandName } = params;
13
+ const installedVersion = await readInstalledVersion(PLUGIN_ID);
14
+ const upgraded = !!installedVersion && ((targetVersion != null && installedVersion === targetVersion)
15
+ || (targetVersion == null && currentVersion != null && installedVersion !== currentVersion));
16
+ if (upgraded) {
17
+ log.warn(`${commandName} 安装命令执行异常,但版本已更新成功`, {
18
+ currentVersion,
19
+ targetVersion,
20
+ installedVersion,
21
+ error: commandResult.error,
22
+ });
23
+ return { upgraded: true, installedVersion };
24
+ }
25
+ log.error(`${commandName} 执行失败`, {
26
+ currentVersion,
27
+ targetVersion,
28
+ installedVersion: installedVersion ?? '(读取失败)',
29
+ error: commandResult.error,
30
+ rateLimited: !!commandResult.rateLimited,
31
+ });
32
+ return { upgraded: false, installedVersion, rateLimited: !!commandResult.rateLimited };
33
+ }
10
34
  async function runSpecifiedVersionFlow(params) {
11
35
  const { targetVersion: _targetVersion, currentVersion, config, onProgress, } = params;
12
36
  const hasTargetVersion = !!_targetVersion;
@@ -85,12 +109,19 @@ async function runSpecifiedVersionFlow(params) {
85
109
  };
86
110
  }
87
111
  if (!installResult.ok) {
88
- log.error('指定版本安装失败:安装步骤失败', { targetVersion, error: installResult.error });
89
- return {
90
- ok: false,
91
- error: installResult.error ?? '插件安装失败',
92
- message: MessageEnum.REPAIR_BOT_CONFIG_GUIDE,
93
- };
112
+ const verify = await verifyVersionAfterFailedCommand({
113
+ currentVersion,
114
+ targetVersion: targetVersion ?? null,
115
+ commandResult: installResult,
116
+ commandName: 'plugins install',
117
+ });
118
+ if (!verify.upgraded) {
119
+ return {
120
+ ok: false,
121
+ error: installResult.error ?? '插件安装失败',
122
+ message: verify.rateLimited ? MessageEnum.RATE_LIMITED : MessageEnum.REPAIR_BOT_CONFIG_GUIDE,
123
+ };
124
+ }
94
125
  }
95
126
  log.info('指定版本安装流程完成', { targetVersion, hasSnapshot: !!restoreSnapshotJson });
96
127
  await onProgress?.(currentVersion
@@ -116,8 +147,23 @@ async function runRegularUpgradeFlow(params) {
116
147
  commandName: 'plugins update',
117
148
  });
118
149
  if (!updateResult.ok) {
119
- log.warn('更新命令执行失败', { error: updateResult.error, ...(updateResult.stderr ? { stderr: updateResult.stderr } : {}) });
120
- return { ok: false, error: updateResult.error ?? '常规升级失败' };
150
+ const verify = await verifyVersionAfterFailedCommand({
151
+ currentVersion,
152
+ targetVersion: latestStableVersion,
153
+ commandResult: updateResult,
154
+ commandName: 'plugins update',
155
+ });
156
+ if (verify.upgraded) {
157
+ await onProgress?.(latestStableVersion
158
+ ? `✅ 更新成功!**元宝 Bot 插件**已从 v${currentVersion} 升级至 v${latestStableVersion}`
159
+ : `✅ 更新成功!**元宝 Bot 插件**已更新至 v${verify.installedVersion}`);
160
+ return { ok: true };
161
+ }
162
+ return {
163
+ ok: false,
164
+ error: updateResult.error ?? '常规升级失败',
165
+ message: verify.rateLimited ? MessageEnum.RATE_LIMITED : undefined,
166
+ };
121
167
  }
122
168
  if (updateResult.stdout?.includes('No install record')) {
123
169
  return { ok: false, error: updateResult.error ?? '常规升级失败,需要重新安装', needToInstall: true };
@@ -19,4 +19,6 @@ export declare function runOpenClawCommandWithRetry(params: {
19
19
  nextAttempt: number;
20
20
  maxAttempts: number;
21
21
  }) => Promise<void>;
22
- }): Promise<Awaited<ReturnType<typeof runOpenClawCommand>>>;
22
+ }): Promise<Awaited<ReturnType<typeof runOpenClawCommand>> & {
23
+ rateLimited?: boolean;
24
+ }>;
@@ -131,18 +131,16 @@ export async function isPublishedVersionOnNpm(version) {
131
131
  }
132
132
  export async function readInstalledVersion(pluginId) {
133
133
  log.info('读取已安装版本', { pluginId });
134
- const result = await runOpenClawCommand(['plugins', 'list']);
134
+ const result = await runOpenClawCommand(['plugins', 'info', pluginId]);
135
135
  if (!result.ok) {
136
- log.warn('openclaw plugins list 执行失败', { summary: result.error, ...(result.stderr ? { stderr: result.stderr } : {}) });
136
+ log.warn('openclaw plugins info 执行失败', { summary: result.error, ...(result.stderr ? { stderr: result.stderr } : {}) });
137
137
  return null;
138
138
  }
139
139
  for (const line of (result.stdout ?? '').split('\n')) {
140
- if (line.toLowerCase().includes(pluginId.toLowerCase())) {
141
- const match = line.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
142
- if (match) {
143
- log.info('已安装版本', { pluginId, version: match[1] });
144
- return match[1];
145
- }
140
+ const match = line.match(/^Version:\s*(\d+\.\d+\.\d+(?:-[\w.]+)?)/i);
141
+ if (match) {
142
+ log.info('已安装版本', { pluginId, version: match[1] });
143
+ return match[1];
146
144
  }
147
145
  }
148
146
  log.warn('未检测到已安装版本', { pluginId });
@@ -199,8 +197,12 @@ export async function runOpenClawCommandWithRetry(params) {
199
197
  if (lastResult.ok)
200
198
  return lastResult;
201
199
  const retriable = isRateLimitPluginCommandError(lastResult);
202
- if (!retriable || attempt >= PLUGIN_CMD_RETRY_MAX_ATTEMPTS)
200
+ if (!retriable || attempt >= PLUGIN_CMD_RETRY_MAX_ATTEMPTS) {
201
+ if (retriable) {
202
+ return { ...lastResult, rateLimited: true };
203
+ }
203
204
  break;
205
+ }
204
206
  const nextAttempt = attempt + 1;
205
207
  const retryDelayMs = PLUGIN_CMD_RETRY_DELAY_MS * attempt;
206
208
  log.warn('openclaw plugin 命令触发限频,准备重试', {
@@ -0,0 +1,2 @@
1
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk';
2
+ export declare const yuanbaoDirectoryAdapter: NonNullable<ChannelPlugin<any>['directory']>;
@@ -0,0 +1,58 @@
1
+ import { getMember } from './module/member.js';
2
+ function toUserEntry(u) {
3
+ return { kind: 'user', id: u.userId, name: u.nickName };
4
+ }
5
+ function toGroupEntry(g) {
6
+ return { kind: 'group', id: g };
7
+ }
8
+ function applyLimit(arr, limit) {
9
+ return limit != null && limit > 0 ? arr.slice(0, limit) : arr;
10
+ }
11
+ export const yuanbaoDirectoryAdapter = {
12
+ async listPeers({ accountId, query, limit }) {
13
+ const member = getMember(accountId ?? 'default');
14
+ if (query?.trim()) {
15
+ const q = query.trim().toLowerCase();
16
+ const seen = new Set();
17
+ const entries = [];
18
+ const collect = (users) => {
19
+ for (const u of users) {
20
+ if (!seen.has(u.userId) && u.nickName.toLowerCase().includes(q)) {
21
+ seen.add(u.userId);
22
+ entries.push(toUserEntry(u));
23
+ }
24
+ }
25
+ };
26
+ collect(member.listC2cUsers());
27
+ for (const code of member.listGroupCodes()) {
28
+ collect(member.lookupUsers(code));
29
+ }
30
+ return applyLimit(entries, limit);
31
+ }
32
+ const seen = new Set();
33
+ const entries = [];
34
+ const collect = (users) => {
35
+ for (const u of users) {
36
+ if (!seen.has(u.userId)) {
37
+ seen.add(u.userId);
38
+ entries.push(toUserEntry(u));
39
+ }
40
+ }
41
+ };
42
+ collect(member.listC2cUsers());
43
+ for (const code of member.listGroupCodes()) {
44
+ collect(member.lookupUsers(code));
45
+ }
46
+ return applyLimit(entries, limit);
47
+ },
48
+ async listGroups({ accountId, limit }) {
49
+ const member = getMember(accountId ?? 'default');
50
+ const codes = member.listGroupCodes();
51
+ return applyLimit(codes.map(toGroupEntry), limit);
52
+ },
53
+ async listGroupMembers({ accountId, groupId, limit }) {
54
+ const member = getMember(accountId ?? 'default');
55
+ const users = await member.queryMembers(groupId);
56
+ return applyLimit(users.map(toUserEntry), limit);
57
+ },
58
+ };
@@ -23,6 +23,7 @@ export declare function parseImageSize(buf: Buffer): {
23
23
  width: number;
24
24
  height: number;
25
25
  } | undefined;
26
+ export declare function validateMediaBeforeQueue(url: string): string | null;
26
27
  export declare function downloadMediaForYuanbao(url: string, maxMb?: number, account?: ResolvedYuanbaoAccount): Promise<MediaFile>;
27
28
  export declare function uploadMediaToCos(mediaFile: MediaFile, account: ResolvedYuanbaoAccount, onProgress?: (percent: number) => void): Promise<MediaUploadResult>;
28
29
  export declare function downloadAndUploadMedia(mediaUrl: string, core: PluginRuntime, account: ResolvedYuanbaoAccount, mediaLocalRoots?: string[], onProgress?: (percent: number) => void): Promise<MediaUploadResult>;
package/dist/src/media.js CHANGED
@@ -1,9 +1,10 @@
1
- import { createReadStream, statSync, existsSync } from 'node:fs';
1
+ import { createReadStream, statSync, existsSync, openSync, readSync, closeSync } from 'node:fs';
2
2
  import { writeFile, mkdir } from 'node:fs/promises';
3
3
  import { tmpdir, homedir } from 'node:os';
4
4
  import path, { basename, extname, join } from 'node:path';
5
5
  import { randomBytes, createHash } from 'node:crypto';
6
6
  import { apiGetDownloadUrl, apiGetUploadInfo } from './yuanbao-server/api.js';
7
+ import { logger } from './logger.js';
7
8
  const DEFAULT_MAX_MB = 20;
8
9
  export const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.heic', '.tiff', '.ico']);
9
10
  export function guessMimeType(filename) {
@@ -148,6 +149,134 @@ function normalizePath(s) {
148
149
  }
149
150
  return p;
150
151
  }
152
+ function readMagicBytes(filePath, n = 16) {
153
+ const buf = Buffer.alloc(n);
154
+ const fd = openSync(filePath, 'r');
155
+ try {
156
+ const bytesRead = readSync(fd, buf, 0, n, 0);
157
+ return buf.subarray(0, bytesRead);
158
+ }
159
+ finally {
160
+ closeSync(fd);
161
+ }
162
+ }
163
+ function isMagicJpeg(h) {
164
+ return h.length >= 3 && h[0] === 0xff && h[1] === 0xd8 && h[2] === 0xff;
165
+ }
166
+ function isMagicPng(h) {
167
+ return h.length >= 8
168
+ && h[0] === 0x89 && h[1] === 0x50 && h[2] === 0x4e && h[3] === 0x47
169
+ && h[4] === 0x0d && h[5] === 0x0a && h[6] === 0x1a && h[7] === 0x0a;
170
+ }
171
+ function isMagicGif(h) {
172
+ if (h.length < 6)
173
+ return false;
174
+ const sig = h.toString('ascii', 0, 6);
175
+ return sig === 'GIF87a' || sig === 'GIF89a';
176
+ }
177
+ function isMagicWebp(h) {
178
+ return h.length >= 12
179
+ && h.toString('ascii', 0, 4) === 'RIFF'
180
+ && h.toString('ascii', 8, 12) === 'WEBP';
181
+ }
182
+ function isMagicBmp(h) {
183
+ return h.length >= 2 && h[0] === 0x42 && h[1] === 0x4d;
184
+ }
185
+ function isMagicPdf(h) {
186
+ return h.length >= 4 && h.toString('ascii', 0, 4) === '%PDF';
187
+ }
188
+ function isMagicZip(h) {
189
+ return h.length >= 3 && h[0] === 0x50 && h[1] === 0x4b && (h[2] === 0x03 || h[2] === 0x05);
190
+ }
191
+ function getTextSnippet(h) {
192
+ const start = (h[0] === 0xef && h[1] === 0xbb && h[2] === 0xbf) ? 3 : 0;
193
+ return h.toString('utf8', start).trimStart()
194
+ .toLowerCase();
195
+ }
196
+ function isMagicHtml(h) {
197
+ const s = getTextSnippet(h);
198
+ return s.startsWith('<!doctype html')
199
+ || s.startsWith('<html')
200
+ || s.startsWith('<head')
201
+ || s.startsWith('<body');
202
+ }
203
+ function isMagicXml(h) {
204
+ const s = getTextSnippet(h);
205
+ return s.startsWith('<?xml') || s.startsWith('<svg');
206
+ }
207
+ function isMagicPrintableText(h) {
208
+ const start = (h[0] === 0xef && h[1] === 0xbb && h[2] === 0xbf) ? 3 : 0;
209
+ return h.length > start && h.subarray(start).every(b => b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e));
210
+ }
211
+ function detectMagicFamily(header) {
212
+ if (header.length < 2)
213
+ return 'unknown';
214
+ if (isMagicJpeg(header))
215
+ return 'jpeg';
216
+ if (isMagicPng(header))
217
+ return 'png';
218
+ if (isMagicGif(header))
219
+ return 'gif';
220
+ if (isMagicWebp(header))
221
+ return 'webp';
222
+ if (isMagicBmp(header))
223
+ return 'bmp';
224
+ if (isMagicPdf(header))
225
+ return 'pdf';
226
+ if (isMagicZip(header))
227
+ return 'zip';
228
+ if (isMagicHtml(header))
229
+ return 'html';
230
+ if (isMagicXml(header))
231
+ return 'xml';
232
+ if (isMagicPrintableText(header))
233
+ return 'text';
234
+ return 'unknown';
235
+ }
236
+ const IMAGE_EXT_TO_EXPECTED_FAMILY = {
237
+ '.jpg': ['jpeg'],
238
+ '.jpeg': ['jpeg'],
239
+ '.png': ['png'],
240
+ '.gif': ['gif'],
241
+ '.webp': ['webp'],
242
+ '.bmp': ['bmp'],
243
+ };
244
+ export function validateMediaBeforeQueue(url) {
245
+ if (!url) {
246
+ return '媒体 URL 为空';
247
+ }
248
+ if (!isLocalPath(url)) {
249
+ return null;
250
+ }
251
+ const filePath = normalizePath(url);
252
+ if (!existsSync(filePath)) {
253
+ return `文件不存在: ${filePath}`;
254
+ }
255
+ let stat;
256
+ try {
257
+ stat = statSync(filePath);
258
+ }
259
+ catch (err) {
260
+ return `无法读取文件信息: ${err instanceof Error ? err.message : String(err)}`;
261
+ }
262
+ if (stat.size === 0) {
263
+ return `文件内容为空(0 字节): ${filePath}`;
264
+ }
265
+ let header;
266
+ try {
267
+ header = readMagicBytes(filePath, 16);
268
+ }
269
+ catch (err) {
270
+ return null;
271
+ }
272
+ const actual = detectMagicFamily(header);
273
+ const ext = extname(filePath).toLowerCase();
274
+ const expected = IMAGE_EXT_TO_EXPECTED_FAMILY[ext];
275
+ if (expected && !expected.includes(actual)) {
276
+ return `文件扩展名 ${ext} 与实际内容不符(期望 ${expected.join('/')}, 检测到 ${actual}): ${filePath}`;
277
+ }
278
+ return null;
279
+ }
151
280
  const MIME_TO_EXT = {
152
281
  'image/jpeg': '.jpg',
153
282
  'image/png': '.png',
@@ -357,6 +486,7 @@ export async function downloadMediasToLocalFiles(medias, account, core, log) {
357
486
  const maxBytes = account.mediaMaxMb * 1024 * 1024;
358
487
  const cacheDir = join(tmpdir(), 'yuanbao-media');
359
488
  const tasks = medias.slice(0, 20).map(async ({ url, mediaName }, i) => {
489
+ logger.info(`开始解析资源: ${url}`);
360
490
  const mediaFile = await downloadMediaForYuanbao(url, account.mediaMaxMb, account);
361
491
  const originalFilename = mediaName || mediaFile.filename;
362
492
  const ext = extname(originalFilename).toLowerCase();
@@ -368,7 +498,7 @@ export async function downloadMediasToLocalFiles(medias, account, core, log) {
368
498
  }
369
499
  const cachedFilePath = join(cacheDir, md5Filename);
370
500
  if (existsSync(cachedFilePath)) {
371
- log.verbose(`媒体 ${i + 1}/${medias.length} 命中本地缓存,跳过保存: ${cachedFilePath}`);
501
+ logger.info(`媒体 ${i + 1}/${medias.length} 命中本地缓存,跳过保存: ${cachedFilePath}`);
372
502
  return { path: cachedFilePath, contentType };
373
503
  }
374
504
  if (typeof core.channel.media?.saveMediaBuffer === 'function') {
@@ -385,10 +515,10 @@ export async function downloadMediasToLocalFiles(medias, account, core, log) {
385
515
  const r = settled[i];
386
516
  if (r.status === 'fulfilled') {
387
517
  results.push(r.value);
388
- log.verbose(`媒体 ${i + 1}/${medias.length} 下载完成: ${r.value.path} (${r.value.contentType})`);
518
+ logger.info(`媒体 ${i + 1}/${medias.length} 下载完成: ${r.value.path} (${r.value.contentType})`);
389
519
  }
390
520
  else {
391
- log.warn(`媒体 ${i + 1}/${medias.length} 下载失败,跳过: ${String(r.reason)}`);
521
+ logger.warn(`媒体 ${i + 1}/${medias.length} 下载失败,跳过: ${String(r.reason)}`);
392
522
  }
393
523
  }
394
524
  return {
@@ -6,6 +6,7 @@ import { fileHandler } from './file.js';
6
6
  import { videoHandler } from './video.js';
7
7
  import { faceHandler } from './face.js';
8
8
  import { sanitizePipeTables } from '../../utils/markdown-table-sanitize.js';
9
+ import { normalizeMathBlocks } from '../../utils/markdown-stream.js';
9
10
  const handlerList = [
10
11
  textHandler,
11
12
  customHandler,
@@ -76,7 +77,7 @@ function resolveAtMentions(text, groupCode, memberInst) {
76
77
  export function prepareOutboundContent(text, groupCode, memberInst) {
77
78
  if (!text)
78
79
  return [];
79
- const sanitizedText = sanitizePipeTables(text);
80
+ const sanitizedText = sanitizePipeTables(normalizeMathBlocks(text));
80
81
  const items = [];
81
82
  if (sanitizedText.length) {
82
83
  const trailing = sanitizedText.trim();
@@ -128,6 +128,7 @@ async function handleC2CMessage(params) {
128
128
  return;
129
129
  }
130
130
  const { rawBody, medias } = extractTextFromMsgBody(ctx, msg.msg_body);
131
+ getMember(account.accountId).recordC2cUser(fromAccount, senderNickname || fromAccount);
131
132
  log.info(`收到消息 <- ${fromAccount}${senderNickname ? `(${senderNickname})` : ''}, msgKey: ${msg.msg_key}`);
132
133
  const quoteInfo = parseQuoteFromCloudCustomData(msg.cloud_custom_data);
133
134
  if (quoteInfo) {
@@ -243,7 +244,7 @@ async function handleC2CMessage(params) {
243
244
  AccountId: route.accountId,
244
245
  ChatType: 'direct',
245
246
  ConversationLabel: fromLabel,
246
- SenderName: senderNickname || fromAccount,
247
+ SenderName: senderNickname,
247
248
  SenderId: fromAccount,
248
249
  Provider: 'yuanbao',
249
250
  Surface: 'yuanbao',
@@ -513,7 +514,7 @@ async function handleGroupMessage(params) {
513
514
  ChatType: 'group',
514
515
  ConversationLabel: groupLabel,
515
516
  GroupSubject: msg.group_name || undefined,
516
- SenderName: senderNickname || fromAccount,
517
+ SenderName: senderNickname,
517
518
  SenderId: fromAccount,
518
519
  Provider: 'yuanbao',
519
520
  Surface: 'yuanbao',
@@ -3,8 +3,8 @@ export function buildMessageToolHints() {
3
3
  'Sticker/react/贴纸 fully supported, no extra setup. react = sticker = 发贴纸 (NOT a message reaction). Use sticker-search then sticker/react. No bare Unicode emoji.',
4
4
  'File/image sending is supported. Use media/mediaUrls with real URLs or absolute paths, not link-only text.',
5
5
  'IMPORTANT: When sending files, always use absolute paths (e.g. /tmp/file.md). Never use relative paths like "hello.md" — they will fail.',
6
- '- Proactive `send` to another user: `to="<userId>"`.',
7
- '- When asked to send a DM, extract the recipient and message from the user\'s request. If either is ambiguous, ask for clarification before calling the tool.',
8
- '- To find a user\'s ID, use the query_session_members tool first, then pass the resolved ID to the message tool.',
6
+ '(Group only) - Proactive `send` to another user: `to="<userId>"`.',
7
+ '(Group only) - When asked to send a DM, extract the recipient and message from the user\'s request. If either is ambiguous, ask for clarification before calling the tool.',
8
+ '(Group only) - To find a user\'s ID, use the query_session_members tool first, then pass the resolved ID to the message tool.',
9
9
  ];
10
10
  }
@@ -0,0 +1,4 @@
1
+ import type { ChannelPlugin } from 'openclaw/plugin-sdk';
2
+ export declare function normalizeYuanbaoMessagingTarget(raw: string): string | undefined;
3
+ export declare function yuanbaoLooksLikeId(raw: string): boolean;
4
+ export declare const yuanbaoMessagingAdapter: ChannelPlugin<any>['messaging'];
@@ -0,0 +1,31 @@
1
+ export function normalizeYuanbaoMessagingTarget(raw) {
2
+ const trimmed = raw.trim();
3
+ if (!trimmed)
4
+ return undefined;
5
+ return trimmed.replace(/^(yuanbao):/i, '').trim() || undefined;
6
+ }
7
+ export function yuanbaoLooksLikeId(raw) {
8
+ if (/(group:|user:|direct:|yuanbao:)/.test(raw))
9
+ return true;
10
+ try {
11
+ const userId = raw.split(':').pop();
12
+ const sourceStr = atob(userId);
13
+ return sourceStr.length >= 16;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export const yuanbaoMessagingAdapter = {
20
+ normalizeTarget: normalizeYuanbaoMessagingTarget,
21
+ targetResolver: {
22
+ looksLikeId: yuanbaoLooksLikeId,
23
+ hint: '<chatId|group:groupCode|direct:userId|userId>',
24
+ },
25
+ inferTargetChatType: ({ to }) => {
26
+ const stripped = to.replace(/^yuanbao:/i, '');
27
+ if (stripped.includes('group:') || /^\d{9}$/.test(stripped))
28
+ return 'group';
29
+ return 'direct';
30
+ },
31
+ };
@@ -35,6 +35,7 @@ export declare class GroupMember {
35
35
  lookupUsers(groupCode: string, nameFilter?: string): UserRecord[];
36
36
  lookupUserByNickName(groupCode: string, nickName: string): UserRecord | undefined;
37
37
  hasCachedData(groupCode: string): boolean;
38
+ listCachedGroupCodes(): string[];
38
39
  refresh(groupCode: string): Promise<UserRecord[]>;
39
40
  queryGroupOwner(groupCode: string): Promise<GroupOwnerInfo | null>;
40
41
  queryGroupInfo(groupCode: string): Promise<GroupInfoData | null>;
@@ -50,9 +51,13 @@ export declare class Member {
50
51
  readonly session: SessionMember;
51
52
  readonly group: GroupMember;
52
53
  private yuanbaoUserIdCache;
54
+ private c2cUsers;
53
55
  constructor(accountId: string);
54
56
  recordUser(groupCode: string, userId: string, nickName: string): void;
57
+ recordC2cUser(userId: string, nickName: string): void;
58
+ listC2cUsers(): UserRecord[];
55
59
  queryMembers(groupCode: string, nameFilter?: string): Promise<UserRecord[]>;
60
+ queryUserIdsByNickName(nickName: string, groupCode?: string): Promise<string[]>;
56
61
  lookupUsers(groupCode: string, nameFilter?: string): UserRecord[];
57
62
  lookupUserByNickName(groupCode: string, nickName: string): UserRecord | undefined;
58
63
  queryGroupOwner(groupCode: string): Promise<GroupOwnerInfo | null>;
@@ -118,6 +118,9 @@ export class GroupMember {
118
118
  hasCachedData(groupCode) {
119
119
  return this.cache.has(groupCode);
120
120
  }
121
+ listCachedGroupCodes() {
122
+ return Array.from(this.cache.keys());
123
+ }
121
124
  async refresh(groupCode) {
122
125
  this.cache.delete(groupCode);
123
126
  return this.getMembers(groupCode);
@@ -241,6 +244,7 @@ export class Member {
241
244
  session = new SessionMember();
242
245
  group;
243
246
  yuanbaoUserIdCache = null;
247
+ c2cUsers = new Map();
244
248
  constructor(accountId) {
245
249
  this.accountId = accountId;
246
250
  this.group = new GroupMember(accountId, this.session);
@@ -248,6 +252,19 @@ export class Member {
248
252
  recordUser(groupCode, userId, nickName) {
249
253
  this.session.recordUser(groupCode, userId, nickName);
250
254
  }
255
+ recordC2cUser(userId, nickName) {
256
+ if (!userId)
257
+ return;
258
+ this.c2cUsers.set(userId, {
259
+ userId,
260
+ nickName: nickName || 'unknown',
261
+ lastSeen: Date.now(),
262
+ });
263
+ logger.debug?.(`[member] recorded c2c user: ${nickName ?? '?'} (${userId})`);
264
+ }
265
+ listC2cUsers() {
266
+ return Array.from(this.c2cUsers.values()).sort((a, b) => b.lastSeen - a.lastSeen);
267
+ }
251
268
  async queryMembers(groupCode, nameFilter) {
252
269
  const groupMembers = await this.group.getMembers(groupCode);
253
270
  if (groupMembers.length > 0) {
@@ -261,6 +278,37 @@ export class Member {
261
278
  logger.debug?.(`[member] GroupMember empty or no match, fallback to SessionMember for group=${groupCode}`);
262
279
  return this.session.lookupUsers(groupCode, nameFilter);
263
280
  }
281
+ async queryUserIdsByNickName(nickName, groupCode) {
282
+ const filter = nickName.trim().toLowerCase();
283
+ const seen = new Set();
284
+ const results = [];
285
+ const collect = (users) => {
286
+ for (const u of users) {
287
+ if (!seen.has(u.userId) && u.nickName.toLowerCase().includes(filter)) {
288
+ seen.add(u.userId);
289
+ results.push(u.userId);
290
+ }
291
+ }
292
+ };
293
+ collect(this.listC2cUsers());
294
+ if (groupCode) {
295
+ const members = await this.group.getMembers(groupCode);
296
+ const source = members.length > 0 ? members : this.session.lookupUsers(groupCode);
297
+ collect(source);
298
+ }
299
+ else {
300
+ const allGroupCodes = new Set([
301
+ ...this.group.listCachedGroupCodes(),
302
+ ...this.session.listGroupCodes(),
303
+ ]);
304
+ for (const code of allGroupCodes) {
305
+ const groupCached = this.group.lookupUsers(code);
306
+ collect(groupCached.length > 0 ? groupCached : this.session.lookupUsers(code));
307
+ }
308
+ }
309
+ logger.debug?.(`[member] queryUserIdsByNickName: "${nickName}"${groupCode ? ` in group=${groupCode}` : ' (all sources)'} → ${results.length} hit(s)`);
310
+ return results;
311
+ }
264
312
  lookupUsers(groupCode, nameFilter) {
265
313
  const groupResults = this.group.lookupUsers(groupCode, nameFilter);
266
314
  if (groupResults.length > 0)
@@ -72,18 +72,6 @@ export interface OutboundQueueConfig {
72
72
  export declare function initOutboundQueue(accountId: string, config: OutboundQueueConfig): OutboundQueueManager;
73
73
  export declare function getOutboundQueue(accountId: string): OutboundQueueManager | null;
74
74
  export declare function destroyOutboundQueue(accountId: string): void;
75
- export declare function endsWithTableRow(text: string): boolean;
76
- export declare function hasUnclosedFence(text: string): boolean;
77
- export declare function startsWithBlockElement(text: string): boolean;
78
- export declare function inferBlockSeparator(buffer: string, incoming: string): string;
79
- export type AtomicBlock = {
80
- start: number;
81
- end: number;
82
- kind: 'table' | 'diagram-fence';
83
- };
84
- export declare function extractAtomicBlocks(text: string): AtomicBlock[];
85
- export declare function chunkMarkdownTextAtomicAware(text: string, maxChars: number, chunkFn: (text: string, max: number) => string[]): string[];
86
- export declare function mergeBlockStreamingFences(buffer: string, incoming: string): string;
87
75
  export interface MergeTextOptions {
88
76
  minChars: number;
89
77
  maxChars: number;
@@ -6,6 +6,7 @@ import { sendStickerYuanbao } from './sticker/sticker-sender.js';
6
6
  import { getYuanbaoRuntime } from './runtime.js';
7
7
  import { buildFileMsgBody, buildImageMsgBody, downloadAndUploadMedia, guessMimeType } from './media.js';
8
8
  import { createReplyHeartbeatController } from './module/reply-heartbeat.js';
9
+ import { endsWithTableRow, hasUnclosedFence, inferBlockSeparator, mergeBlockStreamingFences, chunkMarkdownTextAtomicAware, } from './utils/markdown-stream.js';
9
10
  const activeManagers = new Map();
10
11
  export function initOutboundQueue(accountId, config) {
11
12
  const log = createLog('outbound-queue');
@@ -361,185 +362,6 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
361
362
  },
362
363
  };
363
364
  }
364
- export function endsWithTableRow(text) {
365
- const trimmed = text.trimEnd();
366
- if (!trimmed)
367
- return false;
368
- const lastLine = trimmed.split('\n').at(-1) ?? '';
369
- const line = lastLine.trim();
370
- return line.startsWith('|') && line.endsWith('|');
371
- }
372
- export function hasUnclosedFence(text) {
373
- let inFence = false;
374
- for (const line of text.split('\n')) {
375
- if (line.startsWith('```'))
376
- inFence = !inFence;
377
- }
378
- return inFence;
379
- }
380
- export function startsWithBlockElement(text) {
381
- const firstLine = (text.trimStart().split('\n')[0] ?? '').trimStart();
382
- return /^#{1,6}\s/.test(firstLine)
383
- || firstLine.startsWith('---')
384
- || firstLine.startsWith('***')
385
- || firstLine.startsWith('___')
386
- || firstLine.startsWith('> ')
387
- || firstLine.startsWith('```')
388
- || /^[*\-+]\s/.test(firstLine)
389
- || /^\d+[.)]\s/.test(firstLine)
390
- || firstLine.startsWith('|');
391
- }
392
- export function inferBlockSeparator(buffer, incoming) {
393
- if (hasUnclosedFence(buffer))
394
- return '';
395
- if (buffer.endsWith('\n\n'))
396
- return '';
397
- const lastLine = (buffer.trimEnd().split('\n')
398
- .at(-1) ?? '').trim();
399
- const firstLine = (incoming.trimStart().split('\n')[0] ?? '').trimStart();
400
- if (lastLine.startsWith('|') && !firstLine.startsWith('|')
401
- && firstLine.endsWith('|')) {
402
- return ' ';
403
- }
404
- if (lastLine.startsWith('|') && firstLine.startsWith('|'))
405
- return '\n';
406
- if (startsWithBlockElement(incoming))
407
- return '\n\n';
408
- return '';
409
- }
410
- const DIAGRAM_LANGUAGES = new Set([
411
- 'mermaid', 'plantuml', 'sequence', 'flowchart',
412
- 'gantt', 'classdiagram', 'statediagram', 'erdiagram',
413
- 'journey', 'gitgraph', 'mindmap', 'timeline',
414
- ]);
415
- export function extractAtomicBlocks(text) {
416
- const blocks = [];
417
- const lines = text.split('\n');
418
- let offset = 0;
419
- let inPlainFence = false;
420
- let inDiagram = false;
421
- let diagramStart = 0;
422
- let tableStart = -1;
423
- let tableEnd = -1;
424
- let tableHasSep = false;
425
- let tableLineCount = 0;
426
- const isTableLine = (line) => line.trim().startsWith('|');
427
- const isTableSeparator = (line) => /^\|[\s|:-]+\|$/.test(line.trim());
428
- const flushTable = () => {
429
- if (tableStart !== -1 && tableEnd !== -1 && (tableHasSep || tableLineCount >= 2)) {
430
- blocks.push({ start: tableStart, end: tableEnd, kind: 'table' });
431
- }
432
- tableStart = -1;
433
- tableEnd = -1;
434
- tableHasSep = false;
435
- tableLineCount = 0;
436
- };
437
- for (let i = 0; i < lines.length; i++) {
438
- const line = lines[i];
439
- const lineEnd = offset + line.length + (i < lines.length - 1 ? 1 : 0);
440
- if (inPlainFence || inDiagram) {
441
- if (line.startsWith('```')) {
442
- if (inDiagram) {
443
- blocks.push({ start: diagramStart, end: lineEnd, kind: 'diagram-fence' });
444
- inDiagram = false;
445
- }
446
- else {
447
- inPlainFence = false;
448
- }
449
- }
450
- offset = lineEnd;
451
- continue;
452
- }
453
- if (line.startsWith('```')) {
454
- flushTable();
455
- const lang = line.slice(3).trim()
456
- .toLowerCase();
457
- if (lang && DIAGRAM_LANGUAGES.has(lang)) {
458
- inDiagram = true;
459
- diagramStart = offset;
460
- }
461
- else {
462
- inPlainFence = true;
463
- }
464
- offset = lineEnd;
465
- continue;
466
- }
467
- if (isTableLine(line)) {
468
- if (tableStart === -1) {
469
- tableStart = offset;
470
- tableLineCount = 1;
471
- tableHasSep = false;
472
- }
473
- else {
474
- tableLineCount++;
475
- if (!tableHasSep && tableLineCount === 2 && isTableSeparator(line)) {
476
- tableHasSep = true;
477
- }
478
- }
479
- tableEnd = lineEnd;
480
- }
481
- else {
482
- flushTable();
483
- }
484
- offset = lineEnd;
485
- }
486
- flushTable();
487
- return blocks.sort((a, b) => a.start - b.start);
488
- }
489
- export function chunkMarkdownTextAtomicAware(text, maxChars, chunkFn) {
490
- const rawChunks = chunkFn(text, maxChars);
491
- if (rawChunks.length <= 1)
492
- return rawChunks;
493
- const atomicBlocks = extractAtomicBlocks(text);
494
- if (atomicBlocks.length === 0)
495
- return rawChunks;
496
- const splitIndices = [];
497
- let cumLen = 0;
498
- for (let i = 0; i < rawChunks.length - 1; i++) {
499
- cumLen += rawChunks[i].length;
500
- splitIndices.push(cumLen);
501
- }
502
- const adjustedIndices = [];
503
- let chunkWindowStart = 0;
504
- for (const idx of splitIndices) {
505
- const block = atomicBlocks.find(b => b.start < idx && idx < b.end);
506
- if (!block) {
507
- adjustedIndices.push(idx);
508
- chunkWindowStart = idx;
509
- continue;
510
- }
511
- if (block.start > chunkWindowStart) {
512
- adjustedIndices.push(block.start);
513
- chunkWindowStart = block.start;
514
- }
515
- else {
516
- adjustedIndices.push(block.end);
517
- chunkWindowStart = block.end;
518
- }
519
- }
520
- const result = [];
521
- let prev = 0;
522
- for (const idx of adjustedIndices) {
523
- if (idx > prev)
524
- result.push(text.slice(prev, idx));
525
- prev = idx;
526
- }
527
- if (prev < text.length)
528
- result.push(text.slice(prev));
529
- return result.filter(c => c.length > 0);
530
- }
531
- export function mergeBlockStreamingFences(buffer, incoming) {
532
- const CLOSE_RE = /\n```\s*$/;
533
- const OPEN_RE = /^```[^\n]*\n/;
534
- const normalized = incoming.replace(/\n```\s*```[^\n]*\n/g, '\n');
535
- if (CLOSE_RE.test(buffer) && OPEN_RE.test(normalized)) {
536
- return `${buffer.replace(CLOSE_RE, '')}\n${normalized.replace(OPEN_RE, '')}`;
537
- }
538
- if (hasUnclosedFence(buffer) && OPEN_RE.test(normalized)) {
539
- return `${buffer}\n${normalized.replace(OPEN_RE, '')}`;
540
- }
541
- return `${buffer}${normalized}`;
542
- }
543
365
  function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, opts, heartbeatMeta) {
544
366
  const { sendText, sendSticker, sendMedia } = callbacks;
545
367
  const { minChars, maxChars, chunkText } = opts;
@@ -0,0 +1,14 @@
1
+ export declare function endsWithTableRow(text: string): boolean;
2
+ export declare function hasUnclosedFence(text: string): boolean;
3
+ export declare function hasUnclosedMathBlock(text: string): boolean;
4
+ export declare function normalizeMathBlocks(text: string): string;
5
+ export declare function startsWithBlockElement(text: string): boolean;
6
+ export declare function inferBlockSeparator(buffer: string, incoming: string): string;
7
+ export declare function mergeBlockStreamingFences(buffer: string, incoming: string): string;
8
+ export type AtomicBlock = {
9
+ start: number;
10
+ end: number;
11
+ kind: 'table' | 'diagram-fence';
12
+ };
13
+ export declare function extractAtomicBlocks(text: string): AtomicBlock[];
14
+ export declare function chunkMarkdownTextAtomicAware(text: string, maxChars: number, chunkFn: (text: string, max: number) => string[]): string[];
@@ -0,0 +1,242 @@
1
+ export function endsWithTableRow(text) {
2
+ const trimmed = text.trimEnd();
3
+ if (!trimmed)
4
+ return false;
5
+ const lastLine = trimmed.split('\n').at(-1) ?? '';
6
+ const line = lastLine.trim();
7
+ return line.startsWith('|') && line.endsWith('|');
8
+ }
9
+ export function hasUnclosedFence(text) {
10
+ let inFence = false;
11
+ for (const line of text.split('\n')) {
12
+ if (line.startsWith('```'))
13
+ inFence = !inFence;
14
+ }
15
+ return inFence;
16
+ }
17
+ export function hasUnclosedMathBlock(text) {
18
+ let inFence = false;
19
+ let mathOpen = false;
20
+ for (const line of text.split('\n')) {
21
+ if (line.startsWith('```')) {
22
+ inFence = !inFence;
23
+ continue;
24
+ }
25
+ if (inFence)
26
+ continue;
27
+ let idx = 0;
28
+ while (idx < line.length - 1) {
29
+ if (line[idx] === '$' && line[idx + 1] === '$') {
30
+ mathOpen = !mathOpen;
31
+ idx += 2;
32
+ }
33
+ else {
34
+ idx++;
35
+ }
36
+ }
37
+ }
38
+ return mathOpen;
39
+ }
40
+ export function normalizeMathBlocks(text) {
41
+ if (!text.includes('$$'))
42
+ return text;
43
+ const parts = [];
44
+ let inFence = false;
45
+ let mathOpen = false;
46
+ let segStart = 0;
47
+ for (let i = 0; i < text.length; i++) {
48
+ if ((i === 0 || text[i - 1] === '\n') && text.startsWith('```', i)) {
49
+ inFence = !inFence;
50
+ continue;
51
+ }
52
+ if (inFence)
53
+ continue;
54
+ if (text[i] === '$' && i + 1 < text.length && text[i + 1] === '$') {
55
+ if (!mathOpen) {
56
+ mathOpen = true;
57
+ parts.push(text.slice(segStart, i + 2));
58
+ segStart = i + 2;
59
+ i++;
60
+ }
61
+ else {
62
+ const mathContent = text.slice(segStart, i);
63
+ parts.push(mathContent.replace(/\n\n+/g, '\n'));
64
+ parts.push('$$');
65
+ segStart = i + 2;
66
+ mathOpen = false;
67
+ i++;
68
+ }
69
+ }
70
+ }
71
+ if (segStart < text.length) {
72
+ const remaining = text.slice(segStart);
73
+ parts.push(mathOpen ? remaining.replace(/\n\n+/g, '\n') : remaining);
74
+ }
75
+ return parts.join('');
76
+ }
77
+ export function startsWithBlockElement(text) {
78
+ const firstLine = (text.trimStart().split('\n')[0] ?? '').trimStart();
79
+ return /^#{1,6}\s/.test(firstLine)
80
+ || firstLine.startsWith('---')
81
+ || firstLine.startsWith('***')
82
+ || firstLine.startsWith('___')
83
+ || firstLine.startsWith('> ')
84
+ || firstLine.startsWith('```')
85
+ || /^[*\-+]\s/.test(firstLine)
86
+ || /^\d+[.)]\s/.test(firstLine)
87
+ || firstLine.startsWith('|')
88
+ || firstLine.startsWith('$$');
89
+ }
90
+ export function inferBlockSeparator(buffer, incoming) {
91
+ if (hasUnclosedFence(buffer))
92
+ return '';
93
+ if (hasUnclosedMathBlock(buffer))
94
+ return '';
95
+ if (buffer.endsWith('\n\n'))
96
+ return '';
97
+ const lastLine = (buffer.trimEnd().split('\n')
98
+ .at(-1) ?? '').trim();
99
+ const firstLine = (incoming.trimStart().split('\n')[0] ?? '').trimStart();
100
+ if (lastLine.startsWith('|') && !firstLine.startsWith('|')
101
+ && firstLine.endsWith('|')) {
102
+ return ' ';
103
+ }
104
+ if (lastLine.startsWith('|') && firstLine.startsWith('|'))
105
+ return '\n';
106
+ if (startsWithBlockElement(incoming))
107
+ return '\n\n';
108
+ return '';
109
+ }
110
+ export function mergeBlockStreamingFences(buffer, incoming) {
111
+ const CLOSE_RE = /\n```\s*$/;
112
+ const OPEN_RE = /^```[^\n]*\n/;
113
+ const normalized = incoming.replace(/\n```\s*```[^\n]*\n/g, '\n');
114
+ if (CLOSE_RE.test(buffer) && OPEN_RE.test(normalized)) {
115
+ return `${buffer.replace(CLOSE_RE, '')}\n${normalized.replace(OPEN_RE, '')}`;
116
+ }
117
+ if (hasUnclosedFence(buffer) && OPEN_RE.test(normalized)) {
118
+ return `${buffer}\n${normalized.replace(OPEN_RE, '')}`;
119
+ }
120
+ return `${buffer}${normalized}`;
121
+ }
122
+ const DIAGRAM_LANGUAGES = new Set([
123
+ 'mermaid', 'plantuml', 'sequence', 'flowchart',
124
+ 'gantt', 'classdiagram', 'statediagram', 'erdiagram',
125
+ 'journey', 'gitgraph', 'mindmap', 'timeline',
126
+ ]);
127
+ export function extractAtomicBlocks(text) {
128
+ const blocks = [];
129
+ const lines = text.split('\n');
130
+ let offset = 0;
131
+ let inPlainFence = false;
132
+ let inDiagram = false;
133
+ let diagramStart = 0;
134
+ let tableStart = -1;
135
+ let tableEnd = -1;
136
+ let tableHasSep = false;
137
+ let tableLineCount = 0;
138
+ const isTableLine = (line) => line.trim().startsWith('|');
139
+ const isTableSeparator = (line) => /^\|[\s|:-]+\|$/.test(line.trim());
140
+ const flushTable = () => {
141
+ if (tableStart !== -1 && tableEnd !== -1 && (tableHasSep || tableLineCount >= 2)) {
142
+ blocks.push({ start: tableStart, end: tableEnd, kind: 'table' });
143
+ }
144
+ tableStart = -1;
145
+ tableEnd = -1;
146
+ tableHasSep = false;
147
+ tableLineCount = 0;
148
+ };
149
+ for (let i = 0; i < lines.length; i++) {
150
+ const line = lines[i];
151
+ const lineEnd = offset + line.length + (i < lines.length - 1 ? 1 : 0);
152
+ if (inPlainFence || inDiagram) {
153
+ if (line.startsWith('```')) {
154
+ if (inDiagram) {
155
+ blocks.push({ start: diagramStart, end: lineEnd, kind: 'diagram-fence' });
156
+ inDiagram = false;
157
+ }
158
+ else {
159
+ inPlainFence = false;
160
+ }
161
+ }
162
+ offset = lineEnd;
163
+ continue;
164
+ }
165
+ if (line.startsWith('```')) {
166
+ flushTable();
167
+ const lang = line.slice(3).trim()
168
+ .toLowerCase();
169
+ if (lang && DIAGRAM_LANGUAGES.has(lang)) {
170
+ inDiagram = true;
171
+ diagramStart = offset;
172
+ }
173
+ else {
174
+ inPlainFence = true;
175
+ }
176
+ offset = lineEnd;
177
+ continue;
178
+ }
179
+ if (isTableLine(line)) {
180
+ if (tableStart === -1) {
181
+ tableStart = offset;
182
+ tableLineCount = 1;
183
+ tableHasSep = false;
184
+ }
185
+ else {
186
+ tableLineCount++;
187
+ if (!tableHasSep && tableLineCount === 2 && isTableSeparator(line)) {
188
+ tableHasSep = true;
189
+ }
190
+ }
191
+ tableEnd = lineEnd;
192
+ }
193
+ else {
194
+ flushTable();
195
+ }
196
+ offset = lineEnd;
197
+ }
198
+ flushTable();
199
+ return blocks.sort((a, b) => a.start - b.start);
200
+ }
201
+ export function chunkMarkdownTextAtomicAware(text, maxChars, chunkFn) {
202
+ const rawChunks = chunkFn(text, maxChars);
203
+ if (rawChunks.length <= 1)
204
+ return rawChunks;
205
+ const atomicBlocks = extractAtomicBlocks(text);
206
+ if (atomicBlocks.length === 0)
207
+ return rawChunks;
208
+ const splitIndices = [];
209
+ let cumLen = 0;
210
+ for (let i = 0; i < rawChunks.length - 1; i++) {
211
+ cumLen += rawChunks[i].length;
212
+ splitIndices.push(cumLen);
213
+ }
214
+ const adjustedIndices = [];
215
+ let chunkWindowStart = 0;
216
+ for (const idx of splitIndices) {
217
+ const block = atomicBlocks.find(b => b.start < idx && idx < b.end);
218
+ if (!block) {
219
+ adjustedIndices.push(idx);
220
+ chunkWindowStart = idx;
221
+ continue;
222
+ }
223
+ if (block.start > chunkWindowStart) {
224
+ adjustedIndices.push(block.start);
225
+ chunkWindowStart = block.start;
226
+ }
227
+ else {
228
+ adjustedIndices.push(block.end);
229
+ chunkWindowStart = block.end;
230
+ }
231
+ }
232
+ const result = [];
233
+ let prev = 0;
234
+ for (const idx of adjustedIndices) {
235
+ if (idx > prev)
236
+ result.push(text.slice(prev, idx));
237
+ prev = idx;
238
+ }
239
+ if (prev < text.length)
240
+ result.push(text.slice(prev));
241
+ return result.filter(c => c.length > 0);
242
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-plugin-yuanbao",
3
3
  "name": "元宝 Bot",
4
4
  "description": "Tencent YuanBao intelligent bot channel plugin",
5
- "version": "2.8.0",
5
+ "version": "2.9.0",
6
6
  "channels": [
7
7
  "yuanbao"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-yuanbao",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "type": "module",
5
5
  "description": "Tencent YuanBao intelligent bot channel plugin",
6
6
  "license": "MIT",