@xmanrui/dsh-im 4.34.1 → 4.34.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.34.1",
3
+ "version": "4.34.2",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -0,0 +1,114 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from './i18n.js';
4
+
5
+ // Scroll only the tab strip: scrollIntoView can also move the host settings page.
6
+ function revealTab(strip, tab) {
7
+ if (!strip || !tab) return;
8
+ const viewport = strip.getBoundingClientRect();
9
+ const bounds = tab.getBoundingClientRect();
10
+ if (bounds.left < viewport.left) strip.scrollLeft += bounds.left - viewport.left;
11
+ else if (bounds.right > viewport.right) strip.scrollLeft += bounds.right - viewport.right;
12
+ }
13
+
14
+ export function BotSettingsTabs({ tabs, activeTabId, onChange }) {
15
+ const barRef = React.useRef(null);
16
+ const stripRef = React.useRef(null);
17
+ const listRef = React.useRef(null);
18
+ const [scroll, setScroll] = React.useState({ overflow: false, left: false, right: false });
19
+
20
+ const measure = React.useCallback(() => {
21
+ const bar = barRef.current;
22
+ const strip = stripRef.current;
23
+ const list = listRef.current;
24
+ if (!bar || !strip || !list) return;
25
+ const next = {
26
+ // Compare against the whole bar, so arrows disappear as soon as all tabs fit.
27
+ overflow: list.scrollWidth > bar.clientWidth + 1,
28
+ left: strip.scrollLeft > 1,
29
+ right: strip.scrollWidth - strip.clientWidth - strip.scrollLeft > 1,
30
+ };
31
+ setScroll((previous) => Object.keys(next).every((key) => next[key] === previous[key])
32
+ ? previous : next);
33
+ }, []);
34
+
35
+ React.useEffect(() => {
36
+ const strip = stripRef.current;
37
+ if (!strip) return undefined;
38
+ const refresh = () => {
39
+ revealTab(strip, strip.querySelector('[aria-selected="true"]'));
40
+ measure();
41
+ };
42
+ refresh();
43
+ const view = strip.ownerDocument.defaultView;
44
+ const observer = view.ResizeObserver ? new view.ResizeObserver(refresh) : null;
45
+ for (const node of [barRef.current, strip, listRef.current]) observer?.observe(node);
46
+ view.addEventListener('resize', refresh);
47
+ return () => {
48
+ observer?.disconnect();
49
+ view.removeEventListener('resize', refresh);
50
+ };
51
+ }, [tabs, activeTabId, scroll.overflow, measure]);
52
+
53
+ const scrollTabs = (direction) => {
54
+ const strip = stripRef.current;
55
+ if (!strip) return;
56
+ strip.scrollLeft += direction * Math.max(80, strip.clientWidth * 0.75);
57
+ measure();
58
+ };
59
+
60
+ const handleKeyDown = (event, index) => {
61
+ let next;
62
+ if (event.key === 'Home') next = 0;
63
+ else if (event.key === 'End') next = tabs.length - 1;
64
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
65
+ next = (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
66
+ }
67
+ if (next === undefined) return;
68
+ event.preventDefault();
69
+ onChange(tabs[next].id);
70
+ const target = stripRef.current?.querySelectorAll('[role="tab"]')[next];
71
+ target?.focus({ preventScroll: true });
72
+ revealTab(stripRef.current, target);
73
+ };
74
+
75
+ const arrow = (direction, label, enabled) => scroll.overflow ? h('button', {
76
+ type: 'button',
77
+ className: 'dim-botSettingsScroll',
78
+ 'aria-label': label,
79
+ title: label,
80
+ disabled: !enabled,
81
+ onClick: () => scrollTabs(direction),
82
+ }, h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
83
+ h('path', {
84
+ d: direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5',
85
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
86
+ strokeLinecap: 'round', strokeLinejoin: 'round',
87
+ }))) : null;
88
+
89
+ return h('div', { ref: barRef, className: 'dim-botSettingsTabsBar' },
90
+ arrow(-1, '向左滚动设置页签', scroll.left),
91
+ h('nav', {
92
+ ref: stripRef,
93
+ className: 'dim-botSettingsTabs',
94
+ role: 'tablist',
95
+ 'aria-label': '机器人设置页签',
96
+ onScroll: measure,
97
+ }, h('div', { ref: listRef, className: 'dim-botSettingsTabsList', role: 'presentation' },
98
+ tabs.map((tab, index) => h('button', {
99
+ key: tab.id,
100
+ id: `dim-bot-settings-${tab.id}-tab`,
101
+ type: 'button',
102
+ role: 'tab',
103
+ className: 'dim-botSettingsTab',
104
+ 'aria-selected': tab.id === activeTabId,
105
+ 'aria-controls': `dim-bot-settings-${tab.id}-panel`,
106
+ tabIndex: tab.id === activeTabId ? 0 : -1,
107
+ onClick: (event) => {
108
+ onChange(tab.id);
109
+ revealTab(stripRef.current, event?.currentTarget);
110
+ },
111
+ onKeyDown: (event) => handleKeyDown(event, index),
112
+ }, tab.label)))),
113
+ arrow(1, '向右滚动设置页签', scroll.right));
114
+ }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { AccessPolicySettingsPage } from './access-policy-settings.js';
4
+ import { BotSettingsTabs } from './bot-settings-tabs.js';
4
5
  import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
