@tencent-connect/openclaw-qqbot 1.5.6 → 1.5.7

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/src/api.ts CHANGED
@@ -12,6 +12,35 @@ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
12
12
  // 运行时配置
13
13
  let currentMarkdownSupport = false;
14
14
 
15
+ // 出站消息回调钩子:消息发送成功且回包含 ext_info.ref_idx 时触发
16
+ // 由外层(gateway/outbound)注册,用于统一缓存 bot 出站消息的 refIdx
17
+
18
+ /** 出站消息元信息(结构化存储,不做预格式化) */
19
+ export interface OutboundMeta {
20
+ /** 消息文本内容 */
21
+ text?: string;
22
+ /** 媒体类型 */
23
+ mediaType?: "image" | "voice" | "video" | "file";
24
+ /** 媒体来源:在线 URL */
25
+ mediaUrl?: string;
26
+ /** 媒体来源:本地文件路径或文件名 */
27
+ mediaLocalPath?: string;
28
+ /** TTS 原文本(仅 voice 类型有效,用于保存 TTS 前的文本内容) */
29
+ ttsText?: string;
30
+ }
31
+
32
+ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
33
+ let onMessageSentHook: OnMessageSentCallback | null = null;
34
+
35
+ /**
36
+ * 注册出站消息回调
37
+ * 当消息发送成功且 QQ 返回 ref_idx 时,自动回调此函数
38
+ * 用于在最底层统一缓存 bot 出站消息的 refIdx
39
+ */
40
+ export function onMessageSent(callback: OnMessageSentCallback): void {
41
+ onMessageSentHook = callback;
42
+ }
43
+
15
44
  /**
16
45
  * 初始化 API 配置
17
46
  * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
@@ -98,7 +127,8 @@ async function doFetchToken(appId: string, clientSecret: string): Promise<string
98
127
  response.headers.forEach((value, key) => {
99
128
  responseHeaders[key] = value;
100
129
  });
101
- console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}`);
130
+ const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
131
+ console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
102
132
 
103
133
  let data: { access_token?: string; expires_in?: number };
104
134
  let rawBody: string;
@@ -214,6 +244,7 @@ export async function apiRequest<T = unknown>(
214
244
  if (typeof logBody.file_data === "string") {
215
245
  logBody.file_data = `<base64 ${(logBody.file_data as string).length} chars>`;
216
246
  }
247
+ console.log(`[qqbot-api] >>> Body:`, JSON.stringify(logBody));
217
248
  }
218
249
 
219
250
  let res: Response;
@@ -235,12 +266,14 @@ export async function apiRequest<T = unknown>(
235
266
  res.headers.forEach((value, key) => {
236
267
  responseHeaders[key] = value;
237
268
  });
238
- console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
269
+ const traceId = res.headers.get("x-tps-trace-id") ?? "";
270
+ console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
239
271
 
240
272
  let data: T;
241
273
  let rawBody: string;
242
274
  try {
243
275
  rawBody = await res.text();
276
+ console.log(`[qqbot-api] <<< Body:`, rawBody);
244
277
  data = JSON.parse(rawBody) as T;
245
278
  } catch (err) {
246
279
  throw new Error(`Failed to parse response[${path}]: ${err instanceof Error ? err.message : String(err)}`);
@@ -303,12 +336,39 @@ export async function getGatewayUrl(accessToken: string): Promise<string> {
303
336
  export interface MessageResponse {
304
337
  id: string;
305
338
  timestamp: number | string;
339
+ /** 消息的引用索引信息(出站时由 QQ 服务端返回) */
340
+ ext_info?: {
341
+ ref_idx?: string;
342
+ };
343
+ }
344
+
345
+ /**
346
+ * 发送消息并自动触发 refIdx 回调
347
+ * 所有消息发送函数统一经过此处,确保每条出站消息的 refIdx 都被捕获
348
+ */
349
+ async function sendAndNotify(
350
+ accessToken: string,
351
+ method: string,
352
+ path: string,
353
+ body: unknown,
354
+ meta: OutboundMeta,
355
+ ): Promise<MessageResponse> {
356
+ const result = await apiRequest<MessageResponse>(accessToken, method, path, body);
357
+ if (result.ext_info?.ref_idx && onMessageSentHook) {
358
+ try {
359
+ onMessageSentHook(result.ext_info.ref_idx, meta);
360
+ } catch (err) {
361
+ console.error(`[qqbot-api] onMessageSent hook error: ${err}`);
362
+ }
363
+ }
364
+ return result;
306
365
  }
