@modesto_ma/dsh-channels 0.0.0-stage → 0.1.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 (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Agent-scoped system-prompt guidance for Feishu attachments.
3
+ *
4
+ * The channel archives inbound images/files/forwarded records below
5
+ * `<workspace>/attachments/` without any model turn. This section is what
6
+ * makes that convention discoverable: it tells the Agent where attachments
7
+ * live, how to read them, and that reading is opt-in per user request.
8
+ *
9
+ * Registered inside the Agent scope (through `inject(['systemPrompt'])`), so
10
+ * it is disposed with the Agent and does not leak into other compositions.
11
+ *
12
+ * @module dsh-channels/feishu/agent-context
13
+ */
14
+
15
+ import type { AgentContext } from '../host/agent.ts'
16
+
17
+ /** Stable prompt-section name; one registration per Agent scope. */
18
+ export const FEISHU_ATTACHMENTS_SECTION = 'dsh-channels:feishu-attachments'
19
+
20
+ /**
21
+ * Placement between the harness file-reference section (900) and the
22
+ * filesystem tool descriptions (1000): the convention is read before the
23
+ * tool that implements it.
24
+ */
25
+ const FEISHU_ATTACHMENTS_ORDER = 950
26
+
27
+ /** Model-facing text. Kept stable so unchanged sessions keep their KV cache. */
28
+ export const FEISHU_ATTACHMENTS_GUIDANCE = `## Feishu attachments
29
+ Every Feishu conversation (topic) has its own workspace and its own attachments directory. Images, files, and forwarded chat records sent through Feishu are archived verbatim in this conversation's \`attachments/\` directory. Each file name starts with its receipt time (\`YYYYMMDD-HHmmss-\`), so newer attachments sort later and \`ls -lt <dir>\` lists the newest first.
30
+
31
+ Attachments are never inserted into the conversation automatically. Read them only when the user's request needs them, and do not read attachments the user did not mention:
32
+ - images: use \`read_image\` with the file path;
33
+ - text and code: use \`read\` when the session mounts that tool, otherwise \`bash\` (for example \`sed -n '1,200p' <file>\`);
34
+ - other formats (PDF, Office, archives, ...): inspect or convert them with \`bash\`.
35
+ If the user refers to "the file/image I just sent" without a name, list this conversation's attachments directory and pick the most recent matching attachment. Other Feishu conversations have separate workspaces: never read outside this workspace, and never mention files from another conversation.`
36
+
37
+ /**
38
+ * Full guidance for one Session. The absolute per-session directory is
39
+ * appended when known so the convention still resolves after the Agent changes
40
+ * its working directory with the `working_directory` tool.
41
+ */
42
+ export function attachmentsGuidance(attachmentsDir?: string): string {
43
+ if (attachmentsDir === undefined || attachmentsDir === '') return FEISHU_ATTACHMENTS_GUIDANCE
44
+ return `${FEISHU_ATTACHMENTS_GUIDANCE}\n\nThis conversation's attachment directory is \`${attachmentsDir.replace(/`/gu, "'")}\` (absolute).`
45
+ }
46
+
47
+ /**
48
+ * Mount the attachment guidance into one Agent scope. Call from agent setup;
49
+ * a missing `systemPrompt` service degrades to a no-op (headless hosts).
50
+ */
51
+ export async function mountChannelGuidance(
52
+ agentCtx: AgentContext,
53
+ attachmentsDir?: string,
54
+ ): Promise<void> {
55
+ await agentCtx.inject(['systemPrompt'], (ctx) => {
56
+ ctx.systemPrompt?.section({
57
+ name: FEISHU_ATTACHMENTS_SECTION,
58
+ order: FEISHU_ATTACHMENTS_ORDER,
59
+ text: attachmentsGuidance(attachmentsDir),
60
+ })
61
+ })
62
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Outbound Feishu delivery: card sends, the topic-creating first reply, body
3
+ * segment fallbacks, and the command reaction lifecycle.
4
+ *
5
+ * Every send is gated on the service being started, and commands are the only
6
+ * plane that adds/deletes reactions (`Typing` → `DONE`); ordinary turns get
7
+ * their reaction lifecycle from the streamer's finalize path.
8
+ *
9
+ * @module dsh-channels/feishu/delivery
10
+ */
11
+
12
+ import { buildCommandResultCard } from '../commands.ts'
13
+ import type { CommandCardState } from '../commands.ts'
14
+ import type { RouteRecord } from '../domain.ts'
15
+ import type { FeishuChannel, NormalizedMessage, SendResult } from '../feishu-channel.ts'
16
+
17
+ /** Rejection text for a message in a topic retired by route TTL. */
18
+ export const EXPIRED_ROUTE_TEXT = '该话题已超过路由保留时间,并已与原 Session 解绑。为避免误接续上下文,插件不会在此话题创建新 Session;请从私聊或群聊主会话发起新消息(群聊请 @ 机器人)创建新话题。'
19
+
20
+ export interface CardDeliveryDeps {
21
+ /** The live channel, or undefined before `start()` / after shutdown. */
22
+ channel(): FeishuChannel | undefined
23
+ /** Whether the service is accepting sends. */
24
+ started(): boolean
25
+ /** Route owning a session (segment-card anchoring). */
26
+ routeForSession(sessionId: string): RouteRecord | undefined
27
+ warn(message: string, ...args: unknown[]): void
28
+ }
29
+
30
+ export class CardDelivery {
31
+ constructor(private readonly deps: CardDeliveryDeps) {}
32
+
33
+ /** The live channel; throws instead of silently dropping a delivery. */
34
+ requireChannel(): FeishuChannel {
35
+ const channel = this.deps.channel()
36
+ if (channel === undefined) throw new Error('dsh-channels: channel not started')
37
+ return channel
38
+ }
39
+
40
+ /** Apply the command-only Typing → DONE reaction lifecycle. */
41
+ async withCommandReaction(msg: NormalizedMessage, operation: () => Promise<void>): Promise<void> {
42
+ if (!this.deps.started()) return
43
+ const channel = this.requireChannel()
44
+ const reactionId = await channel.addReaction(msg.messageId, 'Typing').catch(() => undefined)
45
+ try {
46
+ await operation()
47
+ } finally {
48
+ if (reactionId !== undefined) {
49
+ await channel.removeReaction(msg.messageId, reactionId).catch(() => {})
50
+ }
51
+ await channel.addReaction(msg.messageId, 'DONE').catch(() => {})
52
+ }
53
+ }
54
+
55
+ /** Standalone, non-streaming command result card with a non-threaded fallback. */
56
+ async commandCard(
57
+ msg: NormalizedMessage,
58
+ state: CommandCardState,
59
+ options: { createThread?: boolean } = {},
60
+ ): Promise<void> {
61
+ await this.standaloneReply(msg, buildCommandResultCard(state), options)
62
+ }
63
+
64
+ /** Reject a post-TTL topic message without creating or resuming an Agent. */
65
+ async expiredRouteCard(msg: NormalizedMessage, command?: string): Promise<void> {
66
+ await this.commandCard(msg, {
67
+ kind: 'expired',
68
+ ...(command === undefined ? {} : { command }),
69
+ text: EXPIRED_ROUTE_TEXT,
70
+ })
71
+ }
72
+
73
+ /**
74
+ * Shared delivery policy for command-plane cards: reply in a new topic by
75
+ * default, then degrade to a plain reply when Feishu refuses the topic.
76
+ */
77
+ async standaloneReply(
78
+ msg: NormalizedMessage,
79
+ card: object,
80
+ options: { createThread?: boolean } = {},
81
+ ): Promise<void> {
82
+ const channel = this.deps.channel()
83
+ if (!this.deps.started() || channel === undefined) return
84
+ const createThread = options.createThread ?? true
85
+ try {
86
+ await channel.send(msg.chatId, { card }, createThread
87
+ ? { replyTo: msg.messageId, replyInThread: true }
88
+ : { replyTo: msg.messageId })
89
+ } catch (error) {
90
+ if (!createThread) {
91
+ this.deps.warn(
92
+ 'dsh-channels: direct command result delivery failed for message %s: %s',
93
+ msg.messageId, String(error),
94
+ )
95
+ return
96
+ }
97
+ try {
98
+ await channel.send(msg.chatId, { card }, { replyTo: msg.messageId })
99
+ } catch (fallbackError) {
100
+ this.deps.warn(
101
+ 'dsh-channels: command result delivery failed for message %s: %s / %s',
102
+ msg.messageId, String(error), String(fallbackError),
103
+ )
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Send one short plain-text notice anchored to an inbound message. Replies
110
+ * inside an existing topic when the message belongs to one, and never
111
+ * creates a topic: an attachment-only message must stay a notice, not a new
112
+ * conversation. Best-effort — a failed notice only warns.
113
+ */
114
+ async noticeText(msg: NormalizedMessage, text: string): Promise<void> {
115
+ const channel = this.deps.channel()
116
+ if (!this.deps.started() || channel === undefined) return
117
+ try {
118
+ await channel.send(msg.chatId, { text }, {
119
+ replyTo: msg.messageId,
120
+ ...(msg.threadId === undefined ? {} : { replyInThread: true }),
121
+ })
122
+ } catch (error) {
123
+ this.deps.warn(
124
+ 'dsh-channels: attachment notice delivery failed for message %s: %s',
125
+ msg.messageId, String(error),
126
+ )
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Reply with the first card frame. Prefers topic replies (`reply_in_thread`)
132
+ * — the documented way a bot creates a topic — and falls back to a plain
133
+ * reply when Feishu refuses (degraded chat-scoped routing).
134
+ */
135
+ async replyFirstCard(
136
+ msg: NormalizedMessage,
137
+ card: object,
138
+ ): Promise<{ messageId: string; threadId?: string; degraded: boolean }> {
139
+ const channel = this.requireChannel()
140
+ try {
141
+ const res = await channel.send(
142
+ msg.chatId,
143
+ { card },
144
+ { replyTo: msg.messageId, replyInThread: true },
145
+ )
146
+ const threadId = await channel.threadIdOfMessage(res.messageId).catch(() => undefined)
147
+ return { messageId: res.messageId, threadId, degraded: false }
148
+ } catch (error) {
149
+ if (!this.deps.started()) throw error
150
+ const res = await channel.send(msg.chatId, { card }, { replyTo: msg.messageId })
151
+ return { messageId: res.messageId, degraded: true }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Send one body-segment continuation card into the session's topic (or, for
157
+ * degraded chat-scoped routes, as a plain reply anchored to the main card).
158
+ * When Feishu rejects the card (e.g. per-card table limit, error 11310) the
159
+ * segment is re-delivered as a plain-text message so the content is never
160
+ * lost — text messages carry no card constraints.
161
+ */
162
+ async segmentCard(
163
+ sessionId: string,
164
+ segment: number,
165
+ card: object,
166
+ fallbackText: string,
167
+ ): Promise<{ messageId: string; mode: 'card' | 'text' }> {
168
+ const record = this.deps.routeForSession(sessionId)
169
+ if (record === undefined) {
170
+ throw new Error(`dsh-channels: no route record for session ${sessionId}; segment card not sent`)
171
+ }
172
+ const channel = this.requireChannel()
173
+ const options = { replyTo: record.cardMessageId, replyInThread: record.kind === 'thread' }
174
+ try {
175
+ const res: SendResult = await channel.send(record.chatId, { card }, options)
176
+ return { messageId: res.messageId, mode: 'card' }
177
+ } catch (error) {
178
+ this.deps.warn(
179
+ 'dsh-channels: segment %d card for session %s rejected, falling back to plain text: %s',
180
+ segment, sessionId, String(error),
181
+ )
182
+ const res = await channel.send(record.chatId, {
183
+ text: `📄 回复分段 ${segment}(卡片发送失败,已降级为纯文本)\n\n${fallbackText}`,
184
+ }, options)
185
+ return { messageId: res.messageId, mode: 'text' }
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,436 @@
1
+ /**
2
+ * Inbound attachment archival: Feishu image/file resources and merged-forward
3
+ * chat records are written verbatim below `<workspace>/attachments/`.
4
+ *
5
+ * Nothing is forwarded to the model. The Agent's own filesystem tools read
6
+ * whichever attachment the conversation needs, so an attachment message costs
7
+ * no model request at all and unreferenced files never enter the context.
8
+ * Files are streamed straight to disk: there is no plugin-side size cap, so a
9
+ * large upload must not be collected in memory.
10
+ *
11
+ * Two payload shapes need care because the SDK normalizer drops them:
12
+ * rich-text (`post`) messages keep their attached files in a top-level
13
+ * `files` array that `convertPost` ignores, and merged-forward records are
14
+ * plain text rather than resources. Both are recovered here; the post files
15
+ * come from the raw event, which the channel adapter asks the SDK to attach.
16
+ *
17
+ * @module dsh-channels/feishu/inbound-attachments
18
+ */
19
+
20
+ import { randomUUID } from 'node:crypto'
21
+ import { createWriteStream } from 'node:fs'
22
+ import { mkdir, open, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
23
+ import path from 'node:path'
24
+ import { Readable } from 'node:stream'
25
+ import { pipeline } from 'node:stream/promises'
26
+ import type { FeishuChannel, NormalizedMessage } from '../feishu-channel.ts'
27
+ import { sniffImageMediaType } from '../feishu-channel.ts'
28
+
29
+ /** Workspace subdirectory that holds every archived attachment. */
30
+ export const ATTACHMENTS_DIR = 'attachments'
31
+
32
+ /** `<workspace>/attachments` directory of one topic workspace. */
33
+ export function attachmentsDir(workspacePath: string): string {
34
+ return path.join(workspacePath, ATTACHMENTS_DIR)
35
+ }
36
+
37
+ /**
38
+ * Move every staged attachment into one topic workspace. Staging holds files
39
+ * received before their topic (and therefore its workspace) existed; they
40
+ * belong to the first Session created for that topic. Best-effort per file.
41
+ *
42
+ * @returns how many files were adopted.
43
+ */
44
+ export async function adoptStagedAttachments(stagingDir: string, workspacePath: string): Promise<number> {
45
+ let entries: Array<{ name: string; isFile(): boolean }>
46
+ try {
47
+ entries = await readdir(stagingDir, { withFileTypes: true })
48
+ } catch {
49
+ return 0
50
+ }
51
+ const files = entries.filter(entry => entry.isFile() && !entry.name.startsWith('.'))
52
+ if (files.length === 0) return 0
53
+ const target = attachmentsDir(workspacePath)
54
+ try {
55
+ await mkdir(target, { recursive: true })
56
+ } catch {
57
+ return 0
58
+ }
59
+ let adopted = 0
60
+ for (const entry of files) {
61
+ const destination = await uniqueName(target, entry.name)
62
+ try {
63
+ await rename(path.join(stagingDir, entry.name), path.join(target, destination))
64
+ adopted += 1
65
+ } catch {
66
+ // Leave the staged file for the next attempt; never block the turn.
67
+ }
68
+ }
69
+ return adopted
70
+ }
71
+
72
+ /** The SDK placeholder for a rich-text message that carried no visible text. */
73
+ const EMPTY_POST_PLACEHOLDER = '[rich text message]'
74
+
75
+ /** Placeholders the SDK leaves in normalized content for attachment payloads. */
76
+ const IMAGE_PLACEHOLDER = /!\[image\]\([^)]*\)/gu
77
+ const FILE_PLACEHOLDER = /<file[^>]*\/>/gu
78
+ const FORWARDED_PLACEHOLDER = /<forwarded_messages(?:\/>|>[\s\S]*?<\/forwarded_messages>)/gu
79
+ /** An expanded record always starts with this wrapper; a bare empty one means the fetch failed. */
80
+ const EMPTY_FORWARDED = '<forwarded_messages/>'
81
+
82
+ export interface AttachmentIntakeDeps {
83
+ /** Whether this deployment archives inbound attachments at all. */
84
+ enabled(): boolean
85
+ /** Live channel; undefined after shutdown. */
86
+ channel(): FeishuChannel | undefined
87
+ /** Structured warning sink (never throws). */
88
+ warn(message: string, ...args: unknown[]): void
89
+ /** Deterministic clock seam for file-name prefixes. */
90
+ now?(): number
91
+ }
92
+
93
+ /** One decoded attachment recovered from the normalized message or its raw post payload. */
94
+ export interface AttachmentResource {
95
+ readonly type: 'image' | 'file'
96
+ readonly fileKey: string
97
+ readonly fileName?: string
98
+ /** Feishu folder messages cannot be downloaded through the resource API. */
99
+ readonly folder?: boolean
100
+ }
101
+
102
+ /** One attachment that could not be archived, with a user-facing reason. */
103
+ export interface AttachmentFailure {
104
+ readonly name: string
105
+ readonly reason: string
106
+ }
107
+
108
+ export interface AttachmentArchiveResult {
109
+ /** Resources durably written to the workspace. */
110
+ saved: number
111
+ /** Resources that failed to download, write, or are unsupported. */
112
+ failed: number
113
+ /** User-facing per-attachment failure notes (only when `failed > 0`). */
114
+ failures: readonly AttachmentFailure[]
115
+ /** True when the message carried no prompt text after removing placeholders. */
116
+ attachmentOnly: boolean
117
+ /** Remaining text after attachment placeholders are removed. */
118
+ text: string
119
+ }
120
+
121
+ /** One file attached to a rich-text (`post`) message. */
122
+ export interface PostAttachedFile {
123
+ readonly fileKey: string
124
+ readonly fileName?: string
125
+ readonly folder?: boolean
126
+ }
127
+
128
+ /**
129
+ * Every attachment payload of one message, merging SDK-normalized resources
130
+ * with files the `post` converter dropped.
131
+ */
132
+ export function attachmentResources(msg: NormalizedMessage): AttachmentResource[] {
133
+ const resources: AttachmentResource[] = (msg.resources ?? [])
134
+ .filter(resource => resource.type === 'image' || resource.type === 'file')
135
+ .map(resource => ({
136
+ type: resource.type === 'file' ? 'file' : 'image',
137
+ fileKey: resource.fileKey,
138
+ ...(resource.fileName === undefined ? {} : { fileName: resource.fileName }),
139
+ }))
140
+ const seen = new Set(resources.map(resource => resource.fileKey))
141
+ for (const file of postAttachedFiles(msg.rawContentType, msg.raw)) {
142
+ if (seen.has(file.fileKey)) continue
143
+ seen.add(file.fileKey)
144
+ resources.push({ type: 'file', ...file })
145
+ }
146
+ return resources
147
+ }
148
+
149
+ /** Whether one message may carry an attachment payload worth archiving. */
150
+ export function hasAttachmentPayload(msg: NormalizedMessage): boolean {
151
+ if (msg.rawContentType === 'merge_forward') return true
152
+ if (msg.rawContentType === 'post') return true
153
+ return attachmentResources(msg).length > 0
154
+ }
155
+
156
+ /**
157
+ * Recover the files a rich-text message attached.
158
+ *
159
+ * Feishu serializes a post with text and files as `{ title, content, files }`.
160
+ * The SDK's `convertPost` walks `content` elements only, so every entry of
161
+ * `files` is lost from `NormalizedMessage.resources`; the raw event (enabled by
162
+ * `includeRawEvent`) still carries it. Locale-wrapped bodies are searched one
163
+ * level deep for the same shape.
164
+ */
165
+ export function postAttachedFiles(rawContentType: string, raw: unknown): PostAttachedFile[] {
166
+ if (rawContentType !== 'post' || raw === null || raw === undefined) return []
167
+ const message = (raw as { message?: { content?: unknown } }).message
168
+ if (message === undefined) return []
169
+ const content = typeof message.content === 'string' ? safeJson(message.content) : message.content
170
+ if (content === undefined) return []
171
+ const entries = collectFileEntries(content, 0)
172
+ const files: PostAttachedFile[] = []
173
+ for (const entry of entries) {
174
+ const fileKey = typeof entry.file_key === 'string' && entry.file_key !== '' ? entry.file_key : undefined
175
+ if (fileKey === undefined) continue
176
+ files.push({
177
+ fileKey,
178
+ ...(typeof entry.file_name === 'string' && entry.file_name !== '' ? { fileName: entry.file_name } : {}),
179
+ ...(entry.is_folder === true ? { folder: true } : {}),
180
+ })
181
+ }
182
+ return files
183
+ }
184
+
185
+ function safeJson(value: string): unknown {
186
+ try {
187
+ return JSON.parse(value)
188
+ } catch {
189
+ return undefined
190
+ }
191
+ }
192
+
193
+ /** Find every `files` array in a post body (flat or locale-wrapped), depth-bounded. */
194
+ function collectFileEntries(node: unknown, depth: number): Array<Record<string, unknown>> {
195
+ if (depth > 3 || node === null || typeof node !== 'object' || Array.isArray(node)) return []
196
+ const record = node as Record<string, unknown>
197
+ const files = record.files
198
+ if (Array.isArray(files)) {
199
+ return files.filter(
200
+ (entry): entry is Record<string, unknown> => entry !== null && typeof entry === 'object' && !Array.isArray(entry),
201
+ )
202
+ }
203
+ const out: Array<Record<string, unknown>> = []
204
+ for (const value of Object.values(record)) out.push(...collectFileEntries(value, depth + 1))
205
+ return out
206
+ }
207
+
208
+ export class AttachmentIntake {
209
+ constructor(private readonly deps: AttachmentIntakeDeps) {}
210
+
211
+ /**
212
+ * Archive every image/file resource and merged-forward record of one
213
+ * inbound message. Failures are contained per attachment; the method never
214
+ * throws and always reports the remaining prompt text.
215
+ */
216
+ async archive(msg: NormalizedMessage, directory: string): Promise<AttachmentArchiveResult> {
217
+ const resources = attachmentResources(msg)
218
+ const forwarded = msg.rawContentType === 'merge_forward'
219
+ if (resources.length === 0 && !forwarded) {
220
+ return { saved: 0, failed: 0, failures: [], attachmentOnly: false, text: msg.content ?? '' }
221
+ }
222
+
223
+ const stripped = stripAttachmentPlaceholders(msg.content ?? '', forwarded)
224
+ const remainingText = stripped === EMPTY_POST_PLACEHOLDER ? '' : stripped
225
+ const result = (
226
+ saved: number,
227
+ failures: readonly AttachmentFailure[],
228
+ ): AttachmentArchiveResult => ({
229
+ saved,
230
+ failed: failures.length,
231
+ failures,
232
+ attachmentOnly: remainingText === '',
233
+ text: remainingText,
234
+ })
235
+
236
+ if (!this.deps.enabled()) return result(0, [])
237
+
238
+ const channel = this.deps.channel()
239
+ if (channel === undefined) {
240
+ return result(0, resources.map(resource => ({
241
+ name: resourceName(resource),
242
+ reason: '飞书连接未运行,附件未保存',
243
+ })))
244
+ }
245
+
246
+ const dir = directory
247
+ let saved = 0
248
+ const failures: AttachmentFailure[] = []
249
+ try {
250
+ await mkdir(dir, { recursive: true })
251
+ } catch (error) {
252
+ this.deps.warn('dsh-channels: creating attachments directory %s failed: %s', dir, String(error))
253
+ return result(0, resources.map(resource => ({
254
+ name: resourceName(resource),
255
+ reason: `无法创建工作区附件目录:${describeFailure(error)}`,
256
+ })))
257
+ }
258
+
259
+ for (const resource of resources) {
260
+ const name = resourceName(resource)
261
+ if (resource.folder === true) {
262
+ failures.push({ name, reason: '飞书文件夹消息暂不支持下载' })
263
+ continue
264
+ }
265
+ try {
266
+ await this.saveResource(msg, resource, dir)
267
+ saved += 1
268
+ } catch (error) {
269
+ failures.push({ name, reason: describeFailure(error) })
270
+ this.deps.warn(
271
+ 'dsh-channels: archiving resource %s of message %s failed: %s',
272
+ resource.fileKey, msg.messageId, String(error),
273
+ )
274
+ }
275
+ }
276
+
277
+ if (forwarded) {
278
+ try {
279
+ await this.saveForwardedRecord(msg, dir)
280
+ saved += 1
281
+ } catch (error) {
282
+ failures.push({ name: '会话记录', reason: describeFailure(error) })
283
+ this.deps.warn(
284
+ 'dsh-channels: archiving forwarded record of message %s failed: %s',
285
+ msg.messageId, String(error),
286
+ )
287
+ }
288
+ }
289
+
290
+ if (saved > 0) {
291
+ await channel.addReaction(msg.messageId, 'DONE').catch(() => undefined)
292
+ } else if (failures.length > 0) {
293
+ await channel.addReaction(msg.messageId, 'No').catch(() => undefined)
294
+ }
295
+
296
+ return result(saved, failures)
297
+ }
298
+
299
+ /** Stream one image/file resource to disk, then publish it under a stable name. */
300
+ private async saveResource(
301
+ msg: NormalizedMessage,
302
+ resource: AttachmentResource,
303
+ dir: string,
304
+ ): Promise<void> {
305
+ const channel = this.deps.channel()
306
+ if (channel === undefined) throw new Error('channel is not running')
307
+ const stream = await channel.downloadResource(
308
+ msg.messageId,
309
+ resource.fileKey,
310
+ resource.type === 'file' ? 'file' : 'image',
311
+ )
312
+ const temporary = path.join(dir, `.tmp-${randomUUID()}`)
313
+ try {
314
+ await pipeline(Readable.from(stream), createWriteStream(temporary, { mode: 0o600 }))
315
+ const original = sanitizeFileName(resource.fileName)
316
+ const base = original ?? (resource.type === 'image'
317
+ ? `image${await sniffExtension(temporary)}`
318
+ : 'file.bin')
319
+ const finalName = await uniqueName(dir, `${this.stamp()}-${base}`)
320
+ await rename(temporary, path.join(dir, finalName))
321
+ } catch (error) {
322
+ await rm(temporary, { force: true }).catch(() => undefined)
323
+ throw error
324
+ }
325
+ }
326
+
327
+ /**
328
+ * Persist one expanded merged-forward record as UTF-8 text. The content is
329
+ * already plain text (`<forwarded_messages>…`), so no parsing or conversion
330
+ * happens here — the Agent reads the file when the conversation needs it.
331
+ */
332
+ private async saveForwardedRecord(msg: NormalizedMessage, dir: string): Promise<void> {
333
+ const content = msg.content ?? ''
334
+ if (content.trim() === '' || content.trim() === EMPTY_FORWARDED) {
335
+ throw new Error('会话记录为空(机器人可能缺少源消息的读取权限)')
336
+ }
337
+ const name = await uniqueName(dir, `${this.stamp()}-forwarded-record.txt`)
338
+ await writeFile(path.join(dir, name), `${content}\n`, { encoding: 'utf8', mode: 0o600 })
339
+ }
340
+
341
+ private stamp(): string {
342
+ return compactTimestamp(this.deps.now?.() ?? Date.now())
343
+ }
344
+ }
345
+
346
+ /** Remove attachment placeholders while keeping ordinary prompt text. */
347
+ export function stripAttachmentPlaceholders(content: string, forwarded: boolean): string {
348
+ let text = content.replace(IMAGE_PLACEHOLDER, ' ').replace(FILE_PLACEHOLDER, ' ')
349
+ if (forwarded) text = text.replace(FORWARDED_PLACEHOLDER, ' ')
350
+ return text.replace(/[ \t]+/gu, ' ').replace(/\s*\n\s*/gu, '\n').trim()
351
+ }
352
+
353
+ /** `YYYYMMDD-HHmmss`, UTC, filesystem- and sort-friendly. */
354
+ export function compactTimestamp(epochMs: number): string {
355
+ return new Date(epochMs).toISOString().replace(/[-:]/gu, '').replace(/\..+$/u, '').replace('T', '-')
356
+ }
357
+
358
+ /** Strip directories, control characters, and reserved path characters from a display name. */
359
+ export function sanitizeFileName(raw: string | undefined): string | undefined {
360
+ if (raw === undefined) return undefined
361
+ const base = raw.split(/[\\/]/u).pop() ?? raw
362
+ const cleaned = base
363
+ // eslint-disable-next-line no-control-regex
364
+ .replace(/[\u0000-\u001f\u007f<>:"\\|?*]/gu, '_')
365
+ .replace(/^[.\s]+/u, '')
366
+ .replace(/[.\s]+$/u, '')
367
+ if (cleaned === '') return undefined
368
+ return truncateUtf8(cleaned, 120)
369
+ }
370
+
371
+ /** User-facing name for one attachment. */
372
+ function resourceName(resource: AttachmentResource): string {
373
+ if (resource.fileName !== undefined && resource.fileName !== '') return resource.fileName
374
+ return resource.type === 'image' ? '图片' : '文件'
375
+ }
376
+
377
+ /**
378
+ * Turn a download/write failure into a short user-facing reason. Feishu's
379
+ * message-resource API refuses files above 100 MB, which is the common case
380
+ * for a 400 on an otherwise valid file.
381
+ */
382
+ export function describeFailure(error: unknown): string {
383
+ const message = error instanceof Error ? error.message : String(error)
384
+ if (/status code 400|bad request/iu.test(message)) {
385
+ return '飞书下载接口拒绝请求(文件可能已过期、已被删除或暂不可下载)'
386
+ }
387
+ if (/status code 403|forbidden/iu.test(message)) return '飞书下载接口无权限'
388
+ return message
389
+ }
390
+
391
+ /** Truncate a UTF-8 string on a character boundary. */
392
+ function truncateUtf8(value: string, maxBytes: number): string {
393
+ if (Buffer.byteLength(value) <= maxBytes) return value
394
+ let out = ''
395
+ for (const char of value) {
396
+ if (Buffer.byteLength(out + char) > maxBytes) break
397
+ out += char
398
+ }
399
+ return out
400
+ }
401
+
402
+ /** Pick a non-colliding destination name inside one directory. */
403
+ async function uniqueName(dir: string, candidate: string): Promise<string> {
404
+ const parsed = path.parse(candidate)
405
+ for (let attempt = 1; attempt <= 100; attempt += 1) {
406
+ const name = attempt === 1 ? candidate : `${parsed.name}-${attempt}${parsed.ext}`
407
+ try {
408
+ await stat(path.join(dir, name))
409
+ } catch {
410
+ return name
411
+ }
412
+ }
413
+ throw new Error('no free attachment file name after 100 attempts')
414
+ }
415
+
416
+ /** Detect an image extension from the first bytes of a staged file. */
417
+ async function sniffExtension(file: string): Promise<string> {
418
+ let handle: Awaited<ReturnType<typeof open>> | undefined
419
+ try {
420
+ handle = await open(file, 'r')
421
+ const buffer = Buffer.alloc(16)
422
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
423
+ const type = sniffImageMediaType(new Uint8Array(buffer.subarray(0, bytesRead)))
424
+ switch (type) {
425
+ case 'image/png': return '.png'
426
+ case 'image/jpeg': return '.jpg'
427
+ case 'image/webp': return '.webp'
428
+ case 'image/gif': return '.gif'
429
+ default: return '.bin'
430
+ }
431
+ } catch {
432
+ return '.bin'
433
+ } finally {
434
+ await handle?.close().catch(() => undefined)
435
+ }
436
+ }