dsh-lark-agent 0.0.0-stage → 0.1.1

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +92 -2
  3. package/cordis.patch.yml +5 -0
  4. package/dist/artifact-publisher.d.ts +28 -0
  5. package/dist/artifact-publisher.js +246 -0
  6. package/dist/artifacts.d.ts +44 -0
  7. package/dist/artifacts.js +246 -0
  8. package/dist/bot-menu.d.ts +59 -0
  9. package/dist/bot-menu.js +41 -0
  10. package/dist/bot-service.d.ts +20 -0
  11. package/dist/bot-service.js +169 -0
  12. package/dist/bridge.d.ts +104 -0
  13. package/dist/bridge.js +975 -0
  14. package/dist/card-actions.d.ts +22 -0
  15. package/dist/card-actions.js +178 -0
  16. package/dist/client/ConnectionCard.d.ts +6 -0
  17. package/dist/client/api.d.ts +21 -0
  18. package/dist/client/index.d.ts +26 -0
  19. package/dist/client/styles.d.ts +2 -0
  20. package/dist/client/use-connection.d.ts +14 -0
  21. package/dist/client.cjs +644 -0
  22. package/dist/command-feedback.d.ts +11 -0
  23. package/dist/command-feedback.js +29 -0
  24. package/dist/config.d.ts +31 -0
  25. package/dist/config.js +91 -0
  26. package/dist/direct-chats.d.ts +23 -0
  27. package/dist/direct-chats.js +144 -0
  28. package/dist/dsh-runtime.d.ts +92 -0
  29. package/dist/dsh-runtime.js +802 -0
  30. package/dist/index.d.ts +19 -0
  31. package/dist/index.js +27 -0
  32. package/dist/lark-cli.d.ts +74 -0
  33. package/dist/lark-cli.js +589 -0
  34. package/dist/ledger.d.ts +12 -0
  35. package/dist/ledger.js +57 -0
  36. package/dist/lock.d.ts +25 -0
  37. package/dist/lock.js +213 -0
  38. package/dist/memory-store.d.ts +48 -0
  39. package/dist/memory-store.js +333 -0
  40. package/dist/message.d.ts +16 -0
  41. package/dist/message.js +79 -0
  42. package/dist/model-controls.d.ts +12 -0
  43. package/dist/model-controls.js +72 -0
  44. package/dist/model-preferences.d.ts +37 -0
  45. package/dist/model-preferences.js +247 -0
  46. package/dist/native-presentation.d.ts +5 -0
  47. package/dist/native-presentation.js +150 -0
  48. package/dist/onboarding/controller.d.ts +75 -0
  49. package/dist/onboarding/controller.js +548 -0
  50. package/dist/onboarding/host.d.ts +35 -0
  51. package/dist/onboarding/host.js +267 -0
  52. package/dist/onboarding/lark-platform.d.ts +106 -0
  53. package/dist/onboarding/lark-platform.js +462 -0
  54. package/dist/onboarding/pairing.d.ts +26 -0
  55. package/dist/onboarding/pairing.js +72 -0
  56. package/dist/onboarding/protocol.d.ts +60 -0
  57. package/dist/onboarding/protocol.js +2 -0
  58. package/dist/onboarding/store.d.ts +24 -0
  59. package/dist/onboarding/store.js +61 -0
  60. package/dist/presentation.d.ts +83 -0
  61. package/dist/presentation.js +208 -0
  62. package/dist/progress.d.ts +20 -0
  63. package/dist/progress.js +110 -0
  64. package/dist/replies.d.ts +47 -0
  65. package/dist/replies.js +270 -0
  66. package/dist/rich-scopes.d.ts +3 -0
  67. package/dist/rich-scopes.js +15 -0
  68. package/dist/session-store.d.ts +43 -0
  69. package/dist/session-store.js +309 -0
  70. package/package.json +115 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-larkbot contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,93 @@