307
366
 
308
367
  function buildMessageBody(
309
368
  content: string,
310
369
  msgId: string | undefined,
311
- msgSeq: number
370
+ msgSeq: number,
371
+ messageReference?: string
312
372
  ): Record<string, unknown> {
313
373
  const body: Record<string, unknown> = currentMarkdownSupport
314
374
  ? {
@@ -325,6 +385,9 @@ function buildMessageBody(
325
385
  if (msgId) {
326
386
  body.msg_id = msgId;
327
387
  }
388
+ if (messageReference && !currentMarkdownSupport) {
389
+ body.message_reference = { message_id: messageReference };
390
+ }
328
391
  return body;
329
392
  }
330
393
 
@@ -332,11 +395,12 @@ export async function sendC2CMessage(
332
395
  accessToken: string,
333
396
  openid: string,
334
397
  content: string,
335
- msgId?: string
398
+ msgId?: string,
399
+ messageReference?: string
336
400
  ): Promise<MessageResponse> {
337
401
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
338
- const body = buildMessageBody(content, msgId, msgSeq);
339
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
402
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
403
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
340
404
  }
341
405
 
342
406
  export async function sendC2CInputNotify(
@@ -344,7 +408,7 @@ export async function sendC2CInputNotify(
344
408
  openid: string,
345
409
  msgId?: string,
346
410
  inputSecond: number = 60
347
- ): Promise<void> {
411
+ ): Promise<{ refIdx?: string }> {
348
412
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
349
413
  const body = {
350
414
  msg_type: 6,
@@ -355,7 +419,8 @@ export async function sendC2CInputNotify(
355
419
  msg_seq: msgSeq,
356
420
  ...(msgId ? { msg_id: msgId } : {}),
357
421
  };
358
- await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
422
+ const response = await apiRequest<{ ext_info?: { ref_idx?: string } }>(accessToken, "POST", `/v2/users/${openid}/messages`, body);
423
+ return { refIdx: response.ext_info?.ref_idx };
359
424
  }
360
425
 
361
426
  export async function sendChannelMessage(
@@ -396,9 +461,9 @@ export async function sendProactiveC2CMessage(
396
461
  accessToken: string,
397
462
  openid: string,
398
463
  content: string
399
- ): Promise<{ id: string; timestamp: number }> {
464
+ ): Promise<MessageResponse> {
400
465
  const body = buildProactiveMessageBody(content);
401
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
466
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
402
467
  }
403
468
 
404
469
  export async function sendProactiveGroupMessage(
@@ -501,16 +566,17 @@ export async function sendC2CMediaMessage(
501
566
  openid: string,
502
567
  fileInfo: string,
503
568
  msgId?: string,
504
- content?: string
505
- ): Promise<{ id: string; timestamp: number }> {
569
+ content?: string,
570
+ meta?: OutboundMeta,
571
+ ): Promise<MessageResponse> {
506
572
  const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
507
- return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
573
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, {
508
574
  msg_type: 7,
509
575
  media: { file_info: fileInfo },
510
576
  msg_seq: msgSeq,
511
577
  ...(content ? { content } : {}),
512
578
  ...(msgId ? { msg_id: msgId } : {}),
513
- });
579
+ }, meta ?? { text: content });
514
580
  }
515
581
 
516
582
  export async function sendGroupMediaMessage(
@@ -530,21 +596,29 @@ export async function sendGroupMediaMessage(
530
596
  });
531
597
  }
532
598
 
533
- export async function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: number }> {
599
+ export async function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse> {
534
600
  let uploadResult: UploadMediaResponse;
535
- if (imageUrl.startsWith("data:")) {
601
+ const isBase64 = imageUrl.startsWith("data:");
602
+ if (isBase64) {
536
603
  const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
537
604
  if (!matches) throw new Error("Invalid Base64 Data URL format");
538
605
  uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, matches[2], false);
539
606
  } else {
540
607
  uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
541
608
  }
542
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
609
+ const meta: OutboundMeta = {
610
+ text: content,
611
+ mediaType: "image",
612
+ ...(!isBase64 ? { mediaUrl: imageUrl } : {}),
613
+ ...(localPath ? { mediaLocalPath: localPath } : {}),
614
+ };
615
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content, meta);
543
616
  }
544
617
 
545
618
  export async function sendGroupImageMessage(accessToken: string, groupOpenid: string, imageUrl: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: string }> {
546
619
  let uploadResult: UploadMediaResponse;
547
- if (imageUrl.startsWith("data:")) {
620
+ const isBase64 = imageUrl.startsWith("data:");
621
+ if (isBase64) {
548
622
  const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
549
623
  if (!matches) throw new Error("Invalid Base64 Data URL format");
550
624
  uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, matches[2], false);
@@ -554,9 +628,13 @@ export async function sendGroupImageMessage(accessToken: string, groupOpenid: st
554
628
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
555
629
  }
556
630
 
557
- export async function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64: string, msgId?: string): Promise<{ id: string; timestamp: number }> {
631
+ export async function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64: string, msgId?: string, ttsText?: string, filePath?: string): Promise<MessageResponse> {
558
632
  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VOICE, undefined, voiceBase64, false);
559
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId);
633
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined, {
634
+ mediaType: "voice",
635
+ ...(ttsText ? { ttsText } : {}),
636
+ ...(filePath ? { mediaLocalPath: filePath } : {})
637
+ });
560
638
  }
