@xmanrui/dsh-im 4.29.1 → 4.31.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.
@@ -16,6 +16,17 @@
16
16
  * existing "/xxx" text commands.
17
17
  */
18
18
 
19
+ import {
20
+ DEFAULT_SLASH_COMMAND_ICON,
21
+ SLASH_COMMAND_MANIFEST,
22
+ isCustomSlashPanel,
23
+ resolveSlashPanelManifest,
24
+ } from './slash-command-panel.mjs';
25
+
26
+ // The manifest describes the panel, so it lives with the panel's configuration;
27
+ // re-exported here because this is where callers have always imported it from.
28
+ export { SLASH_COMMAND_MANIFEST };
29
+
19
30
  const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
20
31
  const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
21
32
 
@@ -24,50 +35,15 @@ export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
24
35
  'application:app_slash_command:write',
25
36
  ]);
26
37
 
27
- // Icon keys are the documented values in the Feishu Slash Command doc.
28
- const DEFAULT_ICON = 'ai-agent_outlined';
38
+ const DEFAULT_ICON = DEFAULT_SLASH_COMMAND_ICON;
29
39
 
30
40
  /**
31
- * The dsh-im Feishu command manifest. Every entry's `command` is registered
32
- * WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
33
- * and sends "/<command>" back as text, which matches the bridge's regexes.
34
- *
35
- * Descriptions should stay short and match what the command actually does in
36
- * bridge.mjs / the shared command modules.
41
+ * Feishu returns the panel in creation order (newest first, time to the
42
+ * second), so a chosen order is only reachable by creating the commands in
43
+ * reverse, one per second. Anything faster lands in the same second and the
44
+ * order degrades to the platform's tie-break.
37
45
  */
