@xmanrui/dsh-im 4.11.0 → 4.12.0
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.en.md +2 -1
- package/README.md +2 -1
- package/lib/client.js +1209 -1033
- package/lib/index.js +241 -240
- package/package.json +1 -1
- package/plugin-src/client/channels/dingtalk/index.js +16 -5
- package/plugin-src/client/channels/feishu/index.js +45 -36
- package/plugin-src/client/channels/qq/index.js +16 -5
- package/plugin-src/client/channels/shared/collapsible-account.js +80 -0
- package/plugin-src/client/channels/shared/token-channel.js +39 -28
- package/plugin-src/client/channels/wecom/index.js +16 -5
- package/plugin-src/client/channels/weixin/index.js +16 -5
- package/plugin-src/client/channels/whatsapp/index.js +16 -5
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/styles.js +12 -0
- package/src/channels/shared/i18n-en/wecom.mjs +24 -0
- package/src/channels/wecom/wecom-bridge.mjs +275 -2
- package/src/channels/wecom/wecom-cards.mjs +101 -0
- package/src/channels/wecom/wecom-runtime.mjs +2 -0
- package/src/channels/whatsapp/whatsapp-runtime.mjs +78 -26
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { generateReqId } from '@wecom/aibot-node-sdk';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
parseWecomMenu, wecomList, wecomMenu, wecomMenuText, wecomSettings, wecomTemplateCard,
|
|
5
|
+
} from './wecom-cards.mjs';
|
|
2
6
|
import {
|
|
3
7
|
harnessAnswerForQuestion,
|
|
4
8
|
harnessQuestionText,
|
|
@@ -25,7 +29,7 @@ import {
|
|
|
25
29
|
isPresetCommand,
|
|
26
30
|
runPresetCommand,
|
|
27
31
|
} from '../shared/preset-command.mjs';
|
|
28
|
-
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
32
|
+
import { runWorkspaceCommand, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
|
29
33
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
30
34
|
import { captureContextEnhancement, enhanceContextContent } from '../shared/context-enhancement.mjs';
|
|
31
35
|
import {
|
|
@@ -68,6 +72,7 @@ function helpText() {
|
|
|
68
72
|
t('企业微信机器人已连接 DeepSeek Harness。'),
|
|
69
73
|
'',
|
|
70
74
|
t('直接发送文字、图片或文件即可继续当前会话。'),
|
|
75
|
+
t('/menu 或 /m 打开可点击的功能菜单'),
|
|
71
76
|
t('/new 开启一个全新会话'),
|
|
72
77
|
t('/compact 压缩当前会话的较早上下文'),
|
|
73
78
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
@@ -554,6 +559,9 @@ export class WecomHarnessBridge {
|
|
|
554
559
|
#approvals;
|
|
555
560
|
#batchInputs = new BatchInputManager();
|
|
556
561
|
#prefetchedImageCount = 0;
|
|
562
|
+
#menus = new Map();
|
|
563
|
+
#eventIds = new Set();
|
|
564
|
+
#cardFrames = new WeakSet();
|
|
557
565
|
|
|
558
566
|
constructor({
|
|
559
567
|
client,
|
|
@@ -593,6 +601,264 @@ export class WecomHarnessBridge {
|
|
|
593
601
|
return structuredClone(this.#status);
|
|
594
602
|
}
|
|
595
603
|
|
|
604
|
+
#mainMenu() {
|
|
605
|
+
return wecomMenu({
|
|
606
|
+
workspace: this.#harness.currentWorkspace?.(),
|
|
607
|
+
workspaces: [[t('更多选项…'), '/menu workspaces']],
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async #showMain(frame, { welcome = false } = {}) {
|
|
612
|
+
const key = conversationKey(frame);
|
|
613
|
+
const workspace = this.#harness.currentWorkspace?.();
|
|
614
|
+
const sessionId = this.#state.sessionFor(key);
|
|
615
|
+
// The welcome reply has a five-second deadline. Show immediate controls,
|
|
616
|
+
// then load the selectors independently of that reply window.
|
|
617
|
+
if (welcome) await this.#sendMenu(frame, this.#mainMenu(), { welcome: true });
|
|
618
|
+
const options = { signal: this.#signal };
|
|
619
|
+
const settled = await Promise.allSettled([
|
|
620
|
+
workspacePathSnapshot(this.#harness, options),
|
|
621
|
+
this.#harness.listWorkspaceSessions?.(workspace, options),
|
|
622
|
+
(async () => {
|
|
623
|
+
const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
|
|
624
|
+
return typeof session?.models === 'function'
|
|
625
|
+
? session.models(options) : this.#harness.listModels?.(options);
|
|
626
|
+
})(),
|
|
627
|
+
this.#harness.agentPresetSettings?.(options),
|
|
628
|
+
]);
|
|
629
|
+
this.#signal?.throwIfAborted();
|
|
630
|
+
const [paths, listed, catalog, settings] = settled.map((result) => result.status === 'fulfilled' ? result.value : null);
|
|
631
|
+
const sessions = (listed?.sessions ?? []).map((item) => [item.title || t('暂无标题'), `/session ${item.sessionId}`]);
|
|
632
|
+
const models = (catalog?.groups ?? []).flatMap((group) => group.models.map((model) => [
|
|
633
|
+
`${model.name} (${group.id})`, `/model ${group.id}/${model.id}`,
|
|
634
|
+
]));
|
|
635
|
+
const presets = [[t('跟随 Host 默认'), '/preset --default'], ...(settings?.agentPresetCatalog?.items ?? []).map((item) => [
|
|
636
|
+
item.label || item.id, `/preset ${/^\d+$/u.test(item.id) ? 'id:' : ''}${item.id}`,
|
|
637
|
+
])];
|
|
638
|
+
const currentPreset = settings?.agentPreset;
|
|
639
|
+
const settingsMenu = wecomSettings({
|
|
640
|
+
sessions, models, presets, sessionId,
|
|
641
|
+
currentModel: catalog?.current ? `/model ${catalog.current.provider}/${catalog.current.model}` : null,
|
|
642
|
+
currentPreset: currentPreset ? `/preset ${/^\d+$/u.test(currentPreset) ? 'id:' : ''}${currentPreset}` : '/preset --default',
|
|
643
|
+
presetLabel: settings?.agentPresetCatalog?.items.find((item) => item.id === currentPreset)?.label,
|
|
644
|
+
});
|
|
645
|
+
await this.#sendMenu(frame, settingsMenu, { active: welcome });
|
|
646
|
+
if (welcome) return;
|
|
647
|
+
await this.#sendMenu(frame, wecomMenu({ workspace,
|
|
648
|
+
workspaces: (paths?.paths ?? (workspace ? [workspace] : [])).map((path) => [path, `/workspace ${path}`]),
|
|
649
|
+
}), { active: true });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
#rememberMenu(frame, menu) {
|
|
653
|
+
const taskId = `menu_${randomUUID()}`;
|
|
654
|
+
const now = Date.now();
|
|
655
|
+
for (const [id, entry] of this.#menus) {
|
|
656
|
+
if (entry.expiresAt <= now) this.#menus.delete(id);
|
|
657
|
+
}
|
|
658
|
+
while (this.#menus.size >= 256) this.#menus.delete(this.#menus.keys().next().value);
|
|
659
|
+
this.#menus.set(taskId, {
|
|
660
|
+
menu, key: conversationKey(frame),
|
|
661
|
+
workspace: this.#harness.currentWorkspace?.(), expiresAt: now + 30 * 60_000,
|
|
662
|
+
});
|
|
663
|
+
return wecomTemplateCard(menu, taskId);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
async #sendMenu(frame, menu, { welcome = false, active = false } = {}) {
|
|
667
|
+
this.#signal?.throwIfAborted();
|
|
668
|
+
const body = bodyOf(frame);
|
|
669
|
+
const chatId = body.chattype === 'group' ? body.chatid : body.from.userid;
|
|
670
|
+
const card = this.#rememberMenu(frame, menu);
|
|
671
|
+
try {
|
|
672
|
+
if (welcome) {
|
|
673
|
+
await this.#client.replyWelcome(frame, { msgtype: 'template_card', template_card: card });
|
|
674
|
+
} else if (active || this.#cardFrames.has(frame)) {
|
|
675
|
+
await this.#client.sendMessage(chatId, { msgtype: 'template_card', template_card: card });
|
|
676
|
+
} else {
|
|
677
|
+
await this.#client.replyTemplateCard(frame, card);
|
|
678
|
+
}
|
|
679
|
+
} catch (error) {
|
|
680
|
+
this.#menus.delete(card.task_id);
|
|
681
|
+
this.#logger.warn?.('[dsh-im:wecom] menu delivery failed; using text:', error);
|
|
682
|
+
if (welcome) {
|
|
683
|
+
await this.#client.replyWelcome(frame, { msgtype: 'text', text: { content: wecomMenuText(menu) } });
|
|
684
|
+
} else {
|
|
685
|
+
await this.#sendImmediate(frame, chatId, wecomMenuText(menu));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
acceptEvent(frame) {
|
|
691
|
+
const body = bodyOf(frame);
|
|
692
|
+
const id = nonEmptyString(body.msgid);
|
|
693
|
+
if (this.#signal?.aborted || !id || this.#eventIds.has(id) || this.#state.hasSeen(id)) {
|
|
694
|
+
return Promise.resolve();
|
|
695
|
+
}
|
|
696
|
+
this.#eventIds.add(id);
|
|
697
|
+
let task;
|
|
698
|
+
task = this.#processEvent(frame).catch((error) => {
|
|
699
|
+
if (this.#signal?.aborted) return;
|
|
700
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
701
|
+
this.#logger.warn?.('[dsh-im:wecom] menu event failed:', error);
|
|
702
|
+
}).finally(() => {
|
|
703
|
+
this.#eventIds.delete(id);
|
|
704
|
+
this.#commandTasks.delete(task);
|
|
705
|
+
});
|
|
706
|
+
this.#commandTasks.add(task);
|
|
707
|
+
return task;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async #processEvent(frame) {
|
|
711
|
+
const body = bodyOf(frame);
|
|
712
|
+
const senderId = nonEmptyString(body.from?.userid);
|
|
713
|
+
const type = body.event?.eventtype;
|
|
714
|
+
if (!senderId || !['enter_chat', 'template_card_event'].includes(type)) return;
|
|
715
|
+
const chattype = body.chattype ?? (body.chatid ? 'group' : 'single');
|
|
716
|
+
if (!['single', 'group'].includes(chattype) || (chattype === 'group' && !body.chatid)) return;
|
|
717
|
+
const normalized = { ...frame, body: { ...body, chattype } };
|
|
718
|
+
const chatId = chattype === 'group' ? body.chatid : senderId;
|
|
719
|
+
const access = evaluateInboundAccess(this.#accessPolicy, {
|
|
720
|
+
conversationType: chattype === 'single' ? 'direct' : 'group', senderIds: senderId, isCommand: true,
|
|
721
|
+
});
|
|
722
|
+
if (!access.allowed) {
|
|
723
|
+
if (type === 'template_card_event' && access.reason === 'command-not-allowed') {
|
|
724
|
+
await this.#sendActive(chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE));
|
|
725
|
+
}
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
await this.#state.markSeen(body.msgid);
|
|
729
|
+
const key = conversationKey(normalized);
|
|
730
|
+
if (type === 'enter_chat') {
|
|
731
|
+
if (chattype === 'single') await this.#showMain(normalized, { welcome: true });
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
// Live callbacks nest these fields; older SDK examples show them flat.
|
|
735
|
+
const callback = body.event.template_card_event ?? body.event;
|
|
736
|
+
const entry = this.#menus.get(callback.task_id);
|
|
737
|
+
const choice = typeof callback.event_key === 'string' && /^\d+$/u.test(callback.event_key)
|
|
738
|
+
? entry?.menu.entries[callback.event_key] : null;
|
|
739
|
+
if (!entry || entry.expiresAt <= Date.now() || !Array.isArray(choice)
|
|
740
|
+
|| entry.key !== key) {
|
|
741
|
+
await this.#sendActive(chatId, t('这个菜单已过期,请回复 /m 重新打开。'));
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
this.#menus.delete(callback.task_id);
|
|
745
|
+
// Acknowledge before Host RPC. Keep a valid button card: text_notice requires a URL.
|
|
746
|
+
const acknowledgedMenu = { title: choice[0], description: t('已收到操作,请查看下方结果。'),
|
|
747
|
+
entries: [[t('重新打开菜单'), '/menu']] };
|
|
748
|
+
await this.#client.updateTemplateCard(frame, wecomTemplateCard(acknowledgedMenu, callback.task_id), [senderId]).then(() => {
|
|
749
|
+
this.#menus.set(callback.task_id, { ...entry, menu: acknowledgedMenu });
|
|
750
|
+
}).catch((error) => {
|
|
751
|
+
this.#logger.warn?.('[dsh-im:wecom] menu acknowledgement failed:', error);
|
|
752
|
+
});
|
|
753
|
+
let commands = [choice[1]];
|
|
754
|
+
if (choice[1] === 'apply' || choice[1].startsWith('select:')) {
|
|
755
|
+
const selectors = (entry.menu.selectors ?? []).filter((item) => choice[1] === 'apply' || item.key === choice[1].slice(7));
|
|
756
|
+
const selectedItems = callback.selected_items?.selected_item ?? callback.selected_items ?? [];
|
|
757
|
+
if (!Array.isArray(selectedItems)) {
|
|
758
|
+
await this.#sendActive(chatId, t('请选择一个选项后再应用。'));
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
commands = [];
|
|
762
|
+
for (const selector of selectors) {
|
|
763
|
+
const selected = selectedItems.find((item) => item?.question_key === selector.key);
|
|
764
|
+
const ids = selected?.option_ids?.option_id ?? selected?.option_ids ?? ['0'];
|
|
765
|
+
const id = ids[0];
|
|
766
|
+
if (!Array.isArray(ids) || ids.length !== 1 || !/^\d+$/u.test(id) || !selector.entries[Number(id)]) {
|
|
767
|
+
await this.#sendActive(chatId, t('请选择一个选项后再应用。'));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const command = selector.entries[Number(id)][1];
|
|
771
|
+
if (command) commands.push(command);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const navigation = commands.find((command) => parseWecomMenu(command));
|
|
775
|
+
if (navigation) commands = [navigation];
|
|
776
|
+
if (!navigation && entry.workspace !== this.#harness.currentWorkspace?.()) {
|
|
777
|
+
await this.#sendActive(chatId, t('工作区已变化,请从新菜单重新选择。'));
|
|
778
|
+
} else {
|
|
779
|
+
for (const [index, command] of commands.entries()) {
|
|
780
|
+
const commandFrame = { ...normalized, body: { ...normalized.body,
|
|
781
|
+
msgid: `card:${body.msgid}:${index}`, msgtype: 'text', text: { content: command },
|
|
782
|
+
} };
|
|
783
|
+
this.#cardFrames.add(commandFrame);
|
|
784
|
+
await this.accept(commandFrame);
|
|
785
|
+
}
|
|
786
|
+
if (!commands.length) await this.#sendActive(chatId, t('设置未改变。'));
|
|
787
|
+
}
|
|
788
|
+
if (!navigation) {
|
|
789
|
+
this.#cardFrames.add(normalized);
|
|
790
|
+
await this.#showMain(normalized);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async #runMenuCommand(frame, text, _harness, _state, key) {
|
|
795
|
+
const menu = parseWecomMenu(text);
|
|
796
|
+
const options = { signal: this.#signal };
|
|
797
|
+
if (menu) {
|
|
798
|
+
let content;
|
|
799
|
+
if (menu.section === 'main') {
|
|
800
|
+
await this.#showMain(frame);
|
|
801
|
+
return { messages: [] };
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
let title;
|
|
805
|
+
let entries;
|
|
806
|
+
let description = '';
|
|
807
|
+
if (menu.section === 'sessions') {
|
|
808
|
+
title = t('📋 会话列表');
|
|
809
|
+
const listed = await this.#harness.listWorkspaceSessions(this.#harness.currentWorkspace(), options);
|
|
810
|
+
entries = listed.sessions.map((session) => [
|
|
811
|
+
`${session.sessionId === this.#state.sessionFor(key) ? '✓ ' : ''}${session.title || t('暂无标题')}`,
|
|
812
|
+
`/session ${session.sessionId}`,
|
|
813
|
+
]);
|
|
814
|
+
} else if (menu.section === 'workspaces') {
|
|
815
|
+
title = t('🗂 工作区列表');
|
|
816
|
+
const { paths, current } = await workspacePathSnapshot(this.#harness, options);
|
|
817
|
+
entries = paths.map((path) => [`${path === current ? '✓ ' : ''}${path}`, `/workspace ${path}`]);
|
|
818
|
+
} else if (menu.section === 'models') {
|
|
819
|
+
title = t('🧠 切换模型');
|
|
820
|
+
const sessionId = this.#state.sessionFor(key);
|
|
821
|
+
const session = sessionId ? this.#harness.workspaceSession?.(sessionId) : null;
|
|
822
|
+
const catalog = typeof session?.models === 'function'
|
|
823
|
+
? await session.models(options) : await this.#harness.listModels(options);
|
|
824
|
+
entries = catalog.groups.flatMap((group) => group.models.map((model) => [
|
|
825
|
+
`${catalog.current?.provider === group.id && catalog.current?.model === model.id ? '✓ ' : ''}${model.name} (${group.id})`,
|
|
826
|
+
`/model ${group.id}/${model.id}`,
|
|
827
|
+
]));
|
|
828
|
+
if (catalog.failures?.length) description = t('部分模型暂不可用,可稍后重试。');
|
|
829
|
+
} else {
|
|
830
|
+
title = t('🤖 切换预设');
|
|
831
|
+
const settings = await this.#harness.agentPresetSettings(options);
|
|
832
|
+
entries = [[t('🔄 跟随默认'), '/preset --default'], ...settings.agentPresetCatalog.items.map((item) => [
|
|
833
|
+
`${settings.agentPreset === item.id ? '✓ ' : ''}${item.label || item.id}`,
|
|
834
|
+
`/preset ${/^\d+$/u.test(item.id) ? 'id:' : ''}${item.id}`,
|
|
835
|
+
])];
|
|
836
|
+
description = t('预设仅用于之后的新会话。');
|
|
837
|
+
}
|
|
838
|
+
this.#harness.assertWorkspaceScope?.();
|
|
839
|
+
content = wecomList({ title, entries, description, ...menu });
|
|
840
|
+
}
|
|
841
|
+
await this.#sendMenu(frame, content);
|
|
842
|
+
return { messages: [] };
|
|
843
|
+
}
|
|
844
|
+
const command = text.toLowerCase();
|
|
845
|
+
if (command === '/help') return { message: helpText() };
|
|
846
|
+
if (command === '/status') {
|
|
847
|
+
await this.#harness.ensureRunning(options);
|
|
848
|
+
return { message: t('企业微信机器人与 DeepSeek Harness 连接正常。') };
|
|
849
|
+
}
|
|
850
|
+
if (this.#queues.has(key) || this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)
|
|
851
|
+
|| this.#batchInputs.status(key).phase !== 'idle') {
|
|
852
|
+
return { message: t('当前任务仍在运行,请先停止任务或等待任务完成后再开启新会话。') };
|
|
853
|
+
}
|
|
854
|
+
if (command === '/new') {
|
|
855
|
+
await this.#state.clearSession(key);
|
|
856
|
+
return { message: t('已开启新会话。请发送你的问题。') };
|
|
857
|
+
}
|
|
858
|
+
return await runWorkspaceCommand(text, this.#harness, key)
|
|
859
|
+
?? await runCompactCommand(text, this.#harness, this.#state, key, options);
|
|
860
|
+
}
|
|
861
|
+
|
|
596
862
|
accept(frame) {
|
|
597
863
|
if (this.#signal?.aborted) return Promise.resolve();
|
|
598
864
|
const body = bodyOf(frame);
|
|
@@ -617,6 +883,8 @@ export class WecomHarnessBridge {
|
|
|
617
883
|
text: commandText,
|
|
618
884
|
hasImages: hasInboundImages(commandMessage),
|
|
619
885
|
hasFiles: hasInboundFiles(commandMessage),
|
|
886
|
+
...(parseWecomMenu(commandText) && !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
|
|
887
|
+
? { isCommand: true } : {}),
|
|
620
888
|
});
|
|
621
889
|
if (!access.allowed) {
|
|
622
890
|
this.#acceptedMessageIds.set(messageId, null);
|
|
@@ -663,12 +931,16 @@ export class WecomHarnessBridge {
|
|
|
663
931
|
return this.#finishBatchResult(frame, messageId, chatId, result);
|
|
664
932
|
}
|
|
665
933
|
}
|
|
934
|
+
const menuCommand = !hasInboundImages(commandMessage) && !hasInboundFiles(commandMessage)
|
|
935
|
+
&& (parseWecomMenu(commandText) || ['/help', '/status', '/new'].includes(commandText.toLowerCase())
|
|
936
|
+
|| this.#cardFrames.has(frame));
|
|
666
937
|
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
667
938
|
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
668
939
|
? runControlCommand
|
|
669
940
|
: (isModelCommand(commandText)
|
|
670
941
|
? runModelCommand
|
|
671
|
-
: (isPresetCommand(commandText) ? runPresetCommand :
|
|
942
|
+
: (isPresetCommand(commandText) ? runPresetCommand : menuCommand
|
|
943
|
+
? (...args) => this.#runMenuCommand(frame, ...args) : null));
|
|
672
944
|
if (commandRunner) {
|
|
673
945
|
let task;
|
|
674
946
|
task = this.#processFastCommand(
|
|
@@ -904,6 +1176,7 @@ export class WecomHarnessBridge {
|
|
|
904
1176
|
|
|
905
1177
|
async #sendImmediate(frame, chatId, text) {
|
|
906
1178
|
this.#signal?.throwIfAborted();
|
|
1179
|
+
if (this.#cardFrames.has(frame)) return this.#sendActive(chatId, text);
|
|
907
1180
|
const chunks = splitUtf8(text);
|
|
908
1181
|
if (chunks.length === 0) return;
|
|
909
1182
|
try {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { t } from '../shared/i18n.mjs';
|
|
2
|
+
|
|
3
|
+
const MENU_COMMAND = /^\/m(?:enu)?(?:\s+(sessions|workspaces|models|presets)(?:\s+(\d+))?)?$/iu;
|
|
4
|
+
|
|
5
|
+
export function parseWecomMenu(text) {
|
|
6
|
+
const match = MENU_COMMAND.exec(text.trim());
|
|
7
|
+
return match ? { section: match[1]?.toLowerCase() ?? 'main', page: Number(match[2] ?? 0) } : null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clean(value, length = 80) {
|
|
11
|
+
return [...String(value ?? '').replace(/[\p{Cc}\p{Cf}]/gu, ' ')].slice(0, length).join('');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function selector(key, title, entries, currentLabel, currentCommand, moreCommand) {
|
|
15
|
+
const choices = entries.filter(([, command]) => command !== currentCommand);
|
|
16
|
+
const visible = choices.slice(0, choices.length > 9 ? 8 : 9);
|
|
17
|
+
return {
|
|
18
|
+
key, title,
|
|
19
|
+
entries: [[currentLabel || t('保持当前'), null], ...visible,
|
|
20
|
+
...(choices.length > 9 ? [[t('更多选项…'), moreCommand]] : [])],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function wecomSettings({ sessions = [], models = [], presets = [], sessionId,
|
|
25
|
+
currentModel, currentPreset, presetLabel } = {}) {
|
|
26
|
+
return {
|
|
27
|
+
title: t('🤖 助手中心'),
|
|
28
|
+
description: t('选择会话、模型或预设后,点击应用设置。'),
|
|
29
|
+
selectors: [
|
|
30
|
+
selector('session', t('会话'), sessions,
|
|
31
|
+
sessions.find(([, command]) => command === `/session ${sessionId}`)?.[0] || t('新会话'),
|
|
32
|
+
`/session ${sessionId}`, '/menu sessions'),
|
|
33
|
+
selector('model', t('模型'), models,
|
|
34
|
+
models.find(([, command]) => command === currentModel)?.[0] || t('跟随默认模型'),
|
|
35
|
+
currentModel, '/menu models'),
|
|
36
|
+
selector('preset', t('预设'), presets, presetLabel || t('跟随 Host 默认'),
|
|
37
|
+
currentPreset, '/menu presets'),
|
|
38
|
+
],
|
|
39
|
+
entries: [[t('应用设置'), 'apply']],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function wecomMenu({ workspace, workspaces = [] } = {}) {
|
|
44
|
+
return {
|
|
45
|
+
title: t('工作区与任务'),
|
|
46
|
+
selectors: [selector('workspace', t('工作区'), workspaces,
|
|
47
|
+
workspace, `/workspace ${workspace}`, '/menu workspaces')],
|
|
48
|
+
entries: [
|
|
49
|
+
[t('切换工作区'), 'select:workspace'],
|
|
50
|
+
[t('🆕 新会话'), '/new'],
|
|
51
|
+
[t('⏹ 停止'), '/stop'], [t('📐 压缩'), '/compact'],
|
|
52
|
+
[t('📊 状态'), '/status'], [t('📖 帮助'), '/help'],
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// A native selector carries names; buttons only apply or navigate the selection.
|
|
58
|
+
export function wecomList({ title, section, entries, page = 0, description = '' }) {
|
|
59
|
+
const pages = Math.max(1, Math.ceil(entries.length / 10));
|
|
60
|
+
const current = Math.min(Math.max(0, page), pages - 1);
|
|
61
|
+
const visible = entries.slice(current * 10, current * 10 + 10);
|
|
62
|
+
const buttons = visible.length ? [[t('应用选择'), 'select:choice']] : [];
|
|
63
|
+
if (current > 0) buttons.push([t('上一页'), `/menu ${section} ${current - 1}`]);
|
|
64
|
+
if (current + 1 < pages) buttons.push([t('下一页'), `/menu ${section} ${current + 1}`]);
|
|
65
|
+
buttons.push([t('返回'), '/menu']);
|
|
66
|
+
return {
|
|
67
|
+
title,
|
|
68
|
+
description: `${current + 1} / ${pages}${description ? ` · ${description}` : ''}`,
|
|
69
|
+
detail: visible.length ? '' : t('暂无可用选项。'),
|
|
70
|
+
selectors: visible.length ? [{ key: 'choice', title, entries: visible }] : [],
|
|
71
|
+
entries: buttons,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function wecomTemplateCard(menu, taskId) {
|
|
76
|
+
const selections = menu.selectors?.map((item) => ({
|
|
77
|
+
question_key: item.key, title: clean(item.title, 13), selected_id: '0',
|
|
78
|
+
option_list: item.entries.map(([label], index) => ({ id: String(index), text: clean(label, 60) })),
|
|
79
|
+
})) ?? [];
|
|
80
|
+
const multiple = selections.length > 1;
|
|
81
|
+
return {
|
|
82
|
+
card_type: multiple ? 'multiple_interaction' : 'button_interaction',
|
|
83
|
+
task_id: taskId,
|
|
84
|
+
main_title: { title: clean(menu.title, 36),
|
|
85
|
+
...(menu.description ? { desc: clean(menu.description, 100) } : {}) },
|
|
86
|
+
...(menu.detail ? { sub_title_text: menu.detail.slice(0, 500) } : {}),
|
|
87
|
+
...(multiple ? { select_list: selections, submit_button: { text: menu.entries[0][0], key: '0' } }
|
|
88
|
+
: { ...(selections.length ? { button_selection: selections[0] } : {}),
|
|
89
|
+
button_list: menu.entries.map(([label], index) => ({
|
|
90
|
+
text: clean(label, 36), key: String(index), style: index === 0 ? 1 : 2,
|
|
91
|
+
})) }),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function wecomMenuText(menu) {
|
|
96
|
+
return [menu.title, menu.description, menu.detail,
|
|
97
|
+
...menu.selectors?.flatMap((item) => item.entries.filter(([, command]) => command)
|
|
98
|
+
.map(([label, command]) => `${label}:${command}`)) ?? [],
|
|
99
|
+
...menu.entries.filter(([, command]) => command.startsWith('/'))
|
|
100
|
+
.map(([label, command]) => `${label}:${command}`)].filter(Boolean).join('\n');
|
|
101
|
+
}
|
|
@@ -168,6 +168,8 @@ export class WecomRuntime {
|
|
|
168
168
|
client.on('reconnecting', onReconnecting);
|
|
169
169
|
client.on('error', onError);
|
|
170
170
|
client.on('message', onMessage);
|
|
171
|
+
client.on('event.enter_chat', (frame) => this.#bridge?.acceptEvent(frame));
|
|
172
|
+
client.on('event.template_card_event', (frame) => this.#bridge?.acceptEvent(frame));
|
|
171
173
|
|
|
172
174
|
let timer;
|
|
173
175
|
try {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
normalizeMessageContent,
|
|
8
8
|
} from '@whiskeysockets/baileys';
|
|
9
9
|
|
|
10
|
-
import { splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
10
|
+
import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
|
|
11
11
|
import { t } from '../shared/i18n.mjs';
|
|
12
12
|
import { ImagePromptError } from '../shared/image-prompt.mjs';
|
|
13
13
|
import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
|
|
@@ -21,6 +21,7 @@ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'ima
|
|
|
21
21
|
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
22
22
|
const IMAGE_DOWNLOAD_TIMEOUT_MS = 15_000;
|
|
23
23
|
const WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS = 120_000;
|
|
24
|
+
const WHATSAPP_TEXT_LIMIT = 4_000;
|
|
24
25
|
const MESSAGE_WRAPPER_KEYS = [
|
|
25
26
|
'ephemeralMessage',
|
|
26
27
|
'viewOnceMessage',
|
|
@@ -441,49 +442,97 @@ export class WhatsappBotClient {
|
|
|
441
442
|
#socket;
|
|
442
443
|
#outboundIds;
|
|
443
444
|
#signal;
|
|
445
|
+
#abortController = new AbortController();
|
|
444
446
|
#mediaUploadTimeoutMs;
|
|
447
|
+
#logger;
|
|
445
448
|
#typingTimers = new Map();
|
|
449
|
+
#streams = new Set();
|
|
446
450
|
|
|
447
451
|
constructor(socket, outboundIds, {
|
|
448
452
|
signal,
|
|
449
453
|
mediaUploadTimeoutMs = WHATSAPP_MEDIA_UPLOAD_TIMEOUT_MS,
|
|
454
|
+
logger = console,
|
|
450
455
|
} = {}) {
|
|
451
456
|
this.#socket = socket;
|
|
452
457
|
this.#outboundIds = outboundIds;
|
|
453
|
-
this.#signal = signal
|
|
458
|
+
this.#signal = signal
|
|
459
|
+
? AbortSignal.any([signal, this.#abortController.signal])
|
|
460
|
+
: this.#abortController.signal;
|
|
454
461
|
this.#mediaUploadTimeoutMs = mediaUploadTimeoutMs;
|
|
462
|
+
this.#logger = logger;
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
async sendText(target, text) {
|
|
458
466
|
await this.#stopTyping(target.jid);
|
|
459
467
|
const providerMessageIds = [];
|
|
460
|
-
for (const [index, chunk] of splitMessageText(text,
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
...(index === 0 && target.quoted ? { quoted: target.quoted } : {}),
|
|
464
|
-
messageId,
|
|
465
|
-
};
|
|
466
|
-
// Linked-account group messages are valid inbound prompts in open mode.
|
|
467
|
-
// Reserve our own id before dispatch so an early local echo cannot loop
|
|
468
|
-
// back through the bridge as another owner-authored group prompt.
|
|
469
|
-
if (typeof this.#outboundIds.reserve === 'function') {
|
|
470
|
-
this.#outboundIds.reserve(messageId);
|
|
471
|
-
} else {
|
|
472
|
-
this.#outboundIds.remember(messageId);
|
|
473
|
-
}
|
|
474
|
-
const result = await this.#socket.sendMessage(
|
|
475
|
-
target.jid,
|
|
476
|
-
{ text: chunk },
|
|
477
|
-
options,
|
|
478
|
-
);
|
|
479
|
-
this.#outboundIds.remember(result?.key?.id);
|
|
480
|
-
if (typeof result?.key?.id === 'string' && result.key.id) {
|
|
481
|
-
providerMessageIds.push(result.key.id);
|
|
482
|
-
}
|
|
468
|
+
for (const [index, chunk] of splitMessageText(text, WHATSAPP_TEXT_LIMIT).entries()) {
|
|
469
|
+
const result = await this.#sendTextMessage(target, chunk, { quote: index === 0 });
|
|
470
|
+
providerMessageIds.push(result.key.id);
|
|
483
471
|
}
|
|
484
472
|
return { providerMessageIds };
|
|
485
473
|
}
|
|
486
474
|
|
|
475
|
+
async #sendTextMessage(target, text, { edit, quote = true } = {}) {
|
|
476
|
+
this.#signal?.throwIfAborted();
|
|
477
|
+
const messageId = randomBytes(10).toString('hex').toUpperCase();
|
|
478
|
+
// Reserve both the outgoing envelope and the edited message before dispatch:
|
|
479
|
+
// linked-account group and self-chat echoes can arrive before send settles.
|
|
480
|
+
const reserve = (id) => {
|
|
481
|
+
if (typeof this.#outboundIds.reserve === 'function') this.#outboundIds.reserve(id);
|
|
482
|
+
else this.#outboundIds.remember(id);
|
|
483
|
+
};
|
|
484
|
+
reserve(messageId);
|
|
485
|
+
if (edit) reserve(edit.id);
|
|
486
|
+
const pending = this.#socket.sendMessage(
|
|
487
|
+
target.jid,
|
|
488
|
+
{ text, ...(edit ? { edit } : {}) },
|
|
489
|
+
{ ...(quote && !edit && target.quoted ? { quoted: target.quoted } : {}), messageId },
|
|
490
|
+
);
|
|
491
|
+
const result = await waitWithSignal(pending, this.#signal);
|
|
492
|
+
this.#outboundIds.remember(result?.key?.id);
|
|
493
|
+
return {
|
|
494
|
+
...result,
|
|
495
|
+
key: { remoteJid: target.jid, fromMe: true, id: messageId, ...result?.key },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async openStream(target) {
|
|
500
|
+
let messageKey;
|
|
501
|
+
const stream = createEditableMessageStream({
|
|
502
|
+
limit: WHATSAPP_TEXT_LIMIT,
|
|
503
|
+
updateIntervalMs: 1_000,
|
|
504
|
+
create: async (text) => {
|
|
505
|
+
const message = await this.#sendTextMessage(target, text);
|
|
506
|
+
messageKey = message.key;
|
|
507
|
+
return messageKey.id;
|
|
508
|
+
},
|
|
509
|
+
// Baileys edits need the original full message key, not an edit envelope id.
|
|
510
|
+
edit: async (_messageId, text) => this.#sendTextMessage(target, text, { edit: messageKey }),
|
|
511
|
+
sendRemainder: (text) => this.#sendTextMessage(target, text, { quote: false }),
|
|
512
|
+
messageIdForResult: (message) => message?.key?.id,
|
|
513
|
+
logger: this.#logger,
|
|
514
|
+
});
|
|
515
|
+
const finish = stream.finish.bind(stream);
|
|
516
|
+
const cancel = stream.cancel.bind(stream);
|
|
517
|
+
stream.finish = async (text) => {
|
|
518
|
+
try {
|
|
519
|
+
return await finish(text);
|
|
520
|
+
} finally {
|
|
521
|
+
this.#streams.delete(stream);
|
|
522
|
+
await this.#stopTyping(target.jid);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
stream.cancel = () => {
|
|
526
|
+
cancel();
|
|
527
|
+
this.#streams.delete(stream);
|
|
528
|
+
return this.#stopTyping(target.jid);
|
|
529
|
+
};
|
|
530
|
+
await stream.start();
|
|
531
|
+
this.#signal.throwIfAborted();
|
|
532
|
+
this.#streams.add(stream);
|
|
533
|
+
return stream;
|
|
534
|
+
}
|
|
535
|
+
|
|
487
536
|
async sendFile(target, file) {
|
|
488
537
|
return this.#sendArtifact(target, file, {
|
|
489
538
|
document: file.bytes,
|
|
@@ -593,8 +642,10 @@ export class WhatsappBotClient {
|
|
|
593
642
|
}
|
|
594
643
|
|
|
595
644
|
async close() {
|
|
645
|
+
this.#abortController.abort();
|
|
646
|
+
const stoppingStreams = [...this.#streams].map((stream) => stream.cancel());
|
|
596
647
|
const jids = [...this.#typingTimers.keys()];
|
|
597
|
-
await Promise.allSettled(jids.map((jid) => this.#stopTyping(jid)));
|
|
648
|
+
await Promise.allSettled([...stoppingStreams, ...jids.map((jid) => this.#stopTyping(jid))]);
|
|
598
649
|
}
|
|
599
650
|
|
|
600
651
|
async #stopTyping(jid, sendPaused = true) {
|
|
@@ -741,6 +792,7 @@ export class WhatsappRuntime {
|
|
|
741
792
|
const client = new WhatsappBotClient(session.socket, outboundIds, {
|
|
742
793
|
signal: controller.signal,
|
|
743
794
|
mediaUploadTimeoutMs: this.#mediaUploadTimeoutMs,
|
|
795
|
+
logger: this.#logger,
|
|
744
796
|
});
|
|
745
797
|
this.#client = client;
|
|
746
798
|
this.#bridge = new WhatsappHarnessBridge({
|