@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.
@@ -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 : null));
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, 4_000).entries()) {
461
- const messageId = randomBytes(10).toString('hex').toUpperCase();
462
- const options = {
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({