openclaw-plugin-yuanbao 2.2.0 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # openclaw-plugin-yuanbao
2
+
3
+ [![npm version](https://img.shields.io/npm/v/openclaw-plugin-yuanbao.svg)](https://www.npmjs.com/package/openclaw-plugin-yuanbao)
4
+
5
+ 腾讯元宝智能机器人频道插件,让你的 OpenClaw 机器人能够接入元宝 Bot 通道,支持私聊和群聊。
6
+
7
+ ## ✨ 功能特性
8
+
9
+ | 能力 | 描述 |
10
+ |------|------|
11
+ | 💬 群聊互动 | 在元宝派群组中,成员 @元宝Bot 即可触发 AI 回复 |
12
+ | 🧠 长期记忆 | 记住与创建者的历史对话,越聊越懂你 |
13
+ | ⏰ 随时在线 | 云端部署,7×24 小时在线服务派友 |
14
+ | 🔍 联网搜索 | 自动接入 Web Search,回答实时信息 |
15
+
16
+ ## 🚀 快速开始
17
+
18
+ ### 1. 安装插件
19
+
20
+ ```bash
21
+ openclaw plugins install openclaw-plugin-yuanbao
22
+ ```
23
+
24
+ ### 2. 配置通道
25
+
26
+ ```bash
27
+ openclaw channels add
28
+ ```
29
+
30
+ 根据提示输入:
31
+ - **AppID**: 元宝 APP 的 APP ID
32
+ - **AppSecret**: 元宝 APP 的密钥
33
+
34
+ ### 3. 开始使用
35
+
36
+ 配置完成后,用户可以:
37
+ - **私聊** - 直接向机器人发送消息
38
+ - **群聊** - @机器人 或回复机器人消息触发对话
39
+
40
+ ## 🛠️ Bot 常用命令
41
+
42
+ ```
43
+ /yuanbaobot-upgrade # 升级元宝插件(需机器人主人权限)
44
+ /issue-log # 提交问题日志
45
+ ```
46
+
47
+ ## ❓ 常见问题
48
+
49
+ ### 连接失败
50
+ - 检查 `AppID` 和 `AppSecret` 是否正确
51
+
52
+ ## 📚 相关文档
53
+ - [元宝官网](https://yuanbao.tencent.com)
54
+
55
+ ## 🔧 系统要求
56
+
57
+ - OpenClaw >= 2026.3.22
58
+ - Node.js >= 18
@@ -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.2.0",
5
+ "version": "2.3.1",
6
6
  "channels": [
7
7
  "yuanbao"
8
8
  ],
@@ -9,7 +9,7 @@ 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
11
  import { parseTarget, sendDM, } from './dm/index.js';
12
- import { yuanbaoMessageActions } from './message-tool/index.js';
12
+ import { buildMessageToolHints, yuanbaoMessageActions } from './message-tool/index.js';
13
13
  function toChannelResult(result) {
14
14
  return {
15
15
  channel: 'yuanbao',
@@ -149,6 +149,18 @@ export const yuanbaoPlugin = {
149
149
  hint: '<userid> or group:<groupcode>',
150
150
  },
151
151
  },
152
+ agentPrompt: {
153
+ messageToolHints() {
154
+ return buildMessageToolHints();
155
+ },
156
+ },
157
+ streaming: {
158
+ blockStreamingChunkMaxChars: 3000,
159
+ blockStreamingCoalesceDefaults: {
160
+ minChars: 2500,
161
+ idleMs: 15000,
162
+ },
163
+ },
152
164
  outbound: {
153
165
  deliveryMode: 'direct',
154
166
  chunkerMode: 'markdown',
@@ -185,10 +197,11 @@ export const yuanbaoPlugin = {
185
197
  }
186
198
  return toChannelResult(await sendTextToTarget(account, to, text, wsClient));
187
199
  },
188
- sendMedia: async ({ cfg, accountId, to: _to, mediaUrl, text }) => {
200
+ sendMedia: async ({ cfg, accountId, to: _to, mediaUrl, text, mediaLocalRoots }) => {
189
201
  const to = _to.replace(/^yuanbao:/, '');
190
202
  const account = resolveYuanbaoAccount({ cfg, accountId: accountId ?? undefined });
191
203
  const wsClient = getActiveWsClient(account.accountId);
204
+ logger.info('sendMedia', { to, mediaUrl, text, accountId });
192
205
  if (!wsClient) {
193
206
  return { channel: 'yuanbao', ok: false, messageId: '', error: new Error(`WebSocket client not connected for account ${account.accountId}`) };
194
207
  }
@@ -201,15 +214,12 @@ export const yuanbaoPlugin = {
201
214
  const session = queueManager.getSession(safeTo);
202
215
  if (session) {
203
216
  await session.push({ type: 'text', text });
204
- await session.push({ type: 'media', mediaUrl });
217
+ await session.push({ type: 'media', mediaUrl, mediaLocalRoots });
205
218
  return { channel: 'yuanbao', ok: true, messageId: '' };
206
219
  }
207
220
  logger.debug(`sendMedia: 未找到已有 session, to: ${to}`);
208
221
  }
209
- if (text?.trim()) {
210
- return toChannelResult(await sendTextToTarget(account, to, text, wsClient));
211
- }
212
- return { channel: 'yuanbao', ok: false, messageId: '' };
222
+ return { channel: 'yuanbao', ok: false, messageId: '', error: new Error('No session found') };
213
223
  },
214
224
  },
215
225
  status: {
@@ -269,12 +279,10 @@ export const yuanbaoPlugin = {
269
279
  const strategy = cfg.outboundQueueStrategy === 'immediate' ? 'immediate' : 'merge-text';
270
280
  initOutboundQueue(account.accountId, {
271
281
  strategy,
272
- minChars: cfg.minChars,
273
282
  maxChars: cfg.maxChars,
274
- idleMs: cfg.idleMs,
275
283
  chunkText: (text, limit) => getYuanbaoRuntime().channel.text.chunkMarkdownText(text, limit),
276
284
  });
277
- slog.info(`[${account.accountId}] 出站队列已初始化,策略: ${strategy},minChars: ${cfg.minChars ?? 2500}, maxChars: ${cfg.maxChars ?? 3000}, idleMs: ${cfg.idleMs ?? 5000}`);
285
+ slog.info(`[${account.accountId}] 出站队列已初始化,策略: ${strategy},maxChars: ${cfg.maxChars ?? 3000}`);
278
286
  return startYuanbaoWsGateway({
279
287
  account,
280
288
  config: ctx.cfg,
@@ -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 });
@@ -58,7 +58,7 @@ export const yuanbaoConfigSchema = {
58
58
  title: '空闲自动发送超时 (ms)',
59
59
  description: 'merge-text 策略下,超过该时长无新内容时自动发送缓冲区',
60
60
  minimum: 0,
61
- default: 5000,
61
+ default: 15000,
62
62
  },
63
63
  mediaMaxMb: {
64
64
  type: 'number',
package/dist/src/media.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createReadStream, statSync, existsSync } from 'node:fs';
2
2
  import { writeFile, mkdir } from 'node:fs/promises';
3
3
  import { tmpdir, homedir } from 'node:os';
4
- import { basename, extname, join } from 'node:path';
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
7
  const DEFAULT_MAX_MB = 20;
@@ -127,7 +127,8 @@ function isLocalPath(s) {
127
127
  || s.startsWith('./')
128
128
  || s.startsWith('../')
129
129
  || s.startsWith('.\\')
130
- || s.startsWith('..\\'));
130
+ || s.startsWith('..\\')
131
+ || !s.includes('://'));
131
132
  }
132
133
  function normalizePath(s) {
133
134
  let p = s.trim();
@@ -142,6 +143,9 @@ function normalizePath(s) {
142
143
  if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
143
144
  p = homedir() + p.slice(1);
144
145
  }
146
+ if (!path.isAbsolute(p)) {
147
+ p = path.resolve(p);
148
+ }
145
149
  return p;
146
150
  }
147
151
  const MIME_TO_EXT = {
@@ -6,7 +6,7 @@ import { sendYuanbaoMessage, sendYuanbaoMessageBody, sendYuanbaoGroupMessage, se
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 } from '../logger.js';
9
+ import { createLog, logger } from '../logger.js';
10
10
  import { getOutboundQueue } from '../outbound-queue.js';
11
11
  import { UPGRADE_COMMAND_NAMES } from '../commands/upgrade.js';
12
12
  import { sendStickerYuanbao } from '../sticker/sticker-sender.js';
@@ -285,7 +285,7 @@ async function handleC2CMessage(params) {
285
285
  const msgId = msg.msg_id ?? String(msg.msg_seq ?? '');
286
286
  const queueManager = getOutboundQueue(account.accountId);
287
287
  if (queueManager) {
288
- const sendMediaOverride = async (url, fallbackText, mediaType) => {
288
+ const sendMediaOverride = async (url, fallbackText, mediaType, mediaLocalRoots) => {
289
289
  if (mediaType === 'sticker') {
290
290
  const sticker = getCachedSticker(url);
291
291
  if (!sticker) {
@@ -301,7 +301,8 @@ async function handleC2CMessage(params) {
301
301
  });
302
302
  }
303
303
  try {
304
- const uploadResult = await downloadAndUploadMedia(url, core, account);
304
+ logger.info('sendMediaOverride', { url, mediaLocalRoots });
305
+ const uploadResult = await downloadAndUploadMedia(url, core, account, mediaLocalRoots);
305
306
  const mime = guessMimeType(uploadResult.filename);
306
307
  const msgBody = mime.startsWith('image/')
307
308
  ? buildImageMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid, imageInfo: uploadResult.imageInfo })
@@ -321,6 +322,7 @@ async function handleC2CMessage(params) {
321
322
  chatType: 'c2c',
322
323
  account,
323
324
  target: fromAccount,
325
+ toAccount: fromAccount,
324
326
  fromAccount: outboundSender,
325
327
  ctx,
326
328
  sendMediaOverride,
@@ -607,7 +609,7 @@ async function handleGroupMessage(params) {
607
609
  const groupMsgId = msg.msg_id ?? String(msg.msg_seq ?? '');
608
610
  const groupQueueManager = getOutboundQueue(account.accountId);
609
611
  if (groupQueueManager) {
610
- const groupSendMediaOverride = async (url, fallbackText, mediaType) => {
612
+ const groupSendMediaOverride = async (url, fallbackText, mediaType, mediaLocalRoots) => {
611
613
  if (mediaType === 'sticker') {
612
614
  const sticker = await getCachedSticker(url);
613
615
  if (!sticker) {
@@ -616,7 +618,8 @@ async function handleGroupMessage(params) {
616
618
  return sendStickerYuanbao({ account, config, wsClient: ctx.wsClient, toAccount: `group:${groupCode}`, sticker, refMsgId, core });
617
619
  }
618
620
  try {
619
- const uploadResult = await downloadAndUploadMedia(url, core, account);
621
+ logger.info('groupSendMediaOverride', { url, mediaLocalRoots });
622
+ const uploadResult = await downloadAndUploadMedia(url, core, account, mediaLocalRoots);
620
623
  const mime = guessMimeType(uploadResult.filename);
621
624
  const msgBody = mime.startsWith('image/')
622
625
  ? buildImageMsgBody({ url: uploadResult.url, filename: uploadResult.filename, size: uploadResult.size, uuid: uploadResult.uuid, imageInfo: uploadResult.imageInfo })
@@ -653,6 +656,7 @@ async function handleGroupMessage(params) {
653
656
  chatType: 'group',
654
657
  account,
655
658
  target: groupCode,
659
+ toAccount: fromAccount,
656
660
  fromAccount: outboundSender,
657
661
  refMsgId,
658
662
  refFromAccount: fromAccount,
@@ -663,20 +667,27 @@ async function handleGroupMessage(params) {
663
667
  glog.debug(`[${outboundGroupSessionKey}] 群出站队列 session 已注册,msgId: ${groupMsgId}`);
664
668
  }
665
669
  const replyRuntime = buildReplyRuntimeConfig(config, account);
666
- await executeReply({
667
- transport,
668
- ctx,
669
- account,
670
- core,
671
- config,
672
- ctxPayload,
673
- replyRuntime,
674
- tableMode,
675
- splitFinalText,
676
- overflowPolicy: account.overflowPolicy,
677
- sessionKey: outboundGroupSessionKey,
678
- groupCode,
679
- });
670
+ try {
671
+ await executeReply({
672
+ transport,
673
+ ctx,
674
+ account,
675
+ core,
676
+ config,
677
+ ctxPayload,
678
+ replyRuntime,
679
+ tableMode,
680
+ splitFinalText,
681
+ overflowPolicy: account.overflowPolicy,
682
+ sessionKey: outboundGroupSessionKey,
683
+ groupCode,
684
+ });
685
+ }
686
+ catch (err) {
687
+ const session = groupQueueManager?.getSession(outboundGroupSessionKey);
688
+ session?.abort();
689
+ throw err;
690
+ }
680
691
  clearHistoryEntriesIfEnabled({
681
692
  historyMap: chatHistories,
682
693
  historyKey: groupCode,
@@ -1,3 +1,4 @@
1
+ import { WS_HEARTBEAT } from '../yuanbao-server/ws/index.js';
1
2
  import { createLog } from '../logger.js';
2
3
  import { prepareOutboundContent, buildOutboundMsgBody } from './handlers/index.js';
3
4
  import { getMember } from '../module/member.js';
@@ -137,7 +138,7 @@ export async function sendMsgBodyDirect(params) {
137
138
  });
138
139
  }
139
140
  export async function executeReply(params) {
140
- const { transport, ctx, account, core, replyRuntime, splitFinalText, overflowPolicy, ctxPayload, sessionKey, } = params;
141
+ const { transport, ctx, account, core, replyRuntime, splitFinalText, tableMode, overflowPolicy, ctxPayload, sessionKey, } = params;
141
142
  const rlog = createLog('outbound', ctx.log);
142
143
  if (ctx.abortSignal?.aborted) {
143
144
  rlog.warn(`[${account.accountId}] 回复已中止,跳过执行`);
@@ -156,6 +157,12 @@ export async function executeReply(params) {
156
157
  cfg: replyRuntime.config,
157
158
  replyOptions: {
158
159
  disableBlockStreaming: replyRuntime.disableBlockStreaming,
160
+ onAgentRunStart: () => {
161
+ session?.emitReplyHeartbeat(WS_HEARTBEAT.RUNNING);
162
+ },
163
+ onAssistantMessageStart: () => {
164
+ session?.emitReplyHeartbeat(WS_HEARTBEAT.RUNNING);
165
+ },
159
166
  },
160
167
  dispatcherOptions: {
161
168
  deliver: async (payload, info) => {
@@ -169,7 +176,7 @@ export async function executeReply(params) {
169
176
  if (info.kind === 'final') {
170
177
  hasFinalInfo = true;
171
178
  }
172
- const text = payload.text ?? '';
179
+ const text = core.channel.text.convertMarkdownTables(payload.text ?? '', tableMode);
173
180
  if (session) {
174
181
  if (text.trim()) {
175
182
  hasQueuedContent = true;
@@ -212,6 +219,7 @@ export async function executeReply(params) {
212
219
  return;
213
220
  }
214
221
  ctx.statusSink?.({ lastOutboundAt: Date.now() });
222
+ session.emitReplyHeartbeat(WS_HEARTBEAT.FINISH);
215
223
  return;
216
224
  }
217
225
  if (collectedTexts.length === 0) {
@@ -33,7 +33,7 @@ function normalizeStickerId(params) {
33
33
  return '';
34
34
  }
35
35
  export async function handleYuanbaoAction(action, params, context) {
36
- const normalized = action === 'sticker' ? 'sticker-send' : action;
36
+ const normalized = (action === 'sticker' || action === 'react') ? 'sticker-send' : action;
37
37
  switch (normalized) {
38
38
  case 'sticker-search': {
39
39
  const query = normalizeStickerSearchQuery(params);
@@ -1,6 +1,7 @@
1
1
  export function buildMessageToolHints() {
2
2
  return [
3
- 'file/image: real URLs/paths; send with media/mediaUrls, not link-only text.',
4
- '表情=sticker=贴纸表情 (same). Use sticker-search + sticker; do not use Unicode emoji in text.',
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
+ 'File/image sending is supported. Use media/mediaUrls with real URLs or absolute paths, not link-only text.',
5
+ 'IMPORTANT: When sending files, always use absolute paths (e.g. /tmp/file.md). Never use relative paths like "hello.md" — they will fail.',
5
6
  ];
6
7
  }
@@ -0,0 +1,22 @@
1
+ import type { ResolvedYuanbaoAccount } from '../types.js';
2
+ import type { MessageHandlerContext } from '../message-handler/context.js';
3
+ import type { WsHeartbeatValue } from '../yuanbao-server/ws/index.js';
4
+ export interface ReplyHeartbeatMeta {
5
+ ctx: MessageHandlerContext;
6
+ account: ResolvedYuanbaoAccount;
7
+ toAccount: string;
8
+ groupCode?: string;
9
+ }
10
+ export declare function emitReplyHeartbeat(params: ReplyHeartbeatMeta & {
11
+ heartbeat: WsHeartbeatValue;
12
+ sendTime: number;
13
+ }): Promise<void>;
14
+ export interface ReplyHeartbeatController {
15
+ emit(heartbeat: WsHeartbeatValue): void;
16
+ onReplySent(): void;
17
+ stop(): void;
18
+ }
19
+ export declare function createReplyHeartbeatController(params: {
20
+ meta: ReplyHeartbeatMeta;
21
+ runningIntervalMs?: number;
22
+ }): ReplyHeartbeatController;
@@ -0,0 +1,133 @@
1
+ import { createLog } from '../logger.js';
2
+ import { WS_HEARTBEAT } from '../yuanbao-server/ws/index.js';
3
+ const HEARTBEAT_TIMEOUT_MS = 800;
4
+ const DEFAULT_RUNNING_HEARTBEAT_INTERVAL_MS = 2000;
5
+ const MAX_RUNNING_HEARTBEAT_IDLE_MS = 30000;
6
+ const log = createLog('reply-heartbeat');
7
+ export async function emitReplyHeartbeat(params) {
8
+ const { ctx, account, toAccount, groupCode, heartbeat, sendTime } = params;
9
+ const fromAccount = account.botId?.trim() ?? '';
10
+ const targetAccount = toAccount.trim();
11
+ const withTimeout = async (promise, timeoutMs) => new Promise((resolve, reject) => {
12
+ const timer = setTimeout(() => reject(new Error(`heartbeat timeout(${timeoutMs}ms)`)), timeoutMs);
13
+ promise
14
+ .then((value) => {
15
+ clearTimeout(timer);
16
+ resolve(value);
17
+ })
18
+ .catch((err) => {
19
+ clearTimeout(timer);
20
+ reject(err);
21
+ });
22
+ });
23
+ if (!ctx.wsClient) {
24
+ log.warn(`[${account.accountId}] 心跳发送失败: wsClient 不可用`);
25
+ return;
26
+ }
27
+ if (!fromAccount || !targetAccount) {
28
+ log.warn(`[${account.accountId}] 心跳发送失败: from/to 账号缺失`, {
29
+ fromAccount,
30
+ toAccount: targetAccount,
31
+ groupCode,
32
+ heartbeat,
33
+ });
34
+ return;
35
+ }
36
+ try {
37
+ if (groupCode) {
38
+ const rsp = await withTimeout(ctx.wsClient.sendGroupHeartbeat({
39
+ from_account: fromAccount,
40
+ to_account: targetAccount,
41
+ group_code: groupCode,
42
+ send_time: sendTime,
43
+ heartbeat,
44
+ }), HEARTBEAT_TIMEOUT_MS);
45
+ if (rsp.code !== 0) {
46
+ log.warn(`[${account.accountId}] 发送群聊回复状态心跳失败: code=${rsp.code}, msg=${rsp.msg ?? rsp.message ?? ''}`);
47
+ }
48
+ return;
49
+ }
50
+ const rsp = await withTimeout(ctx.wsClient.sendPrivateHeartbeat({
51
+ from_account: fromAccount,
52
+ to_account: targetAccount,
53
+ heartbeat,
54
+ }), HEARTBEAT_TIMEOUT_MS);
55
+ if (rsp.code !== 0) {
56
+ log.warn(`[${account.accountId}] 发送私聊回复状态心跳失败: code=${rsp.code}, msg=${rsp.msg ?? rsp.message ?? ''}`);
57
+ }
58
+ }
59
+ catch (err) {
60
+ log.warn(`[${account.accountId}] 发送回复状态心跳异常: ${String(err)}`);
61
+ }
62
+ }
63
+ export function createReplyHeartbeatController(params) {
64
+ const { meta } = params;
65
+ const runningIntervalMs = params.runningIntervalMs ?? DEFAULT_RUNNING_HEARTBEAT_INTERVAL_MS;
66
+ let runningHeartbeatTimer = null;
67
+ let runningHeartbeatActive = false;
68
+ let runningHeartbeatStartTime = null;
69
+ let lastRunningEmitAt = null;
70
+ const send = (heartbeat, sendTime) => {
71
+ void emitReplyHeartbeat({
72
+ ...meta,
73
+ heartbeat,
74
+ sendTime,
75
+ });
76
+ };
77
+ const sendRunningHeartbeatAndSchedule = async () => {
78
+ if (!runningHeartbeatActive)
79
+ return;
80
+ if (runningHeartbeatStartTime === null)
81
+ return;
82
+ if (lastRunningEmitAt === null)
83
+ return;
84
+ if ((Date.now() - lastRunningEmitAt) > MAX_RUNNING_HEARTBEAT_IDLE_MS) {
85
+ stop();
86
+ return;
87
+ }
88
+ await emitReplyHeartbeat({
89
+ ...meta,
90
+ heartbeat: WS_HEARTBEAT.RUNNING,
91
+ sendTime: runningHeartbeatStartTime,
92
+ });
93
+ if (!runningHeartbeatActive)
94
+ return;
95
+ runningHeartbeatTimer = setTimeout(() => {
96
+ void sendRunningHeartbeatAndSchedule();
97
+ }, runningIntervalMs);
98
+ };
99
+ const stop = () => {
100
+ runningHeartbeatActive = false;
101
+ runningHeartbeatStartTime = null;
102
+ lastRunningEmitAt = null;
103
+ if (runningHeartbeatTimer) {
104
+ clearTimeout(runningHeartbeatTimer);
105
+ runningHeartbeatTimer = null;
106
+ }
107
+ };
108
+ const startRunning = () => {
109
+ if (runningHeartbeatActive)
110
+ return;
111
+ runningHeartbeatActive = true;
112
+ runningHeartbeatStartTime = Date.now();
113
+ lastRunningEmitAt = Date.now();
114
+ void sendRunningHeartbeatAndSchedule();
115
+ };
116
+ const emit = (heartbeat) => {
117
+ if (heartbeat === WS_HEARTBEAT.RUNNING) {
118
+ if (runningHeartbeatActive) {
119
+ lastRunningEmitAt = Date.now();
120
+ return;
121
+ }
122
+ startRunning();
123
+ return;
124
+ }
125
+ stop();
126
+ send(heartbeat, Date.now());
127
+ };
128
+ return {
129
+ emit,
130
+ onReplySent: stop,
131
+ stop,
132
+ };
133
+ }
@@ -1,35 +1,38 @@
1
1
  import type { ModuleLog } from './logger.js';
2
2
  import type { ResolvedYuanbaoAccount } from './types.js';
3
3
  import type { MessageHandlerContext } from './message-handler/context.js';
4
+ import type { WsHeartbeatValue } from './yuanbao-server/ws/index.js';
4
5
  export type OutboundQueueStrategy = 'immediate' | 'merge-text';
5
- export type OutboundQueueItem = {
6
+ type OutboundQueueItem = {
6
7
  type: 'text';
7
8
  text: string;
8
9
  } | {
9
10
  type: 'media';
10
11
  mediaUrl: string;
11
12
  text?: string;
13
+ mediaLocalRoots?: string[];
12
14
  } | {
13
15
  type: 'sticker';
14
16
  sticker_id: string;
15
17
  text?: string;
16
18
  };
17
- export type SendTextFn = (text: string) => Promise<{
19
+ type SendTextFn = (text: string) => Promise<{
18
20
  ok: boolean;
19
21
  error?: string;
20
22
  }>;
21
- export type SendMediaFn = (mediaUrl: string, text?: string, mediaType?: 'image' | 'file' | 'sticker') => Promise<{
23
+ export type SendMediaFn = (mediaUrl: string, text?: string, mediaType?: 'image' | 'file' | 'sticker', mediaLocalRoots?: string[]) => Promise<{
22
24
  ok: boolean;
23
25
  error?: string;
24
26
  }>;
25
- export interface OutboundQueueSession {
27
+ interface OutboundQueueSession {
26
28
  readonly strategy: OutboundQueueStrategy;
27
29
  readonly msgId: string;
28
30
  push(item: OutboundQueueItem): Promise<void>;
29
31
  flush(): Promise<void>;
30
32
  abort(): void;
33
+ emitReplyHeartbeat(heartbeat: WsHeartbeatValue): void;
31
34
  }
32
- export interface RegisterSessionOptions {
35
+ interface RegisterSessionOptions {
33
36
  chatType: 'c2c' | 'group';
34
37
  account: ResolvedYuanbaoAccount;
35
38
  target: string;
@@ -38,10 +41,11 @@ export interface RegisterSessionOptions {
38
41
  refFromAccount?: string;
39
42
  ctx: MessageHandlerContext;
40
43
  msgId: string;
44
+ toAccount?: string;
41
45
  sendMediaOverride?: SendMediaFn;
42
46
  mergeOnFlush?: boolean;
43
47
  }
44
- export interface OutboundQueueManager {
48
+ interface OutboundQueueManager {
45
49
  readonly strategy: OutboundQueueStrategy;
46
50
  registerSession(sessionKey: string, options: RegisterSessionOptions): OutboundQueueSession;
47
51
  getSession(sessionKey: string): OutboundQueueSession | null;
@@ -51,21 +55,25 @@ export interface OutboundQueueConfig {
51
55
  strategy: OutboundQueueStrategy;
52
56
  minChars?: number;
53
57
  maxChars?: number;
54
- idleMs?: number;
55
58
  chunkText?: (text: string, maxChars: number) => string[];
56
59
  }
57
60
  export declare function initOutboundQueue(accountId: string, config: OutboundQueueConfig): OutboundQueueManager;
58
61
  export declare function getOutboundQueue(accountId: string): OutboundQueueManager | null;
59
62
  export declare function destroyOutboundQueue(accountId: string): void;
60
- export declare function getAllOutboundQueues(): ReadonlyMap<string, OutboundQueueManager>;
63
+ export declare function hasUnclosedFence(text: string): boolean;
64
+ export declare function mergeBlockStreamingFences(buffer: string, incoming: string): string;
61
65
  export interface MergeTextOptions {
62
66
  minChars: number;
63
67
  maxChars: number;
64
- idleMs: number;
65
68
  chunkText: (text: string, maxChars: number) => string[];
66
69
  }
67
70
  declare function createMergeTextSession(callbacks: {
68
71
  sendText: SendTextFn;
69
72
  sendMedia: SendMediaFn;
70
- }, msgId: string, sessionKey: string, onComplete: () => void, log: ModuleLog, opts: MergeTextOptions): OutboundQueueSession;
73
+ }, msgId: string, sessionKey: string, onComplete: () => void, log: ModuleLog, opts: MergeTextOptions, heartbeatMeta: {
74
+ ctx: MessageHandlerContext;
75
+ account: ResolvedYuanbaoAccount;
76
+ toAccount: string;
77
+ groupCode?: string;
78
+ }): OutboundQueueSession;
71
79
  export { createMergeTextSession as createMergeTextSessionForTest };