38
- export const SLASH_COMMAND_MANIFEST = Object.freeze([
39
- { command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
40
- { command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
41
- { command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
42
- { command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
43
- { command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
44
- { command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
45
- { command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
46
- { command: 'workspaces', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
47
- { command: 'wsl', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
48
- { command: 'ws', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
49
- { command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
50
- { command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
51
- { command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
52
- { command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
53
- { command: 'history', icon: 'chat-ai_outlined', default: '查看最近历史消息(仅私聊)', en_us: 'Show recent history (private chats only)' },
54
- { command: 'workspace', icon: 'folder_outlined', default: '切换工作区', en_us: 'Switch workspace' },
55
- { command: 'conv', icon: 'folder_outlined', default: '设置当前对话专属工作区', en_us: 'Set the workspace for this conversation' },
56
- { command: 'session', icon: 'chat-ai_outlined', default: '绑定已有会话', en_us: 'Bind an existing session' },
57
- { command: 'models', icon: 'ai-functions_outlined', default: '列出可用模型', en_us: 'List available models' },
58
- { command: 'model', icon: 'ai-agent_outlined', default: '查看或切换当前模型', en_us: 'Show or switch the current model' },
59
- { command: 'reasoninglist', icon: 'ai-deepthink_outlined', default: '列出可用推理等级', en_us: 'List available reasoning efforts' },
60
- { command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
61
- { command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
62
- { command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
63
- { command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
64
- { command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
65
- { command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
66
- { command: 'send', icon: 'chat-ai_outlined', default: '提交当前批次(仅私聊)', en_us: 'Submit the current batch (private chats only)' },
67
- { command: 'cancel', icon: 'clear_outlined', default: '取消当前批次(仅私聊)', en_us: 'Cancel the current batch (private chats only)' },
68
- { command: 'version', icon: 'ai-functions_outlined', default: '查看插件版本', en_us: 'Show the plugin version' },
69
- { command: 'repair', icon: 'ai-functions_outlined', default: '补全飞书权限与卡片回调(仅私聊)', en_us: 'Complete Feishu permissions and card callbacks (private chats only)' },
70
- ]);
46
+ export const SLASH_PANEL_CREATE_INTERVAL_MS = 1_100;
71
47
 
72
48
  // Commands that require a parameter are registered too, so the user can type
73
49
  // "/watch <session ID>" from the panel. A leading placeholder hint is not part of
@@ -276,4 +252,244 @@ export async function registerSlashCommands({
276
252
  };
277
253
  }
278
254
 
255
+ /** Delete one registered command with an already-fetched tenant token. */
256
+ async function deleteSlashCommandWithToken({
257
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId,
258
+ }) {
259
+ await requestJson(httpInstance, {
260
+ method: 'DELETE',
261
+ url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
262
+ headers: { authorization: `Bearer ${tenantAccessToken}` },
263
+ signal: requestSignal(signal, timeoutMs),
264
+ timeout: timeoutMs,
265
+ }, 'Feishu slash command delete');
266
+ }
267
+
268
+ /** Plain timer used between creates; tests inject their own. */
269
+ function defaultWait(ms, signal) {
270
+ return new Promise((resolve) => {
271
+ const timer = setTimeout(resolve, ms);
272
+ signal?.addEventListener('abort', () => {
273
+ clearTimeout(timer);
274
+ resolve();
275
+ }, { once: true });
276
+ });
277
+ }
278
+
279
+ function commandNameOf(item) {
280
+ return typeof item?.command === 'string' && item.command ? item.command : null;
281
+ }
282
+
283
+ /**
284
+ * The commands of ours in panel order. Feishu returns the list newest-first
285
+ * with second precision, so `create_time` is the panel order and `command_id`
286
+ * only breaks ties inside the same second.
287
+ */
288
+ function orderedOwnedNames(items, owned) {
289
+ return items
290
+ .map((item, index) => ({ item, index }))
291
+ .filter(({ item }) => owned.has(commandNameOf(item)))
292
+ .sort((a, b) => {
293
+ const aTime = Number(a.item.create_time);
294
+ const bTime = Number(b.item.create_time);
295
+ if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return bTime - aTime;
296
+ const aId = Number(a.item.command_id);
297
+ const bId = Number(b.item.command_id);
298
+ if (Number.isFinite(aId) && Number.isFinite(bId) && aId !== bId) return bId - aId;
299
+ return a.index - b.index;
300
+ })
301
+ .map(({ item }) => commandNameOf(item))
302
+ .filter(Boolean);
303
+ }
304
+
305
+ function slashPanelDescription(entry, command) {
306
+ return {
307
+ default_value: entry.default ?? entry.en_us ?? command,
308
+ i18n: {
309
+ zh_cn: entry.default ?? command,
310
+ en_us: entry.en_us ?? entry.default ?? command,
311
+ },
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Sync one bot's "/" panel with its configured manifest.
317
+ *
318
+ * `default` mode is the pre-configurable behaviour: create what is missing and
319
+ * leave everything else alone. `custom` mode converges the app to the
320
+ * configured commands *and order*:
321
+ *
322
+ * 1. read the registered commands (with command_id and create_time);
323
+ * 2. stop when the panel already holds exactly that order — Feishu reports the
324
+ * panel in creation order, so a matching order needs no calls at all;
325
+ * 3. otherwise delete our commands and recreate the configured ones
326
+ * newest-first, one per `intervalMs`, because the order *is* the creation
327
+ * order.
328
+ *
329
+ * Commands registered outside the manifest are never deleted: dsh-im does not
330
+ * own them, and their position is the platform's business.
331
+ *
332
+ * @returns {{ created: Array<{command,command_id}>, deleted: string[],
333
+ * existing: string[], external: string[], failed: Array<{command,error}>,
334
+ * changed: boolean, superseded: boolean }} `superseded` marks a run that
335
+ * stopped early because a newer panel config took over; it reports what it
336
+ * managed to do, and the newer run converges the panel from there.
337
+ */
338
+ export async function syncSlashCommands({
339
+ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
340
+ signal, manifest = SLASH_COMMAND_MANIFEST, config = null,
341
+ intervalMs = SLASH_PANEL_CREATE_INTERVAL_MS, wait = defaultWait,
342
+ }) {
343
+ // A superseded run — the runtime aborts this signal when a newer panel config
344
+ // arrives — must not send further create/delete calls: the newer run re-reads
345
+ // the panel and converges it, and letting both write would leave the older
346
+ // plan's commands behind. Requests already in flight are left to finish.
347
+ const stopped = () => signal?.aborted === true;
348
+ const tenantAccessToken = await fetchTenantAccessToken({
349
+ appId, appSecret, domain, httpInstance, timeoutMs, signal,
350
+ });
351
+ const items = await listSlashCommandsWithToken({
352
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
353
+ });
354
+ const owned = new Set(manifest.map((entry) => commandNameOf(entry)).filter(Boolean));
355
+ const external = items
356
+ .map((item) => commandNameOf(item))
357
+ .filter((name) => name && !owned.has(name));
358
+ const registered = new Set(
359
+ items.map((item) => commandNameOf(item)).filter(Boolean),
360
+ );
361
+
362
+ if (!isCustomSlashPanel(config)) {
363
+ const created = [];
364
+ const failed = [];
365
+ for (const entry of resolveSlashPanelManifest(config, manifest)) {
366
+ if (stopped()) break;
367
+ const command = commandNameOf(entry);
368
+ if (!command || registered.has(command)) continue;
369
+ try {
370
+ const commandId = await createSlashCommandWithToken({
371
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
372
+ command,
373
+ description: slashPanelDescription(entry, command),
374
+ icon: entry.icon ?? DEFAULT_ICON,
375
+ });
376
+ created.push({ command, command_id: commandId });
377
+ registered.add(command);
378
+ } catch (error) {
379
+ if (stopped()) break;
380
+ if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
381
+ registered.add(command);
382
+ continue;
383
+ }
384
+ failed.push({ command, error: error?.message ?? String(error) });
385
+ if (MISSING_PERMISSION_CODES.has(error?.code)
386
+ || /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
387
+ break;
388
+ }
389
+ }
390
+ }
391
+ return {
392
+ created,
393
+ deleted: [],
394
+ existing: [...registered].filter(Boolean),
395
+ external,
396
+ failed,
397
+ changed: created.length > 0,
398
+ superseded: stopped(),
399
+ };
400
+ }
401
+
402
+ const current = orderedOwnedNames(items, owned);
403
+ const plannedEntries = resolveSlashPanelManifest(config, manifest)
404
+ .filter((entry) => commandNameOf(entry));
405
+ const plannedNames = plannedEntries.map((entry) => commandNameOf(entry));
406
+ if (current.length === plannedNames.length
407
+ && plannedNames.every((name, index) => name === current[index])) {
408
+ return {
409
+ created: [], deleted: [], existing: current, external, failed: [], changed: false,
410
+ };
411
+ }
412
+
413
+ // A newer config took over while we were reading the panel: leave the rebuild
414
+ // to its run rather than deleting commands for a plan nobody wants anymore.
415
+ if (stopped()) {
416
+ return {
417
+ created: [], deleted: [], existing: current, external, failed: [], changed: false, superseded: true,
418
+ };
419
+ }
420
+
421
+ const byName = new Map(
422
+ items.map((item) => [commandNameOf(item), item]).filter(([name]) => name && owned.has(name)),
423
+ );
424
+ const deleted = [];
425
+ const failed = [];
426
+ for (const name of current) {
427
+ if (stopped()) break;
428
+ const item = byName.get(name);
429
+ if (!item?.command_id) {
430
+ failed.push({ command: name, error: 'the registered command reports no command_id' });
431
+ continue;
432
+ }
433
+ try {
434
+ await deleteSlashCommandWithToken({
435
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal, commandId: item.command_id,
436
+ });
437
+ deleted.push(name);
438
+ } catch (error) {
439
+ if (stopped()) break;
440
+ failed.push({ command: name, error: error?.message ?? String(error) });
441
+ }
442
+ }
443
+ // A superseded run stops before rebuilding: the newer run owns the panel now.
444
+ if (stopped()) {
445
+ return {
446
+ created: [], deleted, existing: current, external, failed, changed: deleted.length > 0, superseded: true,
447
+ };
448
+ }
449
+ // Deleting only part of the panel would leave it half-converged, and
450
+ // recreating a name that still exists is rejected as a duplicate. Stop here;
451
+ // the next sync (config change, reconnect, or a later start) converges.
452
+ if (failed.length > 0) {
453
+ return { created: [], deleted, existing: current, external, failed, changed: deleted.length > 0 };
454
+ }
455
+
456
+ const created = [];
457
+ for (const [index, entry] of [...plannedEntries].reverse().entries()) {
458
+ if (stopped()) break;
459
+ const command = commandNameOf(entry);
460
+ try {
461
+ if (index > 0) {
462
+ await wait(intervalMs, signal);
463
+ // The wait wakes on abort as well, so re-check instead of recording a
464
+ // superseded run as a failed create.
465
+ if (stopped()) break;
466
+ }
467
+ const commandId = await createSlashCommandWithToken({
468
+ tenantAccessToken, domain, httpInstance, timeoutMs, signal,
469
+ command,
470
+ description: slashPanelDescription(entry, command),
471
+ icon: entry.icon ?? DEFAULT_ICON,
472
+ });
473
+ created.push({ command, command_id: commandId });
474
+ } catch (error) {
475
+ if (stopped()) break;
476
+ failed.push({ command, error: error?.message ?? String(error) });
477
+ if (MISSING_PERMISSION_CODES.has(error?.code)
478
+ || /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
479
+ break;
480
+ }
481
+ }
482
+ }
483
+
484
+ return {
485
+ created,
486
+ deleted,
487
+ existing: [],
488
+ external,
489
+ failed,
490
+ changed: created.length > 0 || deleted.length > 0,
491
+ superseded: stopped(),
492
+ };
493
+ }
494
+
279
495
  export default registerSlashCommands;
@@ -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 },