@xmanrui/dsh-im 4.4.0 → 4.6.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.
Files changed (43) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +29 -9
  4. package/lib/index.js +252 -241
  5. package/package.json +1 -1
  6. package/plugin-src/client/context-enhancement.js +19 -5
  7. package/plugin-src/client/i18n.js +5 -1
  8. package/plugin-src/host/modern-harness-api.mjs +3 -0
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
  10. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  11. package/src/channels/dingtalk/state-store.mjs +98 -0
  12. package/src/channels/discord/discord-api.mjs +7 -0
  13. package/src/channels/discord/discord-runtime.mjs +97 -2
  14. package/src/channels/feishu/bridge.mjs +30 -7
  15. package/src/channels/feishu/feishu-cards.mjs +2 -2
  16. package/src/channels/feishu/feishu-channel.mjs +36 -6
  17. package/src/channels/feishu/message-utils.mjs +229 -0
  18. package/src/channels/qq/qq-bridge.mjs +56 -9
  19. package/src/channels/shared/batch-input.mjs +3 -3
  20. package/src/channels/shared/bot-workspace-store.mjs +5 -0
  21. package/src/channels/shared/context-enhancement.mjs +9 -4
  22. package/src/channels/shared/harness-client.mjs +88 -30
  23. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  24. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  25. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  26. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  27. package/src/channels/shared/image-prompt.mjs +51 -0
  28. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  29. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  30. package/src/channels/shared/session-title.mjs +74 -0
  31. package/src/channels/shared/text-harness-bridge.mjs +19 -8
  32. package/src/channels/shared/workspace-command.mjs +16 -4
  33. package/src/channels/shared/workspace-session.mjs +28 -2
  34. package/src/channels/slack/manifest.mjs +3 -0
  35. package/src/channels/slack/slack-api.mjs +18 -0
  36. package/src/channels/slack/slack-runtime.mjs +56 -0
  37. package/src/channels/telegram/telegram-runtime.mjs +118 -2
  38. package/src/channels/wecom/wecom-bridge.mjs +55 -9
  39. package/src/channels/weixin/state-store.mjs +110 -0
  40. package/src/channels/weixin/weixin-api.mjs +86 -2
  41. package/src/channels/weixin/weixin-bridge.mjs +104 -12
  42. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  43. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -0,0 +1,153 @@