5
6
  import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
6
7
  import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
@@ -770,22 +771,7 @@ export function DeliveryTargetSettingsPage({
770
771
  },
771
772
  h('header', { className: 'dim-deliveryHeader' },
772
773
  h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表')),
773
- h('div', { className: 'dim-botSettingsTabsBar' },
774
- h('nav', {
775
- className: 'dim-botSettingsTabs',
776
- role: 'tablist',
777
- 'aria-label': '机器人设置页签',
778
- }, settingsTabs.map((tab) => h('button', {
779
- key: tab.id,
780
- id: `dim-bot-settings-${tab.id}-tab`,
781
- type: 'button',
782
- role: 'tab',
783
- className: 'dim-botSettingsTab',
784
- 'aria-selected': tab.id === activeTab.id,
785
- 'aria-controls': `dim-bot-settings-${tab.id}-panel`,
786
- tabIndex: tab.id === activeTab.id ? 0 : -1,
787
- onClick: () => setActiveTabId(tab.id),
788
- }, tab.label)))),
774
+ h(BotSettingsTabs, { tabs: settingsTabs, activeTabId: activeTab.id, onChange: setActiveTabId }),
789
775
  h('div', {
790
776
  id: activePanelId,
791
777
  className: 'dim-botSettingsTabPanel',
@@ -48,6 +48,8 @@ const EN = Object.freeze({
48
48
  'IM 面板加载失败': 'Could not load the IM panel',
49
49
  '更多机器人设置': 'More bot settings',
50
50
  '更多设置': 'More settings',
51
+ '向左滚动设置页签': 'Scroll settings tabs left',
52
+ '向右滚动设置页签': 'Scroll settings tabs right',
51
53
  '常用配置': 'Common settings',
52
54
  '机器人设置': 'Bot settings',
53
55
  '机器人设置页签': 'Bot settings tabs',
@@ -467,9 +467,14 @@ const CSS = String.raw`
467
467
  .dim-moreSettingsChevron { width: 6px; height: 6px; margin-right: 2px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
468
468
  .dim-deliveryPage { min-width: 0; display: grid; }
469
469
  .dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
470
- .dim-botSettingsTabsBar { min-width: 0; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
471
- .dim-botSettingsTabs { min-width: 0; display: flex; align-items: flex-end; gap: 24px; overflow-x: auto; scrollbar-width: none; }
470
+ .dim-botSettingsTabsBar { min-width: 0; display: flex; align-items: center; gap: 4px; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
471
+ .dim-botSettingsTabs { min-width: 0; flex: 1; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
472
472
  .dim-botSettingsTabs::-webkit-scrollbar { display: none; }
473
+ .dim-botSettingsTabsList { width: max-content; min-width: 100%; display: flex; align-items: flex-end; gap: 24px; }
474
+ .dim-botSettingsScroll { flex: 0 0 30px; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #646a73); cursor: pointer; }
475
+ .dim-botSettingsScroll:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
476
+ .dim-botSettingsScroll:disabled { opacity: .3; cursor: default; }
477
+ .dim-botSettingsScroll:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: -2px; }
473
478
  .dim-botSettingsTab { position: relative; min-height: 38px; flex: none; display: inline-flex; align-items: center; justify-content: center; padding: 6px 2px 9px; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; font-weight: 560; white-space: nowrap; cursor: pointer; transition: color .15s ease; }
474
479
  .dim-botSettingsTab::after { content: ''; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: transparent; transform: scaleX(.45); transition: background .15s ease, transform .15s ease; }
475
480
  .dim-botSettingsTab:hover:not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); }
@@ -735,7 +740,7 @@ const CSS = String.raw`
735
740
  .dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
736
741
  .dim-loopbackRecoveryAction { width: 100%; }
737
742
  .dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
738
- .dim-botSettingsTabs { gap: 18px; }
743
+ .dim-botSettingsTabsList { gap: 18px; }
739
744
  .dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
740
745
  .dim-deliveryBotId > span { grid-column: 1 / -1; }
741
746
  .dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
@@ -35,6 +35,7 @@ import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
35
35
  import {
36
36
  accessPolicyProvider,
37
37
  initialAccessPolicyFor,
38
+ newBotAccessPolicyFor,
38
39
  } from '../shared/access-policy-production.mjs';
39
40
 
40
41
  // The WebSocket agent built here is only used for the Feishu long connection,
@@ -230,7 +231,7 @@ export async function createProductionController(ctx, config = {}, internals = {
230
231
  const id = botId ?? botConfig.id ?? botConfig.appId;
231
232
  await workspaces.ensure(id, {
232
233
  defaultAgentPreset: config.agentPreset,
233
- initialAccessPolicy: initialAccessPolicyFor('feishu', botConfig),
234
+ initialAccessPolicy: newBotAccessPolicyFor('feishu', botConfig),
234
235
  });
235
236
  const workspaceScope = createBotWorkspaceScope(harness, {
236
237
  botId: id, workspaces, state, agentPresetCatalog,
@@ -30,6 +30,7 @@ import {
30
30
  import {
31
31
  accessPolicyProvider,
32
32
  initialAccessPolicyFor,
33
+ newBotAccessPolicyFor,
33
34
  } from '../shared/access-policy-production.mjs';
34
35
 
35
36
  function pluginPaths(config) {
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
117
118
  const state = await stateFor(botId);
118
119
  await workspaces.ensure(botId, {
119
120
  defaultAgentPreset: config.agentPreset,
120
- initialAccessPolicy: initialAccessPolicyFor('qq', botConfig),
121
+ initialAccessPolicy: newBotAccessPolicyFor('qq', botConfig),
121
122
  });
122
123
  const workspaceScope = createBotWorkspaceScope(harness, {
123
124
  botId, workspaces, state, agentPresetCatalog,
@@ -97,6 +97,19 @@ export function initialAccessPolicyFor(channel, config = {}) {
97
97
  throw new TypeError(`Unsupported access-policy channel: ${channel}`);
98
98
  }
99
99
 
100
+ /**
101
+ * Seed newly connected bots after startup has migrated existing bots with
102
+ * initialAccessPolicyFor(). Open Feishu/QQ messages and commands in both chat
103
+ * types; workspace ensure() preserves any policy already saved.
104
+ */
105
+ export function newBotAccessPolicyFor(channel, config = {}) {
106
+ const key = String(channel ?? '').trim().toLowerCase();
107
+ if (key === 'feishu' || key === 'qq') {
108
+ return createAccessPolicy({ direct: openScope(), group: openScope() });
109
+ }
110
+ return initialAccessPolicyFor(channel, config);
111
+ }
112
+
100
113
  export function privilegedSenderIdsFor(channel, config = {}) {
101
114
  const key = String(channel ?? '').trim().toLowerCase();
102
115
  if (key === 'weixin') return cleanIds(config.ownerUserId);
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
4
+ import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
4
5
 
5
6
  const modernApis = new WeakMap();
6
7
 
@@ -25,7 +26,7 @@ function failureOf(error) {
25
26
  return {
26
27
  code: 'internal',
27
28
  message: error instanceof Error ? error.message : String(error),
28
- details: {},
29
+ details: extractConnectionEvidence(error).details,
29
30
  };
30
31
  }
31
32
 
@@ -1,6 +1,7 @@
1
1
  import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import QRCode from 'qrcode';
4
+ import { waitForFeishuOperation } from './feishu-channel.mjs';
4
5
  import {
5
6
  conversationKey,
6
7
  conversationScope,
@@ -77,6 +78,7 @@ import {
77
78
  import {
78
79
  channelDeliveryFailure,
79
80
  clearLastMessageFailure,
81
+ messageFailureDiagnostic,
80
82
  messageFailureText,
81
83
  setLastMessageFailure,
82
84
  } from '../shared/message-failure.mjs';
@@ -720,6 +722,7 @@ export class FeishuHarnessBridge {
720
722
  /** Conversation keys with an IM ask in flight (set BEFORE the turn starts). */
721
723
  #imTurnKeys = new Set();
722
724
  #cardDataTimeoutMs;
725
+ #requestTimeoutMs;
723
726
  /** When true, approval/question interactions render as Feishu cards (buttons). */
724
727
  #interactionCards = true;
725
728
 
@@ -744,6 +747,7 @@ export class FeishuHarnessBridge {
744
747
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
745
748
  repairLinkWaitMs = REPAIR_LINK_WAIT_MS,
746
749
  cardDataTimeoutMs = CARD_DATA_TIMEOUT_MS,
750
+ requestTimeoutMs = 15_000,
747
751
  replyTimeoutMs = 600_000,
748
752
  interactionCards = true,
749
753
  sessionSyncTargetsFor = null,
@@ -766,7 +770,8 @@ export class FeishuHarnessBridge {
766
770
  }
767
771
  if (!Number.isFinite(repairPollIntervalMs) || repairPollIntervalMs <= 0
768
772
  || !Number.isFinite(repairLinkWaitMs) || repairLinkWaitMs <= 0
769
- || !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0) {
773
+ || !Number.isFinite(cardDataTimeoutMs) || cardDataTimeoutMs <= 0
774
+ || !Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
770
775
  throw new TypeError('Feishu timing values must be positive numbers');
771
776
  }
772
777
  if (stepPushClock !== null
@@ -803,6 +808,7 @@ export class FeishuHarnessBridge {
803
808
  this.#repairPollIntervalMs = repairPollIntervalMs;
804
809
  this.#repairLinkWaitMs = repairLinkWaitMs;
805
810
  this.#cardDataTimeoutMs = cardDataTimeoutMs;
811
+ this.#requestTimeoutMs = requestTimeoutMs;
806
812
  this.#replyTimeoutMs = replyTimeoutMs;
807
813
  this.#interactionCards = interactionCards === true;
808
814
  this.#sessionSyncTargetsFor = typeof sessionSyncTargetsFor === 'function'
@@ -1493,7 +1499,7 @@ export class FeishuHarnessBridge {
1493
1499
  const failure = setLastMessageFailure(this.#status, error, { userMessage, reason });
1494
1500
  this.#logger?.[logLevel]?.(
1495
1501
  `[dsh-feishu] ${logLabel} failed [${failure.referenceId}]:`,
1496
- error?.message ?? String(error),
1502
+ messageFailureDiagnostic(error, failure),
1497
1503
  );
1498
1504
  return failure;
1499
1505
  }
@@ -3127,6 +3133,14 @@ export class FeishuHarnessBridge {
3127
3133
  }
3128
3134
  }
3129
3135
 
3136
+ #cardRequest(stage, operation) {
3137
+ return waitForFeishuOperation(operation, {
3138
+ signal: this.#signal,
3139
+ timeoutMs: this.#requestTimeoutMs,
3140
+ stage: `${stage} (${this.#requestTimeoutMs}ms)`,
3141
+ });
3142
+ }
3143
+
3130
3144
  async #sendCard(chatId, cardJson, options = {}) {
3131
3145
  const updateMessageId = nonEmptyString(options.updateMessageId);
3132
3146
  const replyTo = nonEmptyString(options.replyTo);
@@ -3134,14 +3148,14 @@ export class FeishuHarnessBridge {
3134
3148
  // Session-sync cards target the user's openId (the synced DM is a user,
3135
3149
  // not a chat), delivered fresh without topic/thread handling.
3136
3150
  if (options.receiveIdType === 'open_id') {
3137
- const response = await this.#client.im.v1.message.create({
3151
+ const response = await this.#cardRequest('card create', () => this.#client.im.v1.message.create({
3138
3152
  params: { receive_id_type: 'open_id' },
3139
3153
  data: {
3140
3154
  receive_id: chatId,
3141
3155
  msg_type: 'interactive',
3142
3156
  content: cardJson,
3143
3157
  },
3144
- });
3158
+ }));
3145
3159
  if (response?.code && response.code !== 0) {
3146
3160
  throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
3147
3161
  }
@@ -3152,10 +3166,10 @@ export class FeishuHarnessBridge {
3152
3166
 
3153
3167
  if (updateMessageId) {
3154
3168
  try {
3155
- const response = await this.#client.im.v1.message.patch({
3169
+ const response = await this.#cardRequest('card patch', () => this.#client.im.v1.message.patch({
3156
3170
  path: { message_id: updateMessageId },
3157
3171
  data: { content: cardJson },
3158
- });
3172
+ }));
3159
3173
  if (response?.code && response.code !== 0) {
3160
3174
  throw new Error(`Feishu card update failed: ${response.msg || response.code}`);
3161
3175
  }
@@ -3172,14 +3186,14 @@ export class FeishuHarnessBridge {
3172
3186
  const content = cardJson;
3173
3187
  if (replyTo) {
3174
3188
  try {
3175
- const response = await this.#client.im.v1.message.reply({
3189
+ const response = await this.#cardRequest('card reply', () => this.#client.im.v1.message.reply({
3176
3190
  path: { message_id: replyTo },
3177
3191
  data: {
3178
3192
  msg_type: 'interactive',
3179
3193
  content,
3180
3194
  ...(this.#replyInThreadFor(replyTo) ? { reply_in_thread: true } : {}),
3181
3195
  },
3182
- });
3196
+ }));
3183
3197
  if (response?.code && response.code !== 0) {
3184
3198
  throw new Error(`Feishu card reply failed: ${response.msg || response.code}`);
3185
3199
  }
@@ -3195,10 +3209,10 @@ export class FeishuHarnessBridge {
3195
3209
  this.#forgetTopicRoot(replyTo);
3196
3210
  }
3197
3211
  }
3198
- const response = await this.#client.im.v1.message.create({
3212
+ const response = await this.#cardRequest('card create', () => this.#client.im.v1.message.create({
3199
3213
  params: { receive_id_type: 'chat_id' },
3200
3214
  data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
3201
- });
3215
+ }));
3202
3216
  if (response?.code && response.code !== 0) {
3203
3217
  throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
3204
3218
  }
@@ -4834,7 +4848,8 @@ export class FeishuHarnessBridge {
4834
4848
  );
4835
4849
  if (card.deliveryViaOpenId) {
4836
4850
  this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
4837
- error?.message ?? String(error));
4851
+ error?.message ?? String(error),
4852
+ { sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn });
4838
4853
  }
4839
4854
  }
4840
4855
  }
@@ -4885,6 +4900,7 @@ export class FeishuHarnessBridge {
4885
4900
  this.#logger.warn?.(
4886
4901
  '[dsh-feishu] step streaming card finish failed:',
4887
4902
  error?.message ?? String(error),
4903
+ { sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn },
4888
4904
  );
4889
4905
  return null;
4890
4906
  }
@@ -4935,10 +4951,10 @@ export class FeishuHarnessBridge {
4935
4951
  }
4936
4952
 
4937
4953
  async #patchStepCard(messageId, cardJson) {
4938
- const response = await this.#client.im.v1.message.patch({
4954
+ const response = await this.#cardRequest('step card patch', () => this.#client.im.v1.message.patch({
4939
4955
  path: { message_id: messageId },
4940
4956
  data: { content: cardJson },
4941
- });
4957
+ }));
4942
4958
  if (response?.code && response.code !== 0) {
4943
4959
  throw new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
4944
4960
  }