561
639
 
562
640
  export async function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64: string, msgId?: string): Promise<{ id: string; timestamp: string }> {
@@ -564,9 +642,10 @@ export async function sendGroupVoiceMessage(accessToken: string, groupOpenid: st
564
642
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
565
643
  }
566
644
 
567
- export async function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{ id: string; timestamp: number }> {
645
+ export async function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string, localFilePath?: string): Promise<MessageResponse> {
568
646
  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.FILE, fileUrl, fileBase64, false, fileName);
569
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId);
647
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined,
648
+ { mediaType: "file", mediaUrl: fileUrl, mediaLocalPath: localFilePath ?? fileName });
570
649
  }
571
650
 
572
651
  export async function sendGroupFileMessage(accessToken: string, groupOpenid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{ id: string; timestamp: string }> {
@@ -574,9 +653,10 @@ export async function sendGroupFileMessage(accessToken: string, groupOpenid: str
574
653
  return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
575
654
  }
576
655
 
577
- export async function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: number }> {
656
+ export async function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse> {
578
657
  const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VIDEO, videoUrl, videoBase64, false);
579
- return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
658
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content,
659
+ { text: content, mediaType: "video", ...(videoUrl ? { mediaUrl: videoUrl } : {}), ...(localPath ? { mediaLocalPath: localPath } : {}) });
580
660
  }
581
661
 
582
662
  export async function sendGroupVideoMessage(accessToken: string, groupOpenid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: string }> {
package/src/channel.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type ChannelPlugin,
3
3
  type OpenClawConfig,
4
+ type NormalizeTargetResult,
4
5
  applyAccountNameToChannelSection,
5
6
  deleteAccountFromConfigSection,
6
7
  setAccountEnabledInConfigSection,
@@ -167,7 +168,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
167
168
  * - channel:channelid -> 频道
168
169
  * - 纯 openid(32位十六进制)-> 私聊
169
170
  */
170
- normalizeTarget: (target: string) => {
171
+ normalizeTarget: (target: string): NormalizeTargetResult => {
171
172
  // 去掉 qqbot: 前缀(如果有)
172
173
  const id = target.replace(/^qqbot:/i, "");
173
174