@openclaw-china/qqbot 0.1.3 → 0.1.5

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/dist/index.d.ts CHANGED
@@ -4,6 +4,22 @@ declare const QQBotConfigSchema: z.ZodObject<{
4
4
  enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
5
5
  appId: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, unknown>;
6
6
  clientSecret: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, unknown>;
7
+ asr: z.ZodOptional<z.ZodObject<{
8
+ enabled: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
9
+ appId: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, unknown>;
10
+ secretId: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, unknown>;
11
+ secretKey: z.ZodEffects<z.ZodOptional<z.ZodString>, string | undefined, unknown>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ enabled: boolean;
14
+ appId?: string | undefined;
15
+ secretId?: string | undefined;
16
+ secretKey?: string | undefined;
17
+ }, {
18
+ enabled?: boolean | undefined;
19
+ appId?: unknown;
20
+ secretId?: unknown;
21
+ secretKey?: unknown;
22
+ }>>;
7
23
  markdownSupport: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
24
  dmPolicy: z.ZodDefault<z.ZodOptional<z.ZodEnum<["open", "pairing", "allowlist"]>>>;
9
25
  groupPolicy: z.ZodDefault<z.ZodOptional<z.ZodEnum<["open", "allowlist", "disabled"]>>>;
@@ -28,12 +44,24 @@ declare const QQBotConfigSchema: z.ZodObject<{
28
44
  mediaTimeoutMs: number;
29
45
  appId?: string | undefined;
30
46
  clientSecret?: string | undefined;
47
+ asr?: {
48
+ enabled: boolean;
49
+ appId?: string | undefined;
50
+ secretId?: string | undefined;
51
+ secretKey?: string | undefined;
52
+ } | undefined;
31
53
  allowFrom?: string[] | undefined;
32
54
  groupAllowFrom?: string[] | undefined;
33
55
  }, {
34
56
  enabled?: boolean | undefined;
35
57
  appId?: unknown;
36
58
  clientSecret?: unknown;
59
+ asr?: {
60
+ enabled?: boolean | undefined;
61
+ appId?: unknown;
62
+ secretId?: unknown;
63
+ secretKey?: unknown;
64
+ } | undefined;
37
65
  markdownSupport?: boolean | undefined;
38
66
  dmPolicy?: "open" | "pairing" | "allowlist" | undefined;
39
67
  groupPolicy?: "open" | "allowlist" | "disabled" | undefined;
@@ -126,6 +154,24 @@ declare const qqbotPlugin: {
126
154
  clientSecret: {
127
155
  type: string;
128
156
  };
157
+ asr: {
158
+ type: string;
159
+ additionalProperties: boolean;
160
+ properties: {
161
+ enabled: {
162
+ type: string;
163
+ };
164
+ appId: {
165
+ type: string;
166
+ };
167
+ secretId: {
168
+ type: string;
169
+ };
170
+ secretKey: {
171
+ type: string;
172
+ };
173
+ };
174
+ };
129
175
  markdownSupport: {
130
176
  type: string;
131
177
  };
@@ -406,6 +452,24 @@ declare const plugin: {
406
452
  clientSecret: {
407
453
  type: string;
408
454
  };
455
+ asr: {
456
+ type: string;
457
+ additionalProperties: boolean;
458
+ properties: {
459
+ enabled: {
460
+ type: string;
461
+ };
462
+ appId: {
463
+ type: string;
464
+ };
465
+ secretId: {
466
+ type: string;
467
+ };
468
+ secretKey: {
469
+ type: string;
470
+ };
471
+ };
472
+ };
409
473
  markdownSupport: {
410
474
  type: string;
411
475
  };
package/dist/index.js CHANGED
@@ -3,6 +3,7 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import * as fsPromises from 'fs/promises';
6
+ import { createHmac } from 'crypto';
6
7
  import WebSocket from 'ws';
7
8
 
8
9
  var __defProp = Object.defineProperty;
@@ -4064,6 +4065,12 @@ var QQBotConfigSchema = external_exports.object({
4064
4065
  enabled: external_exports.boolean().optional().default(true),
4065
4066
  appId: optionalCoercedString,
4066
4067
  clientSecret: optionalCoercedString,
4068
+ asr: external_exports.object({
4069
+ enabled: external_exports.boolean().optional().default(false),
4070
+ appId: optionalCoercedString,
4071
+ secretId: optionalCoercedString,
4072
+ secretKey: optionalCoercedString
4073
+ }).optional(),
4067
4074
  markdownSupport: external_exports.boolean().optional().default(false),
4068
4075
  dmPolicy: external_exports.enum(["open", "pairing", "allowlist"]).optional().default("open"),
4069
4076
  groupPolicy: external_exports.enum(["open", "allowlist", "disabled"]).optional().default("open"),
@@ -4083,6 +4090,16 @@ function resolveQQBotCredentials(config) {
4083
4090
  if (!config?.appId || !config?.clientSecret) return void 0;
4084
4091
  return { appId: config.appId, clientSecret: config.clientSecret };
4085
4092
  }
4093
+ function resolveQQBotASRCredentials(config) {
4094
+ const asr = config?.asr;
4095
+ if (!asr?.enabled) return void 0;
4096
+ if (!asr.appId || !asr.secretId || !asr.secretKey) return void 0;
4097
+ return {
4098
+ appId: asr.appId,
4099
+ secretId: asr.secretId,
4100
+ secretKey: asr.secretKey
4101
+ };
4102
+ }
4086
4103
 
4087
4104
  // ../../packages/shared/src/logger/logger.ts
4088
4105
  function createLogger(prefix, opts) {
@@ -5085,6 +5102,163 @@ function appendCronHiddenPrompt(text) {
5085
5102
  ${CRON_HIDDEN_PROMPT}`;
5086
5103
  }
5087
5104
 
5105
+ // ../../packages/shared/src/asr/errors.ts
5106
+ var ASRError = class extends Error {
5107
+ constructor(message, kind, provider, retryable = false) {
5108
+ super(message);
5109
+ this.kind = kind;
5110
+ this.provider = provider;
5111
+ this.retryable = retryable;
5112
+ this.name = "ASRError";
5113
+ }
5114
+ };
5115
+ var ASRTimeoutError = class extends ASRError {
5116
+ constructor(provider, timeoutMs) {
5117
+ super(`ASR request timeout after ${timeoutMs}ms`, "timeout", provider, true);
5118
+ this.timeoutMs = timeoutMs;
5119
+ this.name = "ASRTimeoutError";
5120
+ }
5121
+ };
5122
+ var ASRAuthError = class extends ASRError {
5123
+ constructor(provider, message, status) {
5124
+ super(message, "auth", provider, false);
5125
+ this.status = status;
5126
+ this.name = "ASRAuthError";
5127
+ }
5128
+ };
5129
+ var ASRRequestError = class extends ASRError {
5130
+ constructor(provider, message, status) {
5131
+ super(message, "request", provider, true);
5132
+ this.status = status;
5133
+ this.name = "ASRRequestError";
5134
+ }
5135
+ };
5136
+ var ASRResponseParseError = class extends ASRError {
5137
+ constructor(provider, bodySnippet) {
5138
+ super("ASR response is not valid JSON", "response_parse", provider, false);
5139
+ this.bodySnippet = bodySnippet;
5140
+ this.name = "ASRResponseParseError";
5141
+ }
5142
+ };
5143
+ var ASRServiceError = class extends ASRError {
5144
+ constructor(provider, message, serviceCode) {
5145
+ super(message, "service", provider, false);
5146
+ this.serviceCode = serviceCode;
5147
+ this.name = "ASRServiceError";
5148
+ }
5149
+ };
5150
+ var ASREmptyResultError = class extends ASRError {
5151
+ constructor(provider) {
5152
+ super("ASR returned empty transcript", "empty_result", provider, false);
5153
+ this.name = "ASREmptyResultError";
5154
+ }
5155
+ };
5156
+
5157
+ // ../../packages/shared/src/asr/tencent-flash.ts
5158
+ var ASR_FLASH_HOST = "asr.cloud.tencent.com";
5159
+ var ASR_FLASH_PATH_PREFIX = "/asr/flash/v1";
5160
+ var ASR_FLASH_URL_PREFIX = `https://${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}`;
5161
+ var ASR_PROVIDER = "tencent-flash";
5162
+ function encodeQueryValue(value) {
5163
+ return encodeURIComponent(value).replace(/%20/g, "+").replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
5164
+ }
5165
+ function buildSignedQuery(params) {
5166
+ return Object.entries(params).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${encodeURIComponent(key)}=${encodeQueryValue(value)}`).join("&");
5167
+ }
5168
+ function extractTranscript(payload) {
5169
+ const items = Array.isArray(payload.flash_result) ? payload.flash_result : [];
5170
+ const lines = [];
5171
+ for (const item of items) {
5172
+ if (typeof item?.text === "string" && item.text.trim()) {
5173
+ lines.push(item.text.trim());
5174
+ continue;
5175
+ }
5176
+ const sentenceList = Array.isArray(item?.sentence_list) ? item.sentence_list : [];
5177
+ for (const sentence of sentenceList) {
5178
+ if (typeof sentence?.text === "string" && sentence.text.trim()) {
5179
+ lines.push(sentence.text.trim());
5180
+ }
5181
+ }
5182
+ }
5183
+ return lines.join("\n").trim();
5184
+ }
5185
+ async function transcribeTencentFlash(params) {
5186
+ const { audio, config } = params;
5187
+ const timestamp = Math.floor(Date.now() / 1e3).toString();
5188
+ const engineType = config.engineType ?? "16k_zh";
5189
+ const voiceFormat = config.voiceFormat ?? "silk";
5190
+ const query = buildSignedQuery({
5191
+ engine_type: engineType,
5192
+ secretid: config.secretId,
5193
+ timestamp,
5194
+ voice_format: voiceFormat
5195
+ });
5196
+ const signText = `POST${ASR_FLASH_HOST}${ASR_FLASH_PATH_PREFIX}/${config.appId}?${query}`;
5197
+ const authorization = createHmac("sha1", config.secretKey).update(signText).digest("base64");
5198
+ const url = `${ASR_FLASH_URL_PREFIX}/${config.appId}?${query}`;
5199
+ const timeoutMs = config.timeoutMs ?? 3e4;
5200
+ const controller = new AbortController();
5201
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
5202
+ try {
5203
+ const response = await fetch(url, {
5204
+ method: "POST",
5205
+ headers: {
5206
+ Authorization: authorization,
5207
+ "Content-Type": "application/octet-stream"
5208
+ },
5209
+ body: audio,
5210
+ signal: controller.signal
5211
+ });
5212
+ const bodyText = await response.text();
5213
+ let payload;
5214
+ try {
5215
+ payload = JSON.parse(bodyText);
5216
+ } catch {
5217
+ throw new ASRResponseParseError(ASR_PROVIDER, bodyText.slice(0, 300));
5218
+ }
5219
+ if (!response.ok) {
5220
+ const message = payload.message ?? `HTTP ${response.status}`;
5221
+ if (response.status === 401 || response.status === 403) {
5222
+ throw new ASRAuthError(
5223
+ ASR_PROVIDER,
5224
+ `Tencent Flash ASR authentication failed: ${message}`,
5225
+ response.status
5226
+ );
5227
+ }
5228
+ throw new ASRRequestError(
5229
+ ASR_PROVIDER,
5230
+ `Tencent Flash ASR request failed: ${message}`,
5231
+ response.status
5232
+ );
5233
+ }
5234
+ if (payload.code !== 0) {
5235
+ throw new ASRServiceError(
5236
+ ASR_PROVIDER,
5237
+ `Tencent Flash ASR failed: ${payload.message ?? "unknown error"} (code=${payload.code})`,
5238
+ payload.code
5239
+ );
5240
+ }
5241
+ const transcript = extractTranscript(payload);
5242
+ if (!transcript) {
5243
+ throw new ASREmptyResultError(ASR_PROVIDER);
5244
+ }
5245
+ return transcript;
5246
+ } catch (error) {
5247
+ if (error instanceof Error && error.name === "AbortError") {
5248
+ throw new ASRTimeoutError(ASR_PROVIDER, timeoutMs);
5249
+ }
5250
+ if (error instanceof ASRResponseParseError || error instanceof ASRAuthError || error instanceof ASRRequestError || error instanceof ASRServiceError || error instanceof ASREmptyResultError || error instanceof ASRTimeoutError) {
5251
+ throw error;
5252
+ }
5253
+ throw new ASRRequestError(
5254
+ ASR_PROVIDER,
5255
+ `Tencent Flash ASR request failed: ${error instanceof Error ? error.message : String(error)}`
5256
+ );
5257
+ } finally {
5258
+ clearTimeout(timeoutId);
5259
+ }
5260
+ }
5261
+
5088
5262
  // src/client.ts
5089
5263
  var API_BASE = "https://api.sgroup.qq.com";
5090
5264
  var TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
@@ -5192,7 +5366,8 @@ async function sendGroupMessage(params) {
5192
5366
  messageId: params.messageId,
5193
5367
  markdown: params.markdown
5194
5368
  });
5195
- return apiPost(params.accessToken, `/v2/groups/${params.groupOpenid}/messages`, body, {
5369
+ const groupOpenidLower = params.groupOpenid.toLowerCase();
5370
+ return apiPost(params.accessToken, `/v2/groups/${groupOpenidLower}/messages`, body, {
5196
5371
  timeout: 15e3
5197
5372
  });
5198
5373
  }
@@ -5201,7 +5376,8 @@ async function sendChannelMessage(params) {
5201
5376
  if (params.messageId) {
5202
5377
  body.msg_id = params.messageId;
5203
5378
  }
5204
- return apiPost(params.accessToken, `/channels/${params.channelId}/messages`, body, {
5379
+ const channelIdLower = params.channelId.toLowerCase();
5380
+ return apiPost(params.accessToken, `/channels/${channelIdLower}/messages`, body, {
5205
5381
  timeout: 15e3
5206
5382
  });
5207
5383
  }
@@ -5624,6 +5800,9 @@ function parseTextWithAttachments(payload) {
5624
5800
  attachments
5625
5801
  };
5626
5802
  }
5803
+ var VOICE_ASR_FALLBACK_TEXT = "\u5F53\u524D\u8BED\u97F3\u529F\u80FD\u672A\u542F\u52A8\u6216\u8BC6\u522B\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
5804
+ var VOICE_EXTENSIONS = [".silk", ".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac", ".speex"];
5805
+ var VOICE_ASR_ERROR_MAX_LENGTH = 500;
5627
5806
  function isHttpUrl2(value) {
5628
5807
  return /^https?:\/\//i.test(value);
5629
5808
  }
@@ -5641,20 +5820,58 @@ function isImageAttachment(att) {
5641
5820
  return false;
5642
5821
  }
5643
5822
  }
5823
+ function isVoiceAttachment(att) {
5824
+ const contentType = att.contentType?.trim().toLowerCase() ?? "";
5825
+ if (contentType === "voice" || contentType.startsWith("audio/")) {
5826
+ return true;
5827
+ }
5828
+ const lowerName = att.filename?.trim().toLowerCase() ?? "";
5829
+ if (VOICE_EXTENSIONS.some((ext) => lowerName.endsWith(ext))) {
5830
+ return true;
5831
+ }
5832
+ try {
5833
+ const pathname = new URL(att.url).pathname.toLowerCase();
5834
+ return VOICE_EXTENSIONS.some((ext) => pathname.endsWith(ext));
5835
+ } catch {
5836
+ return false;
5837
+ }
5838
+ }
5644
5839
  function scheduleTempCleanup(filePath) {
5645
5840
  const timer = setTimeout(() => {
5646
5841
  void cleanupFileSafe(filePath);
5647
5842
  }, 20 * 60 * 1e3);
5648
5843
  timer.unref?.();
5649
5844
  }
5845
+ function trimTextForReply(text, maxLength) {
5846
+ if (text.length <= maxLength) return text;
5847
+ return `${text.slice(0, maxLength)}...`;
5848
+ }
5849
+ function buildVoiceASRFallbackReply(errorMessage) {
5850
+ const detail = errorMessage?.trim();
5851
+ if (!detail) return VOICE_ASR_FALLBACK_TEXT;
5852
+ return `${VOICE_ASR_FALLBACK_TEXT}
5853
+
5854
+ \u63A5\u53E3\u9519\u8BEF\uFF1A${trimTextForReply(detail, VOICE_ASR_ERROR_MAX_LENGTH)}`;
5855
+ }
5650
5856
  async function resolveInboundAttachmentsForAgent(params) {
5651
5857
  const { attachments, qqCfg, logger } = params;
5652
5858
  const list = attachments ?? [];
5653
- if (list.length === 0) return [];
5859
+ if (list.length === 0) {
5860
+ return {
5861
+ attachments: [],
5862
+ hasVoiceAttachment: false,
5863
+ hasVoiceTranscript: false,
5864
+ asrErrorMessage: void 0
5865
+ };
5866
+ }
5654
5867
  const timeout = qqCfg.mediaTimeoutMs ?? 3e4;
5655
5868
  const maxFileSizeMB = qqCfg.maxFileSizeMB ?? 100;
5656
5869
  const maxSize = Math.floor(maxFileSizeMB * 1024 * 1024);
5870
+ const asrCredentials = resolveQQBotASRCredentials(qqCfg);
5657
5871
  const resolved = [];
5872
+ let hasVoiceAttachment = false;
5873
+ let hasVoiceTranscript = false;
5874
+ let asrErrorMessage;
5658
5875
  for (const att of list) {
5659
5876
  const next = { attachment: att };
5660
5877
  if (isImageAttachment(att) && isHttpUrl2(att.url)) {
@@ -5672,15 +5889,66 @@ async function resolveInboundAttachmentsForAgent(params) {
5672
5889
  logger.warn(`failed to download inbound attachment: ${String(err)}`);
5673
5890
  }
5674
5891
  }
5892
+ if (isVoiceAttachment(att)) {
5893
+ hasVoiceAttachment = true;
5894
+ if (!qqCfg.asr?.enabled) {
5895
+ logger.info("voice attachment received but ASR is disabled");
5896
+ } else if (!asrCredentials) {
5897
+ logger.warn("voice ASR enabled but credentials are missing or invalid");
5898
+ } else if (!isHttpUrl2(att.url)) {
5899
+ logger.warn("voice ASR skipped: attachment URL is not an HTTP URL");
5900
+ } else {
5901
+ try {
5902
+ const media = await fetchMediaFromUrl(att.url, {
5903
+ timeout,
5904
+ maxSize
5905
+ });
5906
+ const transcript = await transcribeTencentFlash({
5907
+ audio: media.buffer,
5908
+ config: {
5909
+ appId: asrCredentials.appId,
5910
+ secretId: asrCredentials.secretId,
5911
+ secretKey: asrCredentials.secretKey,
5912
+ timeoutMs: timeout
5913
+ }
5914
+ });
5915
+ if (transcript.trim()) {
5916
+ next.voiceTranscript = transcript.trim();
5917
+ hasVoiceTranscript = true;
5918
+ logger.info(
5919
+ `[voice-asr] transcript: ${next.voiceTranscript}${att.filename ? ` (file: ${att.filename})` : ""}`
5920
+ );
5921
+ }
5922
+ } catch (err) {
5923
+ if (err instanceof ASRError) {
5924
+ logger.warn(
5925
+ `voice ASR failed: kind=${err.kind} provider=${err.provider} retryable=${err.retryable} message=${err.message}`
5926
+ );
5927
+ asrErrorMessage ??= err.message.trim() || void 0;
5928
+ } else {
5929
+ logger.warn(`voice ASR failed: ${String(err)}`);
5930
+ }
5931
+ }
5932
+ }
5933
+ }
5675
5934
  resolved.push(next);
5676
5935
  }
5677
- return resolved;
5936
+ return {
5937
+ attachments: resolved,
5938
+ hasVoiceAttachment,
5939
+ hasVoiceTranscript,
5940
+ asrErrorMessage
5941
+ };
5678
5942
  }
5679
5943
  function buildInboundContentWithAttachments(params) {
5680
5944
  const { content, attachments } = params;
5681
5945
  const list = attachments ?? [];
5682
5946
  if (list.length === 0) return content;
5683
5947
  const imageRefs = list.map((item) => item.localImagePath).filter((value) => typeof value === "string" && value.trim().length > 0).map((value) => `[Image: source: ${value}]`);
5948
+ const voiceTranscripts = list.filter((item) => typeof item.voiceTranscript === "string" && item.voiceTranscript.trim()).map((item, index) => {
5949
+ const filename = item.attachment.filename?.trim() || `voice-${index + 1}`;
5950
+ return `- ${filename}: ${item.voiceTranscript}`;
5951
+ });
5684
5952
  const lines = list.map((item, index) => {
5685
5953
  const att = item.attachment;
5686
5954
  const filename = att.filename?.trim() ? att.filename.trim() : `attachment-${index + 1}`;
@@ -5692,9 +5960,30 @@ function buildInboundContentWithAttachments(params) {
5692
5960
  const parts = [];
5693
5961
  if (content) parts.push(content);
5694
5962
  if (imageRefs.length > 0) parts.push(imageRefs.join("\n"));
5963
+ if (voiceTranscripts.length > 0) {
5964
+ parts.push(["[QQ voice transcripts]", ...voiceTranscripts].join("\n"));
5965
+ }
5695
5966
  parts.push(block);
5696
5967
  return parts.join("\n\n");
5697
5968
  }
5969
+ function resolveInboundLogContent(params) {
5970
+ const text = params.content.trim();
5971
+ if (text) return text;
5972
+ const attachments = params.attachments ?? [];
5973
+ if (attachments.some((att) => isVoiceAttachment(att))) {
5974
+ return "\u3010\u8BED\u97F3\u3011";
5975
+ }
5976
+ if (attachments.some((att) => isImageAttachment(att))) {
5977
+ return "\u3010\u56FE\u7247\u3011";
5978
+ }
5979
+ if (attachments.length > 0) {
5980
+ return "\u3010\u9644\u4EF6\u3011";
5981
+ }
5982
+ return "\u3010\u7A7A\u6D88\u606F\u3011";
5983
+ }
5984
+ function sanitizeInboundLogText(text) {
5985
+ return text.replace(/\r?\n/g, "\\n");
5986
+ }
5698
5987
  function parseC2CMessage(data) {
5699
5988
  const payload = data;
5700
5989
  const { text, attachments } = parseTextWithAttachments(payload);
@@ -5796,7 +6085,7 @@ function resolveInbound(eventType, data) {
5796
6085
  }
5797
6086
  function resolveChatTarget(event) {
5798
6087
  if (event.type === "group") {
5799
- const group = event.groupOpenid ?? "";
6088
+ const group = (event.groupOpenid ?? "").toLowerCase();
5800
6089
  return {
5801
6090
  to: `group:${group}`,
5802
6091
  peerId: `group:${group}`,
@@ -5804,7 +6093,7 @@ function resolveChatTarget(event) {
5804
6093
  };
5805
6094
  }
5806
6095
  if (event.type === "channel") {
5807
- const channel = event.channelId ?? "";
6096
+ const channel = (event.channelId ?? "").toLowerCase();
5808
6097
  return {
5809
6098
  to: `channel:${channel}`,
5810
6099
  peerId: `channel:${channel}`,
@@ -5819,10 +6108,10 @@ function resolveChatTarget(event) {
5819
6108
  }
5820
6109
  function resolveEnvelopeFrom(event) {
5821
6110
  if (event.type === "group") {
5822
- return `group:${event.groupOpenid ?? "unknown"}`;
6111
+ return `group:${(event.groupOpenid ?? "unknown").toLowerCase()}`;
5823
6112
  }
5824
6113
  if (event.type === "channel") {
5825
- return `channel:${event.channelId ?? "unknown"}`;
6114
+ return `channel:${(event.channelId ?? "unknown").toLowerCase()}`;
5826
6115
  }
5827
6116
  return event.senderName?.trim() || event.senderId;
5828
6117
  }
@@ -5950,11 +6239,24 @@ async function dispatchToAgent(params) {
5950
6239
  );
5951
6240
  const envelopeOptions = replyApi.resolveEnvelopeFormatOptions?.(cfg);
5952
6241
  const previousTimestamp = storePath && sessionApi?.readSessionUpdatedAt ? sessionApi.readSessionUpdatedAt({ storePath, sessionKey: route.sessionKey }) : null;
5953
- const resolvedAttachments = await resolveInboundAttachmentsForAgent({
6242
+ const resolvedAttachmentResult = await resolveInboundAttachmentsForAgent({
5954
6243
  attachments: inbound.attachments,
5955
6244
  qqCfg,
5956
6245
  logger
5957
6246
  });
6247
+ if (qqCfg.asr?.enabled && resolvedAttachmentResult.hasVoiceAttachment && !resolvedAttachmentResult.hasVoiceTranscript) {
6248
+ const fallback = await qqbotOutbound.sendText({
6249
+ cfg: { channels: { qqbot: qqCfg } },
6250
+ to: target.to,
6251
+ text: buildVoiceASRFallbackReply(resolvedAttachmentResult.asrErrorMessage),
6252
+ replyToId: inbound.messageId
6253
+ });
6254
+ if (fallback.error) {
6255
+ logger.error(`sendText ASR fallback failed: ${fallback.error}`);
6256
+ }
6257
+ return;
6258
+ }
6259
+ const resolvedAttachments = resolvedAttachmentResult.attachments;
5958
6260
  const localImageCount = resolvedAttachments.filter((item) => Boolean(item.localImagePath)).length;
5959
6261
  if (localImageCount > 0) {
5960
6262
  logger.info(`prepared ${localImageCount} local image attachment(s) for agent`);
@@ -6213,10 +6515,17 @@ async function handleQQBotDispatch(params) {
6213
6515
  logger.info("qqbot disabled, ignoring inbound message");
6214
6516
  return;
6215
6517
  }
6518
+ const content = inbound.content.trim();
6519
+ const inboundLogContent = sanitizeInboundLogText(
6520
+ resolveInboundLogContent({
6521
+ content,
6522
+ attachments: inbound.attachments
6523
+ })
6524
+ );
6525
+ logger.info(`[inbound-user] senderId=${inbound.senderId} content=${inboundLogContent}`);
6216
6526
  if (!shouldHandleMessage(inbound, qqCfg, logger)) {
6217
6527
  return;
6218
6528
  }
6219
- const content = inbound.content.trim();
6220
6529
  const attachmentCount = inbound.attachments?.length ?? 0;
6221
6530
  if (attachmentCount > 0) {
6222
6531
  logger.info(`inbound message includes ${attachmentCount} attachment(s)`);
@@ -6593,6 +6902,16 @@ var qqbotPlugin = {
6593
6902
  enabled: { type: "boolean" },
6594
6903
  appId: { type: "string" },
6595
6904
  clientSecret: { type: "string" },
6905
+ asr: {
6906
+ type: "object",
6907
+ additionalProperties: false,
6908
+ properties: {
6909
+ enabled: { type: "boolean" },
6910
+ appId: { type: "string" },
6911
+ secretId: { type: "string" },
6912
+ secretKey: { type: "string" }
6913
+ }
6914
+ },
6596
6915
  markdownSupport: { type: "boolean" },
6597
6916
  dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
6598
6917
  groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
@@ -6709,6 +7028,16 @@ var plugin = {
6709
7028
  enabled: { type: "boolean" },
6710
7029
  appId: { type: "string" },
6711
7030
  clientSecret: { type: "string" },
7031
+ asr: {
7032
+ type: "object",
7033
+ additionalProperties: false,
7034
+ properties: {
7035
+ enabled: { type: "boolean" },
7036
+ appId: { type: "string" },
7037
+ secretId: { type: "string" },
7038
+ secretKey: { type: "string" }
7039
+ }
7040
+ },
6712
7041
  markdownSupport: { type: "boolean" },
6713
7042
  dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
6714
7043
  groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },