@kirigaya/openclaw-onebot 1.0.3 → 1.0.4

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.
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * OneBot WebSocket 连接与 API 调用
3
3
  *
4
- * 图片消息:网络 URL 会先下载到本地再发送(兼容 Lagrange.Core retcode 1200),
4
+ * 图片消息:
5
+ * - 本机回环连接时:网络 URL 会先下载到本地再发送(兼容部分实现的 retcode 1200)
6
+ * - 跨机器连接时:本地文件会自动转成 base64://,避免把宿主机绝对路径发给远端 OneBot
5
7
  * 并定期清理临时文件。
6
8
  */
9
+ import Fuse from "fuse.js";
7
10
  import WebSocket from "ws";
8
11
  import { createServer } from "http";
9
12
  import https from "https";
10
13
  import http from "http";
11
- import { writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from "fs";
14
+ import { writeFileSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync } from "fs";
12
15
  import { join } from "path";
13
16
  import { tmpdir } from "os";
14
17
  import { logSend } from "./send-debug-log.js";
@@ -96,6 +99,48 @@ async function resolveImageToLocalPath(image) {
96
99
  }
97
100
  return trimmed.replace(/\\/g, "/");
98
101
  }
102
+ async function resolveImageToBuffer(image) {
103
+ const trimmed = image?.trim();
104
+ if (!trimmed)
105
+ throw new Error("Empty image");
106
+ if (/^https?:\/\//i.test(trimmed)) {
107
+ return downloadUrl(trimmed);
108
+ }
109
+ if (trimmed.startsWith("base64://")) {
110
+ return Buffer.from(trimmed.slice(9), "base64");
111
+ }
112
+ if (trimmed.startsWith("file://")) {
113
+ return readFileSync(trimmed.slice(7));
114
+ }
115
+ return readFileSync(trimmed);
116
+ }
117
+ function normalizePeerHost(host) {
118
+ const trimmed = String(host ?? "").trim().toLowerCase();
119
+ if (!trimmed)
120
+ return "";
121
+ const unwrapped = trimmed.replace(/^\[/, "").replace(/\]$/, "");
122
+ return unwrapped.startsWith("::ffff:") ? unwrapped.slice(7) : unwrapped;
123
+ }
124
+ function isLoopbackHost(host) {
125
+ const normalized = normalizePeerHost(host);
126
+ return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
127
+ }
128
+ function getSocketPeerHost(socket, getConfig) {
129
+ const peerHost = socket.__onebotPeerHost;
130
+ if (peerHost)
131
+ return peerHost;
132
+ return getConfig?.()?.host ?? "";
133
+ }
134
+ function shouldEncodeImageAsBase64(socket, getConfig) {
135
+ const peerHost = getSocketPeerHost(socket, getConfig);
136
+ return !!peerHost && !isLoopbackHost(peerHost);
137
+ }
138
+ async function resolveImageFileForSend(image, socket, getConfig) {
139
+ if (shouldEncodeImageAsBase64(socket, getConfig)) {
140
+ return `base64://${(await resolveImageToBuffer(image)).toString("base64")}`;
141
+ }
142
+ return resolveImageToLocalPath(image);
143
+ }
99
144
  /** 启动临时图片定期清理(每小时执行一次) */
