openclaw-plugin-yuanbao 2.3.0 → 2.4.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.3.0",
5
+ "version": "2.4.0",
6
6
  "channels": [
7
7
  "yuanbao"
8
8
  ],
@@ -2,13 +2,13 @@ import { DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, setAccountEnabledIn
2
2
  import { listYuanbaoAccountIds, resolveDefaultYuanbaoAccountId, resolveYuanbaoAccount } from './accounts.js';
3
3
  import { yuanbaoConfigSchema } from './config-schema.js';
4
4
  import { yuanbaoOnboardingAdapter } from './onboarding.js';
5
- import { createLog, logger } from './logger.js';
5
+ import { createLog } from './logger.js';
6
6
  import { yuanbaoSetupAdapter } from './setup.js';
7
7
  import { startYuanbaoWsGateway, getActiveWsClient } from './yuanbao-server/ws/index.js';
8
8
  import { getYuanbaoRuntime } from './runtime.js';
9
9
  import { sendYuanbaoMessage, sendYuanbaoGroupMessage } from './message-handler/index.js';
10
10
  import { initOutboundQueue, destroyOutboundQueue, getOutboundQueue } from './outbound-queue.js';
11
- import { parseTarget, sendDM, } from './dm/index.js';
11
+ import { ChatType, getGroupCode, parseTarget } from './targets.js';
12
12
  import { buildMessageToolHints, yuanbaoMessageActions } from './message-tool/index.js';
13
13
  function toChannelResult(result) {
14
14
  return {
@@ -18,6 +18,16 @@ function toChannelResult(result) {
18
18
  error: result.error ? new Error(result.error) : undefined,
19
19
  };
20
20
  }
21
+ function buildMinCtx(account, wsClient) {
22
+ return {
23
+ account,
24
+ config: account.config,
25
+ core: {},
26
+ log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
27
+ wsClient,
28
+ groupCode: getGroupCode(),
29
+ };
30
+ }
21
31
  async function sendTextToTarget(account, target, text, wsClient) {
22
32
  const minCtx = wsClient
23
33
  ? {
@@ -28,13 +38,11 @@ async function sendTextToTarget(account, target, text, wsClient) {
28
38
  wsClient,
29
39
  }
30
40
  : undefined;
31
- if (target.startsWith('group:')) {
32
- return sendYuanbaoGroupMessage({ account, groupCode: target.slice('group:'.length), text, fromAccount: account.botId, ctx: minCtx });
41
+ const { chatType, target: targetId } = parseTarget(target, account.accountId);
42
+ if (chatType === ChatType.GROUP) {
43
+ return sendYuanbaoGroupMessage({ account, groupCode: targetId, text, fromAccount: account.botId, ctx: minCtx });
33
44
  }
34
- if (target.startsWith('direct:')) {
35
- return sendYuanbaoMessage({ account, toAccount: target.slice('direct:'.length), text, fromAccount: account.botId, ctx: minCtx });
36
- }
37
- return sendYuanbaoMessage({ account, toAccount: target, text, fromAccount: account.botId, ctx: minCtx });
45
+ return sendYuanbaoMessage({ account, toAccount: targetId, text, fromAccount: account.botId, ctx: minCtx });
38
46
  }
39
47
  const meta = {
40
48
  id: 'yuanbao',
@@ -157,8 +165,8 @@ export const yuanbaoPlugin = {
157
165
  streaming: {
158
166
  blockStreamingChunkMaxChars: 3000,
159
167
  blockStreamingCoalesceDefaults: {
160
- minChars: 2500,
161
- idleMs: 15000,
168
+ minChars: 2800,
169
+ idleMs: 5000,
162
170
  },
163
171
  },
164
172
  outbound: {
@@ -166,42 +174,39 @@ export const yuanbaoPlugin = {
166
174
  chunkerMode: 'markdown',
167
175
  textChunkLimit: 3000,
168
176
  chunker: (text, limit) => getYuanbaoRuntime()?.channel.text.chunkMarkdownText(text, limit) ?? [text],
169
- sendText: async ({ cfg, to: _to, text, accountId }) => {
177
+ sendText: async (params) => {
178
+ const slog = createLog('channel.utbound');
179
+ const { cfg, accountId, to: _to, text } = params;
170
180
  const to = _to.replace(/^yuanbao:/, '');
171
181
  const account = resolveYuanbaoAccount({ cfg, accountId: accountId ?? undefined });
172
- const dmTarget = parseTarget(to);
173
- if (dmTarget?.kind === 'user') {
174
- const dmResult = await sendDM(to, text, { account });
175
- return {
176
- channel: 'yuanbao',
177
- ok: dmResult.ok,
178
- messageId: dmResult.messageId ?? '',
179
- error: dmResult.error
180
- ? new Error('detail' in dmResult.error ? dmResult.error.detail : dmResult.error.kind)
181
- : undefined,
182
- };
183
- }
182
+ slog.info('sendText', { accountId, to });
184
183
  const wsClient = getActiveWsClient(account.accountId);
185
184
  if (!wsClient) {
186
185
  return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(`WebSocket client not connected for account ${account.accountId}`) };
187
186
  }
188
187
  const queueManager = getOutboundQueue(account.accountId);
189
188
  if (queueManager) {
190
- const safeTo = to.replace(/^user:/, 'direct:');
191
- const session = queueManager.getSession(safeTo);
192
- if (session) {
193
- await session.push({ type: 'text', text });
194
- return { channel: 'yuanbao', ok: true, messageId: '' };
195
- }
196
- logger.debug(`sendText: 未找到已有 session, to: ${to}`);
189
+ const { chatType, target, sessionKey } = parseTarget(to, account.accountId);
190
+ const session = queueManager.getOrCreateSession(sessionKey, {
191
+ chatType,
192
+ account,
193
+ target,
194
+ fromAccount: account.botId,
195
+ ctx: buildMinCtx(account, wsClient),
196
+ });
197
+ await session.push({ type: 'text', text });
198
+ await session.flush();
199
+ return { channel: 'yuanbao', ok: true, messageId: '' };
197
200
  }
198
201
  return toChannelResult(await sendTextToTarget(account, to, text, wsClient));
199
202
  },
200
- sendMedia: async ({ cfg, accountId, to: _to, mediaUrl, text, mediaLocalRoots }) => {
203
+ sendMedia: async (params) => {
204
+ const slog = createLog('channel.outbound');
205
+ const { cfg, accountId, to: _to, mediaUrl, text, mediaLocalRoots } = params;
201
206
  const to = _to.replace(/^yuanbao:/, '');
202
207
  const account = resolveYuanbaoAccount({ cfg, accountId: accountId ?? undefined });
203
208
  const wsClient = getActiveWsClient(account.accountId);
204
- logger.info('sendMedia', { to, mediaUrl, text, accountId });
209
+ slog.info('sendMedia', { accountId, to, mediaUrl, text });
205
210
  if (!wsClient) {
206
211
  return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(`WebSocket client not connected for account ${account.accountId}`) };
207
212
  }
@@ -210,14 +215,20 @@ export const yuanbaoPlugin = {
210
215
  }
211
216
  const queueManager = getOutboundQueue(account.accountId);
212
217
  if (queueManager) {
213
- const safeTo = to.replace(/^user:/, 'direct:');
214
- const session = queueManager.getSession(safeTo);
215
- if (session) {
218
+ const { chatType, target, sessionKey } = parseTarget(to, account.accountId);
219
+ const session = queueManager.getOrCreateSession(sessionKey, {
220
+ chatType,
221
+ account,
222
+ target,
223
+ fromAccount: account.botId,
224
+ ctx: buildMinCtx(account, wsClient),
225
+ });
226
+ if (text?.trim()) {
216
227
  await session.push({ type: 'text', text });
217
- await session.push({ type: 'media', mediaUrl, mediaLocalRoots });
218
- return { channel: 'yuanbao', ok: true, messageId: '' };
219
228
  }
220
- logger.debug(`sendMedia: 未找到已有 session, to: ${to}`);
229
+ await session.push({ type: 'media', mediaUrl, mediaLocalRoots });
230
+ await session.flush();
231
+ return { channel: 'yuanbao', ok: true, messageId: '' };
221
232
  }
222
233
  return { channel: 'yuanbao', ok: false, messageId: '', error: new Error('No session found') };
223
234
  },
@@ -261,7 +261,7 @@ function performUpgrade(config, accountId) {
261
261
  accountId: resolvedAccountId,
262
262
  });
263
263
  const { appKey, appSecret, token } = resolvedAccount;
264
- lines.push('执行openclaw plugins update失败,尝试重新安装最新版本,预计2分钟后自动升级成功,请稍后确认是否能正常使用');
264
+ lines.push('执行openclaw plugins update失败,尝试重新安装最新版本,预计需要花费 1-2 分钟,请耐心等待');
265
265
  reinstallViaCdn({ appKey, appSecret, token });
266
266
  const finalVersion = readInstalledVersion(PLUGIN_ID);
267
267
  log.info('CDN 重装后版本检查', { finalVersion: finalVersion ?? '(未检测到)', expected: latestVersion });
@@ -44,7 +44,7 @@ export const yuanbaoConfigSchema = {
44
44
  title: '消息聚合最小字符数',
45
45
  description: 'merge-text 策略下,缓冲区积累到此字符数后触发发送',
46
46
  minimum: 1,
47
- default: 2500,
47
+ default: 2800,
48
48
  },
49
49
  maxChars: {
50
50
  type: 'integer',
@@ -58,7 +58,7 @@ export const yuanbaoConfigSchema = {
58
58
  title: '空闲自动发送超时 (ms)',
59
59
  description: 'merge-text 策略下,超过该时长无新内容时自动发送缓冲区',
60
60
  minimum: 0,
61
- default: 15000,
61
+ default: 5000,
62
62
  },
63
63
  mediaMaxMb: {
64
64
  type: 'number',
@@ -1,7 +1,6 @@
1
1
  export interface CachedUserEntry {
2
- id: string;
3
- name?: string;
4
- handle?: string;
2
+ userId: string;
3
+ nickName?: string;
5
4
  }
6
5
  export declare function getCachedMember(key: string): CachedUserEntry | undefined;
7
6
  export declare function cacheMember(key: string, entry: CachedUserEntry): void;
@@ -1,9 +1,8 @@
1
1
  import type { CachedUserEntry } from './directory-cache.js';
2
2
  export interface DirectoryEntry {
3
3
  kind: 'user' | 'group';
4
- id: string;
5
- name?: string;
6
- handle?: string;
4
+ userId: string;
5
+ nickName: string;
7
6
  }
8
7
  export declare function resolveUsername(nameOrHandle: string, accountId: string, groupCode?: string): CachedUserEntry | null;
9
8
  export declare function listKnownPeers(accountId: string): DirectoryEntry[];
@@ -19,9 +19,8 @@ export function resolveUsername(nameOrHandle, accountId, groupCode = '') {
19
19
  || u.userId.toLowerCase() === query.toLowerCase());
20
20
  const best = exactMatch ?? results[0];
21
21
  const entry = {
22
- id: best.userId,
23
- name: best.nickName,
24
- handle: best.nickName,
22
+ userId: best.userId,
23
+ nickName: best.nickName,
25
24
  };
26
25
  cacheMember(query, entry);
27
26
  cacheMember(best.nickName, entry);
@@ -44,9 +43,8 @@ export function listKnownPeers(accountId) {
44
43
  seen.add(u.userId);
45
44
  entries.push({
46
45
  kind: 'user',
47
- id: u.userId,
48
- name: u.nickName,
49
- handle: u.nickName,
46
+ userId: u.userId,
47
+ nickName: u.nickName,
50
48
  });
51
49
  }
52
50
  }
@@ -52,7 +52,7 @@ export async function handleAction(ctx) {
52
52
  });
53
53
  }
54
54
  if (target.kind === 'user') {
55
- console.log('handleAction DM', ctx);
55
+ log.info('处理 DM 发送', { to, targetId: target.id, senderId: ctx.requesterSenderId });
56
56
  const senderId = ctx.requesterSenderId ?? '';
57
57
  const accessResult = enforceDMAccess(senderId, target.id, message.length, DEFAULT_DM_ACCESS_POLICY);
58
58
  if (!accessResult.allowed) {
@@ -53,7 +53,7 @@ export async function sendDM(to, text, opts) {
53
53
  },
54
54
  };
55
55
  }
56
- userId = resolved.id;
56
+ userId = resolved.userId;
57
57
  }
58
58
  const wsClient = opts.ctx?.wsClient ?? getActiveWsClient(account.accountId);
59
59
  if (!wsClient) {
@@ -84,6 +84,7 @@ const SENSITIVE_KEYS = new Set([
84
84
  'x-token',
85
85
  'user_input',
86
86
  'cloud_custom_data',
87
+ 'model_output',
87
88
  ]);
88
89
  function maskValue(value) {
89
90
  if (value.length < 8)
@@ -43,6 +43,7 @@ export declare function buildFileMsgBody(params: {
43
43
  url: string;
44
44
  filename: string;
45
45
  size?: number;
46
+ uuid?: string;
46
47
  }): Array<{
47
48
  msg_type: string;
48
49
  msg_content: Record<string, unknown>;
package/dist/src/media.js CHANGED
@@ -343,7 +343,7 @@ export function buildFileMsgBody(params) {
343
343
  {
344
344
  msg_type: 'TIMFileElem',
345
345
  msg_content: {
346
- uuid: params.filename,
346
+ uuid: params.uuid ?? params.filename,
347
347
  file_name: params.filename,
348
348
  file_size: params.size ?? 0,
349
349
  url: params.url,
@@ -1,19 +1,18 @@
1
1
  import { recordPendingHistoryEntryIfEnabled, buildPendingHistoryContextFromMap, clearHistoryEntriesIfEnabled, resolveControlCommandGate, } from 'openclaw/plugin-sdk/mattermost';
2
- import { downloadMediasToLocalFiles, downloadAndUploadMedia, guessMimeType, buildImageMsgBody, buildFileMsgBody } from '../media.js';
2
+ import { downloadMediasToLocalFiles } from '../media.js';
3
3
  import { resolveOutboundSenderAccount, rewriteSlashCommand, YUANBAO_FINAL_TEXT_CHUNK_LIMIT, } from './context.js';
4
4
  import { extractTextFromMsgBody } from './extract.js';
5
- import { sendYuanbaoMessage, sendYuanbaoMessageBody, sendYuanbaoGroupMessage, sendYuanbaoGroupMessageBody, sendMsgBodyDirect, executeReply, } from './outbound.js';
5
+ import { sendYuanbaoMessage, sendYuanbaoMessageBody, sendYuanbaoGroupMessageBody, executeReply, } from './outbound.js';
6
6
  import { buildOutboundMsgBody, prepareOutboundContent } from './handlers/index.js';
7
7
  import { parseQuoteFromCloudCustomData, formatQuoteContext } from './quote.js';
8
8
  import { getMember } from '../module/member.js';
9
- import { createLog, logger } from '../logger.js';
9
+ import { createLog } from '../logger.js';
10
10
  import { getOutboundQueue } from '../outbound-queue.js';
11
11
  import { UPGRADE_COMMAND_NAMES } from '../commands/upgrade.js';
12
- import { sendStickerYuanbao } from '../sticker/sticker-sender.js';
13
- import { getCachedSticker } from '../sticker/sticker-cache.js';
14
12
  import { dispatchSystemCallback } from './system-callbacks.js';
15
13
  import { chatHistories, chatMediaHistories, recordMediaHistory, } from './chat-history.js';
16
14
  import './callbacks/recall.js';
15
+ import { setGroupCode } from '../targets.js';
17
16
  const conversationQueues = new Map();
18
17
  function enqueueForConversation(key, task) {
19
18
  const prev = conversationQueues.get(key) ?? Promise.resolve();
@@ -114,6 +113,7 @@ async function handleC2CMessage(params) {
114
113
  const { core, config, account } = ctx;
115
114
  if (msg.private_from_group_code) {
116
115
  ctx.groupCode = msg.private_from_group_code;
116
+ setGroupCode(ctx.groupCode);
117
117
  }
118
118
  const fromAccount = msg.from_account?.trim() || 'unknown';
119
119
  const senderNickname = msg.sender_nickname?.trim() || undefined;
@@ -125,7 +125,6 @@ async function handleC2CMessage(params) {
125
125
  }
126
126
  const { rawBody, medias } = extractTextFromMsgBody(ctx, msg.msg_body);
127
127
  log.info(`收到消息 <- ${fromAccount}${senderNickname ? `(${senderNickname})` : ''}, msgKey: ${msg.msg_key}`);
128
- log.debug('消息内容', { user_input: rawBody });
129
128
  const quoteInfo = parseQuoteFromCloudCustomData(msg.cloud_custom_data);
130
129
  if (quoteInfo) {
131
130
  log.info(`检测到引用消息, 引用来自: ${quoteInfo.sender_nickname || quoteInfo.sender_id || 'unknown'}`);
@@ -249,11 +248,7 @@ async function handleC2CMessage(params) {
249
248
  log.error('failed updating session meta', { error: String(err) });
250
249
  },
251
250
  });
252
- const tableMode = core.channel.text.resolveMarkdownTableMode({
253
- cfg: config,
254
- channel: 'yuanbao',
255
- accountId: account.accountId,
256
- });
251
+ const tableMode = 'off';
257
252
  const finalTextChunkLimit = core.channel.text.resolveTextChunkLimit(config, 'yuanbao', account.accountId, {
258
253
  fallbackLimit: YUANBAO_FINAL_TEXT_CHUNK_LIMIT,
259
254
  });
@@ -285,38 +280,6 @@ async function handleC2CMessage(params) {
285
280
  const msgId = msg.msg_id ?? String(msg.msg_seq ?? '');
286
281
  const queueManager = getOutboundQueue(account.accountId);
287
282
  if (queueManager) {
288
- const sendMediaOverride = async (url, fallbackText, mediaType, mediaLocalRoots) => {
289
- if (mediaType === 'sticker') {
290
- const sticker = getCachedSticker(url);
291
- if (!sticker) {
292
- return { ok: false, error: `sticker not found: ${url}` };
293
- }
294
- return sendStickerYuanbao({
295
- account,
296
- config,
297
- wsClient: ctx.wsClient,
298
- toAccount: fromAccount,
299
- sticker,
300
- core,
301
- });
302
- }
303
- try {
304
- logger.info('sendMediaOverride', { url, mediaLocalRoots });
305
- const uploadResult = await downloadAndUploadMedia(url, core, account, mediaLocalRoots);
306
- const mime = guessMimeType(uploadResult.filename);
307
- const msgBody = mime.startsWith('image/')
308
- ? buildImageMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid, imageInfo: uploadResult.imageInfo })
309
- : buildFileMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size });
310
- const result = await sendMsgBodyDirect({ account, config, target: fromAccount, msgBody: msgBody, wsClient: ctx.wsClient, core });
311
- return { ok: result.ok, error: result.error };
312
- }
313
- catch (err) {
314
- const errMsg = err instanceof Error ? err.message : String(err);
315
- log.error(`sendMediaOverride 失败: ${errMsg}`);
316
- const fallback = fallbackText ? `${fallbackText}\n${url}` : url;
317
- return sendYuanbaoMessage({ account, toAccount: fromAccount, text: fallback, fromAccount: outboundSender, ctx });
318
- }
319
- };
320
283
  queueManager.registerSession(outboundSessionKey, {
321
284
  msgId,
322
285
  chatType: 'c2c',
@@ -325,7 +288,6 @@ async function handleC2CMessage(params) {
325
288
  toAccount: fromAccount,
326
289
  fromAccount: outboundSender,
327
290
  ctx,
328
- sendMediaOverride,
329
291
  mergeOnFlush: account.disableBlockStreaming,
330
292
  });
331
293
  log.debug(`[${outboundSessionKey}] 出站队列 session 已注册,msgId: ${msgId}`);
@@ -356,19 +318,13 @@ async function handleGroupMessage(params) {
356
318
  const senderNickname = msg.sender_nickname?.trim() || undefined;
357
319
  const outboundSender = resolveOutboundSenderAccount(account);
358
320
  const glog = createLog('inbound', ctx.log);
321
+ setGroupCode(groupCode);
359
322
  if (outboundSender && fromAccount === outboundSender) {
360
323
  glog.info('跳过机器人自身消息', { groupCode, fromAccount });
361
324
  return;
362
325
  }
363
326
  const { rawBody, isAtBot, medias, mentions } = extractTextFromMsgBody(ctx, msg.msg_body);
364
- glog.info('收到群消息', {
365
- user_input: rawBody,
366
- groupCode,
367
- fromAccount,
368
- senderNickname,
369
- msgSeq: msg.msg_seq,
370
- isAtBot,
371
- });
327
+ glog.info(`收到群消息 <- group:${groupCode}, from: ${fromAccount}${senderNickname ? `(${senderNickname})` : ''}, msgSeq: ${msg.msg_seq}, isAtBot: ${isAtBot}`);
372
328
  const quoteInfo = parseQuoteFromCloudCustomData(msg.cloud_custom_data);
373
329
  if (quoteInfo) {
374
330
  glog.info(`群消息检测到引用消息, 引用来自: ${quoteInfo.sender_nickname || quoteInfo.sender_id || 'unknown'}`);
@@ -566,11 +522,7 @@ async function handleGroupMessage(params) {
566
522
  glog.error('failed updating group session meta', { error: String(err) });
567
523
  },
568
524
  });
569
- const tableMode = core.channel.text.resolveMarkdownTableMode({
570
- cfg: config,
571
- channel: 'yuanbao',
572
- accountId: account.accountId,
573
- });
525
+ const tableMode = 'off';
574
526
  const finalTextChunkLimit = core.channel.text.resolveTextChunkLimit(config, 'yuanbao', account.accountId, {
575
527
  fallbackLimit: YUANBAO_FINAL_TEXT_CHUNK_LIMIT,
576
528
  });
@@ -609,48 +561,6 @@ async function handleGroupMessage(params) {
609
561
  const groupMsgId = msg.msg_id ?? String(msg.msg_seq ?? '');
610
562
  const groupQueueManager = getOutboundQueue(account.accountId);
611
563
  if (groupQueueManager) {
612
- const groupSendMediaOverride = async (url, fallbackText, mediaType, mediaLocalRoots) => {
613
- if (mediaType === 'sticker') {
614
- const sticker = await getCachedSticker(url);
615
- if (!sticker) {
616
- return { ok: false, error: `sticker not found: ${url}` };
617
- }
618
- return sendStickerYuanbao({ account, config, wsClient: ctx.wsClient, toAccount: `group:${groupCode}`, sticker, refMsgId, core });
619
- }
620
- try {
621
- logger.info('groupSendMediaOverride', { url, mediaLocalRoots });
622
- const uploadResult = await downloadAndUploadMedia(url, core, account, mediaLocalRoots);
623
- const mime = guessMimeType(uploadResult.filename);
624
- const msgBody = mime.startsWith('image/')
625
- ? buildImageMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid, imageInfo: uploadResult.imageInfo })
626
- : buildFileMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size });
627
- const result = await sendMsgBodyDirect({
628
- account,
629
- config,
630
- target: `group:${groupCode}`,
631
- msgBody: msgBody,
632
- wsClient: ctx.wsClient,
633
- core,
634
- refMsgId,
635
- refFromAccount: fromAccount,
636
- });
637
- return { ok: result.ok, error: result.error };
638
- }
639
- catch (err) {
640
- const errMsg = err instanceof Error ? err.message : String(err);
641
- glog.error(`群 sendMediaOverride 失败: ${errMsg}`);
642
- const fallback = fallbackText ? `${fallbackText}\n${url}` : url;
643
- return sendYuanbaoGroupMessage({
644
- account,
645
- groupCode,
646
- text: fallback,
647
- fromAccount: outboundSender,
648
- refMsgId,
649
- refFromAccount: fromAccount,
650
- ctx,
651
- });
652
- }
653
- };
654
564
  groupQueueManager.registerSession(outboundGroupSessionKey, {
655
565
  msgId: groupMsgId,
656
566
  chatType: 'group',
@@ -661,7 +571,6 @@ async function handleGroupMessage(params) {
661
571
  refMsgId,
662
572
  refFromAccount: fromAccount,
663
573
  ctx,
664
- sendMediaOverride: groupSendMediaOverride,
665
574
  mergeOnFlush: account.disableBlockStreaming,
666
575
  });
667
576
  glog.debug(`[${outboundGroupSessionKey}] 群出站队列 session 已注册,msgId: ${groupMsgId}`);
@@ -5,6 +5,7 @@ import { getMember } from '../module/member.js';
5
5
  import { YUANBAO_OVERFLOW_NOTICE_TEXT, } from './context.js';
6
6
  import { getOutboundQueue } from '../outbound-queue.js';
7
7
  import { InMemoryTtlDb } from '../utils/ttl-db.js';
8
+ import { ChatType, getGroupCode, parseTarget } from '../targets.js';
8
9
  const firstReplyRefDb = new InMemoryTtlDb({
9
10
  ttlMs: 60 * 1000,
10
11
  maxKeys: 100,
@@ -104,37 +105,32 @@ export async function sendYuanbaoGroupMessage(params) {
104
105
  }
105
106
  export async function sendMsgBodyDirect(params) {
106
107
  const { account, config, target, msgBody, wsClient, core, refMsgId, refFromAccount } = params;
107
- if (target?.startsWith('group:')) {
108
- const groupCode = target.slice('group:'.length);
108
+ const { chatType, target: targetId } = parseTarget(target, account.accountId);
109
+ const minCtx = {
110
+ account,
111
+ config,
112
+ core,
113
+ log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
114
+ wsClient,
115
+ groupCode: getGroupCode(),
116
+ };
117
+ if (chatType === ChatType.GROUP) {
109
118
  return sendYuanbaoGroupMessageBody({
110
119
  account,
111
- groupCode,
120
+ groupCode: targetId,
112
121
  msgBody,
113
122
  fromAccount: account.botId,
114
123
  refMsgId,
115
124
  refFromAccount,
116
- ctx: {
117
- account,
118
- config,
119
- core,
120
- log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
121
- wsClient,
122
- },
125
+ ctx: minCtx,
123
126
  });
124
127
  }
125
- const toAccount = target?.startsWith('direct:') ? target.slice('direct:'.length) : target;
126
128
  return sendYuanbaoMessageBody({
127
129
  account,
128
- toAccount,
130
+ toAccount: targetId,
129
131
  msgBody,
130
132
  fromAccount: account.botId,
131
- ctx: {
132
- account,
133
- config,
134
- core,
135
- log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
136
- wsClient,
137
- },
133
+ ctx: minCtx,
138
134
  });
139
135
  }
140
136
  export async function executeReply(params) {
@@ -151,7 +147,7 @@ export async function executeReply(params) {
151
147
  : null;
152
148
  const collectedTexts = [];
153
149
  let hasFinalInfo = false;
154
- let hasQueuedContent = false;
150
+ let prevDeliverKind = null;
155
151
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
156
152
  ctx: ctxPayload,
157
153
  cfg: replyRuntime.config,
@@ -170,22 +166,24 @@ export async function executeReply(params) {
170
166
  rlog.warn(`[${account.accountId}] 回复已中止,停止处理后续回复块`);
171
167
  return;
172
168
  }
169
+ rlog.info('[OpenClaw] 收到回复数据', { kind: info.kind, model_output: payload.text });
173
170
  if (hasFinalInfo) {
174
171
  rlog.warn(`[${account.accountId}] 出现多次final回复,忽略后续回复 ${payload.text}`);
175
172
  }
176
173
  if (info.kind === 'final') {
177
174
  hasFinalInfo = true;
178
175
  }
176
+ const prevKind = prevDeliverKind;
177
+ prevDeliverKind = info.kind;
179
178
  const text = core.channel.text.convertMarkdownTables(payload.text ?? '', tableMode);
180
179
  if (session) {
181
180
  if (text.trim()) {
182
- hasQueuedContent = true;
183
- await session.push({ type: 'text', text });
181
+ const isAfterToolCall = info.kind === 'block' && prevKind !== null && prevKind !== 'block';
182
+ await session.push({ type: 'text', text: isAfterToolCall ? `\n\n${text}` : text });
184
183
  }
185
184
  const mediaUrls = payload.mediaUrls ?? [];
186
185
  for (const mediaUrl of mediaUrls) {
187
186
  if (mediaUrl) {
188
- hasQueuedContent = true;
189
187
  await session.push({ type: 'media', mediaUrl });
190
188
  }
191
189
  }
@@ -206,8 +204,8 @@ export async function executeReply(params) {
206
204
  },
207
205
  });
208
206
  if (session) {
209
- await session.flush();
210
- if (!hasQueuedContent) {
207
+ const hasSentContent = await session.flush();
208
+ if (!hasSentContent) {
211
209
  const { fallbackReply } = account;
212
210
  if (fallbackReply) {
213
211
  rlog.info(`[${L}] AI 未返回回复内容(队列模式),使用兜底回复`);
@@ -1,6 +1,8 @@
1
1
  import { searchStickers } from '../sticker/sticker-cache.js';
2
2
  import { getOutboundQueue } from '../outbound-queue.js';
3
3
  import { logger } from '../logger.js';
4
+ import { getYuanbaoRuntime } from '../runtime.js';
5
+ import { getGroupCode, parseTarget } from '../targets.js';
4
6
  function normalizeStickerSearchQuery(params) {
5
7
  const raw = params.query ?? params.keyword ?? params.q ?? params.text ?? params.search;
6
8
  if (typeof raw === 'string') {
@@ -47,14 +49,29 @@ export async function handleYuanbaoAction(action, params, context) {
47
49
  return { ok: false, error: 'sticker_id is required' };
48
50
  }
49
51
  const queueManager = getOutboundQueue(context.account.accountId);
50
- const toAccount = typeof params.to === 'string' && params.to.trim() ? params.to.trim() : context.toAccount;
51
- const safeToAccount = toAccount.replace(/^user:/, 'direct:');
52
- const session = queueManager?.getSession(safeToAccount);
52
+ const to = typeof params.to === 'string' && params.to.trim() ? params.to.trim() : context.toAccount;
53
+ const { chatType, target, sessionKey } = parseTarget(to, context.account.accountId);
54
+ const minCtx = {
55
+ account: context.account,
56
+ config: context.config,
57
+ core: getYuanbaoRuntime(),
58
+ log: { info: () => { }, warn: () => { }, error: () => { }, verbose: () => { } },
59
+ wsClient: context.wsClient,
60
+ groupCode: getGroupCode(),
61
+ };
62
+ const session = queueManager?.getOrCreateSession(sessionKey, {
63
+ chatType,
64
+ account: context.account,
65
+ target,
66
+ fromAccount: context.account.botId,
67
+ ctx: minCtx,
68
+ });
53
69
  if (!session) {
54
70
  logger.debug(`sendMedia: 未找到已有 session,基于 to 创建新 session 作为 fallback: ${context.toAccount}`);
55
71
  return { ok: false, error: `未找到已有 session,基于 to 创建新 session 作为 fallback: ${context.toAccount}` };
56
72
  }
57
73
  await session.push({ type: 'sticker', sticker_id: stickerId, text: '' });
74
+ await session.flush();
58
75
  return { ok: true };
59
76
  }
60
77
  default:
@@ -3,5 +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
9
  ];
7
10
  }
@@ -24,11 +24,15 @@ export type SendMediaFn = (mediaUrl: string, text?: string, mediaType?: 'image'
24
24
  ok: boolean;
25
25
  error?: string;
26
26
  }>;
27
+ export type SendStickerFn = (stickerId: string, text?: string) => Promise<{
28
+ ok: boolean;
29
+ error?: string;
30
+ }>;
27
31
  interface OutboundQueueSession {
28
32
  readonly strategy: OutboundQueueStrategy;
29
33
  readonly msgId: string;
30
34
  push(item: OutboundQueueItem): Promise<void>;
31
- flush(): Promise<void>;
35
+ flush(): Promise<boolean>;
32
36
  abort(): void;
33
37
  emitReplyHeartbeat(heartbeat: WsHeartbeatValue): void;
34
38
  }
@@ -42,13 +46,20 @@ interface RegisterSessionOptions {
42
46
  ctx: MessageHandlerContext;
43
47
  msgId: string;
44
48
  toAccount?: string;
45
- sendMediaOverride?: SendMediaFn;
46
49
  mergeOnFlush?: boolean;
47
50
  }
51
+ export interface LightRegisterSessionOptions {
52
+ chatType: 'c2c' | 'group';
53
+ account: ResolvedYuanbaoAccount;
54
+ target: string;
55
+ fromAccount?: string;
56
+ ctx: MessageHandlerContext;
57
+ }
48
58
  interface OutboundQueueManager {
49
59
  readonly strategy: OutboundQueueStrategy;
50
60
  registerSession(sessionKey: string, options: RegisterSessionOptions): OutboundQueueSession;
51
61
  getSession(sessionKey: string): OutboundQueueSession | null;
62
+ getOrCreateSession(sessionKey: string, options: LightRegisterSessionOptions): OutboundQueueSession;
52
63
  unregisterSession(sessionKey: string): void;
53
64
  }
54
65
  export interface OutboundQueueConfig {
@@ -60,6 +71,7 @@ export interface OutboundQueueConfig {
60
71
  export declare function initOutboundQueue(accountId: string, config: OutboundQueueConfig): OutboundQueueManager;
61
72
  export declare function getOutboundQueue(accountId: string): OutboundQueueManager | null;
62
73
  export declare function destroyOutboundQueue(accountId: string): void;
74
+ export declare function endsWithTableRow(text: string): boolean;
63
75
  export declare function hasUnclosedFence(text: string): boolean;
64
76
  export declare function mergeBlockStreamingFences(buffer: string, incoming: string): string;
65
77
  export interface MergeTextOptions {
@@ -69,6 +81,7 @@ export interface MergeTextOptions {
69
81
  }
70
82
  declare function createMergeTextSession(callbacks: {
71
83
  sendText: SendTextFn;
84
+ sendSticker: SendStickerFn;
72
85
  sendMedia: SendMediaFn;
73
86
  }, msgId: string, sessionKey: string, onComplete: () => void, log: ModuleLog, opts: MergeTextOptions, heartbeatMeta: {
74
87
  ctx: MessageHandlerContext;
@@ -1,5 +1,9 @@
1
1
  import { createLog } from './logger.js';
2
- import { sendYuanbaoMessage, sendYuanbaoGroupMessage } from './message-handler/outbound.js';
2
+ import { sendYuanbaoMessage, sendYuanbaoGroupMessage, sendMsgBodyDirect } from './message-handler/outbound.js';
3
+ import { getCachedSticker } from './sticker/sticker-cache.js';
4
+ import { sendStickerYuanbao } from './sticker/sticker-sender.js';
5
+ import { getYuanbaoRuntime } from './runtime.js';
6
+ import { buildFileMsgBody, buildImageMsgBody, downloadAndUploadMedia, guessMimeType } from './media.js';
3
7
  import { createReplyHeartbeatController } from './module/reply-heartbeat.js';
4
8
  const activeManagers = new Map();
5
9
  export function initOutboundQueue(accountId, config) {
@@ -31,12 +35,13 @@ function defaultChunkText(text, max) {
31
35
  function createManager(config) {
32
36
  const { strategy } = config;
33
37
  const mergeTextOpts = {
34
- minChars: config.minChars ?? 2500,
38
+ minChars: config.minChars ?? 2800,
35
39
  maxChars: config.maxChars ?? 3000,
36
40
  chunkText: config.chunkText ?? defaultChunkText,
37
41
  };
38
42
  const sessions = new Map();
39
43
  const log = createLog('outbound-queue');
44
+ const core = getYuanbaoRuntime();
40
45
  return {
41
46
  strategy,
42
47
  registerSession(sessionKey, options) {
@@ -46,10 +51,11 @@ function createManager(config) {
46
51
  existing.abort();
47
52
  }
48
53
  const onComplete = () => sessions.delete(sessionKey);
49
- const { chatType, account, target, fromAccount, refMsgId, refFromAccount, ctx, msgId, sendMediaOverride, mergeOnFlush, toAccount, } = options;
54
+ const { chatType, account, target, fromAccount, refMsgId, refFromAccount, ctx, msgId, mergeOnFlush, toAccount, } = options;
50
55
  const heartbeatTarget = (toAccount ?? (chatType === 'c2c' ? target : '')).trim();
51
56
  const heartbeatGroupCode = chatType === 'group' ? target : undefined;
52
57
  const sendText = async (text) => {
58
+ log.info('sendText', { chatType, fromAccount, target });
53
59
  try {
54
60
  const result = chatType === 'group'
55
61
  ? await sendYuanbaoGroupMessage({
@@ -65,16 +71,82 @@ function createManager(config) {
65
71
  return { ok: result.ok, error: result.error };
66
72
  }
67
73
  catch (err) {
68
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
74
+ const errMsg = err instanceof Error ? err.message : String(err);
75
+ log.error(`sendText failed: ${errMsg}`, { chatType, fromAccount, target });
76
+ return { ok: false, error: errMsg };
77
+ }
78
+ };
79
+ const sendSticker = async (id) => {
80
+ log.info('sendSticker', { chatType, fromAccount, target });
81
+ try {
82
+ const sticker = getCachedSticker(id);
83
+ if (!sticker) {
84
+ return { ok: false, error: `sticker not found: ${id}` };
85
+ }
86
+ const result = await sendStickerYuanbao({
87
+ account,
88
+ config,
89
+ wsClient: ctx.wsClient,
90
+ toAccount: chatType === 'group' ? `group:${target}` : target,
91
+ sticker,
92
+ core,
93
+ refMsgId,
94
+ });
95
+ return { ok: result.ok, error: result.error };
96
+ }
97
+ catch (e) {
98
+ const errMsg = e instanceof Error ? e.message : String(e);
99
+ log.error(`sendSticker failed: ${errMsg}`, { chatType, fromAccount, target });
100
+ return { ok: false, error: errMsg };
101
+ }
102
+ };
103
+ const sendMedia = async (url, fallbackText) => {
104
+ log.info('sendMedia', { chatType, fromAccount, target });
105
+ const isGroup = chatType === 'group';
106
+ try {
107
+ const uploadResult = await downloadAndUploadMedia(url, core, account);
108
+ const mime = guessMimeType(uploadResult.filename);
109
+ const msgBody = mime.startsWith('image/')
110
+ ? buildImageMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid, imageInfo: uploadResult.imageInfo })
111
+ : buildFileMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid });
112
+ const result = await sendMsgBodyDirect({
113
+ account,
114
+ config,
115
+ target: isGroup ? `group:${target}` : target,
116
+ msgBody: msgBody,
117
+ wsClient: ctx.wsClient,
118
+ core,
119
+ ...(isGroup ? { refMsgId, refFromAccount } : {}),
120
+ });
121
+ return { ok: result.ok, error: result.error };
122
+ }
123
+ catch (err) {
124
+ const errMsg = err instanceof Error ? err.message : String(err);
125
+ log.error(`sendMedia failed: ${errMsg}`, { chatType, fromAccount, target });
126
+ const fallback = fallbackText ? `${fallbackText}\n${url}` : url;
127
+ return isGroup
128
+ ? sendYuanbaoGroupMessage({
129
+ account,
130
+ groupCode: target,
131
+ text: fallback,
132
+ fromAccount: fromAccount || account.botId,
133
+ refMsgId,
134
+ refFromAccount,
135
+ ctx,
136
+ })
137
+ : sendYuanbaoMessage({
138
+ account,
139
+ toAccount: target,
140
+ text: fallback,
141
+ fromAccount: fromAccount || account.botId,
142
+ ...(isGroup ? { refMsgId, refFromAccount } : {}),
143
+ ctx,
144
+ });
69
145
  }
70
146
  };
71
- const sendMedia = sendMediaOverride ?? (async (mediaUrl, text) => {
72
- const fallbackText = text ? `${text}\n${mediaUrl}` : mediaUrl;
73
- return sendText(fallbackText);
74
- });
75
147
  let session;
76
148
  if (mergeOnFlush) {
77
- session = createMergeOnFlushSession({ sendText, sendMedia }, msgId, onComplete, log, {
149
+ session = createMergeOnFlushSession({ sendText, sendSticker, sendMedia }, msgId, onComplete, log, {
78
150
  ctx,
79
151
  account,
80
152
  toAccount: heartbeatTarget,
@@ -84,7 +156,7 @@ function createManager(config) {
84
156
  else {
85
157
  switch (strategy) {
86
158
  case 'immediate':
87
- session = createImmediateSession({ sendText, sendMedia }, msgId, onComplete, log, {
159
+ session = createImmediateSession({ sendText, sendSticker, sendMedia }, msgId, onComplete, log, {
88
160
  ctx,
89
161
  account,
90
162
  toAccount: heartbeatTarget,
@@ -92,7 +164,7 @@ function createManager(config) {
92
164
  });
93
165
  break;
94
166
  case 'merge-text':
95
- session = createMergeTextSession({ sendText, sendMedia }, msgId, sessionKey, onComplete, log, mergeTextOpts, {
167
+ session = createMergeTextSession({ sendText, sendSticker, sendMedia }, msgId, sessionKey, onComplete, log, mergeTextOpts, {
96
168
  ctx,
97
169
  account,
98
170
  toAccount: heartbeatTarget,
@@ -110,15 +182,27 @@ function createManager(config) {
110
182
  getSession(sessionKey) {
111
183
  return sessions.get(sessionKey) ?? null;
112
184
  },
185
+ getOrCreateSession(sessionKey, options) {
186
+ log.info(`[${sessionKey}:${options.target}] 获取或创建会话队列`);
187
+ const existing = sessions.get(sessionKey);
188
+ if (existing)
189
+ return existing;
190
+ log.debug(`[${sessionKey}] 自动创建轻量 session(委托 registerSession)`);
191
+ return this.registerSession(sessionKey, {
192
+ ...options,
193
+ msgId: `auto-${Date.now()}`,
194
+ });
195
+ },
113
196
  unregisterSession(sessionKey) {
114
197
  sessions.delete(sessionKey);
115
198
  },
116
199
  };
117
200
  }
118
201
  function createImmediateSession(callbacks, msgId, onComplete, log, heartbeatMeta) {
119
- const { sendText, sendMedia } = callbacks;
202
+ const { sendText, sendSticker, sendMedia } = callbacks;
120
203
  let aborted = false;
121
204
  let sendChain = Promise.resolve();
205
+ let hasSentContent = false;
122
206
  const replyHeartbeat = createReplyHeartbeatController({ meta: heartbeatMeta });
123
207
  return {
124
208
  strategy: 'immediate',
@@ -129,23 +213,26 @@ function createImmediateSession(callbacks, msgId, onComplete, log, heartbeatMeta
129
213
  sendChain = sendChain.then(async () => {
130
214
  if (aborted)
131
215
  return;
216
+ let result;
132
217
  if (item.type === 'text') {
133
218
  if (!item.text.trim())
134
219
  return;
135
- const result = await sendText(item.text);
220
+ result = await sendText(item.text);
136
221
  if (!result.ok) {
137
222
  log.error(`immediate 发送文本失败: ${result.error}`);
138
223
  }
139
224
  else {
225
+ hasSentContent = true;
140
226
  replyHeartbeat.onReplySent();
141
227
  }
142
228
  }
143
229
  else if (item.type === 'sticker') {
144
- const result = await sendMedia(item.sticker_id, item.text, 'sticker');
230
+ const result = await sendSticker(item.sticker_id, item.text);
145
231
  if (!result.ok) {
146
232
  log.error(`immediate 发送表情失败: ${result.error}`);
147
233
  }
148
234
  else {
235
+ hasSentContent = true;
149
236
  replyHeartbeat.onReplySent();
150
237
  }
151
238
  }
@@ -155,9 +242,11 @@ function createImmediateSession(callbacks, msgId, onComplete, log, heartbeatMeta
155
242
  log.error(`immediate 发送媒体失败: ${result.error}`);
156
243
  }
157
244
  else {
245
+ hasSentContent = true;
158
246
  replyHeartbeat.onReplySent();
159
247
  }
160
248
  }
249
+ return result;
161
250
  });
162
251
  return sendChain;
163
252
  },
@@ -165,6 +254,7 @@ function createImmediateSession(callbacks, msgId, onComplete, log, heartbeatMeta
165
254
  await sendChain;
166
255
  replyHeartbeat.stop();
167
256
  onComplete();
257
+ return hasSentContent;
168
258
  },
169
259
  abort() {
170
260
  aborted = true;
@@ -177,10 +267,12 @@ function createImmediateSession(callbacks, msgId, onComplete, log, heartbeatMeta
177
267
  };
178
268
  }
179
269
  function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatMeta) {
180
- const { sendText, sendMedia } = callbacks;
270
+ const { sendText, sendSticker, sendMedia } = callbacks;
181
271
  let aborted = false;
182
272
  const collectedTexts = [];
273
+ const collectedStickers = [];
183
274
  const collectedMedias = [];
275
+ let hasSentContent = false;
184
276
  const replyHeartbeat = createReplyHeartbeatController({ meta: heartbeatMeta });
185
277
  return {
186
278
  strategy: 'immediate',
@@ -193,7 +285,7 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
193
285
  collectedTexts.push(item.text);
194
286
  }
195
287
  else if (item.type === 'sticker') {
196
- collectedMedias.push({ mediaUrl: item.sticker_id, text: item.text, mediaType: 'sticker' });
288
+ collectedStickers.push({ stickerId: item.sticker_id, text: item.text });
197
289
  }
198
290
  else {
199
291
  collectedMedias.push({ mediaUrl: item.mediaUrl, text: item.text, mediaLocalRoots: item.mediaLocalRoots });
@@ -202,7 +294,7 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
202
294
  },
203
295
  async flush() {
204
296
  if (aborted)
205
- return;
297
+ return hasSentContent;
206
298
  if (collectedTexts.length > 0) {
207
299
  const merged = collectedTexts.join('\n\n');
208
300
  collectedTexts.length = 0;
@@ -212,10 +304,22 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
212
304
  log.error(`mergeOnFlush 发送合并文本失败: ${result.error}`);
213
305
  }
214
306
  else {
307
+ hasSentContent = true;
215
308
  replyHeartbeat.onReplySent();
216
309
  }
217
310
  }
218
311
  }
312
+ for (const sticker of collectedStickers) {
313
+ if (aborted)
314
+ break;
315
+ const result = await sendSticker(sticker.stickerId, sticker.text);
316
+ if (!result.ok) {
317
+ log.error(`mergeOnFlush 发送表情失败: ${result.error}`);
318
+ }
319
+ else {
320
+ hasSentContent = true;
321
+ }
322
+ }
219
323
  for (const media of collectedMedias) {
220
324
  if (aborted)
221
325
  break;
@@ -224,12 +328,16 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
224
328
  log.error(`mergeOnFlush 发送媒体失败: ${result.error}`);
225
329
  }
226
330
  else {
331
+ hasSentContent = true;
227
332
  replyHeartbeat.onReplySent();
228
333
  }
229
334
  }
335
+ collectedTexts.length = 0;
230
336
  collectedMedias.length = 0;
337
+ collectedStickers.length = 0;
231
338
  replyHeartbeat.stop();
232
339
  onComplete();
340
+ return hasSentContent;
233
341
  },
234
342
  abort() {
235
343
  aborted = true;
@@ -243,6 +351,14 @@ function createMergeOnFlushSession(callbacks, msgId, onComplete, log, heartbeatM
243
351
  },
244
352
  };
245
353
  }
354
+ export function endsWithTableRow(text) {
355
+ const trimmed = text.trimEnd();
356
+ if (!trimmed)
357
+ return false;
358
+ const lastLine = trimmed.split('\n').at(-1) ?? '';
359
+ const line = lastLine.trim();
360
+ return line.startsWith('|') && line.endsWith('|');
361
+ }
246
362
  export function hasUnclosedFence(text) {
247
363
  let inFence = false;
248
364
  for (const line of text.split('\n')) {
@@ -264,11 +380,12 @@ export function mergeBlockStreamingFences(buffer, incoming) {
264
380
  return `${buffer}${normalized}`;
265
381
  }
266
382
  function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, opts, heartbeatMeta) {
267
- const { sendText, sendMedia } = callbacks;
268
- const { maxChars, chunkText } = opts;
383
+ const { sendText, sendSticker, sendMedia } = callbacks;
384
+ const { minChars, maxChars, chunkText } = opts;
269
385
  let aborted = false;
270
386
  let textBuffer = '';
271
387
  let sendChain = Promise.resolve();
388
+ let hasSentContent = false;
272
389
  const replyHeartbeat = createReplyHeartbeatController({ meta: heartbeatMeta });
273
390
  async function drainBuffer(force) {
274
391
  if (textBuffer.length === 0)
@@ -280,13 +397,21 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
280
397
  log.debug(`[${sessionKey}] drainBuffer: single chunk has unclosed fence, keeping in buffer (bufLen=${textBuffer.length})`);
281
398
  return;
282
399
  }
400
+ if (!force && chunks.length === 1 && endsWithTableRow(chunks[0])) {
401
+ log.debug(`[${sessionKey}] drainBuffer: single chunk ends with table row, keeping in buffer for continuation (bufLen=${textBuffer.length})`);
402
+ return;
403
+ }
404
+ if (!force && chunks.length === 1 && textBuffer.length < minChars) {
405
+ log.debug(`[${sessionKey}] drainBuffer: bufLen=${textBuffer.length} < minChars=${minChars}, waiting for more content`);
406
+ return;
407
+ }
283
408
  textBuffer = '';
284
409
  for (const chunk of chunks) {
285
410
  if (aborted)
286
411
  return;
287
412
  if (!chunk.trim())
288
413
  continue;
289
- log.debug(`[${sessionKey}] emit chunk(force=${force}): len=${chunk.length} head=${JSON.stringify(chunk.slice(0, 5))} tail=${JSON.stringify(chunk.slice(-5))}`);
414
+ log.debug(`[${sessionKey}] emit chunk(force=${force}): len=${chunk.length} head=${JSON.stringify(chunk.slice(0, 3))} tail=${JSON.stringify(chunk.slice(-3))}`);
290
415
  const result = await sendText(chunk);
291
416
  if (!result.ok)
292
417
  log.error(`[${sessionKey}] send failed: ${result.error}`);
@@ -301,12 +426,13 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
301
426
  return;
302
427
  if (!chunk.trim())
303
428
  continue;
304
- log.debug(`[${sessionKey}] emit chunk(split): len=${chunk.length} head=${JSON.stringify(chunk.slice(0, 5))} tail=${JSON.stringify(chunk.slice(-5))}`);
429
+ log.debug(`[${sessionKey}] emit chunk(split): len=${chunk.length} head=${JSON.stringify(chunk.slice(0, 3))} tail=${JSON.stringify(chunk.slice(-3))}`);
305
430
  const result = await sendText(chunk);
306
431
  if (!result.ok) {
307
432
  log.error(`[${sessionKey}] merge-text send failed: ${result.error}`);
308
433
  }
309
434
  else {
435
+ hasSentContent = true;
310
436
  replyHeartbeat.onReplySent();
311
437
  }
312
438
  }
@@ -324,9 +450,21 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
324
450
  if (item.type === 'text') {
325
451
  if (!item.text.trim())
326
452
  return;
327
- textBuffer = textBuffer ? `${textBuffer}${item.text}` : item.text;
328
- textBuffer = textBuffer.replace(/\n`{6}([^\n]*)\n/g, '\n');
453
+ if (textBuffer) {
454
+ const incoming = hasUnclosedFence(textBuffer) && item.text.startsWith('\n\n')
455
+ ? item.text.slice(2)
456
+ : item.text;
457
+ const SENTENCE_END_RE = /[。?!:]\s*$/;
458
+ const needsSeparator = SENTENCE_END_RE.test(textBuffer)
459
+ && !hasUnclosedFence(textBuffer)
460
+ && !endsWithTableRow(textBuffer);
461
+ textBuffer = mergeBlockStreamingFences(needsSeparator ? `${textBuffer}\n\n` : textBuffer, incoming);
462
+ }
463
+ else {
464
+ textBuffer = item.text;
465
+ }
329
466
  log.debug(`[${sessionKey}] merge-text push: bufLen=${textBuffer.length}`);
467
+ hasSentContent = true;
330
468
  await drainBuffer(false);
331
469
  }
332
470
  else {
@@ -338,7 +476,7 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
338
476
  return;
339
477
  let result;
340
478
  if (item.type === 'sticker') {
341
- result = await sendMedia(item.sticker_id, item.text, 'sticker');
479
+ result = await sendSticker(item.sticker_id, item.text);
342
480
  }
343
481
  else {
344
482
  result = await sendMedia(item.mediaUrl, item.text, 'image', item.mediaLocalRoots);
@@ -347,6 +485,7 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
347
485
  log.error(`[${sessionKey}] merge-text send failed: ${result.error}`);
348
486
  }
349
487
  else {
488
+ hasSentContent = true;
350
489
  replyHeartbeat.onReplySent();
351
490
  }
352
491
  }
@@ -357,10 +496,11 @@ function createMergeTextSession(callbacks, msgId, sessionKey, onComplete, log, o
357
496
  log.debug(`[${sessionKey}] merge-text session flush: bufLen=${textBuffer.length}`);
358
497
  await sendChain;
359
498
  if (aborted)
360
- return;
499
+ return hasSentContent;
361
500
  await drainBuffer(true);
362
501
  replyHeartbeat.stop();
363
502
  onComplete();
503
+ return hasSentContent;
364
504
  },
365
505
  abort() {
366
506
  const bufLen = textBuffer.length;
@@ -0,0 +1,13 @@
1
+ export declare function setGroupCode(code: string): void;
2
+ export declare function getGroupCode(): string;
3
+ export declare function looksLikeYuanbaoId(raw: string): boolean;
4
+ export declare enum ChatType {
5
+ C2C = "c2c",
6
+ GROUP = "group"
7
+ }
8
+ export interface MessagingTarget {
9
+ chatType: ChatType;
10
+ target: string;
11
+ sessionKey: string;
12
+ }
13
+ export declare function parseTarget(to: string, accountId?: string): MessagingTarget;
@@ -0,0 +1,47 @@
1
+ import { resolveUsername } from './dm/directory.js';
2
+ let groupCode = '';
3
+ export function setGroupCode(code) {
4
+ groupCode = code;
5
+ }
6
+ export function getGroupCode() {
7
+ if (!groupCode) {
8
+ throw new Error('GroupCode not initialized');
9
+ }
10
+ return groupCode;
11
+ }
12
+ export function looksLikeYuanbaoId(raw) {
13
+ const trimmed = raw.trim();
14
+ if (trimmed.length < 16) {
15
+ return false;
16
+ }
17
+ if (trimmed.length % 4 !== 0) {
18
+ return false;
19
+ }
20
+ if (!/^[A-Za-z0-9+/=]+$/.test(trimmed)) {
21
+ return false;
22
+ }
23
+ const equalsIndex = trimmed.indexOf('=');
24
+ if (equalsIndex !== -1) {
25
+ if (equalsIndex < trimmed.length - 2 || trimmed.slice(equalsIndex).match(/[^=]/)) {
26
+ return false;
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+ export var ChatType;
32
+ (function (ChatType) {
33
+ ChatType["C2C"] = "c2c";
34
+ ChatType["GROUP"] = "group";
35
+ })(ChatType || (ChatType = {}));
36
+ export function parseTarget(to, accountId = 'default') {
37
+ to = to.trim().replace(/^yuanbao:/, '');
38
+ if (to.startsWith('group:')) {
39
+ return { chatType: ChatType.GROUP, target: to.slice('group:'.length), sessionKey: to };
40
+ }
41
+ to = to.replace(/^user:/, '').replace(/^direct:/, '');
42
+ if (!looksLikeYuanbaoId(to)) {
43
+ const { userId } = resolveUsername(to, accountId, groupCode) || { userId: to };
44
+ return { chatType: ChatType.C2C, target: userId, sessionKey: `direct:${userId}` };
45
+ }
46
+ return { chatType: ChatType.C2C, target: to, sessionKey: `direct:${to}` };
47
+ }
@@ -10,6 +10,7 @@ const USER_TYPE_LABEL = {
10
10
  function toMembers(records) {
11
11
  return records.map(u => ({
12
12
  nickname: u.nickName,
13
+ userId: u.userId,
13
14
  ...(u.userType !== undefined ? { role: USER_TYPE_LABEL[u.userType] ?? `type_${u.userType}` } : {}),
14
15
  }));
15
16
  }
@@ -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.3.0",
5
+ "version": "2.4.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.3.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "description": "Tencent YuanBao intelligent bot channel plugin",
6
6
  "license": "MIT",