@xmanrui/dsh-im 4.30.0 → 4.32.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/locale/en.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "meta": {
3
+ "description": "Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH."
4
+ }
5
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "meta": {
3
+ "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。"
4
+ }
5
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.30.0",
3
+ "version": "4.32.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
- "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
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": [
7
7
  "deepseek-harness",
8
8
  "dsh",
@@ -90,6 +90,10 @@
90
90
  {
91
91
  "name": "Biertolovisk",
92
92
  "url": "https://github.com/rosalynthompson32-a11y"
93
+ },
94
+ {
95
+ "name": "Tianjun Cai",
96
+ "url": "https://github.com/Pegasus02"
93
97
  }
94
98
  ],
95
99
  "license": "MIT",
@@ -112,13 +116,15 @@
112
116
  "exports": {
113
117
  ".": "./lib/index.js",
114
118
  "./client": "./lib/client.js",
115
- "./package.json": "./package.json"
119
+ "./package.json": "./package.json",
120
+ "./locale/*.json": "./locale/*.json"
116
121
  },
117
122
  "files": [
118
123
  "assets",
119
124
  "bin",
120
125
  "cordis.patch.yml",
121
126
  "lib",
127
+ "locale/*.json",
122
128
  "plugin-src",
123
129
  "scripts/verify-interface-language.mjs",
124
130
  "scripts/verify-injected-context.mjs",
@@ -178,7 +184,7 @@
178
184
  "mailparser": "^3.9.28",
179
185
  "nodemailer": "^10.0.10",
180
186
  "qrcode": "1.5.4",
181
- "undici": "7.29.0"
187
+ "undici": "7.29.1"
182
188
  },