100
145
  export function startImageTempCleanup() {
101
146
  stopImageTempCleanup();
@@ -271,7 +316,7 @@ export async function sendGroupImage(groupId, image, log = getLogger(), getConfi
271
316
  const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
272
317
  log.info?.(`222[onebot] sendGroupImage entry: groupId=${groupId} image=${image?.slice?.(0, 80) ?? ""}`);
273
318
  try {
274
- const filePath = image.startsWith("[") ? null : await resolveImageToLocalPath(image);
319
+ const filePath = image.startsWith("[") ? null : await resolveImageFileForSend(image, socket, getConfig);
275
320
  const seg = image.startsWith("[")
276
321
  ? JSON.parse(image)
277
322
  : [{ type: "image", data: { file: filePath } }];
@@ -335,7 +380,7 @@ export async function sendPrivateImage(userId, image, log = getLogger(), getConf
335
380
  });
336
381
  log.info?.(`[onebot] sendPrivateImage entry: userId=${userId} image=${image?.slice?.(0, 80) ?? ""}`);
337
382
  const socket = getConfig ? await ensureConnection(getConfig) : await waitForConnection();
338
- const filePath = image.startsWith("[") ? null : await resolveImageToLocalPath(image);
383
+ const filePath = image.startsWith("[") ? null : await resolveImageFileForSend(image, socket, getConfig);
339
384
  const seg = image.startsWith("[")
340
385
  ? JSON.parse(image)
341
386
  : [{ type: "image", data: { file: filePath } }];
@@ -348,15 +393,23 @@ export async function sendPrivateImage(userId, image, log = getLogger(), getConf
348
393
  logSend("connection", "sendPrivateImage", { targetId: userId, messageId: mid, sessionId: getActiveReplyTarget(), replySessionId: getActiveReplySessionId() });
349
394
  return mid;
350
395
  }
351
- export async function uploadGroupFile(groupId, file, name) {
352
- if (!ws || ws.readyState !== WebSocket.OPEN)
353
- throw new Error("OneBot WebSocket not connected");
354
- await sendOneBotAction(ws, "upload_group_file", { group_id: groupId, file, name });
396
+ export async function uploadGroupFile(groupId, file, name, getConfig) {
397
+ const socket = getConfig
398
+ ? await ensureConnection(getConfig)
399
+ : await waitForConnection();
400
+ const res = await sendOneBotAction(socket, "upload_group_file", { group_id: groupId, file, name });
401
+ if (res?.retcode !== 0) {
402
+ throw new Error(res?.msg ?? `OneBot upload_group_file failed (retcode=${res?.retcode})`);
403
+ }
355
404
  }
356
- export async function uploadPrivateFile(userId, file, name) {
357
- if (!ws || ws.readyState !== WebSocket.OPEN)
358
- throw new Error("OneBot WebSocket not connected");
359
- await sendOneBotAction(ws, "upload_private_file", { user_id: userId, file, name });
405
+ export async function uploadPrivateFile(userId, file, name, getConfig) {
406
+ const socket = getConfig
407
+ ? await ensureConnection(getConfig)
408
+ : await waitForConnection();
409
+ const res = await sendOneBotAction(socket, "upload_private_file", { user_id: userId, file, name });
410
+ if (res?.retcode !== 0) {
411
+ throw new Error(res?.msg ?? `OneBot upload_private_file failed (retcode=${res?.retcode})`);
412
+ }
360
413
  }
361
414
  /** 撤回消息 */
362
415
  export async function deleteMsg(messageId) {
@@ -404,6 +457,49 @@ export async function getGroupMemberInfo(groupId, userId) {
404
457
  return null;
405
458
  }
406
459
  }
460
+ /**
461
+ * 获取群成员列表(OneBot get_group_member_list)
462
+ */
463
+ export async function getGroupMemberList(groupId) {
464
+ if (!ws || ws.readyState !== WebSocket.OPEN)
465
+ return [];
466
+ try {
467
+ const res = await sendOneBotAction(ws, "get_group_member_list", { group_id: groupId });
468
+ if (res?.retcode !== 0 || !Array.isArray(res?.data))
469
+ return [];
470
+ return res.data.map((m) => ({
471
+ user_id: Number(m.user_id),
472
+ nickname: String(m.nickname ?? ""),
473
+ card: String(m.card ?? ""),
474
+ }));
475
+ }
476
+ catch {
477
+ return [];
478
+ }
479
+ }
480
+ /**
481
+ * 按名字模糊匹配群成员(匹配群名片 card 与昵称 nickname),返回匹配到的 QQ 与展示名。
482
+ * 使用 Fuse.js 模糊匹配,结果按相关度排序。
483
+ */
484
+ export async function searchGroupMemberByName(groupId, name) {
485
+ const list = await getGroupMemberList(groupId);
486
+ const keyword = (name || "").trim();
487
+ if (!keyword)
488
+ return [];
489
+ const fuse = new Fuse(list, {
490
+ keys: ["card", "nickname"],
491
+ includeScore: true,
492
+ threshold: 0.4,
493
+ ignoreLocation: true,
494
+ });
495
+ const results = fuse.search(keyword);
496
+ return results.map(({ item: m }) => ({
497
+ user_id: m.user_id,
498
+ nickname: m.nickname,
499
+ card: m.card,
500
+ displayName: m.card || m.nickname || String(m.user_id),
501
+ }));
502
+ }
407
503
  /** 获取群信息(含 group_name) */
408
504
  export async function getGroupInfo(groupId) {
409
505
  if (!ws || ws.readyState !== WebSocket.OPEN)
@@ -437,20 +533,24 @@ export async function getMsg(messageId) {
437
533
  }
438
534
  }
439
535
  /**
440
- * 获取群聊历史消息(Lagrange.Core 扩展 API,go-cqhttp 等可能不支持)
536
+ * 获取群聊历史消息(Lagrange.Core 扩展 API,与 Lagrange.onebot context 一致)
537
+ * 仅使用 message_seq 分页(不传 message_id),与 Tiphareth getLast24HGroupMessages 调用方式一致。
441
538
  * @param groupId 群号
442
- * @param opts message_seq 起始序号;message_id 起始消息 ID;count 数量
539
+ * @param opts message_seq 起始序号(不传表示从最新一页);count 本页条数;reverse_order true 表示从旧到新,便于用 batch[0].message_seq 向前翻页
443
540
  */
444
541
  export async function getGroupMsgHistory(groupId, opts = { count: 20 }) {
445
542
  if (!ws || ws.readyState !== WebSocket.OPEN)
446
543
  return [];
447
544
  try {
448
- const res = await sendOneBotAction(ws, "get_group_msg_history", {
545
+ const params = {
449
546
  group_id: groupId,
450
- message_seq: opts.message_seq,
451
- message_id: opts.message_id,
452
547
  count: opts.count ?? 20,
453
- });
548
+ reverse_order: opts.reverse_order !== false,
549
+ };
550
+ if (opts.message_seq != null && Number.isFinite(opts.message_seq)) {
551
+ params.message_seq = opts.message_seq;
552
+ }
553
+ const res = await sendOneBotAction(ws, "get_group_msg_history", params);
454
554
  if (res?.retcode === 0 && res?.data?.messages)
455
555
  return res.data.messages;
456
556
  return [];
@@ -459,6 +559,56 @@ export async function getGroupMsgHistory(groupId, opts = { count: 20 }) {
459
559
  return [];
460
560
  }
461
561
  }
562
+ /** 单页请求之间的延迟(毫秒),与 Tiphareth historyMessages 一致 */
563
+ const HISTORY_PAGE_DELAY_MS = 500;
564
+ /**
565
+ * 按时间范围分页获取群历史消息,严格对齐 Tiphareth getLast24HGroupMessages 算法:
566
+ * getGroupMsgHistory(groupId, messageSeq, chunkSize, true),用 batch[0] 的 message_seq 向前翻页,去重与时间截断。
567
+ * @param groupId 群号
568
+ * @param opts startTime 仅保留 >= startTime 的消息(Unix 秒);limit 最多条数;chunkSize 每页条数
569
+ */
570
+ export async function getGroupMsgHistoryInRange(groupId, opts = {}) {
571
+ const { startTime = 0, limit = 3000, chunkSize = 100 } = opts;
572
+ let messageSeq = undefined;
573
+ const allMessages = [];
574
+ const seenMessageIds = new Set();
575
+ let stopLoop = false;
576
+ let pageCount = 0;
577
+ while (!stopLoop) {
578
+ pageCount++;
579
+ const batch = await getGroupMsgHistory(groupId, {
580
+ message_seq: messageSeq,
581
+ count: chunkSize,
582
+ reverse_order: true,
583
+ });
584
+ if (!batch.length) {
585
+ break;
586
+ }
587
+ await new Promise((r) => setTimeout(r, HISTORY_PAGE_DELAY_MS));
588
+ for (const msg of batch) {
589
+ if (seenMessageIds.has(msg.message_id))
590
+ continue;
591
+ seenMessageIds.add(msg.message_id);
592
+ if (msg.time < startTime) {
593
+ stopLoop = true;
594
+ }
595
+ else {
596
+ allMessages.push(msg);
597
+ }
598
+ }
599
+ const oldest = batch[0];
600
+ const nextSeq = oldest.message_seq ?? oldest.message_id;
601
+ if (nextSeq == null || nextSeq === messageSeq) {
602
+ break;
603
+ }
604
+ messageSeq = nextSeq;
605
+ if (allMessages.length >= limit) {
606
+ break;
607
+ }
608
+ }
609
+ allMessages.sort((a, b) => a.time - b.time);
610
+ return allMessages;
611
+ }
462
612
  export async function connectForward(config) {
463
613
  const path = config.path ?? "/onebot/v11/ws";
464
614
  const pathNorm = path.startsWith("/") ? path : `/${path}`;
@@ -472,6 +622,7 @@ export async function connectForward(config) {
472
622
  w.on("open", () => resolve());
473
623
  w.on("error", reject);
474
624
  });
625
+ w.__onebotPeerHost = config.host;
475
626
  return w;
476
627
  }
477
628
  export async function createServerAndWait(config) {
@@ -486,7 +637,8 @@ export async function createServerAndWait(config) {
486
637
  server.listen(config.port, host);
487
638
  wsServer = wss;
488
639
  return new Promise((resolve) => {
489
- wss.on("connection", (socket) => {
640
+ wss.on("connection", (socket, req) => {
641
+ socket.__onebotPeerHost = req.socket.remoteAddress ?? undefined;
490
642
  resolve(socket);
491
643
  });
492
644
  });
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { getOneBotConfig } from "../config.js";
5
5
  import { getRawText, getTextFromSegments, getReplyMessageId, getTextFromMessageContent, isMentioned, } from "../message.js";
6
- import { getRenderMarkdownToPlain, getCollapseDoubleNewlines, getWhitelistUserIds } from "../config.js";
6
+ import { getRenderMarkdownToPlain, getCollapseDoubleNewlines, getWhitelistUserIds, getOgImageRenderTheme, getNormalModeFlushIntervalMs, getNormalModeFlushChars, } from "../config.js";
7
7
  import { markdownToPlain, collapseDoubleNewlines } from "../markdown.js";
8
8
  import { markdownToImage } from "../og-image.js";
9
9
  import { sendPrivateMsg, sendGroupMsg, sendPrivateImage, sendGroupImage, sendGroupForwardMsg, sendPrivateForwardMsg, setMsgEmojiLike, getMsg, } from "../connection.js";
@@ -231,6 +231,9 @@ export async function processInboundMessage(api, msg) {
231
231
  api.logger?.info?.(`[onebot] dispatching message for session ${sessionId}`);
232
232
  const longMessageMode = onebotCfg.longMessageMode ?? "normal";
233
233
  const longMessageThreshold = onebotCfg.longMessageThreshold ?? 300;
234
+ api.logger?.info?.(`[onebot] longMessageMode=${longMessageMode}, threshold=${longMessageThreshold}`);
235
+ const normalModeFlushIntervalMs = getNormalModeFlushIntervalMs(cfg);
236
+ const normalModeFlushChars = getNormalModeFlushChars(cfg);
234
237
  const replySessionId = `onebot-reply-${Date.now()}-${sessionId}`;
235
238
  setActiveReplyTarget(replyTarget);
236
239
  setActiveReplySessionId(replySessionId);
@@ -239,8 +242,28 @@ export async function processInboundMessage(api, msg) {
239
242
  setForwardSuppressDelivery(true);
240
243
  const deliveredChunks = [];
241
244
  let chunkIndex = 0;
245
+ let normalModeBufferedText = "";
246
+ let normalModeBufferedRawText = "";
247
+ let normalModeFlushTimer = null;
248
+ let normalModeFlushChain = Promise.resolve();
242
249
  const getConfig = () => getOneBotConfig(api);
243
250
  const onReplySessionEnd = onebotCfg.onReplySessionEnd;
251
+ const normalModePunctuationFlushMinChars = 24;
252
+ const clearNormalModeFlushTimer = () => {
253
+ if (normalModeFlushTimer) {
254
+ clearTimeout(normalModeFlushTimer);
255
+ normalModeFlushTimer = null;
256
+ }
257
+ };
258
+ const hasBufferedNormalModeText = () => normalModeBufferedText.length > 0 || normalModeBufferedRawText.length > 0;
259
+ const queueNormalModeFlush = (action) => {
260
+ normalModeFlushChain = normalModeFlushChain
261
+ .then(action)
262
+ .catch((e) => {
263
+ api.logger?.error?.(`[onebot] normal-mode flush failed: ${e?.message ?? e}`);
264
+ });
265
+ return normalModeFlushChain;
266
+ };
244
267
  const doSendChunk = async (effectiveIsGroup, effectiveGroupId, uid, text, mediaUrl) => {
245
268
  if (text) {
246
269
  if (effectiveIsGroup && effectiveGroupId)
@@ -255,6 +278,50 @@ export async function processInboundMessage(api, msg) {
255
278
  await sendPrivateImage(uid, mediaUrl, api.logger, getConfig);
256
279
  }
257
280
  };
281
+ const flushBufferedNormalModeText = async (effectiveIsGroup, effectiveGroupId, uid) => {
282
+ clearNormalModeFlushTimer();
283
+ if (!hasBufferedNormalModeText())
284
+ return;
285
+ const text = normalModeBufferedText;
286
+ const rawText = normalModeBufferedRawText;
287
+ normalModeBufferedText = "";
288
+ normalModeBufferedRawText = "";
289
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, text, undefined);
290
+ deliveredChunks.push({
291
+ index: chunkIndex++,
292
+ text: text || undefined,
293
+ rawText: rawText || undefined,
294
+ });
295
+ };
296
+ const scheduleNormalModeFlush = (effectiveIsGroup, effectiveGroupId, uid) => {
297
+ if (normalModeFlushTimer)
298
+ return;
299
+ normalModeFlushTimer = setTimeout(() => {
300
+ void queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
301
+ }, normalModeFlushIntervalMs);
302
+ };
303
+ const shouldFlushNormalModeBuffer = () => {
304
+ const rawText = normalModeBufferedRawText || normalModeBufferedText;
305
+ if (!rawText)
306
+ return false;
307
+ if (normalModeBufferedText.length >= normalModeFlushChars)
308
+ return true;
309
+ if (rawText.length < normalModePunctuationFlushMinChars)
310
+ return false;
311
+ return /[.!?。!?]\s*$/.test(rawText);
312
+ };
313
+ const appendNormalModeText = (current, next) => {
314
+ if (!current)
315
+ return next;
316
+ if (!next)
317
+ return current;
318
+ const lastChar = current[current.length - 1];
319
+ const firstChar = next[0];
320
+ if (/[A-Za-z0-9]/.test(lastChar) && /[A-Za-z0-9]/.test(firstChar)) {
321
+ return `${current} ${next}`;
322
+ }
323
+ return `${current}${next}`;
324
+ };
258
325
  try {
259
326
  await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
260
327
  ctx: ctxPayload,
@@ -277,13 +344,15 @@ export async function processInboundMessage(api, msg) {
277
344
  let textPlain = usePlain ? markdownToPlain(trimmed) : trimmed;
278
345
  if (getCollapseDoubleNewlines(cfg))
279
346
  textPlain = collapseDoubleNewlines(textPlain);
280
- deliveredChunks.push({
281
- index: chunkIndex++,
282
- text: textPlain || undefined,
283
- rawText: trimmed || undefined,
284
- mediaUrl: mediaUrl || undefined,
285
- });
286
347
  const shouldSendNow = longMessageMode === "normal";
348
+ if (!shouldSendNow) {
349
+ deliveredChunks.push({
350
+ index: chunkIndex++,
351
+ text: textPlain || undefined,
352
+ rawText: trimmed || undefined,
353
+ mediaUrl: mediaUrl || undefined,
354
+ });
355
+ }
287
356
  // forward 模式且非最后一条:仅暂存,绝不发送,等 final 时再统一处理
288
357
  if (longMessageMode === "forward" && info.kind !== "final") {
289
358
  forwardPendingSessions.set(replySessionId, Date.now());
@@ -294,29 +363,133 @@ export async function processInboundMessage(api, msg) {
294
363
  }
295
364
  try {
296
365
  if (shouldSendNow) {
297
- await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, textPlain, mediaUrl);
366
+ if (mediaUrl) {
367
+ await queueNormalModeFlush(async () => {
368
+ await flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid);
369
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, textPlain, mediaUrl);
370
+ deliveredChunks.push({
371
+ index: chunkIndex++,
372
+ text: textPlain || undefined,
373
+ rawText: trimmed || undefined,
374
+ mediaUrl: mediaUrl || undefined,
375
+ });
376
+ });
377
+ }
378
+ else {
379
+ normalModeBufferedText = appendNormalModeText(normalModeBufferedText, textPlain);
380
+ normalModeBufferedRawText = appendNormalModeText(normalModeBufferedRawText, trimmed);
381
+ if (shouldFlushNormalModeBuffer()) {
382
+ await queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
383
+ }
384
+ else {
385
+ scheduleNormalModeFlush(effectiveIsGroup, effectiveGroupId, uid);
386
+ }
387
+ }
298
388
  }
299
389
  if (info.kind === "final") {
390
+ if (shouldSendNow) {
391
+ await queueNormalModeFlush(() => flushBufferedNormalModeText(effectiveIsGroup, effectiveGroupId, uid));
392
+ }
300
393
  const lastSentCount = lastSentChunkCountBySession.get(replySessionId) ?? 0;
301
394
  const chunksToSend = deliveredChunks.slice(lastSentCount);
302
395
  if (chunksToSend.length === 0)
303
396
  return;
304
397
  const totalLen = deliveredChunks.reduce((s, c) => s + (c.rawText ?? c.text ?? "").length, 0);
398
+ const incrementalLen = chunksToSend.reduce((s, c) => s + (c.rawText ?? c.text ?? "").length, 0);
305
399
  const isLong = totalLen > longMessageThreshold;
400
+ const isIncrementalLong = incrementalLen > longMessageThreshold;
306
401
  const isIncremental = lastSentCount > 0;
402
+ api.logger?.info?.(`[onebot] final check: totalLen=${totalLen}, threshold=${longMessageThreshold}, isLong=${isLong}, isIncremental=${isIncremental}, deliveredChunks=${deliveredChunks.length}`);
307
403
  if (isIncremental) {
308
404
  setForwardSuppressDelivery(false);
309
- for (const c of chunksToSend) {
310
- if (c.text || c.mediaUrl)
311
- await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
405
+ // normal 模式下增量 chunk 已在 deliver 中实时发出;这里不能在 final 再补发一次。
406
+ if (!shouldSendNow && isIncrementalLong && (longMessageMode === "og_image" || longMessageMode === "forward")) {
407
+ const fullRaw = chunksToSend.map((c) => c.rawText ?? c.text ?? "").join("\n\n");
408
+ if (fullRaw.trim()) {
409
+ if (longMessageMode === "og_image") {
410
+ try {
411
+ const imgUrl = await markdownToImage(fullRaw, { theme: getOgImageRenderTheme(api?.config) });
412
+ if (imgUrl) {
413
+ if (effectiveIsGroup && effectiveGroupId)
414
+ await sendGroupImage(effectiveGroupId, imgUrl, api.logger, getConfig);
415
+ else if (uid)
416
+ await sendPrivateImage(uid, imgUrl, api.logger, getConfig);
417
+ }
418
+ else {
419
+ api.logger?.warn?.("[onebot] og_image (incremental): node-html-to-image not installed, falling back to normal send");
420
+ for (const c of chunksToSend) {
421
+ if (c.text || c.mediaUrl)
422
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
423
+ }
424
+ }
425
+ }
426
+ catch (e) {
427
+ api.logger?.error?.(`[onebot] og_image (incremental) failed: ${e?.message}`);
428
+ for (const c of chunksToSend) {
429
+ if (c.text || c.mediaUrl)
430
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
431
+ }
432
+ }
433
+ }
434
+ else {
435
+ try {
436
+ const nodes = [];
437
+ for (const c of chunksToSend) {
438
+ if (c.mediaUrl) {
439
+ const mid = await sendPrivateImage(selfId, c.mediaUrl, api.logger, getConfig);
440
+ if (mid)
441
+ nodes.push({ type: "node", data: { id: String(mid) } });
442
+ }
443
+ else if (c.text) {
444
+ const mid = await sendPrivateMsg(selfId, c.text, getConfig);
445
+ if (mid)
446
+ nodes.push({ type: "node", data: { id: String(mid) } });
447
+ }
448
+ }
449
+ if (nodes.length > 0) {
450
+ if (effectiveIsGroup && effectiveGroupId)
451
+ await sendGroupForwardMsg(effectiveGroupId, nodes, getConfig);
452
+ else if (uid)
453
+ await sendPrivateForwardMsg(uid, nodes, getConfig);
454
+ }
455
+ else {
456
+ for (const c of chunksToSend) {
457
+ if (c.text || c.mediaUrl)
458
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
459
+ }
460
+ }
461
+ }
462
+ catch (e) {
463
+ api.logger?.error?.(`[onebot] forward (incremental) failed: ${e?.message}`);
464
+ for (const c of chunksToSend) {
465
+ if (c.text || c.mediaUrl)
466
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ else {
472
+ for (const c of chunksToSend) {
473
+ if (c.text || c.mediaUrl)
474
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
475
+ }
476
+ }
477
+ }
478
+ else if (!shouldSendNow) {
479
+ for (const c of chunksToSend) {
480
+ if (c.text || c.mediaUrl)
481
+ await doSendChunk(effectiveIsGroup, effectiveGroupId, uid, c.text ?? "", c.mediaUrl);
482
+ }
312
483
  }
313
484
  }
314
485
  else if (!shouldSendNow && (longMessageMode === "og_image" || longMessageMode === "forward")) {
486
+ api.logger?.info?.(`[onebot] checking og_image: isLong=${isLong}, mode=${longMessageMode}`);
315
487
  if (isLong && longMessageMode === "og_image") {
488
+ api.logger?.info?.(`[onebot] triggering og_image for ${totalLen} chars`);
316
489
  const fullRaw = deliveredChunks.map((c) => c.rawText ?? c.text ?? "").join("\n\n");
317
490
  if (fullRaw.trim()) {
318
491
  try {
319
- const imgUrl = await markdownToImage(fullRaw);
492
+ const imgUrl = await markdownToImage(fullRaw, { theme: getOgImageRenderTheme(api?.config) });
320
493
  if (imgUrl) {
321
494
  if (effectiveIsGroup && effectiveGroupId)
322
495
  await sendGroupImage(effectiveGroupId, imgUrl, api.logger, getConfig);
@@ -419,7 +592,7 @@ export async function processInboundMessage(api, msg) {
419
592
  await clearEmojiReaction();
420
593
  },
421
594
  },
422
- replyOptions: { disableBlockStreaming: true },
595
+ replyOptions: { disableBlockStreaming: longMessageMode !== "normal" },
423
596
  });
424
597
  }
425
598
  catch (err) {
@@ -435,6 +608,7 @@ export async function processInboundMessage(api, msg) {
435
608
  catch (_) { }
436
609
  }
437
610
  finally {
611
+ clearNormalModeFlushTimer();
438
612
  setForwardSuppressDelivery(false);
439
613
  setActiveReplySelfId(null);
440
614
  lastSentChunkCountBySession.delete(replySessionId);
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import { OneBotChannelPlugin } from "./channel.js";
12
12
  import { registerService } from "./service.js";
13
13
  import { startImageTempCleanup } from "./connection.js";
14
14
  import { startForwardCleanupTimer } from "./handlers/process-inbound.js";
15
+ import { registerOneBotCli } from "./cli-commands.js";
15
16
  export default function register(api) {
16
17
  globalThis.__onebotApi = api;
17
18
  globalThis.__onebotGatewayConfig = api.config;
@@ -22,11 +23,12 @@ export default function register(api) {
22
23
  api.registerCli((ctx) => {
23
24
  const prog = ctx.program;
24
25
  if (prog && typeof prog.command === "function") {
25
- const onebot = prog.command("onebot").description("OneBot 渠道配置");
26
+ const onebot = prog.command("onebot").description("OneBot 渠道配置与工具");
26
27
  onebot.command("setup").description("交互式配置 OneBot 连接参数").action(async () => {
27
28
  const { runOneBotSetup } = await import("./setup.js");
28
29
  await runOneBotSetup();
29
30
  });
31
+ registerOneBotCli(onebot, api);
30
32
  }
31
33
  }, { commands: ["onebot"] });
32
34
  }
@@ -3,4 +3,8 @@
3
3
  * 用于 OG 图片模式下的 Markdown 渲染
4
4
  */
5
5
  export declare function markdownToHtml(md: string): string;
6
- export declare function getMarkdownStyles(): string;
6
+ /**
7
+ * 获取用于 OG 图片的完整样式(基础 + 主题)
8
+ * @param theme "default" 无额外样式;"dust" 内置 dust 主题;或 custom 时的 CSS 文件绝对路径
9
+ */
10
+ export declare function getMarkdownStyles(theme?: string): string;