@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- 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
|
+
}
|