dsh-lark-agent 0.3.3 → 0.4.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.
@@ -0,0 +1,192 @@
1
+ import { constants } from 'node:fs';
2
+ import { chmod, mkdir, open, rename, unlink } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { join } from 'node:path';
5
+ export class TopicStoreError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super({ INVALID_INPUT: '话题参数无效。', CORRUPT: '话题目录损坏,请管理员检查备份;已有记录未被清空。',
9
+ CONFLICT: '话题绑定已变化,请重新打开历史对话。', CAPACITY: '话题数量已达到上限,已有话题会保留。',
10
+ WRITE_FAILED: '话题目录保存失败,请稍后重试。', UNAVAILABLE: '话题目录的保存结果未能确认,请管理员检查后重新启动。' }[code]);
11
+ this.code = code;
12
+ this.name = 'TopicStoreError';
13
+ }
14
+ }
15
+ const KEY = /^[a-f0-9]{64}$/;
16
+ const MESSAGE = /^om_[A-Za-z0-9_-]{1,256}$/;
17
+ const THREAD = /^(?:om|omt)_[A-Za-z0-9_-]{1,256}$/;
18
+ const MAX_TOPICS = 10_000;
19
+ const MAX_OWNER_TOPICS = 1000;
20
+ const MAX_BYTES = 8 * 1024 * 1024;
21
+ /** Use only links returned by Lark's read API/CLI enrichment; never synthesize a deep link. */
22
+ export function topicLink(value) {
23
+ if (typeof value !== 'string' || value.length > 4096)
24
+ return;
25
+ try {
26
+ const url = new URL(value);
27
+ if (url.protocol === 'https:' && !url.username && !url.password && !url.port
28
+ && ['applink.feishu.cn', 'applink.larksuite.com'].includes(url.hostname)
29
+ && /^\/client\/(?:thread\/open|chat\/open|message\/link\/open)$/.test(url.pathname))
30
+ return url.href;
31
+ }
32
+ catch { /* An unavailable link does not invalidate a confirmed topic. */ }
33
+ }
34
+ function validScope(scope) {
35
+ return Boolean(scope && typeof scope.ownerKey === 'string' && KEY.test(scope.ownerKey)
36
+ && typeof scope.chatKey === 'string' && KEY.test(scope.chatKey));
37
+ }
38
+ function parseBinding(value) {
39
+ if (!value || typeof value !== 'object' || Array.isArray(value))
40
+ throw new TopicStoreError('INVALID_INPUT');
41
+ const item = value;
42
+ if (!validScope(item) || typeof item.laneKey !== 'string' || !KEY.test(item.laneKey)
43
+ || typeof item.conversationKey !== 'string' || !KEY.test(item.conversationKey)
44
+ || typeof item.threadId !== 'string' || !THREAD.test(item.threadId)
45
+ || typeof item.messageId !== 'string' || !MESSAGE.test(item.messageId)
46
+ || (item.sessionId !== undefined && (typeof item.sessionId !== 'string' || !/^[a-f0-9]{12}$/.test(item.sessionId)))
47
+ || (item.groupAccessKey !== undefined && (typeof item.groupAccessKey !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(item.groupAccessKey)))
48
+ || (item.link !== undefined && topicLink(item.link) !== item.link))
49
+ throw new TopicStoreError('INVALID_INPUT');
50
+ return { ownerKey: item.ownerKey, chatKey: item.chatKey, laneKey: item.laneKey, conversationKey: item.conversationKey,
51
+ threadId: item.threadId, messageId: item.messageId,
52
+ ...(item.sessionId === undefined ? {} : { sessionId: item.sessionId }),
53
+ ...(item.groupAccessKey === undefined ? {} : { groupAccessKey: item.groupAccessKey }),
54
+ ...(item.link === undefined ? {} : { link: item.link }) };
55
+ }
56
+ function sameScope(a, b) { return a.ownerKey === b.ownerKey && a.chatKey === b.chatKey; }
57
+ /** The state lock owns this store. Ambiguous publication poisons it until restart. */
58
+ export class TopicStore {
59
+ directory;
60
+ entries = [];
61
+ tail = Promise.resolve();
62
+ unavailable = false;
63
+ constructor(directory) {
64
+ this.directory = directory;
65
+ }
66
+ static async open(directory) {
67
+ await mkdir(directory, { recursive: true, mode: 0o700 });
68
+ await chmod(directory, 0o700);
69
+ const store = new TopicStore(directory);
70
+ let handle;
71
+ try {
72
+ handle = await open(join(directory, 'topics.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
73
+ const info = await handle.stat();
74
+ if (!info.isFile() || info.size > MAX_BYTES)
75
+ throw new Error();
76
+ const bytes = await handle.readFile();
77
+ if (bytes.length > MAX_BYTES)
78
+ throw new Error();
79
+ const data = JSON.parse(bytes.toString('utf8'));
80
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !('version' in data) || data.version !== 1
81
+ || !('topics' in data) || !Array.isArray(data.topics) || data.topics.length > MAX_TOPICS)
82
+ throw new Error();
83
+ store.entries = data.topics.map(parseBinding);
84
+ store.validate(store.entries);
85
+ await handle.chmod(0o600);
86
+ }
87
+ catch (error) {
88
+ if (error.code !== 'ENOENT')
89
+ throw new TopicStoreError('CORRUPT');
90
+ }
91
+ finally {
92
+ await handle?.close();
93
+ }
94
+ return store;
95
+ }
96
+ validate(entries) {
97
+ const threads = new Set(), lanes = new Set(), sessions = new Set(), owners = new Map();
98
+ for (const item of entries) {
99
+ const thread = JSON.stringify([item.chatKey, item.threadId]);
100
+ if (threads.has(thread) || lanes.has(item.laneKey) || (item.sessionId && sessions.has(item.sessionId)))
101
+ throw new TopicStoreError('CONFLICT');
102
+ threads.add(thread);
103
+ lanes.add(item.laneKey);
104
+ if (item.sessionId)
105
+ sessions.add(item.sessionId);
106
+ const count = (owners.get(item.ownerKey) ?? 0) + 1;
107
+ owners.set(item.ownerKey, count);
108
+ if (count > MAX_OWNER_TOPICS || entries.length > MAX_TOPICS)
109
+ throw new TopicStoreError('CAPACITY');
110
+ }
111
+ }
112
+ assertAvailable() { if (this.unavailable)
113
+ throw new TopicStoreError('UNAVAILABLE'); }
114
+ async list(scope) {
115
+ if (!validScope(scope))
116
+ throw new TopicStoreError('INVALID_INPUT');
117
+ const identity = { ...scope };
118
+ await this.tail;
119
+ this.assertAvailable();
120
+ return this.entries.filter(item => sameScope(identity, item)).map(item => ({ ...item }));
121
+ }
122
+ async get(scope, threadId) {
123
+ return (await this.list(scope)).find(item => item.threadId === threadId);
124
+ }
125
+ async occupied(chatKey, threadId) {
126
+ if (!KEY.test(chatKey) || !THREAD.test(threadId))
127
+ throw new TopicStoreError('INVALID_INPUT');
128
+ await this.tail;
129
+ this.assertAvailable();
130
+ return this.entries.some(item => item.chatKey === chatKey && item.threadId === threadId);
131
+ }
132
+ record(binding) {
133
+ // Snapshot the caller before queued I/O, and retain only the schema's fields.
134
+ const next = parseBinding(binding);
135
+ const job = this.tail.then(async () => {
136
+ this.assertAvailable();
137
+ const existing = this.entries.find(item => item.chatKey === next.chatKey && item.threadId === next.threadId);
138
+ if (existing) {
139
+ if (!sameScope(existing, next) || existing.laneKey !== next.laneKey || existing.conversationKey !== next.conversationKey
140
+ || existing.groupAccessKey !== next.groupAccessKey || (existing.sessionId && existing.sessionId !== next.sessionId))
141
+ throw new TopicStoreError('CONFLICT');
142
+ // A later event must not move the topic's stable anchor or discard its selected session/link.
143
+ next.messageId = existing.messageId;
144
+ next.sessionId ??= existing.sessionId;
145
+ next.link ??= existing.link;
146
+ if (JSON.stringify(existing) === JSON.stringify(next))
147
+ return { ...existing };
148
+ }
149
+ const entries = this.entries.filter(item => item !== existing).concat(next);
150
+ this.validate(entries);
151
+ await this.persist(entries);
152
+ this.entries = entries;
153
+ return { ...next };
154
+ });
155
+ this.tail = job.then(() => undefined, () => undefined);
156
+ return job;
157
+ }
158
+ async persist(topics) {
159
+ const contents = JSON.stringify({ version: 1, topics }) + '\n';
160
+ // Long read-enriched links count toward the same bound used when reopening.
161
+ if (Buffer.byteLength(contents) > MAX_BYTES)
162
+ throw new TopicStoreError('CAPACITY');
163
+ const temporary = join(this.directory, `topics.${randomUUID()}.tmp`);
164
+ let publicationAttempted = false;
165
+ try {
166
+ const handle = await open(temporary, 'wx', 0o600);
167
+ try {
168
+ await handle.writeFile(contents);
169
+ await handle.sync();
170
+ }
171
+ finally {
172
+ await handle.close();
173
+ }
174
+ publicationAttempted = true;
175
+ await rename(temporary, join(this.directory, 'topics.json'));
176
+ const directory = await open(this.directory, 'r');
177
+ try {
178
+ await directory.sync();
179
+ }
180
+ finally {
181
+ await directory.close();
182
+ }
183
+ }
184
+ catch {
185
+ if (publicationAttempted)
186
+ this.unavailable = true;
187
+ await unlink(temporary).catch(() => undefined);
188
+ throw new TopicStoreError(publicationAttempted ? 'UNAVAILABLE' : 'WRITE_FAILED');
189
+ }
190
+ }
191
+ }
192
+ //# sourceMappingURL=topic-store.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-lark-agent",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "Reach your DeepSeek Harness assistant from Feishu / Lark: a dsh plugin built on the official Lark CLI",
5
5
  "keywords": [
6
6
  "deepseek",