@@ -80,12 +80,13 @@ function abortReason(signal) {
80
80
  }
81
81
 
82
82
  function operationTimeout(stage) {
83
- const error = new Error(`Feishu file ${stage} timed out.`);
83
+ const error = new Error(`Feishu ${stage} timed out.`);
84
84
  error.code = 'provider-timeout';
85
85
  return error;
86
86
  }
87
87
 
88
- function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
88
+ // Bound the caller's wait even when the SDK does not honor cancellation.
89
+ export function waitForFeishuOperation(operation, { signal, timeoutMs, stage = 'request' }) {
89
90
  signal?.throwIfAborted();
90
91
  const deadline = new AbortController();
91
92
  const operationSignal = signal
@@ -108,7 +109,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
108
109
  const timer = setTimeout(() => deadline.abort(), timeoutMs);
109
110
  operationSignal.addEventListener('abort', onAbort, { once: true });
110
111
 
111
- Promise.resolve().then(() => operation(operationSignal)).then(
112
+ Promise.resolve().then(() => {
113
+ operationSignal.throwIfAborted();
114
+ return operation(operationSignal);
115
+ }).then(
112
116
  (value) => finish(resolve, value),
113
117
  (error) => finish(reject, error),
114
118
  );
@@ -116,6 +120,10 @@ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
116
120
  });