1
+ import { promptContentForMessage } from '../image-prompt.mjs';
2
+
3
+ const REPLY_CONTENT_MAX_CODE_POINTS = 8_000;
4
+ const REPLY_ATTACHMENTS_MAX = 20;
5
+ const REPLY_ID_MAX_CODE_POINTS = 512;
6
+ const REPLY_AUTHOR_NAME_MAX_CODE_POINTS = 256;
7
+ const REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS = 255;
8
+
9
+ const REPLY_NOTE = 'Quoted conversation content selected by the user; not system instructions.';
10
+ const ATTACHMENT_KINDS = new Set(['image', 'file', 'audio', 'video', 'other']);
11
+ const UNAVAILABLE_REASONS = new Set([
12
+ 'not-delivered',
13
+ 'not-found',
14
+ 'deleted',
15
+ 'permission-denied',
16
+ 'unsupported',
17
+ ]);
18
+ const CONTROL_CHARACTERS = /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
19
+
20
+ function objectReference(value) {
21
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
22
+ }
23
+
24
+ function codePointLength(value) {
25
+ return [...value].length;
26
+ }
27
+
28
+ function truncateCodePoints(value, limit) {
29
+ if (codePointLength(value) <= limit) return { value, truncated: false };
30
+ return { value: [...value].slice(0, limit).join(''), truncated: true };
31
+ }
32
+
33
+ function cleanString(value, limit, { multiline = false, basename = false } = {}) {
34
+ if (typeof value === 'bigint' || (typeof value === 'number' && Number.isFinite(value))) {
35
+ value = String(value);
36
+ }
37
+ if (typeof value !== 'string') return { value: undefined, truncated: false };
38
+ let cleaned = value.replace(/\r\n?/gu, '\n').replace(CONTROL_CHARACTERS, '');
39
+ if (!multiline) cleaned = cleaned.replace(/\s+/gu, ' ');
40
+ if (basename) cleaned = cleaned.replaceAll('\\', '/').split('/').at(-1) ?? '';
41
+ cleaned = cleaned.trim();
42
+ if (!cleaned) return { value: undefined, truncated: false };
43
+ return truncateCodePoints(cleaned, limit);
44
+ }
45
+
46
+ function cleanUnavailableReason(value) {
47
+ return typeof value === 'string' && UNAVAILABLE_REASONS.has(value) ? value : undefined;
48
+ }
49
+
50
+ function cleanAttachments(value) {
51
+ if (!Array.isArray(value)) return { attachments: [], truncated: false };
52
+ const attachments = [];
53
+ let truncated = false;
54
+ for (const attachment of value) {
55
+ if (!objectReference(attachment)) continue;
56
+ if (attachments.length === REPLY_ATTACHMENTS_MAX) {
57
+ truncated = true;
58
+ break;
59
+ }
60
+ const kind = ATTACHMENT_KINDS.has(attachment.kind) ? attachment.kind : 'other';
61
+ const name = cleanString(attachment.name, REPLY_ATTACHMENT_NAME_MAX_CODE_POINTS, {
62
+ basename: true,
63
+ });
64
+ truncated ||= name.truncated;
65
+ attachments.push({ kind, ...(name.value ? { name: name.value } : {}) });
66
+ }
67
+ return { attachments, truncated };
68
+ }
69
+
70
+ function errorUnavailableReason(error) {
71
+ const supplied = cleanUnavailableReason(error?.code);
72
+ if (supplied) return supplied;
73
+ const status = Number(error?.status ?? error?.statusCode ?? error?.response?.status);
74
+ if (status === 401 || status === 403) return 'permission-denied';
75
+ if (status === 404) return 'not-found';
76
+ if (status === 410) return 'deleted';
77
+ return 'not-delivered';
78
+ }
79
+
80
+ function mergeDefined(base, loaded) {
81
+ const merged = { ...base };
82
+ for (const key of [
83
+ 'messageId', 'authorId', 'authorName', 'content', 'attachments', 'unavailableReason',
84
+ ]) {
85
+ if (loaded[key] !== undefined) merged[key] = loaded[key];
86
+ }
87
+ return merged;
88
+ }
89
+
90
+ async function resolveReference(reference, signal) {
91
+ if (typeof reference.load !== 'function') return reference;
92
+ signal?.throwIfAborted();
93
+ try {
94
+ const loaded = await reference.load({ signal });
95
+ signal?.throwIfAborted();
96
+ if (loaded === null) return { ...reference, unavailableReason: 'not-found' };
97
+ if (!objectReference(loaded)) {
98
+ return { ...reference, unavailableReason: 'not-delivered' };
99
+ }
100
+ return mergeDefined(reference, loaded);
101
+ } catch (error) {
102
+ if (signal?.aborted) signal.throwIfAborted();
103
+ return { ...reference, unavailableReason: errorUnavailableReason(error) };
104
+ }
105
+ }
106
+
107
+ function normalizeReference(reference) {
108
+ const messageId = cleanString(reference.messageId, REPLY_ID_MAX_CODE_POINTS);
109
+ const authorId = cleanString(reference.authorId, REPLY_ID_MAX_CODE_POINTS);
110
+ const authorName = cleanString(reference.authorName, REPLY_AUTHOR_NAME_MAX_CODE_POINTS);
111
+ const content = cleanString(reference.content, REPLY_CONTENT_MAX_CODE_POINTS, { multiline: true });
112
+ const { attachments, truncated: attachmentsTruncated } = cleanAttachments(reference.attachments);
113
+ let unavailableReason = cleanUnavailableReason(reference.unavailableReason);
114
+ if (!content.value && attachments.length === 0 && !unavailableReason) {
115
+ unavailableReason = 'not-delivered';
116
+ }
117
+ return {
118
+ note: REPLY_NOTE,
119
+ ...(messageId.value ? { messageId: messageId.value } : {}),
120
+ ...(authorId.value ? { authorId: authorId.value } : {}),
121
+ ...(authorName.value ? { authorName: authorName.value } : {}),
122
+ ...(content.value ? { content: content.value } : {}),
123
+ attachments,
124
+ ...(unavailableReason ? { unavailableReason } : {}),
125
+ truncated: messageId.truncated
126
+ || authorId.truncated
127
+ || authorName.truncated
128
+ || content.truncated
129
+ || attachmentsTruncated,
130
+ };
131
+ }
132
+
133
+ function replyBlock(reference) {
134
+ const json = JSON.stringify(reference).replace(/[<>&]/gu, (character) => ({
135
+ '<': '\\u003c',
136
+ '>': '\\u003e',
137
+ '&': '\\u0026',
138
+ })[character]);
139
+ return `<dsh_im_reply_to>${json}</dsh_im_reply_to>`;
140
+ }
141
+
142
+ export function hasReplyReference(message) {
143
+ return objectReference(message?.replyTo);
144
+ }
145
+
146
+ export async function promptContentForInboundMessage(message, { signal } = {}) {
147
+ if (!hasReplyReference(message)) {
148
+ return promptContentForMessage(message, { signal });
149
+ }
150
+ const reference = normalizeReference(await resolveReference(message.replyTo, signal));
151
+ const currentContent = await promptContentForMessage(message, { signal });
152
+ return [{ type: 'text', text: replyBlock(reference) }, ...currentContent];
153
+ }
@@ -0,0 +1,104 @@
1
+ function assistantText(event) {
2
+ if (event?.type !== 'assistant/message'
3
+ || !Number.isSafeInteger(event.data?.turn)
4
+ || event.data.turn < 0
5
+ || !Array.isArray(event.data?.message?.content)) return null;
6
+ const text = event.data.message.content
7
+ .flatMap((block) => (block?.type === 'text' && typeof block.text === 'string'
8
+ ? [block.text]
9
+ : []))
10
+ .join('\n')
11
+ .trim();
12
+ return text ? { turn: event.data.turn, time: event.time, text } : null;
13
+ }
14
+
15
+ function completedAssistantTurns(events) {
16
+ const starts = new Map();
17
+ const assistants = new Map();
18
+ const completed = [];
19
+ for (const event of [...events].sort((left, right) => left.seq - right.seq)) {
20
+ if (event?.type === 'turn/start' && Number.isSafeInteger(event.data?.turn)) {
21
+ starts.set(event.data.turn, event.time);
22
+ }
23
+ const assistant = assistantText(event);
24
+ if (assistant) assistants.set(assistant.turn, assistant);
25
+ if (event?.type !== 'turn/end' || !Number.isSafeInteger(event.data?.turn)) continue;
26
+ const final = assistants.get(event.data.turn);
27
+ assistants.delete(event.data.turn);
28
+ if ((event.data?.reason?.kind ?? event.data?.reason) !== 'completed' || !final) continue;
29
+ completed.push({
30
+ ...final,
31
+ startedAt: starts.get(event.data.turn),
32
+ completedAt: event.time,
33
+ });
34
+ }
35
+ return completed;
36
+ }
37
+
38
+ function matchingAssistantText(events, quotedAt, toleranceMs) {
39
+ const candidates = completedAssistantTurns(events).filter((entry) => {
40
+ if (Number.isFinite(entry.time) && Math.abs(entry.time - quotedAt) <= toleranceMs) {
41
+ return true;
42
+ }
43
+ return Number.isFinite(entry.startedAt) && Number.isFinite(entry.completedAt)
44
+ && quotedAt >= entry.startedAt - toleranceMs
45
+ && quotedAt <= entry.completedAt + toleranceMs;
46
+ });
47
+ return candidates.length === 1 ? candidates[0].text : null;
48
+ }
49
+
50
+ /**
51
+ * Recover one completed assistant answer near a provider message timestamp.
52
+ *
53
+ * @param {object} options Recovery inputs.
54
+ * @param {{readHistory: Function}} options.session Bound Harness Session handle.
55
+ * @param {number} options.quotedAt Provider message timestamp in milliseconds.
56
+ * @param {AbortSignal} [options.signal] Caller cancellation signal.
57
+ * @param {number} [options.pageSize=100] History events requested per page.
58
+ * @param {number} [options.maxPages=3] Maximum history pages to inspect.
59
+ * @param {number} [options.timeoutMs=5000] Total history read deadline.
60
+ * @param {number} [options.toleranceMs=15000] Provider/session clock tolerance.
61
+ * @returns {Promise<string|null>} The unique matching answer, or null.
62
+ */
63
+ export async function recoverAssistantTextByTimestamp({
64
+ session,
65
+ quotedAt,
66
+ signal: callerSignal,
67
+ pageSize = 100,
68
+ maxPages = 3,
69
+ timeoutMs = 5_000,
70
+ toleranceMs = 15_000,
71
+ } = {}) {
72
+ if (typeof session?.readHistory !== 'function' || !Number.isFinite(quotedAt)) return null;
73
+ const timeout = AbortSignal.timeout(timeoutMs);
74
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeout]) : timeout;
75
+ const deadline = Date.now() + timeoutMs;
76
+ const events = new Map();
77
+ let beforeSeq;
78
+ for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
79
+ signal.throwIfAborted();
80
+ const page = await session.readHistory({
81
+ maxMessages: pageSize,
82
+ ...(beforeSeq === undefined ? {} : { beforeSeq }),
83
+ timeoutMs: Math.max(1, deadline - Date.now()),
84
+ signal,
85
+ });
86
+ if (!page || !Array.isArray(page.events) || typeof page.hasMore !== 'boolean') return null;
87
+ let oldestSeq = beforeSeq ?? Infinity;
88
+ let oldestTime = Infinity;
89
+ for (const entry of page.events) {
90
+ const event = entry?.event;
91
+ if (!event || !Number.isSafeInteger(event.seq) || event.seq < 0) continue;
92
+ events.set(event.seq, event);
93
+ oldestSeq = Math.min(oldestSeq, event.seq);
94
+ if (Number.isFinite(event.time)) oldestTime = Math.min(oldestTime, event.time);
95
+ }
96
+ const text = matchingAssistantText([...events.values()], quotedAt, toleranceMs);
97
+ const passedTarget = oldestTime <= quotedAt - toleranceMs;
98
+ if (text && (passedTarget || !page.hasMore)) return text;
99
+ if (!page.hasMore || passedTarget
100
+ || !Number.isFinite(oldestSeq) || oldestSeq === beforeSeq) break;
101
+ beforeSeq = oldestSeq;
102
+ }
103
+ return null;
104
+ }
@@ -0,0 +1,74 @@
1
+ const DEFAULT_MAX_TITLE_BYTES = 60;
2
+
3
+ const OSC_SEQUENCE = /(?:\u001b\]|\u009d)(?:(?!\u0007|\u001b\\)[\s\S])*(?:\u0007|\u001b\\|$)/gu;
4
+ const CSI_SEQUENCE = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu;
5
+ const ESC_SEQUENCE = /\u001b[@-_]/gu;
6
+ const CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
7
+ const DIRECTIONAL_CONTROL = /[\u200b\u200e\u200f\u202a-\u202e\u2060-\u2064\u2066-\u206f\ufeff]/gu;
8
+ const INJECTED_CONTEXT_PREFIX = /^(?:<dsh_im_source>[\s\S]*?<\/dsh_im_source>\s*)?(?:<dsh_im_source_guidance>[\s\S]*?<\/dsh_im_source_guidance>\s*)?(?:<dsh_im_reply_to>[\s\S]*?<\/dsh_im_reply_to>\s*)?/u;
9
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
10
+
11
+ function cleanTitleText(input) {
12
+ return input
13
+ .replace(OSC_SEQUENCE, '')
14
+ .replace(CSI_SEQUENCE, '')
15
+ .replace(ESC_SEQUENCE, '')
16
+ .replace(CONTROL_CHARACTER, '')
17
+ .replace(DIRECTIONAL_CONTROL, '')
18
+ .replace(/\s+/gu, ' ')
19
+ .trim();
20
+ }
21
+
22
+ function truncateTitle(input, maxBytes = DEFAULT_MAX_TITLE_BYTES) {
23
+ if (Buffer.byteLength(input, 'utf8') <= maxBytes) return input;
24
+ const suffix = '…';
25
+ const budget = maxBytes - Buffer.byteLength(suffix, 'utf8');
26
+ let output = '';
27
+ for (const { segment } of SEGMENTER.segment(input)) {
28
+ if (Buffer.byteLength(output + segment, 'utf8') > budget) break;
29
+ output += segment;
30
+ }
31
+ return `${output.trimEnd()}${suffix}`;
32
+ }
33
+
34
+ function contentText(content) {
35
+ if (typeof content === 'string') return content;
36
+ if (!Array.isArray(content)) return '';
37
+ return content
38
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
39
+ .map((block) => block.text)
40
+ .join('\n');
41
+ }
42
+
43
+ function fileDisplayName(file) {
44
+ const value = file?.name ?? file?.filename ?? file?.fileName;
45
+ if (typeof value !== 'string') return '';
46
+ return value
47
+ .replaceAll('\\', '/')
48
+ .split('/')
49
+ .at(-1)
50
+ ?.replace(/[\u0000-\u001f\u007f]/gu, '')
51
+ .trim() ?? '';
52
+ }
53
+
54
+ /** Build a deterministic title from the unenhanced first user message. */
55
+ export function initialSessionTitle({ text, content, files } = {}) {
56
+ const original = typeof text === 'string' ? cleanTitleText(text) : '';
57
+ if (original) return truncateTitle(original);
58
+
59
+ // Structured image prompts may only expose their default text through content.
60
+ // Strip only the leading blocks inserted by dsh-im; matching tags later in user
61
+ // content remain ordinary user text.
62
+ const visibleContent = cleanTitleText(
63
+ contentText(content).replace(INJECTED_CONTEXT_PREFIX, ''),
64
+ );
65
+ if (visibleContent) return truncateTitle(visibleContent);
66
+
67
+ const fileName = Array.isArray(files)
68
+ ? files.map(fileDisplayName).find(Boolean)
69
+ : '';
70
+ const cleanFileName = fileName ? cleanTitleText(fileName) : '';
71
+ return cleanFileName ? truncateTitle(cleanFileName) : null;
72
+ }
73
+
74
+ export const INITIAL_SESSION_TITLE_MAX_BYTES = DEFAULT_MAX_TITLE_BYTES;
@@ -35,7 +35,6 @@ import {
35
35
  hasInboundImages,
36
36
  imagePromptDiagnostic,
37
37
  imagePromptUserMessage,
38
- promptContentForMessage,
39
38
  } from './image-prompt.mjs';
