dsh-lark-agent 0.3.2 → 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.
- package/README.md +4 -4
- package/dist/bot-menu.d.ts +12 -0
- package/dist/bot-menu.js +14 -0
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +25 -0
- package/dist/bridge.js +316 -57
- package/dist/card-actions.js +3 -1
- package/dist/client.cjs +23 -1
- package/dist/config.d.ts +2 -1
- package/dist/config.js +2 -2
- package/dist/dsh-runtime.js +2 -0
- package/dist/lark-cli.d.ts +13 -1
- package/dist/lark-cli.js +63 -1
- package/dist/live-reply.js +8 -5
- package/dist/onboarding/controller.d.ts +2 -0
- package/dist/onboarding/controller.js +7 -1
- package/dist/onboarding/host.js +5 -17
- package/dist/onboarding/lark-platform.d.ts +2 -0
- package/dist/onboarding/lark-platform.js +5 -0
- package/dist/onboarding/model-check.d.ts +5 -0
- package/dist/onboarding/model-check.js +51 -0
- package/dist/onboarding/protocol.d.ts +9 -0
- package/dist/replies.d.ts +16 -0
- package/dist/replies.js +37 -11
- package/dist/session-controls.d.ts +34 -0
- package/dist/session-controls.js +66 -0
- package/dist/session-store.d.ts +2 -0
- package/dist/session-store.js +12 -7
- package/dist/topic-cards.d.ts +3 -0
- package/dist/topic-cards.js +8 -0
- package/dist/topic-store.d.ts +40 -0
- package/dist/topic-store.js +192 -0
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SavedSession } from './session-store.js';
|
|
2
|
+
import { type ReplyAction, type ReplyPanel } from './replies.js';
|
|
3
|
+
export interface SessionView {
|
|
4
|
+
page: number;
|
|
5
|
+
query: string;
|
|
6
|
+
}
|
|
7
|
+
export interface SessionChoice extends SavedSession {
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const SESSION_PAGE_SIZE = 20;
|
|
11
|
+
export declare const SESSION_QUERY_LENGTH = 80;
|
|
12
|
+
export declare function sessionQuery(value: unknown): string | undefined;
|
|
13
|
+
/** The query travels inside the signed command, never as an executable command fragment. */
|
|
14
|
+
export declare function sessionViewCommand(kind: 'page' | 'pick', view: SessionView): string;
|
|
15
|
+
export declare function sessionViewCommandParse(command: string): {
|
|
16
|
+
kind: 'page' | 'pick';
|
|
17
|
+
view: SessionView;
|
|
18
|
+
} | undefined;
|
|
19
|
+
export declare function sessionPage<T extends SessionChoice>(sessions: readonly T[], view: SessionView): {
|
|
20
|
+
items: T[];
|
|
21
|
+
total: number;
|
|
22
|
+
pages: number;
|
|
23
|
+
view: {
|
|
24
|
+
page: number;
|
|
25
|
+
query: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
/** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
|
|
29
|
+
export declare function sessionControlPanel(sessions: readonly SessionChoice[], currentId: string | undefined, action: (label: string, command: string) => ReplyAction | undefined, options?: {
|
|
30
|
+
view?: SessionView;
|
|
31
|
+
selectedId?: string;
|
|
32
|
+
busy?: boolean;
|
|
33
|
+
status?: ReplyPanel['status'];
|
|
34
|
+
}): ReplyPanel;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { plainLabel } from './replies.js';
|
|
2
|
+
export const SESSION_PAGE_SIZE = 20;
|
|
3
|
+
export const SESSION_QUERY_LENGTH = 80;
|
|
4
|
+
export function sessionQuery(value) {
|
|
5
|
+
if (typeof value !== 'string' || Array.from(value).length > SESSION_QUERY_LENGTH || /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u.test(value))
|
|
6
|
+
return;
|
|
7
|
+
return value.normalize('NFC').trim();
|
|
8
|
+
}
|
|
9
|
+
/** The query travels inside the signed command, never as an executable command fragment. */
|
|
10
|
+
export function sessionViewCommand(kind, view) {
|
|
11
|
+
return `/sessions ${kind} ${view.page + 1}${view.query ? ` ${Buffer.from(view.query).toString('base64url')}` : ''}`;
|
|
12
|
+
}
|
|
13
|
+
export function sessionViewCommandParse(command) {
|
|
14
|
+
const match = /^\/sessions (page|pick) ([1-9][0-9]?)(?: ([A-Za-z0-9_-]{1,428}))?$/.exec(command);
|
|
15
|
+
if (!match)
|
|
16
|
+
return;
|
|
17
|
+
const query = match[3] ? Buffer.from(match[3], 'base64url').toString('utf8') : '';
|
|
18
|
+
if (sessionQuery(query) !== query || (match[3] && Buffer.from(query).toString('base64url') !== match[3]))
|
|
19
|
+
return;
|
|
20
|
+
return { kind: match[1], view: { page: Number(match[2]) - 1, query } };
|
|
21
|
+
}
|
|
22
|
+
export function sessionPage(sessions, view) {
|
|
23
|
+
const query = view.query.normalize('NFC').toLocaleLowerCase();
|
|
24
|
+
const matches = sessions.filter(session => session.title.normalize('NFC').toLocaleLowerCase().includes(query))
|
|
25
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
26
|
+
const pages = Math.max(1, Math.ceil(matches.length / SESSION_PAGE_SIZE));
|
|
27
|
+
const page = Math.max(0, Math.min(view.page, pages - 1));
|
|
28
|
+
return { items: matches.slice(page * SESSION_PAGE_SIZE, (page + 1) * SESSION_PAGE_SIZE), total: matches.length, pages, view: { ...view, page } };
|
|
29
|
+
}
|
|
30
|
+
/** A short picker stays the same height as the catalog grows. Only its selected topic gets a detail row. */
|
|
31
|
+
export function sessionControlPanel(sessions, currentId, action, options = {}) {
|
|
32
|
+
const page = sessionPage(sessions, options.view ?? { page: 0, query: '' });
|
|
33
|
+
const selected = sessions.find(session => session.id === (options.selectedId ?? currentId));
|
|
34
|
+
const selectedVisible = page.items.some(session => session.id === selected?.id);
|
|
35
|
+
const pick = action('选择话题', sessionViewCommand('pick', page.view));
|
|
36
|
+
const search = action('搜索', '/sessions search');
|
|
37
|
+
const actions = [
|
|
38
|
+
...(page.view.page > 0 ? [action('上一页', sessionViewCommand('page', { ...page.view, page: page.view.page - 1 }))] : []),
|
|
39
|
+
...(page.view.page + 1 < page.pages ? [action('下一页', sessionViewCommand('page', { ...page.view, page: page.view.page + 1 }))] : []),
|
|
40
|
+
action('新建话题', '/new'),
|
|
41
|
+
].filter((item) => Boolean(item));
|
|
42
|
+
const duplicates = new Map();
|
|
43
|
+
const dateFormat = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false });
|
|
44
|
+
for (const session of page.items) {
|
|
45
|
+
const title = plainLabel(session.title, 30);
|
|
46
|
+
duplicates.set(title, (duplicates.get(title) ?? 0) + 1);
|
|
47
|
+
}
|
|
48
|
+
const count = page.view.query ? `找到 ${page.total} 个话题 · 共 ${sessions.length} 个` : `共 ${sessions.length} 个话题 · 最近活跃优先`;
|
|
49
|
+
return { title: '以前的对话', summary: page.view.query ? `搜索历史话题:${page.total} 个结果,可选择话题继续` : `浏览 ${sessions.length} 个历史话题,选择后打开继续聊天`,
|
|
50
|
+
subtitle: count, ...(options.status ? { status: options.status, tone: options.status.tone } : {}),
|
|
51
|
+
...(search ? { search: { query: page.view.query, value: search.value, disabled: options.busy } } : {}),
|
|
52
|
+
...(pick && page.items.length ? { select: { placeholder: '选择一个话题继续聊天', value: pick.value, disabled: options.busy,
|
|
53
|
+
...(selectedVisible ? { initialOption: selected.id } : {}),
|
|
54
|
+
options: page.items.map(session => {
|
|
55
|
+
const title = plainLabel(session.title, 30);
|
|
56
|
+
const date = dateFormat.format(session.updatedAt);
|
|
57
|
+
return { value: session.id, label: `${title}${Array.from(session.title).length > 30 ? '…' : ''} · ${date}${duplicates.get(title) > 1 ? ` · ${session.id.slice(-4)}` : ''}` };
|
|
58
|
+
}) } } : {}),
|
|
59
|
+
sections: !page.total ? [{ text: sessions.length ? '没有找到匹配的话题。换个关键词,或清空搜索查看全部。' : '还没有历史话题。直接发消息,就会开始新的对话。' }]
|
|
60
|
+
: !pick ? page.items.map(session => ({ title: plainLabel(session.title, 60), text: `/resume ${session.id}` }))
|
|
61
|
+
: selected ? [{ ...(!selectedVisible ? { title: plainLabel(selected.title, 60) } : {}), text: selected.link ? `[打开话题继续](${selected.link})`
|
|
62
|
+
: selected.id === currentId ? '当前正在聊的话题' : '话题入口暂不可用,请在聊天中找到对应话题后继续。' }] : [],
|
|
63
|
+
actions, actionColumns: actions.length === 1 ? 1 : actions.length === 2 ? 2 : 3,
|
|
64
|
+
note: `第 ${page.view.page + 1} / ${page.pages} 页${page.view.query ? ' · 清空搜索可查看全部话题' : ''}${!pick ? '\n文字操作:/sessions detail;/resume <ID>' : ''}`, };
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=session-controls.js.map
|
package/dist/session-store.d.ts
CHANGED
package/dist/session-store.js
CHANGED
|
@@ -30,7 +30,8 @@ function validKey(value) {
|
|
|
30
30
|
&& value === value.trim() && !CONTROLS.test(value);
|
|
31
31
|
}
|
|
32
32
|
function validateContext(value) {
|
|
33
|
-
if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey)
|
|
33
|
+
if (!value || !validKey(value.ownerKey) || !validKey(value.conversationKey)
|
|
34
|
+
|| (value.sessionId !== undefined && (typeof value.sessionId !== 'string' || !SESSION_ID.test(value.sessionId))))
|
|
34
35
|
throw new SessionStoreError('INVALID_INPUT');
|
|
35
36
|
}
|
|
36
37
|
function titleText(value) {
|
|
@@ -175,7 +176,7 @@ export class SessionStore {
|
|
|
175
176
|
}
|
|
176
177
|
mutate(context, update) {
|
|
177
178
|
// Snapshot caller-owned identity before an earlier queued mutation awaits I/O.
|
|
178
|
-
const identity = {
|
|
179
|
+
const identity = { ...context };
|
|
179
180
|
const job = this.tail.then(async () => {
|
|
180
181
|
this.assertAvailable();
|
|
181
182
|
validateContext(identity);
|
|
@@ -196,7 +197,9 @@ export class SessionStore {
|
|
|
196
197
|
await this.tail;
|
|
197
198
|
this.assertAvailable();
|
|
198
199
|
const entry = this.locate(this.document, identity);
|
|
199
|
-
const current = entry?.sessions.find(session => session.id === entry.activeId);
|
|
200
|
+
const current = entry?.sessions.find(session => session.id === (identity.sessionId ?? entry.activeId));
|
|
201
|
+
if (identity.sessionId && !current)
|
|
202
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
200
203
|
return current && { ...current };
|
|
201
204
|
}
|
|
202
205
|
async list(context) {
|
|
@@ -210,6 +213,8 @@ export class SessionStore {
|
|
|
210
213
|
add(context, nativeId, title = '新会话') {
|
|
211
214
|
const identity = { ...context };
|
|
212
215
|
return this.mutate(identity, draft => {
|
|
216
|
+
if (identity.sessionId !== undefined)
|
|
217
|
+
throw new SessionStoreError('INVALID_INPUT');
|
|
213
218
|
if (!NATIVE_ID.test(nativeId))
|
|
214
219
|
throw new SessionStoreError('INVALID_INPUT');
|
|
215
220
|
const safeTitle = titleText(title);
|
|
@@ -228,7 +233,7 @@ export class SessionStore {
|
|
|
228
233
|
const time = this.nextTimestamp(draft);
|
|
229
234
|
const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time };
|
|
230
235
|
if (!entry) {
|
|
231
|
-
entry = {
|
|
236
|
+
entry = { ownerKey: identity.ownerKey, conversationKey: identity.conversationKey, activeId: null, sessions: [] };
|
|
232
237
|
draft.contexts.push(entry);
|
|
233
238
|
}
|
|
234
239
|
entry.sessions.push(session);
|
|
@@ -275,8 +280,8 @@ export class SessionStore {
|
|
|
275
280
|
const identity = { ...context };
|
|
276
281
|
return this.mutate(identity, draft => {
|
|
277
282
|
const entry = this.locate(draft, identity);
|
|
278
|
-
const changed = Boolean(entry?.activeId);
|
|
279
|
-
if (entry)
|
|
283
|
+
const changed = Boolean(entry?.activeId && (!identity.sessionId || identity.sessionId === entry.activeId));
|
|
284
|
+
if (entry && changed)
|
|
280
285
|
entry.activeId = null;
|
|
281
286
|
return { value: undefined, changed };
|
|
282
287
|
});
|
|
@@ -286,7 +291,7 @@ export class SessionStore {
|
|
|
286
291
|
return this.mutate(identity, draft => {
|
|
287
292
|
const safeTitle = titleText(title);
|
|
288
293
|
const entry = this.locate(draft, identity);
|
|
289
|
-
const session = entry?.sessions.find(candidate => candidate.id === entry.activeId);
|
|
294
|
+
const session = entry?.sessions.find(candidate => candidate.id === (identity.sessionId ?? entry.activeId));
|
|
290
295
|
if (!session)
|
|
291
296
|
throw new SessionStoreError('NO_CURRENT');
|
|
292
297
|
session.title = safeTitle;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The entry itself owns the native thread; it needs no separate navigation confirmation. */
|
|
2
|
+
export function topicEntryPanel(title, chatType) {
|
|
3
|
+
return { title, summary: `${title},在话题内继续对话`,
|
|
4
|
+
sections: [{ text: '在下方点「回复话题」,继续聊天。' }],
|
|
5
|
+
...(chatType === 'group' ? { note: '群聊中每条消息仍需 @机器人。' } : {}),
|
|
6
|
+
};
|
|
7
|
+
}
|
|
8
|
+
//# sourceMappingURL=topic-cards.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface TopicScope {
|
|
2
|
+
ownerKey: string;
|
|
3
|
+
chatKey: string;
|
|
4
|
+
}
|
|
5
|
+
export interface TopicReceipt {
|
|
6
|
+
threadId: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
link?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Keys come exclusively from verified events and confirmed bot receipts. No message text is stored. */
|
|
11
|
+
export interface TopicBinding extends TopicScope, TopicReceipt {
|
|
12
|
+
laneKey: string;
|
|
13
|
+
conversationKey: string;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
groupAccessKey?: string;
|
|
16
|
+
}
|
|
17
|
+
type ErrorCode = 'INVALID_INPUT' | 'CORRUPT' | 'CONFLICT' | 'CAPACITY' | 'WRITE_FAILED' | 'UNAVAILABLE';
|
|
18
|
+
export declare class TopicStoreError extends Error {
|
|
19
|
+
readonly code: ErrorCode;
|
|
20
|
+
constructor(code: ErrorCode);
|
|
21
|
+
}
|
|
22
|
+
/** Use only links returned by Lark's read API/CLI enrichment; never synthesize a deep link. */
|
|
23
|
+
export declare function topicLink(value: unknown): string | undefined;
|
|
24
|
+
/** The state lock owns this store. Ambiguous publication poisons it until restart. */
|
|
25
|
+
export declare class TopicStore {
|
|
26
|
+
private readonly directory;
|
|
27
|
+
private entries;
|
|
28
|
+
private tail;
|
|
29
|
+
private unavailable;
|
|
30
|
+
private constructor();
|
|
31
|
+
static open(directory: string): Promise<TopicStore>;
|
|
32
|
+
private validate;
|
|
33
|
+
private assertAvailable;
|
|
34
|
+
list(scope: TopicScope): Promise<TopicBinding[]>;
|
|
35
|
+
get(scope: TopicScope, threadId: string): Promise<TopicBinding | undefined>;
|
|
36
|
+
occupied(chatKey: string, threadId: string): Promise<boolean>;
|
|
37
|
+
record(binding: TopicBinding): Promise<TopicBinding>;
|
|
38
|
+
private persist;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -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
|