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 +58 -0
- package/dist/openclaw.plugin.json +1 -1
- package/dist/src/channel.js +18 -10
- package/dist/src/commands/upgrade.js +1 -1
- package/dist/src/config-schema.js +1 -1
- package/dist/src/media.js +6 -2
- package/dist/src/message-handler/inbound.js +30 -19
- package/dist/src/message-handler/outbound.js +10 -2
- package/dist/src/message-tool/action-runtime.js +1 -1
- package/dist/src/message-tool/hints.js +3 -2
- package/dist/src/module/reply-heartbeat.d.ts +22 -0
- package/dist/src/module/reply-heartbeat.js +133 -0
- package/dist/src/outbound-queue.d.ts +18 -10
- package/dist/src/outbound-queue.js +117 -90
- package/dist/src/tools/remind.js +141 -146
- package/dist/src/yuanbao-server/ws/biz-codec.d.ts +9 -1
- package/dist/src/yuanbao-server/ws/biz-codec.js +43 -0
- package/dist/src/yuanbao-server/ws/client.d.ts +5 -1
- package/dist/src/yuanbao-server/ws/client.js +23 -1
- package/dist/src/yuanbao-server/ws/index.d.ts +3 -2
- package/dist/src/yuanbao-server/ws/index.js +2 -1
- package/dist/src/yuanbao-server/ws/proto/biz.json +71 -0
- package/dist/src/yuanbao-server/ws/types.d.ts +24 -0
- package/dist/src/yuanbao-server/ws/types.js +5 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +7 -2
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# openclaw-plugin-yuanbao
|
|
2
|
+
|
|
3
|
+
[](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
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
|
|
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},
|
|
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
|
|
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 });
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
'
|
|
4
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 };
|