@dingxiang-me/openclaw-wechat 0.4.9

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/core.js ADDED
@@ -0,0 +1,732 @@
1
+ import crypto from "node:crypto";
2
+
3
+ export const WECOM_TEXT_BYTE_LIMIT = 2000;
4
+ export const INBOUND_DEDUPE_TTL_MS = 5 * 60 * 1000;
5
+ const FALSE_LIKE_VALUES = new Set(["0", "false", "off", "no"]);
6
+ const TRUE_LIKE_VALUES = new Set(["1", "true", "on", "yes"]);
7
+ const LOCAL_STT_DIRECT_SUPPORTED_CONTENT_TYPES = new Set([
8
+ "audio/flac",
9
+ "audio/m4a",
10
+ "audio/mp3",
11
+ "audio/mp4",
12
+ "audio/mpeg",
13
+ "audio/ogg",
14
+ "audio/wav",
15
+ "audio/webm",
16
+ "audio/x-m4a",
17
+ "audio/x-wav",
18
+ "audio/x-flac",
19
+ ]);
20
+ const AUDIO_CONTENT_TYPE_TO_EXTENSION = Object.freeze({
21
+ "audio/amr": ".amr",
22
+ "audio/flac": ".flac",
23
+ "audio/m4a": ".m4a",
24
+ "audio/mp3": ".mp3",
25
+ "audio/mp4": ".m4a",
26
+ "audio/mpeg": ".mp3",
27
+ "audio/ogg": ".ogg",
28
+ "audio/silk": ".sil",
29
+ "audio/wav": ".wav",
30
+ "audio/webm": ".webm",
31
+ "audio/x-m4a": ".m4a",
32
+ "audio/x-wav": ".wav",
33
+ "audio/x-flac": ".flac",
34
+ });
35
+ const DEFAULT_COMMAND_ALLOWLIST = Object.freeze([
36
+ "/help",
37
+ "/status",
38
+ "/clear",
39
+ "/reset",
40
+ "/new",
41
+ "/compact",
42
+ ]);
43
+ const DEFAULT_ALLOW_FROM_REJECT_MESSAGE = "当前账号未授权,请联系管理员。";
44
+
45
+ const inboundMessageDedupe = new Map();
46
+
47
+ export function buildWecomSessionId(userId) {
48
+ return `wecom:${String(userId ?? "").trim().toLowerCase()}`;
49
+ }
50
+
51
+ export function buildInboundDedupeKey(msgObj, namespace = "default") {
52
+ const ns = String(namespace ?? "default").trim().toLowerCase() || "default";
53
+ const msgId = String(msgObj?.MsgId ?? "").trim();
54
+ if (msgId) return `${ns}:id:${msgId}`;
55
+ const fromUser = String(msgObj?.FromUserName ?? "").trim().toLowerCase();
56
+ const createTime = String(msgObj?.CreateTime ?? "").trim();
57
+ const msgType = String(msgObj?.MsgType ?? "").trim().toLowerCase();
58
+ const stableHint = String(
59
+ msgObj?.Content ?? msgObj?.MediaId ?? msgObj?.EventKey ?? msgObj?.Event ?? "",
60
+ )
61
+ .trim()
62
+ .slice(0, 160);
63
+ if (!fromUser && !createTime && !msgType && !stableHint) return null;
64
+ return `${ns}:${fromUser}|${createTime}|${msgType}|${stableHint}`;
65
+ }
66
+
67
+ export function markInboundMessageSeen(msgObj, namespace = "default") {
68
+ const dedupeKey = buildInboundDedupeKey(msgObj, namespace);
69
+ if (!dedupeKey) return true;
70
+
71
+ const now = Date.now();
72
+ for (const [key, expiresAt] of inboundMessageDedupe) {
73
+ if (expiresAt <= now) inboundMessageDedupe.delete(key);
74
+ }
75
+
76
+ const existingExpiry = inboundMessageDedupe.get(dedupeKey);
77
+ if (typeof existingExpiry === "number" && existingExpiry > now) return false;
78
+
79
+ inboundMessageDedupe.set(dedupeKey, now + INBOUND_DEDUPE_TTL_MS);
80
+ return true;
81
+ }
82
+
83
+ export function resetInboundMessageDedupeForTests() {
84
+ inboundMessageDedupe.clear();
85
+ }
86
+
87
+ function sha1(text) {
88
+ return crypto.createHash("sha1").update(text).digest("hex");
89
+ }
90
+
91
+ export function computeMsgSignature({ token, timestamp, nonce, encrypt }) {
92
+ const arr = [token, timestamp, nonce, encrypt].map(String).sort();
93
+ return sha1(arr.join(""));
94
+ }
95
+
96
+ export function getByteLength(str) {
97
+ return Buffer.byteLength(str, "utf8");
98
+ }
99
+
100
+ export function splitWecomText(text, byteLimit = WECOM_TEXT_BYTE_LIMIT) {
101
+ if (getByteLength(text) <= byteLimit) return [text];
102
+
103
+ const chunks = [];
104
+ let remaining = text;
105
+
106
+ while (remaining.length > 0) {
107
+ if (getByteLength(remaining) <= byteLimit) {
108
+ chunks.push(remaining);
109
+ break;
110
+ }
111
+
112
+ let low = 1;
113
+ let high = remaining.length;
114
+
115
+ while (low < high) {
116
+ const mid = Math.floor((low + high + 1) / 2);
117
+ if (getByteLength(remaining.slice(0, mid)) <= byteLimit) {
118
+ low = mid;
119
+ } else {
120
+ high = mid - 1;
121
+ }
122
+ }
123
+ let splitIndex = low;
124
+
125
+ const searchStart = Math.max(0, splitIndex - 200);
126
+ const searchText = remaining.slice(searchStart, splitIndex);
127
+
128
+ let naturalBreak = searchText.lastIndexOf("\n\n");
129
+ if (naturalBreak === -1) {
130
+ naturalBreak = searchText.lastIndexOf("\n");
131
+ }
132
+ if (naturalBreak === -1) {
133
+ naturalBreak = searchText.lastIndexOf("。");
134
+ if (naturalBreak !== -1) naturalBreak += 1;
135
+ }
136
+ if (naturalBreak !== -1 && naturalBreak > 0) {
137
+ splitIndex = searchStart + naturalBreak;
138
+ }
139
+
140
+ if (splitIndex <= 0) {
141
+ splitIndex = Math.min(remaining.length, Math.floor(byteLimit / 3));
142
+ }
143
+
144
+ chunks.push(remaining.slice(0, splitIndex));
145
+ remaining = remaining.slice(splitIndex);
146
+ }
147
+
148
+ return chunks.filter((chunk) => chunk.length > 0);
149
+ }
150
+
151
+ export function pickAccountBySignature({ accounts, msgSignature, timestamp, nonce, encrypt }) {
152
+ if (!msgSignature || !encrypt) return null;
153
+ for (const account of accounts) {
154
+ if (!account?.callbackToken || !account?.callbackAesKey) continue;
155
+ const expected = computeMsgSignature({
156
+ token: account.callbackToken,
157
+ timestamp,
158
+ nonce,
159
+ encrypt,
160
+ });
161
+ if (expected === msgSignature) return account;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ function pickFirstNonEmptyString(...values) {
167
+ for (const value of values) {
168
+ if (typeof value !== "string") continue;
169
+ const trimmed = value.trim();
170
+ if (trimmed) return trimmed;
171
+ }
172
+ return "";
173
+ }
174
+
175
+ function normalizeAccountIdForEnv(accountId) {
176
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
177
+ return normalized || "default";
178
+ }
179
+
180
+ function readAllowFromEnv(envVars, processEnv, accountId = "default") {
181
+ const normalizedId = normalizeAccountIdForEnv(accountId);
182
+ const scopedAllowFromKey = normalizedId === "default" ? null : `WECOM_${normalizedId.toUpperCase()}_ALLOW_FROM`;
183
+ const scoped = parseStringList(
184
+ scopedAllowFromKey ? envVars?.[scopedAllowFromKey] : undefined,
185
+ scopedAllowFromKey ? processEnv?.[scopedAllowFromKey] : undefined,
186
+ );
187
+ if (scoped.length > 0) return scoped;
188
+ return parseStringList(envVars?.WECOM_ALLOW_FROM, processEnv?.WECOM_ALLOW_FROM);
189
+ }
190
+
191
+ function readAllowFromRejectMessageEnv(envVars, processEnv, accountId = "default") {
192
+ const normalizedId = normalizeAccountIdForEnv(accountId);
193
+ const scopedRejectMessageKey =
194
+ normalizedId === "default" ? null : `WECOM_${normalizedId.toUpperCase()}_ALLOW_FROM_REJECT_MESSAGE`;
195
+ return pickFirstNonEmptyString(
196
+ scopedRejectMessageKey ? envVars?.[scopedRejectMessageKey] : undefined,
197
+ scopedRejectMessageKey ? processEnv?.[scopedRejectMessageKey] : undefined,
198
+ envVars?.WECOM_ALLOW_FROM_REJECT_MESSAGE,
199
+ processEnv?.WECOM_ALLOW_FROM_REJECT_MESSAGE,
200
+ );
201
+ }
202
+
203
+ function readProxyEnv(envVars, processEnv, accountId = "default") {
204
+ const normalizedId = normalizeAccountIdForEnv(accountId);
205
+ const scopedProxyKey = normalizedId === "default" ? null : `WECOM_${normalizedId.toUpperCase()}_PROXY`;
206
+ return pickFirstNonEmptyString(
207
+ scopedProxyKey ? envVars?.[scopedProxyKey] : undefined,
208
+ scopedProxyKey ? processEnv?.[scopedProxyKey] : undefined,
209
+ envVars?.WECOM_PROXY,
210
+ processEnv?.WECOM_PROXY,
211
+ processEnv?.HTTPS_PROXY,
212
+ processEnv?.HTTP_PROXY,
213
+ );
214
+ }
215
+
216
+ export function resolveWecomProxyConfig({
217
+ channelConfig = {},
218
+ accountConfig = {},
219
+ envVars = {},
220
+ processEnv = process.env,
221
+ accountId = "default",
222
+ } = {}) {
223
+ const fromAccountConfig = pickFirstNonEmptyString(
224
+ accountConfig?.outboundProxy,
225
+ accountConfig?.proxyUrl,
226
+ accountConfig?.proxy,
227
+ );
228
+ const fromChannelConfig = pickFirstNonEmptyString(
229
+ channelConfig?.outboundProxy,
230
+ channelConfig?.proxyUrl,
231
+ channelConfig?.proxy,
232
+ );
233
+ const fromEnv = readProxyEnv(envVars, processEnv, accountId);
234
+ const resolved = pickFirstNonEmptyString(fromAccountConfig, fromChannelConfig, fromEnv);
235
+ return resolved || undefined;
236
+ }
237
+
238
+ function asPositiveInteger(value, fallback) {
239
+ const n = Number(value);
240
+ if (!Number.isFinite(n) || n <= 0) return fallback;
241
+ return Math.floor(n);
242
+ }
243
+
244
+ function asBoundedPositiveInteger(value, fallback, minimum, maximum) {
245
+ const n = asPositiveInteger(value, fallback);
246
+ if (!Number.isFinite(n)) return fallback;
247
+ return Math.max(minimum, Math.min(maximum, n));
248
+ }
249
+
250
+ function parseBooleanLike(value, fallback) {
251
+ if (typeof value === "boolean") return value;
252
+ if (typeof value !== "string") return fallback;
253
+ const normalized = value.trim().toLowerCase();
254
+ if (!normalized) return fallback;
255
+ if (TRUE_LIKE_VALUES.has(normalized)) return true;
256
+ if (FALSE_LIKE_VALUES.has(normalized)) return false;
257
+ return fallback;
258
+ }
259
+
260
+ function parseStringList(...values) {
261
+ const out = [];
262
+ for (const value of values) {
263
+ if (Array.isArray(value)) {
264
+ for (const item of value) {
265
+ const trimmed = String(item ?? "").trim();
266
+ if (trimmed) out.push(trimmed);
267
+ }
268
+ continue;
269
+ }
270
+ if (typeof value === "string") {
271
+ for (const part of value.split(/[,\n]/)) {
272
+ const trimmed = part.trim();
273
+ if (trimmed) out.push(trimmed);
274
+ }
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function uniqueLowerCaseList(values) {
281
+ const deduped = new Set();
282
+ for (const raw of values) {
283
+ const normalized = String(raw ?? "").trim().toLowerCase();
284
+ if (normalized) deduped.add(normalized);
285
+ }
286
+ return Array.from(deduped);
287
+ }
288
+
289
+ function normalizeCommandToken(value) {
290
+ const normalized = String(value ?? "").trim().toLowerCase();
291
+ if (!normalized) return "";
292
+ return normalized.startsWith("/") ? normalized : `/${normalized}`;
293
+ }
294
+
295
+ function uniqueCommandList(values) {
296
+ const deduped = new Set();
297
+ for (const value of values) {
298
+ const normalized = normalizeCommandToken(value);
299
+ if (normalized) deduped.add(normalized);
300
+ }
301
+ return Array.from(deduped);
302
+ }
303
+
304
+ export function normalizeWecomAllowFromEntry(raw) {
305
+ const trimmed = String(raw ?? "").trim();
306
+ if (!trimmed) return "";
307
+ if (trimmed === "*") return "*";
308
+ return trimmed
309
+ .replace(/^(wecom|wework):/i, "")
310
+ .replace(/^user:/i, "")
311
+ .toLowerCase();
312
+ }
313
+
314
+ function uniqueAllowFromList(values) {
315
+ const deduped = new Set();
316
+ for (const value of values) {
317
+ const normalized = normalizeWecomAllowFromEntry(value);
318
+ if (normalized) deduped.add(normalized);
319
+ }
320
+ return Array.from(deduped);
321
+ }
322
+
323
+ export function extractLeadingSlashCommand(text) {
324
+ const normalized = String(text ?? "").trim();
325
+ if (!normalized.startsWith("/")) return "";
326
+ const command = normalized.split(/\s+/)[0]?.trim().toLowerCase() ?? "";
327
+ return normalizeCommandToken(command);
328
+ }
329
+
330
+ export function resolveWecomCommandPolicyConfig({
331
+ channelConfig = {},
332
+ envVars = {},
333
+ processEnv = process.env,
334
+ } = {}) {
335
+ const commandConfig =
336
+ channelConfig?.commands && typeof channelConfig.commands === "object" ? channelConfig.commands : {};
337
+ const enabled = parseBooleanLike(
338
+ commandConfig.enabled,
339
+ parseBooleanLike(envVars?.WECOM_COMMANDS_ENABLED, parseBooleanLike(processEnv?.WECOM_COMMANDS_ENABLED, false)),
340
+ );
341
+ const configuredAllowlist = uniqueCommandList(
342
+ parseStringList(
343
+ commandConfig.allowlist,
344
+ envVars?.WECOM_COMMANDS_ALLOWLIST,
345
+ processEnv?.WECOM_COMMANDS_ALLOWLIST,
346
+ ),
347
+ );
348
+ const allowlist = configuredAllowlist.length > 0 ? configuredAllowlist : Array.from(DEFAULT_COMMAND_ALLOWLIST);
349
+ const adminUsers = uniqueLowerCaseList(
350
+ parseStringList(channelConfig?.adminUsers, envVars?.WECOM_ADMIN_USERS, processEnv?.WECOM_ADMIN_USERS),
351
+ );
352
+ const rejectMessage = pickFirstNonEmptyString(
353
+ commandConfig.rejectMessage,
354
+ envVars?.WECOM_COMMANDS_REJECT_MESSAGE,
355
+ processEnv?.WECOM_COMMANDS_REJECT_MESSAGE,
356
+ "该指令未开放,请联系管理员。",
357
+ );
358
+
359
+ return {
360
+ enabled,
361
+ allowlist,
362
+ adminUsers,
363
+ rejectMessage,
364
+ };
365
+ }
366
+
367
+ export function resolveWecomAllowFromPolicyConfig({
368
+ channelConfig = {},
369
+ accountConfig = {},
370
+ envVars = {},
371
+ processEnv = process.env,
372
+ accountId = "default",
373
+ } = {}) {
374
+ const accountAllowFrom = uniqueAllowFromList(parseStringList(accountConfig?.allowFrom));
375
+ const channelAllowFrom = uniqueAllowFromList(parseStringList(channelConfig?.allowFrom));
376
+ const envAllowFrom = uniqueAllowFromList(readAllowFromEnv(envVars, processEnv, accountId));
377
+ const allowFrom = accountAllowFrom.length > 0 ? accountAllowFrom : channelAllowFrom.length > 0 ? channelAllowFrom : envAllowFrom;
378
+ const rejectMessage = pickFirstNonEmptyString(
379
+ accountConfig?.allowFromRejectMessage,
380
+ accountConfig?.rejectUnauthorizedMessage,
381
+ channelConfig?.allowFromRejectMessage,
382
+ channelConfig?.rejectUnauthorizedMessage,
383
+ readAllowFromRejectMessageEnv(envVars, processEnv, accountId),
384
+ DEFAULT_ALLOW_FROM_REJECT_MESSAGE,
385
+ );
386
+ return {
387
+ allowFrom,
388
+ rejectMessage,
389
+ };
390
+ }
391
+
392
+ export function isWecomSenderAllowed({ senderId, allowFrom = [] } = {}) {
393
+ const sender = normalizeWecomAllowFromEntry(senderId);
394
+ if (!sender) return false;
395
+ const normalizedAllowFrom = uniqueAllowFromList(Array.isArray(allowFrom) ? allowFrom : parseStringList(allowFrom));
396
+ if (normalizedAllowFrom.length === 0 || normalizedAllowFrom.includes("*")) return true;
397
+ return normalizedAllowFrom.includes(sender);
398
+ }
399
+
400
+ export function resolveWecomGroupChatConfig({
401
+ channelConfig = {},
402
+ envVars = {},
403
+ processEnv = process.env,
404
+ } = {}) {
405
+ const groupConfig =
406
+ channelConfig?.groupChat && typeof channelConfig.groupChat === "object" ? channelConfig.groupChat : {};
407
+ const enabled = parseBooleanLike(
408
+ groupConfig.enabled,
409
+ parseBooleanLike(envVars?.WECOM_GROUP_CHAT_ENABLED, parseBooleanLike(processEnv?.WECOM_GROUP_CHAT_ENABLED, true)),
410
+ );
411
+ const requireMention = parseBooleanLike(
412
+ groupConfig.requireMention,
413
+ parseBooleanLike(
414
+ envVars?.WECOM_GROUP_CHAT_REQUIRE_MENTION,
415
+ parseBooleanLike(processEnv?.WECOM_GROUP_CHAT_REQUIRE_MENTION, false),
416
+ ),
417
+ );
418
+ const mentionPatterns = parseStringList(
419
+ groupConfig.mentionPatterns,
420
+ envVars?.WECOM_GROUP_CHAT_MENTION_PATTERNS,
421
+ processEnv?.WECOM_GROUP_CHAT_MENTION_PATTERNS,
422
+ "@",
423
+ );
424
+ const dedupedPatterns = [];
425
+ const seen = new Set();
426
+ for (const pattern of mentionPatterns) {
427
+ const token = String(pattern ?? "").trim();
428
+ if (!token || seen.has(token)) continue;
429
+ seen.add(token);
430
+ dedupedPatterns.push(token);
431
+ }
432
+
433
+ return {
434
+ enabled,
435
+ requireMention,
436
+ mentionPatterns: dedupedPatterns.length > 0 ? dedupedPatterns : ["@"],
437
+ };
438
+ }
439
+
440
+ export function shouldTriggerWecomGroupResponse(content, groupChatConfig) {
441
+ if (groupChatConfig?.enabled === false) return false;
442
+ if (groupChatConfig?.requireMention !== true) return true;
443
+ const text = String(content ?? "");
444
+ if (!text.trim()) return false;
445
+ const patterns =
446
+ Array.isArray(groupChatConfig?.mentionPatterns) && groupChatConfig.mentionPatterns.length > 0
447
+ ? groupChatConfig.mentionPatterns
448
+ : ["@"];
449
+ return patterns.some((pattern) => {
450
+ const normalized = String(pattern ?? "").trim();
451
+ if (!normalized) return false;
452
+ if (normalized === "@") {
453
+ // Avoid false positives for email-like "user@domain" content.
454
+ return /(^|[^A-Za-z0-9._%+-])@[^\s@]+/u.test(text);
455
+ }
456
+ const escaped = escapeRegExp(normalized);
457
+ if (normalized.startsWith("@")) {
458
+ return new RegExp(`(^|[^A-Za-z0-9._%+-])${escaped}(?=$|\\s|[()\\[\\]{}<>,.!?;:,。!?、;:])`, "u").test(
459
+ text,
460
+ );
461
+ }
462
+ return new RegExp(
463
+ `(^|\\s|[()\\[\\]{}<>,.!?;:,。!?、;:])${escaped}(?=$|\\s|[()\\[\\]{}<>,.!?;:,。!?、;:])`,
464
+ "u",
465
+ ).test(text);
466
+ });
467
+ }
468
+
469
+ function escapeRegExp(value) {
470
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
471
+ }
472
+
473
+ export function stripWecomGroupMentions(content, mentionPatterns = ["@"]) {
474
+ let text = String(content ?? "");
475
+ const patterns = Array.isArray(mentionPatterns) && mentionPatterns.length > 0 ? mentionPatterns : ["@"];
476
+ for (const rawPattern of patterns) {
477
+ const pattern = String(rawPattern ?? "").trim();
478
+ if (!pattern) continue;
479
+ if (pattern === "@") {
480
+ // Remove "@name" mentions while keeping email-like local@domain untouched.
481
+ text = text.replace(/(^|[^A-Za-z0-9._%+-])@[^\s@]+/gu, "$1");
482
+ continue;
483
+ }
484
+ const escaped = escapeRegExp(pattern);
485
+ if (pattern.startsWith("@")) {
486
+ text = text.replace(new RegExp(`(^|[^A-Za-z0-9._%+-])${escaped}\\S*`, "gu"), "$1");
487
+ continue;
488
+ }
489
+ text = text.replace(
490
+ new RegExp(`(^|\\s|[()\\[\\]{}<>,.!?;:,。!?、;:])${escaped}\\S*`, "gu"),
491
+ "$1",
492
+ );
493
+ }
494
+ return text.replace(/\s{2,}/g, " ").trim();
495
+ }
496
+
497
+ export function resolveWecomDebounceConfig({
498
+ channelConfig = {},
499
+ envVars = {},
500
+ processEnv = process.env,
501
+ } = {}) {
502
+ const debounceConfig =
503
+ channelConfig?.debounce && typeof channelConfig.debounce === "object" ? channelConfig.debounce : {};
504
+ const enabled = parseBooleanLike(
505
+ debounceConfig.enabled,
506
+ parseBooleanLike(envVars?.WECOM_DEBOUNCE_ENABLED, parseBooleanLike(processEnv?.WECOM_DEBOUNCE_ENABLED, false)),
507
+ );
508
+ const windowMs = asBoundedPositiveInteger(
509
+ debounceConfig.windowMs ?? envVars?.WECOM_DEBOUNCE_WINDOW_MS ?? processEnv?.WECOM_DEBOUNCE_WINDOW_MS,
510
+ 1200,
511
+ 100,
512
+ 10000,
513
+ );
514
+ const maxBatch = asBoundedPositiveInteger(
515
+ debounceConfig.maxBatch ?? envVars?.WECOM_DEBOUNCE_MAX_BATCH ?? processEnv?.WECOM_DEBOUNCE_MAX_BATCH,
516
+ 6,
517
+ 1,
518
+ 50,
519
+ );
520
+ return {
521
+ enabled,
522
+ windowMs,
523
+ maxBatch,
524
+ };
525
+ }
526
+
527
+ export function resolveWecomStreamingConfig({
528
+ channelConfig = {},
529
+ envVars = {},
530
+ processEnv = process.env,
531
+ } = {}) {
532
+ const streamingConfig =
533
+ channelConfig?.streaming && typeof channelConfig.streaming === "object" ? channelConfig.streaming : {};
534
+ const enabled = parseBooleanLike(
535
+ streamingConfig.enabled,
536
+ parseBooleanLike(envVars?.WECOM_STREAMING_ENABLED, parseBooleanLike(processEnv?.WECOM_STREAMING_ENABLED, false)),
537
+ );
538
+ const minChars = asBoundedPositiveInteger(
539
+ streamingConfig.minChars ?? envVars?.WECOM_STREAMING_MIN_CHARS ?? processEnv?.WECOM_STREAMING_MIN_CHARS,
540
+ 120,
541
+ 20,
542
+ 2000,
543
+ );
544
+ const minIntervalMs = asBoundedPositiveInteger(
545
+ streamingConfig.minIntervalMs ??
546
+ envVars?.WECOM_STREAMING_MIN_INTERVAL_MS ??
547
+ processEnv?.WECOM_STREAMING_MIN_INTERVAL_MS,
548
+ 1200,
549
+ 200,
550
+ 10000,
551
+ );
552
+ return {
553
+ enabled,
554
+ minChars,
555
+ minIntervalMs,
556
+ };
557
+ }
558
+
559
+ export function resolveWecomBotModeConfig({
560
+ channelConfig = {},
561
+ envVars = {},
562
+ processEnv = process.env,
563
+ } = {}) {
564
+ const botConfig = channelConfig?.bot && typeof channelConfig.bot === "object" ? channelConfig.bot : {};
565
+ const enabled = parseBooleanLike(
566
+ botConfig.enabled,
567
+ parseBooleanLike(envVars?.WECOM_BOT_ENABLED, parseBooleanLike(processEnv?.WECOM_BOT_ENABLED, false)),
568
+ );
569
+ const token = pickFirstNonEmptyString(
570
+ botConfig.token,
571
+ envVars?.WECOM_BOT_TOKEN,
572
+ processEnv?.WECOM_BOT_TOKEN,
573
+ );
574
+ const encodingAesKey = pickFirstNonEmptyString(
575
+ botConfig.encodingAesKey,
576
+ envVars?.WECOM_BOT_ENCODING_AES_KEY,
577
+ processEnv?.WECOM_BOT_ENCODING_AES_KEY,
578
+ );
579
+ const webhookPath = pickFirstNonEmptyString(
580
+ botConfig.webhookPath,
581
+ envVars?.WECOM_BOT_WEBHOOK_PATH,
582
+ processEnv?.WECOM_BOT_WEBHOOK_PATH,
583
+ "/wecom/bot/callback",
584
+ );
585
+ const hasOwn = (obj, key) => Object.prototype.hasOwnProperty.call(obj ?? {}, key);
586
+ const placeholderText = (() => {
587
+ if (hasOwn(botConfig, "placeholderText")) return String(botConfig.placeholderText ?? "");
588
+ if (hasOwn(envVars, "WECOM_BOT_PLACEHOLDER_TEXT")) return String(envVars.WECOM_BOT_PLACEHOLDER_TEXT ?? "");
589
+ if (hasOwn(processEnv, "WECOM_BOT_PLACEHOLDER_TEXT"))
590
+ return String(processEnv.WECOM_BOT_PLACEHOLDER_TEXT ?? "");
591
+ return "消息已收到,正在处理中,请稍等片刻。";
592
+ })();
593
+ const streamExpireMs = asBoundedPositiveInteger(
594
+ botConfig.streamExpireMs ??
595
+ envVars?.WECOM_BOT_STREAM_EXPIRE_MS ??
596
+ processEnv?.WECOM_BOT_STREAM_EXPIRE_MS,
597
+ 10 * 60 * 1000,
598
+ 30 * 1000,
599
+ 60 * 60 * 1000,
600
+ );
601
+
602
+ return {
603
+ enabled,
604
+ token: token || undefined,
605
+ encodingAesKey: encodingAesKey || undefined,
606
+ webhookPath: webhookPath || "/wecom/bot/callback",
607
+ placeholderText,
608
+ streamExpireMs,
609
+ };
610
+ }
611
+
612
+ function readVoiceEnv(envVars, processEnv, suffix) {
613
+ const keys = [`WECOM_VOICE_TRANSCRIBE_${suffix}`, `WECOM_VOICE_${suffix}`];
614
+ for (const key of keys) {
615
+ const fromConfig = envVars?.[key];
616
+ if (fromConfig != null && String(fromConfig).trim() !== "") return fromConfig;
617
+ const fromProcess = processEnv?.[key];
618
+ if (fromProcess != null && String(fromProcess).trim() !== "") return fromProcess;
619
+ }
620
+ return undefined;
621
+ }
622
+
623
+ export function normalizeAudioContentType(contentType) {
624
+ const normalized = String(contentType ?? "")
625
+ .trim()
626
+ .toLowerCase()
627
+ .split(";")[0]
628
+ .trim();
629
+ return normalized || "";
630
+ }
631
+
632
+ export function isLocalVoiceInputTypeDirectlySupported(contentType) {
633
+ const normalized = normalizeAudioContentType(contentType);
634
+ if (!normalized) return false;
635
+ return LOCAL_STT_DIRECT_SUPPORTED_CONTENT_TYPES.has(normalized);
636
+ }
637
+
638
+ export function pickAudioFileExtension({ contentType, fileName } = {}) {
639
+ const normalized = normalizeAudioContentType(contentType);
640
+ if (normalized && AUDIO_CONTENT_TYPE_TO_EXTENSION[normalized]) {
641
+ return AUDIO_CONTENT_TYPE_TO_EXTENSION[normalized];
642
+ }
643
+ const extMatch = String(fileName ?? "")
644
+ .trim()
645
+ .toLowerCase()
646
+ .match(/\.([a-z0-9]{1,8})$/);
647
+ if (extMatch) return `.${extMatch[1]}`;
648
+ return ".bin";
649
+ }
650
+
651
+ export function resolveVoiceTranscriptionConfig({ channelConfig, envVars = {}, processEnv = process.env } = {}) {
652
+ const voiceConfig =
653
+ channelConfig?.voiceTranscription && typeof channelConfig.voiceTranscription === "object"
654
+ ? channelConfig.voiceTranscription
655
+ : {};
656
+
657
+ const enabled = parseBooleanLike(
658
+ voiceConfig.enabled,
659
+ parseBooleanLike(readVoiceEnv(envVars, processEnv, "ENABLED"), true),
660
+ );
661
+ const providerRaw = pickFirstNonEmptyString(
662
+ voiceConfig.provider,
663
+ readVoiceEnv(envVars, processEnv, "PROVIDER"),
664
+ "local-whisper-cli",
665
+ );
666
+ const provider = providerRaw.toLowerCase();
667
+ const command = pickFirstNonEmptyString(
668
+ voiceConfig.command,
669
+ readVoiceEnv(envVars, processEnv, "COMMAND"),
670
+ );
671
+ const homebrewPrefix = pickFirstNonEmptyString(processEnv?.HOMEBREW_PREFIX);
672
+ const defaultHomebrewModelPath = homebrewPrefix
673
+ ? `${homebrewPrefix}/opt/whisper-cpp/share/whisper-cpp/for-tests-ggml-tiny.bin`
674
+ : "";
675
+ const modelPath = pickFirstNonEmptyString(
676
+ voiceConfig.modelPath,
677
+ readVoiceEnv(envVars, processEnv, "MODEL_PATH"),
678
+ processEnv?.WHISPER_MODEL,
679
+ processEnv?.WHISPER_MODEL_PATH,
680
+ defaultHomebrewModelPath,
681
+ "/usr/local/opt/whisper-cpp/share/whisper-cpp/for-tests-ggml-tiny.bin",
682
+ "/opt/homebrew/opt/whisper-cpp/share/whisper-cpp/for-tests-ggml-tiny.bin",
683
+ );
684
+ const model = pickFirstNonEmptyString(
685
+ voiceConfig.model,
686
+ readVoiceEnv(envVars, processEnv, "MODEL"),
687
+ "base",
688
+ );
689
+ const language = pickFirstNonEmptyString(
690
+ voiceConfig.language,
691
+ readVoiceEnv(envVars, processEnv, "LANGUAGE"),
692
+ );
693
+ const prompt = pickFirstNonEmptyString(
694
+ voiceConfig.prompt,
695
+ readVoiceEnv(envVars, processEnv, "PROMPT"),
696
+ );
697
+ const timeoutMs = asPositiveInteger(
698
+ voiceConfig.timeoutMs,
699
+ asPositiveInteger(readVoiceEnv(envVars, processEnv, "TIMEOUT_MS"), 120000),
700
+ );
701
+ const maxBytes = asPositiveInteger(
702
+ voiceConfig.maxBytes,
703
+ asPositiveInteger(readVoiceEnv(envVars, processEnv, "MAX_BYTES"), 10 * 1024 * 1024),
704
+ );
705
+ const ffmpegEnabled = parseBooleanLike(
706
+ voiceConfig.ffmpegEnabled,
707
+ parseBooleanLike(readVoiceEnv(envVars, processEnv, "FFMPEG_ENABLED"), true),
708
+ );
709
+ const transcodeToWav = parseBooleanLike(
710
+ voiceConfig.transcodeToWav,
711
+ parseBooleanLike(readVoiceEnv(envVars, processEnv, "TRANSCODE_TO_WAV"), true),
712
+ );
713
+ const requireModelPath = parseBooleanLike(
714
+ voiceConfig.requireModelPath,
715
+ parseBooleanLike(readVoiceEnv(envVars, processEnv, "REQUIRE_MODEL_PATH"), true),
716
+ );
717
+
718
+ return {
719
+ enabled,
720
+ provider,
721
+ command: command || undefined,
722
+ modelPath: modelPath || undefined,
723
+ model,
724
+ language: language || undefined,
725
+ prompt: prompt || undefined,
726
+ timeoutMs,
727
+ maxBytes,
728
+ ffmpegEnabled,
729
+ transcodeToWav,
730
+ requireModelPath,
731
+ };
732
+ }