1
- # Temporary Holding Version
1
+ <img src="https://raw.githubusercontent.com/Nirvana-Jie/dsh-larkbot/main/assets/plugin-icon.png" width="80" align="right" alt="dsh-larkbot icon">
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # dsh-larkbot
4
+
5
+ [中文](https://github.com/Nirvana-Jie/dsh-larkbot/blob/main/README_zh.md)
6
+
7
+ [![npm version](https://img.shields.io/npm/v/dsh-lark-agent)](https://www.npmjs.com/package/dsh-lark-agent)
8
+
9
+ **Reach your [DeepSeek Harness](https://www.deepseek.com/harness/) assistant from Feishu / Lark when you're away from your computer.**
10
+
11
+ dsh-larkbot (npm package [`dsh-lark-agent`](https://www.npmjs.com/package/dsh-lark-agent)) adds a Feishu / Lark private chat to a running DeepSeek Harness: hand off tasks, talk through plans, and pick up earlier topics from your phone. Feishu gets its own sessions and never takes over chats already open in the desktop app or on the web.
12
+
13
+ [![dsh-larkbot illustrated walkthrough (Feishu chat redrawn with demo data, not a screen recording): from the Harness settings page into a Feishu private chat that returns chart and form cards](https://raw.githubusercontent.com/Nirvana-Jie/dsh-larkbot/main/assets/readme/intro.gif)](https://github.com/Nirvana-Jie/dsh-larkbot/blob/main/assets/readme/intro.mp4)
14
+
15
+ ## Install / Update
16
+
17
+ Before you start:
18
+
19
+ - DeepSeek Harness is running, can chat, and has a default model selected: PC App `0.2.1-rc.2`, or Web / `dsh` CLI `0.1.1-rc.2`.
20
+ - The official Lark CLI is installed on the same machine **before** you enable the plugin (needs Node.js / npm):
21
+
22
+ ```sh
23
+ npm install -g @larksuite/cli@1.0.98
24
+ lark-cli --version
25
+ ```
26
+
27
+ Don't run `lark-cli config init` or `lark-cli auth login`: the plugin creates its own dedicated CLI profile when you scan, and `config init` can overwrite existing profiles.
28
+
29
+ ### Install on the DeepSeek Harness desktop app
30
+
31
+ In the sidebar, open **插件 → 添加插件** (Plugins → Add plugin), enter `dsh-lark-agent`, click **安装** (Install), then **立即启用** (Enable now). Restart if the app asks.
32
+
33
+ <img src="https://raw.githubusercontent.com/Nirvana-Jie/dsh-larkbot/main/assets/readme/desktop-install-02.jpg" width="720" alt="The Add plugin dialog in the DeepSeek Harness desktop app">
34
+
35
+ ### Install with the `dsh` CLI
36
+
37
+ [`dsh plugin`](https://www.npmjs.com/package/@deepseek-ai/dsh) runs pnpm, so pnpm must be on your PATH:
38
+
39
+ ```sh
40
+ dsh plugin --profile web add dsh-lark-agent --allow-build=protobufjs
41
+ ```
42
+
43
+ Then restart `dsh web`.
44
+
45
+ > [!NOTE]
46
+ > `--allow-build=protobufjs` lets a dependency of the Lark SDK run its install script. Without it, pnpm 11 stops with `ERR_PNPM_IGNORED_BUILDS` and dsh does not enable the plugin. If that already happened, run:
47
+ >
48
+ > ```sh
49
+ > dsh plugin --profile web approve-builds '!protobufjs'
50
+ > ```
51
+ >
52
+ > The `!` skips that optional script (it only prints a version hint) and lets dsh finish enabling the plugin. Then restart `dsh web`.
53
+
54
+ ### Update
55
+
56
+ 1. On the Feishu settings page, click **暂停连接** (pause) and back up as described in [Upgrade](https://github.com/Nirvana-Jie/dsh-larkbot/blob/main/DEVELOPMENT.md#升级).
57
+ 2. Desktop app: the Add plugin dialog says third-party plugins must be uninstalled before a new version is installed, so uninstall `dsh-lark-agent` on the Plugins page and add it again (this in-app upgrade hasn't been tested end to end yet; an earlier source build is listed as `dsh-larkbot`). CLI: run `dsh plugin --profile web update dsh-lark-agent@latest`, then restart `dsh web`.
58
+ 3. Click **恢复连接** (resume) and check that the bot replies.
59
+
60
+ ## Connect Feishu
61
+
62
+ Button labels are quoted in Chinese, as they appear, with an English gloss.
63
+
64
+ 1. Open the Feishu settings page — desktop app: **设置 → 内置插件 → 飞书** (Settings → Built-in plugins → Feishu); web: **设置 → 插件 → 飞书** (Settings → Plugins → Feishu) — and click **扫码创建并连接** (scan to create and connect).
65
+ 2. Scan the QR code with your own Feishu account and approve. If your organization requires admin approval, click **重新检查** (check again) once it is approved.
66
+ 3. Click **打开飞书私聊** (open private chat) and, from the account that scanned, send the pairing word (`DSH-…`, valid for 10 minutes) as a plain-text message on its own, exactly as shown. If asked, confirm with **这是我,完成连接** (this is me).
67
+ 4. When the page shows **已连接** (connected), send the bot a message.
68
+
69
+ The Feishu entry works only while the host is online: not when the app is closed, the web host is stopped, or the computer sleeps.
70
+
71
+ ## What it can do
72
+
73
+ | In Feishu, you… | The assistant… |
74
+ | --- | --- |
75
+ | Send a request | Shows a `Typing` reaction while it works (on by default for new bots), then replies in segments |
76
+ | Say "lead with the conclusion from now on" | Remembers the preference |
77
+ | Keep chatting, or use `/sessions`, `/new` | Continues the topic, returns to an earlier one, or starts fresh |
78
+ | Ask for a bar chart of some numbers | Replies with a native Feishu chart card |
79
+ | Ask it to let you pick between options first | Sends a form card and continues once you submit |
80
+ | Ask for an architecture diagram or a report | Creates a Feishu doc (with a whiteboard for diagrams) shared read-only with you alone; in App `0.2.1-rc.2`, opening that Feishu session in the app also shows a local preview |
81
+ | Use `/model`, `/thinking` | Switches between the host's configured models and thinking depth |
82
+
83
+ By default it answers only your own private chat. Sessions and memory stay on the computer running Harness; messages pass through Feishu and your model provider.
84
+
85
+ ## Good to know
86
+
87
+ - **More docs**: commands, privacy, upgrades, the chat bottom menu, team deployment and troubleshooting are in [DEVELOPMENT.md](https://github.com/Nirvana-Jie/dsh-larkbot/blob/main/DEVELOPMENT.md) (Chinese).
88
+ - **Compatibility**: verified with PC App `0.2.1-rc.2` and `dsh` CLI / Web `0.1.1-rc.2`; other versions, platforms and tenants are not yet verified.
89
+ - **Language**: the settings page, the bot's built-in messages (`/help`, status text, card buttons, bottom menu) and the intro video are in Chinese.
90
+
91
+ ## License
92
+
93
+ [MIT](https://github.com/Nirvana-Jie/dsh-larkbot/blob/main/LICENSE). DeepSeek and Feishu / Lark brand assets belong to their owners and are not covered by the MIT license; this is an independent open-source plugin, not an official product.
@@ -0,0 +1,5 @@
1
+ - insert:
2
+ - id: im-larkbot
3
+ name: dsh-lark-agent
4
+ config:
5
+ configFile: ''
@@ -0,0 +1,28 @@
1
+ import { type PlatformRunner } from './onboarding/lark-platform.js';
2
+ import { type PublishedArtifact, type SavedArtifact } from './artifacts.js';
3
+ export interface ArtifactPublisher {
4
+ preflight(diagram: boolean, signal: AbortSignal): Promise<void>;
5
+ publish(artifact: SavedArtifact, ownerId: string, signal: AbortSignal, recordDocument: (id: string) => Promise<void>): Promise<PublishedArtifact>;
6
+ verify(remote: PublishedArtifact, ownerId: string, signal: AbortSignal): Promise<void>;
7
+ }
8
+ /** Only controlled, private outputs are published. Neither model data nor public API callers choose an endpoint or an audience. */
9
+ export declare class LarkArtifactPublisher implements ArtifactPublisher {
10
+ private readonly profile;
11
+ private readonly botOpenId;
12
+ private readonly run;
13
+ private brand;
14
+ private appId?;
15
+ constructor(profile: string, botOpenId: string, options?: {
16
+ executable?: string;
17
+ run?: PlatformRunner;
18
+ });
19
+ private json;
20
+ private api;
21
+ preflight(diagram: boolean, signal: AbortSignal): Promise<void>;
22
+ private loadIdentity;
23
+ private access;
24
+ verify(remote: PublishedArtifact, ownerId: string, signal: AbortSignal): Promise<void>;
25
+ publish(saved: SavedArtifact, ownerId: string, signal: AbortSignal, recordDocument: (id: string) => Promise<void>): Promise<PublishedArtifact>;
26
+ private existingDiagram;
27
+ private preview;
28
+ }
@@ -0,0 +1,246 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { commandRunner } from './onboarding/lark-platform.js';
6
+ import { diagramMermaid, plainRecord, presentationDraft, PresentationError } from './presentation.js';
7
+ import { publishedArtifact } from './artifacts.js';
8
+ import { documentScopesReady } from './rich-scopes.js';
9
+ const TOKEN = /^[A-Za-z0-9_-]{1,128}$/;
10
+ const PRIVATE_PERMISSIONS = { external_access_entity: 'closed', link_share_entity: 'closed',
11
+ manage_collaborator_entity: 'collaborator_full_access', share_entity: 'same_tenant',
12
+ security_entity: 'only_full_access', copy_entity: 'only_full_access', comment_entity: 'anyone_can_edit' };
13
+ const failed = () => new PresentationError('云端作品尚未确认完成,已保留本地内容。请勿重复创建;可在本机检查作品状态。');
14
+ function token(value) { if (typeof value !== 'string' || !TOKEN.test(value))
15
+ throw failed(); return value; }
16
+ function elements(value) { return [{ text_run: { content: value } }]; }
17
+ function textBlock(value, heading = false) { return heading ? { block_type: 4, heading2: { elements: elements(value) } } : { block_type: 2, text: { elements: elements(value) } }; }
18
+ /** Only controlled, private outputs are published. Neither model data nor public API callers choose an endpoint or an audience. */
19
+ export class LarkArtifactPublisher {
20
+ profile;
21
+ botOpenId;
22
+ run;
23
+ brand = 'feishu';
24
+ appId;
25
+ constructor(profile, botOpenId, options = {}) {
26
+ this.profile = profile;
27
+ this.botOpenId = botOpenId;
28
+ if (!/^[A-Za-z0-9_-]{1,64}$/.test(profile) || !/^ou_[A-Za-z0-9_-]{1,128}$/.test(botOpenId))
29
+ throw failed();
30
+ // A bot lease cannot be released until cancelled publisher children actually close.
31
+ this.run = options.run ?? commandRunner(options.executable ?? 'lark-cli', 30_000, 1_048_576, true);
32
+ }
33
+ async json(args, signal, stdin, cwd) {
34
+ signal.throwIfAborted();
35
+ try {
36
+ const result = plainRecord(JSON.parse(await this.run(['--profile', this.profile, ...args, '--as', 'bot', '--format', 'json'], { signal, stdin, cwd })));
37
+ signal.throwIfAborted();
38
+ if (result.ok !== true || result.identity !== 'bot' || result.dry_run === true)
39
+ throw failed();
40
+ return plainRecord(result.data);
41
+ }
42
+ catch {
43
+ if (signal.aborted)
44
+ signal.throwIfAborted();
45
+ throw failed();
46
+ }
47
+ }
48
+ api(method, path, signal, data, params) {
49
+ return this.json(['api', method, path, ...(params ? ['--params', JSON.stringify(params)] : []), ...(data ? ['--data', '-'] : [])], signal, data ? JSON.stringify(data) : undefined);
50
+ }
51
+ async preflight(diagram, signal) {
52
+ const scopes = await this.api('GET', '/open-apis/application/v6/scopes', signal);
53
+ if (!Array.isArray(scopes.scopes) || scopes.scopes.length > 3000)
54
+ throw failed();
55
+ const granted = scopes.scopes.map(plainRecord).filter(scope => scope.grant_status === 1).map(scope => scope.scope_name).filter((name) => typeof name === 'string');
56
+ if (!documentScopesReady(granted, diagram))
57
+ throw new PresentationError('这个机器人的文档权限尚未就绪。请在 dsh 设置 → 飞书中点击“补充默认权限”,扫码确认后再点这张卡片。内容已保存在本地。');
58
+ await this.loadIdentity(signal);
59
+ }
60
+ async loadIdentity(signal) {
61
+ // This metadata command does not read secrets or change the default profile.
62
+ const profiles = JSON.parse(await this.run(['profile', 'list'], { signal }));
63
+ if (!Array.isArray(profiles))
64
+ throw failed();
65
+ const selected = profiles.map(plainRecord).find(profile => profile.name === this.profile);
66
+ if (!selected || !['feishu', 'lark'].includes(selected.brand)
67
+ || typeof selected.appId !== 'string' || !/^cli_[A-Za-z0-9_-]{1,128}$/.test(selected.appId))
68
+ throw failed();
69
+ this.brand = selected.brand;
70
+ this.appId = selected.appId;
71
+ }
72
+ async access(documentId, ownerId, signal, ownerMayBeMissing = false) {
73
+ const permissions = await this.api('GET', `/open-apis/drive/v2/permissions/${token(documentId)}/public`, signal, undefined, { type: 'docx' });
74
+ const settings = plainRecord(permissions.permission_public);
75
+ if (Object.entries(PRIVATE_PERMISSIONS).some(([key, value]) => settings[key] !== value))
76
+ throw failed();
77
+ const members = await this.api('GET', `/open-apis/drive/v1/permissions/${documentId}/members`, signal, undefined, { type: 'docx', fields: 'type' });
78
+ if (!Array.isArray(members.items) || members.items.length > 2 || members.has_more === true)
79
+ throw failed();
80
+ let foundOwner = false;
81
+ for (const value of members.items) {
82
+ const member = plainRecord(value);
83
+ // Bot-created root documents list their owning application by appid, not the IM bot's open_id.
84
+ if (this.appId && member.member_type === 'appid' && member.member_id === this.appId && member.perm === 'full_access')
85
+ continue;
86
+ if (member.member_type !== 'openid')
87
+ throw failed();
88
+ if (member.member_id === this.botOpenId && member.perm === 'full_access')
89
+ continue;
90
+ if (ownerId && member.member_id === ownerId && member.perm === 'view' && member.type === 'user' && !foundOwner) {
91
+ foundOwner = true;
92
+ continue;
93
+ }
94
+ throw failed();
95
+ }
96
+ if (ownerId && !foundOwner && !ownerMayBeMissing)
97
+ throw failed();
98
+ return foundOwner;
99
+ }
100
+ async verify(remote, ownerId, signal) {
101
+ if (!/^ou_[A-Za-z0-9_-]{1,128}$/.test(ownerId) || ownerId === this.botOpenId)
102
+ throw failed();
103
+ await this.loadIdentity(signal);
104
+ await this.access(publishedArtifact(remote).documentId, ownerId, signal);
105
+ }
106
+ async publish(saved, ownerId, signal, recordDocument) {
107
+ if (!/^ou_[A-Za-z0-9_-]{1,128}$/.test(ownerId) || ownerId === this.botOpenId || saved.publication !== 'publishing')
108
+ throw failed();
109
+ const draft = presentationDraft(saved.draft);
110
+ if (draft.kind !== 'report' && draft.kind !== 'diagram')
111
+ throw failed();
112
+ signal.throwIfAborted();
113
+ if (!this.appId)
114
+ await this.loadIdentity(signal);
115
+ const children = [textBlock(draft.title, true), textBlock(draft.summary), ...(draft.kind === 'diagram'
116
+ ? [{ block_type: 43, board: { align: 2 } }]
117
+ : draft.content.sections.flatMap(section => [textBlock(section.heading, true), textBlock(section.body)]))];
118
+ // No sensitive title or content leaves this machine before private ACLs have been verified.
119
+ let documentId;
120
+ let existingChildren;
121
+ if (saved.pendingDocumentId) {
122
+ documentId = token(saved.pendingDocumentId);
123
+ // Reconcile only the exact controlled snapshot; never append to partially written or edited content.
124
+ const blocks = await this.api('GET', `/open-apis/docx/v1/documents/${documentId}/blocks`, signal, undefined, { page_size: 50 });
125
+ if (blocks.has_more === true || !Array.isArray(blocks.items) || !blocks.items.length)
126
+ throw failed();
127
+ const root = plainRecord(blocks.items[0]);
128
+ if (root.block_id !== documentId || root.block_type !== 1 || (root.children !== undefined && !Array.isArray(root.children)))
129
+ throw failed();
130
+ if (blocks.items.length > 1) {
131
+ if (!Array.isArray(root.children) || root.children.length !== children.length || blocks.items.length !== children.length + 1)
132
+ throw failed();
133
+ existingChildren = root.children.map(id => plainRecord(blocks.items.find(value => plainRecord(value).block_id === id)));
134
+ for (const [i, block] of existingChildren.entries()) {
135
+ const expected = children[i];
136
+ if (block.block_type !== expected.block_type || (Array.isArray(block.children) && block.children.length))
137
+ throw failed();
138
+ if (block.block_type !== 43) {
139
+ const field = block.block_type === 4 ? 'heading2' : 'text', elements = plainRecord(block[field]).elements;
140
+ const expectedText = plainRecord(expected[field]).elements[0].text_run.content;
141
+ if (!Array.isArray(elements) || elements.length !== 1 || plainRecord(plainRecord(elements[0]).text_run).content !== expectedText)
142
+ throw failed();
143
+ }
144
+ }
145
+ }
146
+ else if (Array.isArray(root.children) && root.children.length)
147
+ throw failed();
148
+ }
149
+ else {
150
+ const created = await this.api('POST', '/open-apis/docx/v1/documents', signal, { title: 'dsh 的作品' });
151
+ documentId = token(plainRecord(created.document).document_id);
152
+ await recordDocument(documentId);
153
+ await this.api('PATCH', `/open-apis/drive/v2/permissions/${documentId}/public`, signal, PRIVATE_PERMISSIONS, { type: 'docx' });
154
+ }
155
+ const alreadyGranted = await this.access(documentId, saved.pendingDocumentId ? ownerId : undefined, signal, true);
156
+ const stable = (stage) => createHash('sha256').update(`${saved.id}:${stage}`).digest('hex').slice(0, 40);
157
+ // Text runs are inert. Do not use DocxXML/Markdown importers that may fetch URLs or @files.
158
+ const written = existingChildren ? { children: existingChildren } : await this.api('POST', `/open-apis/docx/v1/documents/${documentId}/blocks/${documentId}/children`, signal, { children, index: -1 }, { client_token: stable('blocks'), document_revision_id: -1 });
159
+ if (!Array.isArray(written.children) || written.children.length !== children.length)
160
+ throw failed();
161
+ let boardId, imageKey;
162
+ if (draft.kind === 'diagram') {
163
+ const board = written.children.map(plainRecord).find(block => block.block_type === 43);
164
+ boardId = token(plainRecord(board?.board).token);
165
+ if (!existingChildren || !await this.existingDiagram(boardId, draft.content, signal)) {
166
+ const imported = await this.json(['whiteboard', '+update', '--whiteboard-token', boardId,
167
+ '--input_format', 'mermaid', '--source', '-', '--idempotent-token', stable('diagram')], signal, diagramMermaid(draft.content));
168
+ // Whiteboard node IDs are coordinates such as "t1:2", unlike document/board resource tokens.
169
+ if (typeof imported.created_node_id !== 'string' || !/^[A-Za-z0-9_:-]{1,128}$/.test(imported.created_node_id))
170
+ throw failed();
171
+ }
172
+ // Preview is cosmetic; failure must not create a second document or block an otherwise verified link.
173
+ try {
174
+ imageKey = await this.preview(boardId, signal);
175
+ }
176
+ catch {
177
+ signal.throwIfAborted();
178
+ }
179
+ }
180
+ if (!alreadyGranted)
181
+ await this.api('POST', `/open-apis/drive/v1/permissions/${documentId}/members`, signal, { member_type: 'openid', member_id: ownerId, perm: 'view', type: 'user' }, { type: 'docx' });
182
+ await this.access(documentId, ownerId, signal);
183
+ return publishedArtifact({ documentId, url: `https://www.${this.brand === 'lark' ? 'larksuite.com' : 'feishu.cn'}/docx/${documentId}`,
184
+ ...(boardId ? { boardId } : {}), ...(imageKey ? { imageKey } : {}) });
185
+ }
186
+ async existingDiagram(boardId, diagram, signal) {
187
+ const data = await this.api('GET', `/open-apis/board/v1/whiteboards/${boardId}/nodes`, signal);
188
+ if (!Array.isArray(data.nodes) || data.has_more === true)
189
+ throw failed();
190
+ if (!data.nodes.length)
191
+ return false;
192
+ const nodes = data.nodes.map(plainRecord), sections = nodes.filter(node => node.type === 'section');
193
+ if (sections.length !== 1 || nodes.length !== 1 + diagram.nodes.length + diagram.edges.length)
194
+ throw failed();
195
+ const section = sections[0], syntax = plainRecord(section.syntax);
196
+ if (syntax.syntax_type !== 2 || syntax.code !== diagramMermaid(diagram))
197
+ throw failed();
198
+ const shapes = nodes.filter(node => node.type === 'composite_shape'), labels = new Map();
199
+ for (const shape of shapes) {
200
+ const label = plainRecord(shape.text).text;
201
+ if (typeof label !== 'string' || typeof shape.id !== 'string' || shape.parent_id !== section.id || labels.has(shape.id))
202
+ throw failed();
203
+ labels.set(shape.id, label);
204
+ }
205
+ const same = (a, b) => JSON.stringify(a.sort()) === JSON.stringify(b.sort());
206
+ if (!same([...labels.values()], diagram.nodes.map(node => node.label)))
207
+ throw failed();
208
+ const edges = nodes.filter(node => node.type === 'connector').map(node => {
209
+ if (node.parent_id !== section.id)
210
+ throw failed();
211
+ const connector = plainRecord(node.connector), from = labels.get(plainRecord(connector.start_object).id), to = labels.get(plainRecord(connector.end_object).id);
212
+ const captions = connector.captions === undefined ? [] : plainRecord(connector.captions).data;
213
+ if (!from || !to || !Array.isArray(captions) || captions.length > 1)
214
+ throw failed();
215
+ const label = captions.length ? plainRecord(captions[0]).text : '';
216
+ if (typeof label !== 'string')
217
+ throw failed();
218
+ return JSON.stringify([from, to, label]);
219
+ });
220
+ if (!same(edges, diagram.edges.map(edge => JSON.stringify([diagram.nodes.find(node => node.id === edge.from).label, diagram.nodes.find(node => node.id === edge.to).label, edge.label ?? '']))))
221
+ throw failed();
222
+ return true;
223
+ }
224
+ async preview(boardId, signal) {
225
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-board-preview-'));
226
+ try {
227
+ await this.json(['api', 'GET', `/open-apis/board/v1/whiteboards/${boardId}/download_as_image`, '--output', 'preview.bin'], signal, undefined, directory);
228
+ const file = join(directory, 'preview.bin'), info = await stat(file);
229
+ if (!info.isFile() || info.size < 24 || info.size > 5 * 1024 * 1024)
230
+ return;
231
+ const image = await readFile(file);
232
+ // Only accept PNG with bounded dimensions here. SVG is never evaluated, and other formats keep a link-only card.
233
+ if (!image.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) || image.toString('ascii', 12, 16) !== 'IHDR'
234
+ || image.readUInt32BE(16) > 6000 || image.readUInt32BE(20) > 6000 || !image.readUInt32BE(16) || !image.readUInt32BE(20))
235
+ return;
236
+ const uploaded = await this.json(['api', 'POST', '/open-apis/im/v1/images', '--data', '{"image_type":"message"}', '--file', 'image=preview.bin'], signal, undefined, directory);
237
+ if (typeof uploaded.image_key !== 'string' || !/^img_[A-Za-z0-9_-]{1,200}$/.test(uploaded.image_key))
238
+ return;
239
+ return uploaded.image_key;
240
+ }
241
+ finally {
242
+ await rm(directory, { recursive: true, force: true });
243
+ }
244
+ }
245
+ }
246
+ //# sourceMappingURL=artifact-publisher.js.map
@@ -0,0 +1,44 @@
1
+ import { type PresentationDraft } from './presentation.js';
2
+ export interface ArtifactContext {
3
+ ownerKey: string;
4
+ conversationKey: string;
5
+ sessionId: string;
6
+ }
7
+ export interface PublishedArtifact {
8
+ documentId: string;
9
+ boardId?: string;
10
+ url: string;
11
+ imageKey?: string;
12
+ }
13
+ export interface SavedArtifact extends ArtifactContext {
14
+ id: string;
15
+ rootId: string;
16
+ createdAt: number;
17
+ draft: PresentationDraft;
18
+ publication: 'local' | 'publishing' | 'ready' | 'uncertain';
19
+ remote?: PublishedArtifact;
20
+ /** Safe recovery evidence; never shown as a link until the ACL is verified. */
21
+ pendingDocumentId?: string;
22
+ submitted?: boolean;
23
+ }
24
+ export declare function publishedArtifact(value: unknown): PublishedArtifact;
25
+ /** Owned by the bot state lock. Snapshots and publication claims survive restart; paths and secrets never enter this file. */
26
+ export declare class ArtifactStore {
27
+ private readonly directory;
28
+ private rows;
29
+ private tail;
30
+ private uncertain;
31
+ private constructor();
32
+ static open(directory: string): Promise<ArtifactStore>;
33
+ private write;
34
+ list(ctx: ArtifactContext): Promise<SavedArtifact[]>;
35
+ get(ctx: ArtifactContext, id: string): Promise<SavedArtifact | undefined>;
36
+ add(ctx: ArtifactContext, requestId: string, draftValue: PresentationDraft): Promise<SavedArtifact>;
37
+ claimPublish(ctx: ArtifactContext, id: string): Promise<SavedArtifact>;
38
+ publication(ctx: ArtifactContext, id: string, update: {
39
+ documentId?: string;
40
+ remote?: PublishedArtifact;
41
+ uncertain?: boolean;
42
+ }): Promise<void>;
43
+ submit(ctx: ArtifactContext, id: string, input: unknown): Promise<string>;
44
+ }