40
39
  import {
41
40
  hasInboundFiles,
@@ -47,6 +46,10 @@ import {
47
46
  validHarnessQuestion,
48
47
  } from './harness-question.mjs';
49
48
  import { deliverOutboundArtifacts } from './semantic/artifact-delivery.mjs';
49
+ import {
50
+ hasReplyReference,
51
+ promptContentForInboundMessage,
52
+ } from './semantic/reply-reference.mjs';
50
53
  import {
51
54
  createDeliveryReceipt,
52
55
  createTextDeliveryBlock,
@@ -283,7 +286,8 @@ export class TextHarnessBridge {
283
286
  plainText: Boolean(text)
284
287
  && normalized.plainText !== false
285
288
  && !hasInboundImages(normalized)
286
- && !hasInboundFiles(normalized),
289
+ && !hasInboundFiles(normalized)
290
+ && !hasReplyReference(normalized),
287
291
  });
288
292
  if (batch.handled) {
289
293
  if (batch.kind === 'submit') {
@@ -301,7 +305,8 @@ export class TextHarnessBridge {
301
305
  plainText: Boolean(text)
302
306
  && normalized.plainText !== false
303
307
  && !hasInboundImages(normalized)
304
- && !hasInboundFiles(normalized),
308
+ && !hasInboundFiles(normalized)
309
+ && !hasReplyReference(normalized),
305
310
  });
306
311
  if (batch.handled) {
307
312
  return this.#finishLocalMessage(normalized, messageId, batch.message);
@@ -575,7 +580,8 @@ export class TextHarnessBridge {
575
580
  }
576
581
  const hasImages = hasInboundImages(message);
577
582
  const hasFiles = hasInboundFiles(message);
578
- if (!text && !hasImages && !hasFiles) {
583
+ const hasReply = hasReplyReference(message);
584
+ if (!text && !hasImages && !hasFiles && !hasReply) {
579
585
  await this.#bot.sendText(target, t('目前支持文字、图片和文件消息。'));
580
586
  return;
581
587
  }
@@ -588,7 +594,7 @@ export class TextHarnessBridge {
588
594
  t('/new 开启一个全新会话'),
589
595
  t('/compact 压缩当前会话的较早上下文'),
590
596
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
591
- t('/workspace 工作区绝对路径 切换工作区'),
597
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
592
598
  t('/workspacelist 列出工作区绝对路径'),
593
599
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
594
600
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -669,16 +675,20 @@ export class TextHarnessBridge {
669
675
  );
670
676
  }
671
677
  }
672
- let content = hasImages
673
- ? await promptContentForMessage(message, { signal: this.#signal })
678
+ let content = hasImages || hasReply
679
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
674
680
  : undefined;
675
681
  const snapshot = this.#acceptedMessageIds.get(messageId);
682
+ let contextEnhanced = false;
676
683
  if (snapshot) {
677
- content = enhanceContextContent(content ?? text, snapshot, () => ({
684
+ const originalContent = content ?? text;
685
+ content = enhanceContextContent(originalContent, snapshot, () => ({
678
686
  channel: this.#descriptor.key,
679
687
  senderId,
680
688
  senderName: message.contextSource?.()?.senderName,
689
+ conversationTitle: message.contextSource?.()?.conversationTitle,
681
690
  }));
691
+ contextEnhanced = content !== originalContent;
682
692
  }
683
693
  const { answer, artifacts = [] } = await askInWorkspaceSession({
684
694
  harness: this.#harness,
@@ -686,6 +696,7 @@ export class TextHarnessBridge {
686
696
  key: conversationKey,
687
697
  text,
688
698
  content,
699
+ contextEnhanced,
689
700
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
690
701
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
691
702
  askOptions: {
@@ -142,7 +142,7 @@ async function runWorkspaceListCommand(match, harness) {
142
142
  `${index + 1}. ${workspace}${workspace === current ? t('(当前)') : ''}`
143
143
  )),
144
144
  '',
145
- t('切换用法:/workspace 工作区绝对路径'),
145
+ t('切换用法:/workspace 工作区序号或绝对路径'),
146
146
  t('查看会话:/sessionlist 工作区序号或绝对路径'),
147
147
  ];
148
148
  const message = lines.join('\n');
@@ -366,18 +366,30 @@ export async function runWorkspaceCommand(text, harness, conversationKey) {
366
366
  if (!match) return null;
367
367
  const workspace = match[1]?.trim();
368
368
  if (!workspace) {
369
- return commandResult(t('用法:/workspace 工作区绝对路径'));
369
+ return commandResult(t('用法:/workspace 工作区序号或绝对路径'));
370
370
  }
371
371
  if (typeof harness?.switchWorkspace !== 'function') {
372
372
  return commandResult(t('当前机器人暂不支持切换工作区。'));
373
373
  }
374
374
  try {
375
- const current = await harness.switchWorkspace(workspace);
375
+ let selected = workspace;
376
+ if (/^\d+$/u.test(workspace)) {
377
+ if (typeof harness?.listWorkspaces !== 'function') {
378
+ return commandResult(t('当前机器人暂不支持按序号选择工作区。'));
379
+ }
380
+ const { paths } = await workspacePathSnapshot(harness);
381
+ const position = Number(workspace);
382
+ if (!Number.isSafeInteger(position) || position < 1 || position > paths.length) {
383
+ return commandResult(t('工作区序号不存在,请先执行 /workspacelist。'));
384
+ }
385
+ selected = paths[position - 1];
386
+ }
387
+ const current = await harness.switchWorkspace(selected);
376
388
  return commandResult(t('工作区已切换为:{workspace}', { workspace: current }));
377
389
  } catch (error) {
378
390
  if (['workspace-not-absolute', 'workspace-not-found', 'workspace-not-directory'].includes(error?.code)) {
379
391
  return commandResult(t(`{message}
380
- 用法:/workspace 工作区绝对路径`, { message: error.message }));
392
+ 用法:/workspace 工作区序号或绝对路径`, { message: error.message }));
381
393
  }
382
394
  if (error?.code === 'workspace-bot-not-found') {
383
395
  return commandResult(t('机器人正在移除或已重新接入,无法切换原会话的工作区。'));
@@ -1,4 +1,5 @@
1
1
  import { withSessionBindingLock } from './session-binding-lock.mjs';
2
+ import { initialSessionTitle } from './session-title.mjs';
2
3
 
3
4
  export const WORKSPACE_SESSION_STALE = 'workspace-session-stale';
4
5
 
@@ -6,7 +7,7 @@ function workspaceSession(harness, sessionId) {
6
7
  if (typeof harness.workspaceSession === 'function') {
7
8
  return harness.workspaceSession(sessionId);
8
9
  }
9
- return Object.freeze({
10
+ const session = {
10
11
  sessionId,
11
12
  sessionExists: (...args) => harness.sessionExists(sessionId, ...args),
12
13
  models: (...args) => harness.getSessionModels(sessionId, ...args),
@@ -16,7 +17,11 @@ function workspaceSession(harness, sessionId) {
16
17
  stopActiveTurn: (...args) => harness.stopActiveTurn(sessionId, ...args),
17
18
  steerActiveTurn: (...args) => harness.steerActiveTurn(sessionId, ...args),
18
19
  ask: (...args) => harness.ask(sessionId, ...args),
19
- });
20
+ };
21
+ if (typeof harness.renameSession === 'function') {
22
+ session.renameTitle = (...args) => harness.renameSession(sessionId, ...args);
23
+ }
24
+ return Object.freeze(session);
20
25
  }
21
26
 
22
27
  async function sessionExists(session, options) {
@@ -42,10 +47,21 @@ export async function askInWorkspaceSession({
42
47
  key,
43
48
  text,
44
49
  content,
50
+ contextEnhanced = false,
45
51
  createOptions,
46
52
  existsOptions,
47
53
  askOptions,
48
54
  }) {
55
+ const initialTitle = contextEnhanced
56
+ ? initialSessionTitle({
57
+ text,
58
+ content,
59
+ files: typeof askOptions === 'object' ? askOptions?.files : undefined,
60
+ })
61
+ : null;
62
+ const renameSignal = createOptions?.signal
63
+ ?? (typeof askOptions === 'object' ? askOptions?.signal : undefined);
64
+ const renameOptions = renameSignal ? { signal: renameSignal } : undefined;
49
65
  while (true) {
50
66
  try {
51
67
  const binding = await withSessionBindingLock(state, key, async () => {
@@ -55,6 +71,16 @@ export async function askInWorkspaceSession({
55
71
  sessionId = await createSession(harness, createOptions);
56
72
  if (await state.setSession(key, sessionId) === false) return null;
57
73
  session = workspaceSession(harness, sessionId);
74
+ if (initialTitle && typeof session.renameTitle === 'function') {
75
+ try {
76
+ await session.renameTitle(initialTitle, renameOptions);
77
+ } catch (error) {
78
+ if (error?.code === WORKSPACE_SESSION_STALE || renameOptions?.signal?.aborted) {
79
+ throw error;
80
+ }
81
+ console.warn('[dsh-im] unable to set the initial Session title:', error?.message ?? error);
82
+ }
83
+ }
58
84
  }
59
85
  return { sessionId, session };
60
86
  });
@@ -17,9 +17,12 @@ oauth_config:
17
17
  bot:
18
18
  - app_mentions:read
19
19
  - chat:write
20
+ - channels:history
20
21
  - files:read
21
22
  - files:write
23
+ - groups:history
22
24
  - im:history
25
+ - mpim:history
23
26
  - reactions:write
24
27
  settings:
25
28
  event_subscriptions:
@@ -246,6 +246,24 @@ export class SlackApi {
246
246
  return value.file;
247
247
  }
248
248
 
249
+ async getMessage({ channelId, messageTs, signal } = {}) {
250
+ const timestamp = requiredString(messageTs, 'message timestamp');
251
+ const value = await this.#request('conversations.history', {
252
+ tokenKind: 'bot',
253
+ signal,
254
+ body: {
255
+ channel: slackId(channelId, 'channel id'),
256
+ oldest: timestamp,
257
+ latest: timestamp,
258
+ inclusive: true,
259
+ limit: 1,
260
+ },
261
+ });
262
+ return Array.isArray(value?.messages)
263
+ ? value.messages.find((message) => String(message?.ts ?? '') === timestamp) ?? null
264
+ : null;
265
+ }
266
+
249
267
  postMessage({ channelId, text, threadTs, signal }) {
250
268
  return this.#request('chat.postMessage', {
251
269
  tokenKind: 'bot',
@@ -49,6 +49,58 @@ function slackFileUrl(file) {
49
49
  ? file.url_private_download : file?.url_private;
50
50
  }
51
51
 
52
+ function slackReplyAttachment(file) {
53
+ if (!file || typeof file !== 'object') return null;
54
+ const mediaType = typeof file.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
55
+ const kind = mediaType.startsWith('image/') ? 'image'
56
+ : mediaType.startsWith('audio/') ? 'audio'
57
+ : mediaType.startsWith('video/') ? 'video' : 'file';
58
+ const name = typeof file.name === 'string' && file.name
59
+ ? file.name : typeof file.title === 'string' && file.title ? file.title : undefined;
60
+ return { kind, ...(name ? { name } : {}) };
61
+ }
62
+
63
+ function slackReplySnapshot(message, messageTs) {
64
+ if (!message || String(message.ts ?? '') !== messageTs) return null;
65
+ const authorId = typeof message.user === 'string' && message.user
66
+ ? message.user : typeof message.bot_id === 'string' && message.bot_id
67
+ ? message.bot_id : undefined;
68
+ const authorName = typeof message.username === 'string' && message.username
69
+ ? message.username : undefined;
70
+ return {
71
+ messageId: messageTs,
72
+ ...(authorId ? { authorId } : {}),
73
+ ...(authorName ? { authorName } : {}),
74
+ content: decodeSlackText(message.text ?? ''),
75
+ attachments: Array.isArray(message.files)
76
+ ? message.files.map(slackReplyAttachment).filter(Boolean)
77
+ : [],
78
+ };
79
+ }
80
+
81
+ function slackThreadReplyReference(event, loadReply) {
82
+ const messageTs = typeof event?.thread_ts === 'string' ? event.thread_ts : '';
83
+ if (!messageTs || messageTs === String(event?.ts ?? '')) return undefined;
84
+ return {
85
+ messageId: messageTs,
86
+ load: async ({ signal } = {}) => {
87
+ try {
88
+ const message = await loadReply({
89
+ channelId: String(event.channel),
90
+ messageTs,
91
+ signal,
92
+ });
93
+ return slackReplySnapshot(message, messageTs);
94
+ } catch (error) {
95
+ if (error?.code === 'slack-missing-scope') {
96
+ return { messageId: messageTs, unavailableReason: 'permission-denied' };
97
+ }
98
+ throw error;
99
+ }
100
+ },
101
+ };
102
+ }
103
+
52
104
  function slackImageSource(file, loadFile) {
53
105
  const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
54
106
  const url = slackFileUrl(file);
@@ -104,6 +156,7 @@ export function normalizeSlackEvent(payload, botUserId, {
104
156
  loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
105
157
  loadFileStream = loadFile,
106
158
  loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
159
+ loadReply = async () => { throw new Error('Slack reply loader is unavailable'); },
107
160
  } = {}) {
108
161
  const event = payload?.event;
109
162
  if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
@@ -112,6 +165,7 @@ export function normalizeSlackEvent(payload, botUserId, {
112
165
  if (!direct && !mentioned) return null;
113
166
  if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
114
167
  const threadTs = String(event.thread_ts ?? event.ts);
168
+ const replyTo = slackThreadReplyReference(event, loadReply);
115
169
  return {
116
170
  messageId: String(payload.event_id),
117
171
  senderId: String(event.user),
@@ -126,6 +180,7 @@ export function normalizeSlackEvent(payload, botUserId, {
126
180
  files: Array.isArray(event.files)
127
181
  ? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
128
182
  : [],
183
+ ...(replyTo ? { replyTo } : {}),
129
184
  addressed: direct || mentioned,
130
185
  reactionTarget: {
131
186
  channelId: String(event.channel),
@@ -555,6 +610,7 @@ export class SlackRuntime {
555
610
  loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
556
611
  loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
557
612
  loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
613
+ loadReply: (options) => this.#api.getMessage(options),
558
614
  });
559
615
  const bridge = this.#bridge;
560
616
  if (message && bridge) {