117
121
  }
118
122
 
123
+ function waitForFileOperation(operation, { stage, ...options }) {
124
+ return waitForFeishuOperation(operation, { ...options, stage: `file ${stage}` });
125
+ }
126
+
119
127
  function deliveryUuid(file, chatId, messageType) {
120
128
  const seed = `${file.deliveryKey}\u0000${chatId}`;
121
129
  const digest = createHash('sha256')
@@ -2,7 +2,7 @@ import { createConnectionDiagnostics, atConnectionStage } from '../shared/connec
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { FeishuHarnessBridge } from './bridge.mjs';
4
4
  import { cardActionProbeCard } from './feishu-cards.mjs';
5
- import { VerifiedFeishuChannel } from './feishu-channel.mjs';
5
+ import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
6
6
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
7
7
  import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
8
  import { createVoice } from './voice.mjs';
@@ -367,6 +367,7 @@ export class FeishuRuntime {
367
367
  voice: this.#voice,
368
368
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
369
369
  repair: this.#repair,
370
+ requestTimeoutMs: this.#requestTimeoutMs,
370
371
  replyTimeoutMs: this.#replyTimeoutMs,
371
372
  // Interaction cards (approval/question buttons) are on by default.
372
373
  // Set DSH_IM_INTERACTION_CARDS=0 to fall back to plain-text replies.
@@ -703,13 +704,20 @@ export class FeishuRuntime {
703
704
  const content = format === 'markdown'
704
705
  ? { schema: '2.0', body: { elements: [{ tag: 'markdown', content: text }] } }
705
706
  : { text };
706
- const response = await this.#client.im.v1.message.create({
707
+ const activeSignal = signal
708
+ ? AbortSignal.any([signal, this.#abortController.signal])
709
+ : this.#abortController.signal;
710
+ const response = await waitForFeishuOperation(() => this.#client.im.v1.message.create({
707
711
  params: { receive_id_type: receiveIdType },
708
712
  data: {
709
713
  receive_id: receiveId,
710
714
  msg_type: format === 'markdown' ? 'interactive' : 'text',
711
715
  content: JSON.stringify(content),
712
716
  },
717
+ }), {
718
+ signal: activeSignal,
719
+ timeoutMs: this.#requestTimeoutMs,
720
+ stage: `proactive text send (${this.#requestTimeoutMs}ms)`,
713
721
  });
714
722
  if (response?.code && response.code !== 0) {
715
723
  const error = new Error(`Feishu proactive delivery failed: ${response.msg || response.code}`);
@@ -6,7 +6,7 @@ import { t } from './i18n.mjs';
6
6
  const reported = new WeakMap();
7
7
  const lastReport = new WeakMap();
8
8
  const stages = new WeakMap();
9
- const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
9
+ const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
10
10
  const STANDARD_NAMES = new Set(['Error', 'TypeError', 'SyntaxError', 'AggregateError', 'DOMException', 'AxiosError', 'TimeoutError', 'AbortError']);
11
11
  const KNOWN_MESSAGES = {
12
12
  'discord-401': 'Discord Bot Token 无效,请重新填写。',
@@ -37,18 +37,24 @@ export function extractConnectionEvidence(error) {
37
37
  const { chain, truncated } = connectionErrorChain(error);
38
38
  const errors = chain.map(e => ({
39
39
  type: STANDARD_NAMES.has(e.name) ? e.name : 'Error',
40
+ // Host RPC adapters carry safe diagnostic facts without the original Error graph.
40
41
  ...normalizeDiagnosticDetails({
41
- reason: OWNED_REASONS[e.code] ?? e.code ?? (e.name === 'TimeoutError' ? 'timeout' : e instanceof SyntaxError ? 'invalid-json' : undefined),
42
+ reason: e.details?.reason, reasons: e.details?.reasons,
43
+ httpStatus: e.details?.httpStatus, providerCode: e.details?.providerCode,
44
+ }),
45
+ ...normalizeDiagnosticDetails({
46
+ // DOMException TimeoutError has a numeric legacy code; its name is authoritative.
47
+ reason: OWNED_REASONS[e.code] ?? (e.name === 'TimeoutError' ? 'timeout' : e.code ?? (e instanceof SyntaxError ? 'invalid-json' : undefined)),
42
48
  httpStatus: e.status ?? e.statusCode ?? e.response?.status,
43
49
  providerCode: e.providerCode,
44
50
  }),
45
51
  hasCause: Boolean(e.cause && typeof e.cause === 'object'),
46
52
  hasErrors: Array.isArray(e.errors),
47
53
  }));
48
- const allReasons = [...new Set(errors.map(e => e.reason).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
54
+ const allReasons = [...new Set(errors.flatMap(e => e.reason === 'multiple-causes' ? e.reasons ?? [] : [e.reason]).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
49
55
  const precise = allReasons.filter(r => !['timeout', 'http-error', 'provider-rejected', 'invalid-response'].includes(r));
50
56
  const reasons = precise.length ? precise : allReasons;
51
- const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] })[field]).find(v => v !== undefined);
57
+ const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
52
58
  return {
53
59
  chain, errors,
54
60
  details: normalizeDiagnosticDetails({
@@ -104,6 +104,10 @@ export default {
104
104
  'This message contains an image or file that cannot be processed. Adjust it and send it again.',
105
105
  '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。':
106
106
  'The task did not finish and the cause could not be determined. Try again; if it persists, give the reference ID to an administrator.',
107
+ '请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
108
+ 'The request timed out and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
109
+ '网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。':
110
+ 'The network request failed and its outcome is unconfirmed. Check the task status or chat history before submitting it again.',
107
111
  '错误码:{code};参考号:{referenceId}': 'Error code: {code}; reference: {referenceId}',
108
112
  '{label}机器人': '{label} bot',
109
113
  '目前支持文字和图片消息。': 'Only text and image messages are supported at the moment.',
@@ -1,4 +1,4 @@
1
- import { extractConnectionEvidence } from './connection-error.mjs';
1
+ import { connectionHint, extractConnectionEvidence } from './connection-error.mjs';
2
2
  import { diagnosticFields } from './diagnostic-details.mjs';
3
3
  import { randomUUID } from 'node:crypto';
4
4
 
@@ -90,6 +90,10 @@ const FAILURE_MESSAGES = Object.freeze({
90
90
  '回复已经生成,但当前渠道暂时无法发送。请稍后重试。',
91
91
  INPUT_INVALID:
92
92
  '当前消息包含无法处理的图片或文件。请调整后重新发送。',
93
+ REQUEST_TIMEOUT:
94
+ '请求超时,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
95
+ NETWORK_ERROR:
96
+ '网络请求失败,当前操作结果尚未确认。请先检查任务状态或聊天记录,不要立即重复提交。',
93
97
  INTERNAL_UNKNOWN:
94
98
  '任务未完成,暂时无法确定原因。请重试;若持续发生,请将参考号提供给管理员。',
95
99
  });
@@ -156,16 +160,30 @@ function failureCode(error) {
156
160
  return 'CHANNEL_RATE_LIMIT';
157
161
  }
158
162
  if (['channel-delivery-uncertain', 'delivery-uncertain', 'artifact-delivery-uncertain']
159
- .includes(code)) {
163
+ .includes(code) || error?.deliveryOutcome === 'unknown') {
160
164
  return 'CHANNEL_DELIVERY_UNCERTAIN';
161
165
  }
162
166
  if (code === 'channel-delivery-failed' || code.startsWith('artifact-')
163
- || ['network-error', 'timeout'].includes(code)) {
167
+ || code === 'network-error') {
164
168
  return 'CHANNEL_DELIVERY';
165
169
  }
166
170
  return 'INTERNAL_UNKNOWN';
167
171
  }
168
172
 
173
+ // Only refine the unknown fallback, using the same allowlisted evidence as the UI.
174
+ // Mixed unrelated causes and cancellation do not establish a network diagnosis.
175
+ function transportFailureCode(error, details) {
176
+ if (error?.name === 'AbortError' || error?.code === 'cancelled') return 'INTERNAL_UNKNOWN';
177
+ const reasons = details.reason === 'multiple-causes' ? details.reasons : [details.reason];
178
+ const timeout = reason => reason === 'timeout' || reason === 'ETIMEDOUT' || /_TIMEOUT$/u.test(reason);
179
+ if (reasons?.length && reasons.every(timeout)) return 'REQUEST_TIMEOUT';
180
+ const network = reason => timeout(reason) || [
181
+ 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET',
182
+ 'ENETUNREACH', 'EHOSTUNREACH', 'ERR_NETWORK_ACCESS_DENIED', 'ERR_PROXY_CONNECTION_FAILED',
183
+ ].includes(reason) || /CERT|TLS|SSL|ISSUER|SIGNATURE/u.test(reason);
184
+ return reasons?.length && reasons.every(network) ? 'NETWORK_ERROR' : 'INTERNAL_UNKNOWN';
185
+ }
186
+
169
187
  function safeReferenceId(value) {
170
188
  return typeof value === 'string' && /^[A-Z0-9-]{6,40}$/u.test(value)
171
189
  ? value
@@ -185,13 +203,17 @@ export function classifyMessageFailure(error, {
185
203
  } = {}) {
186
204
  const safeReason = safeFailureReason(reason);
187
205
  const classifiedCode = failureCode(error);
188
- const code = classifiedCode === 'INTERNAL_UNKNOWN'
206
+ const businessCode = classifiedCode === 'INTERNAL_UNKNOWN'
189
207
  && safeReason
190
208
  && typeof userMessage === 'string'
191
209
  && userMessage.trim()
192
210
  ? 'INPUT_INVALID'
193
211
  : classifiedCode;
194
212
  const details = extractConnectionEvidence(error).details;
213
+ const code = businessCode === 'INTERNAL_UNKNOWN'
214
+ ? transportFailureCode(error, details)
215
+ : businessCode;
216
+ if (code === 'REQUEST_TIMEOUT' || code === 'NETWORK_ERROR') details.hint = connectionHint(details);
195
217
  return Object.freeze({
196
218
  ...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
197
219
  code,
@@ -936,12 +936,12 @@ export class TextHarnessBridge {
936
936
  const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
937
937
  if (textReceipt?.deliveryOutcome === 'failed') {
938
938
  textDeliveryError = channelDeliveryFailure(
939
- new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
939
+ Object.assign(new Error('Final text delivery failed'), { code: textReceipt.reason }),
940
940
  { uncertain: false },
941
941
  );
942
942
  } else if (finalDeliveryUnknown) {
943
943
  textDeliveryError = channelDeliveryFailure(
944
- new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
944
+ Object.assign(new Error('Final text delivery outcome is unknown'), { code: textReceipt.reason }),
945
945
  );
946
946
  }
947
947
  // A failed final text must not discard an already registered result file.