183
189
  "devDependencies": {
184
190
  "@deepseek-ai/cordis": "4.0.1",
@@ -20,6 +20,7 @@ const FIELD_LABELS = Object.freeze({
20
20
  chatId: '会话标识',
21
21
  threadId: '话题标识',
22
22
  botId: '机器人标识',
23
+ sentAt: '发送时间',
23
24
  });
24
25
 
25
26
  const FIELD_HELP = Object.freeze({
@@ -39,6 +40,10 @@ const FIELD_HELP = Object.freeze({
39
40
  labelKey: 'threadIdHelpLabel',
40
41
  text: '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。',
41
42
  }),
43
+ sentAt: Object.freeze({
44
+ labelKey: 'sentAtHelpLabel',
45
+ text: '当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。',
46
+ }),
42
47
  });
43
48
 
44
49
  const SCOPE_COPY = Object.freeze({
@@ -50,6 +55,7 @@ const SCOPE_COPY = Object.freeze({
50
55
  conversationTitleHelpLabel: '查看群聊会话标题字段说明',
51
56
  chatIdHelpLabel: '查看群聊会话标识字段说明',
52
57
  threadIdHelpLabel: '查看群聊话题标识字段说明',
58
+ sentAtHelpLabel: '查看群聊发送时间字段说明',
53
59
  guidanceLabel: '增强提示词',
54
60
  guidanceHelpLabel: '查看群聊增强提示词使用说明',
55
61
  guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -63,6 +69,7 @@ const SCOPE_COPY = Object.freeze({
63
69
  conversationTitleHelpLabel: '查看私聊会话标题字段说明',
64
70
  chatIdHelpLabel: '查看私聊会话标识字段说明',
65
71
  threadIdHelpLabel: '查看私聊话题标识字段说明',
72
+ sentAtHelpLabel: '查看私聊发送时间字段说明',
66
73
  guidanceLabel: '增强提示词',
67
74
  guidanceHelpLabel: '查看私聊增强提示词使用说明',
68
75
  guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -42,6 +42,8 @@ const EN = Object.freeze({
42
42
  '无需安装第三方服务;请按指引授予 macOS Messages 访问权限。': 'No third-party service is required; follow the guide to grant macOS Messages access.',
43
43
  'macOS Messages 权限': 'macOS Messages permissions',
44
44
  'IM机器人': 'IM bots',
45
+ '打开 IM机器人': 'Open IM bots',
46
+ '返回插件详情': 'Back to plugin details',
45
47
  'IM机器人设置': 'IM bot settings',
46
48
  'IM 面板加载失败': 'Could not load the IM panel',
47
49
  '更多机器人设置': 'More bot settings',
@@ -420,6 +422,10 @@ const EN = Object.freeze({
420
422
  '查看私聊话题标识字段说明': 'View direct topic ID field details',
421
423
  '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。': 'This field is not available on every channel. Messages in a Feishu topic chat carry the topic ID, which distinguishes different topics inside the same group. If the current message is not inside a topic, <dsh_im_source> omits threadId even when the field is selected.',
422
424
  '机器人标识': 'Bot ID',
425
+ '发送时间': 'Sent at',
426
+ '查看群聊发送时间字段说明': 'View group sent-at field details',
427
+ '查看私聊发送时间字段说明': 'View direct sent-at field details',
428
+ '当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。': 'When the current message was sent, as YYYY-MM-DD HH:mm:ss in the local time zone. The platform send time is used when available; otherwise the send time is decoded from the WeChat message ID. If neither is available, <dsh_im_source> omits sentAt even when the field is selected. WeChat is the only channel that provides it today. Naming your time zone in the guidance makes ordering judgements more accurate.',
423
429
  '增强提示词': 'Guidance',
424
430
  '查看增强提示词使用说明': 'View guidance instructions',
425
431
  '查看群聊增强提示词使用说明': 'View group guidance instructions',
@@ -446,7 +452,7 @@ const EN = Object.freeze({
446
452
  '上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
447
453
  '请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
448
454
  '群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
449
- '来源字段只能选择已定义的八个字段。': 'Source fields must be chosen from the eight defined fields.',
455
+ '来源字段只能选择已定义的字段。': 'Source fields must be chosen from the defined fields.',
450
456
  '保存中…': 'Saving…',
451
457
  '未设置': 'Not set',
452
458
  '工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
@@ -83,6 +83,7 @@ import { installSessionChannelLogos } from './session-channel-logos.js';
83
83
  import { UpdatePanel, UPDATE_RPC_CHANNEL } from './update-panel.js';
84
84
  import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
85
85
  import { IMPanelErrorBoundary } from './panel-error-boundary.js';
86
+ import { installImPluginPage } from './plugin-page.js';
86
87
 
87
88
  export const name = 'im-settings';
88
89
  export const inject = ['slots', 'connection', 'locale', 'workspaces'];
@@ -566,6 +567,8 @@ export function apply(ctx) {
566
567
  preferredSectionId: props.preferredSectionId,
567
568
  });
568
569
 
570
+ installImPluginPage(ctx, { packageName: manifest.name, renderPanel: buildPanelElement });
571
+
569
572
  ctx.effect(() => {
570
573
  let disposed = false;
571
574
  let stopSettings = null;
@@ -0,0 +1,51 @@
1
+ import * as React from 'react';
2
+ import { IM_LOCALE_NAMESPACE } from './i18n.js';
3
+
4
+ export const IM_MAIN_PANEL_ID = 'xmanrui-dsh-im';
5
+ const h = React.createElement;
6
+
7
+ function OpenImAction({ subject, packageName, onOpen, t }) {
8
+ if (subject?.kind !== 'bundle' && subject?.kind !== 'row') return null;
9
+ if (subject.pkg?.name !== packageName) return null;
10
+ if (subject.kind === 'row' && subject.row?.moduleName !== packageName) return null;
11
+ return h('button', {
12
+ type: 'button', className: 'dim-pluginOpen', onClick: onOpen,
13
+ }, t('打开 IM机器人'));
14
+ }
15
+
16
+ function ImPluginPage({ renderPanel, onBack, t }) {
17
+ return h('div', { className: 'dim-pluginPage' },
18
+ h('div', { className: 'dim-pluginPageContent' },
19
+ h('button', { type: 'button', className: 'dim-pluginBack', onClick: onBack },
20
+ h('span', { 'aria-hidden': true }, '‹'), t('返回插件详情')),
21
+ renderPanel()));
22
+ }
23
+
24
+ // Optional services and slots keep the existing settings entry working on
25
+ // older DSH versions. Reuse the same panel through public navigation APIs.
26
+ export function installImPluginPage(ctx, { packageName, renderPanel }) {
27
+ if (typeof ctx.inject !== 'function') return;
28
+ ctx.inject(['layout', 'pluginNavigation'], (pageCtx) =>
29
+ pageCtx.slots.inject('main', () => {
30
+ const unregisterPage = pageCtx.slots.register({
31
+ name: 'main', key: IM_MAIN_PANEL_ID, locale: IM_LOCALE_NAMESPACE,
32
+ inject: () => ({
33
+ renderPanel,
34
+ onBack: () => pageCtx.pluginNavigation.openBundle(packageName),
35
+ }),
36
+ }, ImPluginPage);
37
+ const stopActions = pageCtx.slots.inject('plugins.detail.actions', () =>
38
+ pageCtx.slots.register({
39
+ name: 'plugins.detail.actions', id: 'xmanrui-dsh-im-open',
40
+ order: 20, locale: IM_LOCALE_NAMESPACE,
41
+ inject: () => ({
42
+ packageName,
43
+ onOpen: () => pageCtx.layout.selectPanel(IM_MAIN_PANEL_ID),
44
+ }),
45
+ }, OpenImAction));
46
+ return () => {
47
+ stopActions();
48
+ unregisterPage();
49
+ };
50
+ }));
51
+ }
@@ -1,6 +1,15 @@
1
1
  export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
2
2
 
3
3
  const CSS = String.raw`
4
+ .dim-pluginOpen { display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 7px 14px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
5
+ .dim-pluginOpen:hover { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
6
+ .dim-pluginOpen:focus-visible, .dim-pluginBack:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 3px; }
7
+ .dim-pluginPage { height: 100%; min-height: 0; overflow: auto; box-sizing: border-box; padding: 24px clamp(16px, 4vw, 48px) 32px; color: var(--dsw-alias-label-primary, #1f2329); }
8
+ .dim-pluginPageContent { max-width: 1080px; margin: 0 auto; }
9
+ .dim-pluginBack { display: inline-flex; align-items: center; gap: 8px; min-height: 36px; margin: 0 0 24px; padding: 6px 8px; border: 0; border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; cursor: pointer; }
10
+ .dim-pluginBack:hover { color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
11
+ .dim-pluginBack > span { font-size: 22px; line-height: 20px; }
12
+ @media (pointer: coarse) { .dim-pluginOpen, .dim-pluginBack { min-height: 44px; } }
4
13
  .dim-connectionDiagnostic { min-width: 0; width: 100%; color: var(--dsw-alias-label-secondary, #646a73); overflow-wrap: anywhere; font-size: 13px; line-height: 1.6; }
5
14
  .dim-connectionDiagnostic[data-warning="true"] { color: var(--dsw-alias-state-warn-primary, #d97706); }
6
15
  .dim-connectionDiagnostic p { margin: 4px 0; }
@@ -219,7 +219,7 @@ const directDependencies = {
219
219
  '@tencent-connect/qqbot-nodejs': '1.0.4',
220
220
  '@wecom/aibot-node-sdk': '1.0.7',
221
221
  qrcode: '1.5.4',
222
- undici: '7.29.0',
222
+ undici: '7.29.1',
223
223
  };
224
224
  for (const [name, version] of Object.entries(directDependencies)) {
225
225
  if (manifest.dependencies?.[name] !== version) {
@@ -1264,7 +1264,7 @@ export class FeishuHarnessBridge {
1264
1264
  this.#commandTasks.add(current);
1265
1265
  return current;
1266
1266
  }
1267
- if (this.#isResolvedQuestionReply(event, key)) {
1267
+ if (!hasImages && this.#isResolvedQuestionReply(event, key)) {
1268
1268
  const current = Promise.resolve()
1269
1269
  .then(() => this.#discardResolvedInteractionReply(event, messageId))
1270
1270
  .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
@@ -1317,6 +1317,19 @@ export class FeishuHarnessBridge {
1317
1317
  if (pending && senderOpenId(event) !== pending.actor) {
1318
1318
  return this.#enqueueMessage(event, messageId, key, processingReaction);
1319
1319
  }
1320
+ if (pending && hasImages) {
1321
+ const previous = pending.queue ?? Promise.resolve();
1322
+ const interruption = previous.catch(() => undefined)
1323
+ .then(() => this.#interruptQuestionForImage(event, key, pending))
1324
+ .finally(() => {
1325
+ if (pending.queue === interruption) pending.queue = null;
1326
+ this.#interactionTasks.delete(interruption);
1327
+ });
1328
+ pending.queue = interruption;
1329
+ this.#interactionTasks.add(interruption);
1330
+ // Reserve the image's FIFO position before stopping releases the original turn.
1331
+ return this.#enqueueMessage(event, messageId, key, processingReaction);
1332
+ }
1320
1333
  if (pending?.submitting || pending?.claimedReplyMessageId) {
1321
1334
  return this.#enqueueMessage(event, messageId, key, processingReaction);
1322
1335
  }
@@ -5809,6 +5822,40 @@ export class FeishuHarnessBridge {
5809
5822
  });
5810
5823
  }
5811
5824
 
5825
+ async #interruptQuestionForImage(event, key, pending) {
5826
+ if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
5827
+ const question = pending.questions[pending.index];
5828
+ const questionMessageId = pending.questionCardMessageId;
5829
+ const index = pending.index;
5830
+ const wasSubmitting = pending.submitting;
5831
+ pending.submitting = true;
5832
+ try {
5833
+ await runControlCommand('/stop', this.#harness, this.#state, key, {
5834
+ signal: this.#signal,
5835
+ control: { owner: this, key },
5836
+ });
5837
+ } catch (error) {
5838
+ if (this.#pendingInteractions.get(key) === pending) pending.submitting = wasSubmitting;
5839
+ this.#logger.warn?.('[dsh-feishu] could not stop the question turn for an image:', error.message);
5840
+ await this.#send(event.message.chat_id,
5841
+ t('图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。'),
5842
+ { replyTo: event.message.message_id }).catch(() => undefined);
5843
+ return;
5844
+ }
5845
+ await this.#cancelPendingInteraction(key, pending.interactionId);
5846
+ const resolvedText = t('已结束本轮提问,将在当前会话中继续处理图片。');
5847
+ if (questionMessageId) {
5848
+ await this.#patchCardMessage(event.message.chat_id, questionMessageId, questionCard({
5849
+ ...question,
5850
+ interactionId: pending.interactionId,
5851
+ index,
5852
+ total: pending.questions.length,
5853
+ resolvedText,
5854
+ }));
5855
+ }
5856
+ await this.#send(event.message.chat_id, resolvedText, { replyTo: event.message.message_id }).catch(() => undefined);
5857
+ }
5858
+
5812
5859
  async #submitQuestionAnswer(pending, answerText, { chatId, messageId, questionMessageId } = {}) {
5813
5860
  const question = pending.questions[pending.index];
5814
5861
  if (!question) return;
@@ -6146,8 +6193,8 @@ export class FeishuHarnessBridge {
6146
6193
  return this.#takePendingInteraction(key, interactionId) !== null;
6147
6194
  }
6148
6195
 
6149
- async #cancelPendingInteraction(key) {
6150
- const pending = this.#takePendingInteraction(key);
6196
+ async #cancelPendingInteraction(key, interactionId) {
6197
+ const pending = this.#takePendingInteraction(key, interactionId);
6151
6198
  if (!pending || pending.kind !== 'question') return;
6152
6199
  this.#rememberResolvedInteraction(key, pending);
6153
6200
  try {
@@ -1211,9 +1211,10 @@ export function splitStepStreamCardBlocks(
1211
1211
  * rendered as its own button; the selected option label is submitted via a
1212
1212
  * card callback. Multi-select questions fall back to the plain-text flow (the
1213
1213
  * caller decides), because a multi-select needs a confirm step.
1214
+ * A resolvedText replaces the buttons when an image ends the question flow.
1214
1215
  * Action: answer:<interactionId>:<optionLabel>
1215
1216
  */
1216
- export function questionCard({ interactionId, header, question, detail, options, index, total }) {
1217
+ export function questionCard({ interactionId, header, question, detail, options, index, total, resolvedText }) {
1217
1218
  const elements = [];
1218
1219
  const progress = total > 1 ? `(${index + 1}/${total})` : '';
1219
1220
  if (header) elements.push({ tag: 'div', text: markdown(String(header)) });
@@ -1221,6 +1222,11 @@ export function questionCard({ interactionId, header, question, detail, options,
1221
1222
  elements.push({ tag: 'div', text: markdown(String(qText)) });
1222
1223
  if (detail) elements.push({ tag: 'div', text: markdown(String(detail)) });
1223
1224
 
1225
+ if (resolvedText) {
1226
+ elements.push({ tag: 'hr' }, { tag: 'div', text: markdown(resolvedText) });
1227
+ return cardWith(t('⏹ 提问已结束{progress}', { progress }), elements);
1228
+ }
1229
+
1224
1230
  if (Array.isArray(options) && options.length > 0) {
1225
1231
  elements.push({ tag: 'hr' });
1226
1232
  for (const option of options) {
@@ -1,7 +1,7 @@
1
1
  // Shared by the Host and settings UI; keep this module browser-compatible.
2
2
  export const CONTEXT_ENHANCEMENT_FIELDS = Object.freeze([
3
3
  'channel', 'conversationType', 'senderId', 'senderName', 'conversationTitle',
4
- 'chatId', 'threadId', 'botId',
4
+ 'chatId', 'threadId', 'botId', 'sentAt',
5
5
  ]);
6
6
 
7
7
  /**
@@ -56,7 +56,7 @@ const CHANNELS = new Set([
56
56
  ]);
57
57
  const SOURCE_LIMITS = {
58
58
  channel: 16, conversationType: 6, senderId: 256, senderName: 256,
59
- conversationTitle: 256, chatId: 256, threadId: 256, botId: 128,
59
+ conversationTitle: 256, chatId: 256, threadId: 256, botId: 128, sentAt: 32,
60
60
  };
61
61
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g;
62
62
 
@@ -82,7 +82,7 @@ function validateContextEnhancementScope(input) {
82
82
  throw invalidConfig('群聊和私聊开关必须是布尔值。');
83
83
  }
84
84
  if (!Array.isArray(fields) || ![...fields].every((field) => CONTEXT_ENHANCEMENT_FIELDS.includes(field))) {
85
- throw invalidConfig('来源字段只能选择已定义的八个字段。');
85
+ throw invalidConfig('来源字段只能选择已定义的字段。');
86
86
  }
87
87
  if (typeof guidance !== 'string' || guidance.length > CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH) {
88
88
  throw invalidConfig(`增强提示词不得超过 ${CONTEXT_ENHANCEMENT_GUIDANCE_MAX_LENGTH} 个字符。`);
@@ -155,6 +155,70 @@ export function captureContextEnhancement(provider, conversationType) {
155
155
  }
156
156
  }
157
157
 
158
+ /**
159
+ * Property a channel hangs its captured moment on, using `withSentAt`.
160
+ *
161
+ * It sits on the source factory instead of being read out of it, because a
162
+ * factory reads the inbound event's fields -- sometimes through throwing
163
+ * getters -- and only a prompt that actually renders a source block may do
164
+ * that. Local commands (`/help`, `/new`, `/steer`) take the enhancement to keep
165
+ * its privacy rules but never read a source, and that must stay true.
166
+ */
167
+ const SENT_AT = 'sentAt';
168
+
169
+ /** One-shot latch: a wiring bug is worth saying once, not once per message. */
170
+ let warnedMissingSentAt = false;
171
+
172
+ /**
173
+ * Wrap one channel source factory so it also publishes the moment of the
174
+ * message, without reading any of the message's fields yet.
175
+ *
176
+ * `value` may itself be a thunk: a channel that derives the moment from the
177
+ * inbound event (a `create_time_ms` field, a message-id decode) should pass one,
178
+ * because evaluating it here would read the inbound message even when the scope
179
+ * is disabled or the `sentAt` field is not selected -- and a disabled scope must
180
+ * read nothing at all.
181
+ *
182
+ * @param factory - the channel's source factory, or nothing.
183
+ * @param value - platform send time (epoch ms or ISO-8601), or a thunk
184
+ * returning one. It is evaluated only when a rendered block selects `sentAt`;
185
+ * an unusable value, or a thunk that throws, omits the field.
186
+ * @returns the same shape, plus `sentAt` when a usable value was supplied.
187
+ */
188
+ export function withSentAt(factory, value) {
189
+ const wrapped = typeof factory === 'function' ? (...args) => factory(...args) : () => factory;
190
+ const published = typeof value === 'function' ? value : () => value;
191
+ Object.defineProperty(wrapped, SENT_AT, { value: published, enumerable: false });
192
+ return wrapped;
193
+ }
194
+
195
+ /**
196
+ * Evaluate the moment a channel published, without invoking its source factory.
197
+ *
198
+ * Called only while a prompt is rendering a block that selected `sentAt` --
199
+ * never at capture time -- so an unselected field, a disabled scope, a missing
200
+ * provider or a local command never reads the inbound message's clock. A
201
+ * throwing accessor (for example a getter on the inbound event) is contained
202
+ * here: it costs this one field, not the message.
203
+ *
204
+ * @param source - a source factory carrying `sentAt`, or a plain source object.
205
+ * @returns `{ published, ms }`: whether a moment was published at all, and its
206
+ * epoch milliseconds when it was usable.
207
+ */
208
+ function readPublishedSentAt(source) {
209
+ const own = typeof source === 'function' || (source !== null && typeof source === 'object')
210
+ ? source?.[SENT_AT]
211
+ : undefined;
212
+ if (typeof own !== 'function') return { published: false, ms: undefined };
213
+ let value;
214
+ try {
215
+ value = own();
216
+ } catch {
217
+ return { published: true, ms: undefined };
218
+ }
219
+ return { published: true, ms: timestampMs(value) ?? undefined };
220
+ }
221
+
158
222
  /**
159
223
  * Capture the enhancement one prompt replays, together with the source factory
160
224
  * that fills its selected fields.
@@ -167,12 +231,55 @@ export function captureContextEnhancement(provider, conversationType) {
167
231
  *
168
232
  * @param provider - the bot's enhancement provider.
169
233
  * @param conversationType - the inbound message's scope.
170
- * @param source - factory for the currently selected source fields.
234
+ * @param source - factory for the currently selected source fields; attach the
235
+ * moment with `withSentAt` to publish a platform send time.
171
236
  * @returns the enhancement to apply, or null when the scope is off.
172
237
  */
173
238
  export function captureContextEnhancementSource(provider, conversationType, source) {
239
+ // No provider means enhancement is not configured for this channel at all, so
240
+ // there is nothing to capture -- and the thunk a channel published must not be
241
+ // evaluated. Answering null here is what lets every channel call this
242
+ // unconditionally, including bots with no enhancement settings.
243
+ if (typeof provider?.getSettings !== 'function') return null;
174
244
  const snapshot = captureContextEnhancement(provider, conversationType);
175
- return snapshot === null ? null : Object.freeze({ snapshot, source });
245
+ if (snapshot === null) return null;
246
+ // Nothing is read here -- not the message's fields and not the moment a
247
+ // channel published through `withSentAt`. Local commands capture too (to keep
248
+ // the privacy rules for a later prompt) but never render, and a capture whose
249
+ // scope does not select `sentAt` must never touch the clock; the moment is
250
+ // evaluated only by the render that actually needs it.
251
+ return Object.freeze({ snapshot, source });
252
+ }
253
+
254
+ /** Accept a millisecond epoch or an ISO-8601 string; anything else yields null. */
255
+ function timestampMs(value) {
256
+ if (value === undefined || value === null || value === '') return null;
257
+ if (typeof value === 'bigint') value = Number(value);
258
+ if (typeof value === 'number') return Number.isSafeInteger(value) ? value : null;
259
+ if (typeof value === 'string') {
260
+ const parsed = Date.parse(value);
261
+ return Number.isSafeInteger(parsed) ? parsed : null;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /**
267
+ * Render one epoch instant as `YYYY-MM-DD HH:mm:ss` in local time. The zone is
268
+ * deliberately omitted: four independent components already carry the digits,
269
+ * and the guidance text is the place to name the timezone.
270
+ *
271
+ * Padding is spelled out per component instead of calling a module-level
272
+ * helper: `sourceBlock` can run while this module is still initializing, and
273
+ * reading a `const` declared below would throw a temporal-dead-zone error that
274
+ * the caller's catch silently swallows -- dropping the entire prefix.
275
+ */
276
+ function formatTimestamp(ms) {
277
+ const date = new Date(ms);
278
+ const year = date.getFullYear();
279
+ if (!Number.isInteger(year)) return undefined;
280
+ const pad = (value) => String(value).padStart(2, '0');
281
+ return `${year}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
282
+ + ` ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
176
283
  }
177
284
 
178
285
  function sourceString(value, field) {
@@ -193,6 +300,25 @@ function sourceBlock(snapshot, sourceFactory) {
193
300
  const source = needsSource ? sourceFactory?.() : null;
194
301
  const projected = {};
195
302
  for (const field of fields) {
303
+ if (field === 'sentAt') {
304
+ // Only a channel that published a moment through `withSentAt` renders
305
+ // this field, and the moment is evaluated here, the first and only time
306
+ // it is needed. A field the factory returns is never read for it: a
307
+ // channel that did not wire a moment omits the field rather than fall
308
+ // back to the local clock, which the model could not tell apart.
309
+ const { published, ms } = readPublishedSentAt(sourceFactory);
310
+ const formatted = ms === undefined ? undefined : formatTimestamp(ms);
311
+ if (typeof formatted === 'string' && formatted) {
312
+ projected[field] = formatted.slice(0, SOURCE_LIMITS[field]);
313
+ } else if (published && !warnedMissingSentAt) {
314
+ // The channel did publish a moment but it was unusable or failed to
315
+ // read: say so once, because the omitted field is otherwise
316
+ // indistinguishable from a channel that supplies none.
317
+ warnedMissingSentAt = true;
318
+ console.warn('[dsh-im] a published sentAt was unusable and was omitted from the block');
319
+ }
320
+ continue;
321
+ }
196
322
  const value = field === 'botId' || field === 'conversationType'
197
323
  ? snapshot[field] : source?.[field];
198
324
  const normalized = sourceString(value, field);
@@ -213,11 +339,26 @@ function guidanceBlock(guidance) {
213
339
  return `${INJECTED_CONTEXT_TAGS.guidanceOpen}\n${body}\n${INJECTED_CONTEXT_TAGS.guidanceClose}`;
214
340
  }
215
341
 
216
- /** Add one text prefix; never inspect sources, format or copy content when off. */
342
+ /**
343
+ * Add one text prefix; never inspect sources, format or copy content when off.
344
+ *
345
+ * @param content - the message body the channel is about to send.
346
+ * @param snapshot - either a capture from `captureContextEnhancementSource`
347
+ * (which already carries the moment and its own source) or a bare scope
348
+ * snapshot from `captureContextEnhancement`.
349
+ * @param sourceFactory - the channel's source factory, when the caller holds it
350
+ * separately from the capture. Omit it for a capture: the capture's own
351
+ * factory and moment are used, so a `sentAt` selection cannot be lost by
352
+ * passing only its `.snapshot`.
353
+ */
217
354
  export function enhanceContextContent(content, snapshot, sourceFactory) {
218
355
  if (!snapshot) return content;
356
+ // A capture is `{snapshot, source}`; a bare scope is `{config, ...}`.
357
+ const scope = snapshot.config ? snapshot : snapshot.snapshot;
358
+ if (!scope?.config) return content;
359
+ const factory = sourceFactory ?? (snapshot.config ? undefined : snapshot.source);
219
360
  try {
220
- const blocks = [sourceBlock(snapshot, sourceFactory), guidanceBlock(snapshot.config.guidance)]
361
+ const blocks = [sourceBlock(scope, factory), guidanceBlock(scope.config.guidance)]
221
362
  .filter(Boolean);
222
363
  if (blocks.length === 0) return content;
223
364
  const prefix = blocks.join(INJECTED_CONTEXT_SEPARATOR);
@@ -1,5 +1,10 @@
1
1
  // English translations (feishu area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '已结束本轮提问,将在当前会话中继续处理图片。':
4
+ 'The questions have been closed. Your image will be processed in this conversation.',
5
+ '图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。':
6
+ 'Your image is queued, but the current questions could not be closed. Answer them or send /stop; your image will be processed afterward.',
7
+ '⏹ 提问已结束{progress}': '⏹ Questions closed{progress}',
3
8
  '内容已合并至后续消息。': 'Content has been consolidated into the following messages.',
4
9
  // feishu/bridge.mjs — welcome / help
5
10
  '北汇星河 AIOS 已连接 DeepSeek Harness。':
@@ -40,6 +40,7 @@ import {
40
40
  captureContextEnhancement,
41
41
  captureContextEnhancementSource,
42
42
  enhanceContextContent,
43
+ withSentAt,
43
44
  } from '../shared/context-enhancement.mjs';
44
45
  import {
45
46
  hasInboundImages,
@@ -398,9 +399,20 @@ export class WeixinHarnessBridge {
398
399
  this.#acceptedMessageIds.set(messageId, null);
399
400
  return this.#finishAccessDecision(messageId, sender, message, access);
400
401
  }
401
- this.#acceptedMessageIds.set(messageId, captureContextEnhancement(
402
+ // Capture through the *source* entry point, not the bare config snapshot:
403
+ // the enhancement that renders the user message is read back from this map
404
+ // at prompt time, and a rendered `sentAt` selection needs the factory that
405
+ // carries the moment. Nothing is read here; the thunk runs only when that
406
+ // render selects `sentAt`.
407
+ this.#acceptedMessageIds.set(messageId, captureContextEnhancementSource(
402
408
  this.#contextEnhancement,
403
409
  'direct',
410
+ withSentAt(
411
+ () => ({ channel: 'weixin', senderId: sender, chatId: sender }),
412
+ // A thunk: the platform send time (falling back to the iLink message-id
413
+ // clock) is only decoded when a `sentAt` selection actually renders.
414
+ () => message?.create_time_ms ?? weixinMessageTimestampMs(messageId),
415
+ ),
404
416
  ));
405
417
  if (sender === this.#ownerUserId) {
406
418
  rememberConnectionTestTarget(this.#state, { toUserId: sender });
@@ -717,7 +729,14 @@ export class WeixinHarnessBridge {
717
729
  enhancement: captureContextEnhancementSource(
718
730
  this.#contextEnhancement,
719
731
  'direct',
720
- () => ({ channel: 'weixin', senderId: sender, chatId: sender }),
732
+ // The moment rides on the factory as a thunk, so a local command that
733
+ // never renders a source block -- and a scope that is off or does not
734
+ // select `sentAt` -- reads nothing from the inbound message.
735
+ withSentAt(
736
+ () => ({ channel: 'weixin', senderId: sender, chatId: sender }),
737
+ // Platform send time, falling back to the iLink message-id clock.
738
+ () => message?.create_time_ms ?? weixinMessageTimestampMs(messageId),
739
+ ),
721
740
  ),
722
741
  });
723
742
  if (result?.stopped) {
@@ -817,15 +836,14 @@ export class WeixinHarnessBridge {
817
836
  let content = hasImages || hasReply
818
837
  ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal, deferImages: true })
819
838
  : undefined;
820
- const snapshot = this.#acceptedMessageIds.get(messageId);
839
+ const captured = this.#acceptedMessageIds.get(messageId);
821
840
  let contextEnhanced = false;
822
- if (snapshot) {
841
+ if (captured) {
823
842
  const originalContent = content ?? text;
824
- content = enhanceContextContent(originalContent, snapshot, () => ({
825
- channel: 'weixin',
826
- senderId: sender,
827
- chatId: sender,
828
- }));
843
+ // The capture already carries the moment its own source published, so
844
+ // `enhanceContextContent` accepts it as-is: passing only `.snapshot`
845
+ // here would drop `sentAt` and silently render no block at all.
846
+ content = enhanceContextContent(originalContent, captured);
829
847
  contextEnhanced = content !== originalContent;
830
848
  }
831
849
  await this.#state.markSeen(messageId);
@@ -838,7 +856,7 @@ export class WeixinHarnessBridge {
838
856
  text,
839
857
  content,
840
858
  titleText: batchSubmission?.title,
841
- sourceGuidance: snapshot?.config?.guidance,
859
+ sourceGuidance: captured?.snapshot?.config?.guidance,
842
860
  contextEnhanced,
843
861
  createOptions: { signal: this.#signal },
844
862
  existsOptions: { signal: this.#signal },