dsh-lark-agent 0.3.3 → 0.5.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 +10 -5
- package/dist/bot-service.js +4 -2
- package/dist/bridge.d.ts +27 -1
- package/dist/bridge.js +333 -66
- package/dist/card-actions.js +3 -1
- package/dist/client/AgentSettings.d.ts +12 -0
- package/dist/client/ConnectionCard.d.ts +5 -1
- package/dist/client/ManagerPanel.d.ts +3 -0
- package/dist/client/api.d.ts +14 -2
- package/dist/client/index.d.ts +1 -1
- package/dist/client/manager-styles.d.ts +2 -0
- package/dist/client/use-connection.d.ts +2 -2
- package/dist/client.cjs +727 -258
- package/dist/config.d.ts +4 -0
- package/dist/config.js +8 -0
- package/dist/dsh-runtime.d.ts +3 -0
- package/dist/dsh-runtime.js +46 -4
- package/dist/lark-cli.d.ts +20 -1
- package/dist/lark-cli.js +168 -2
- package/dist/media-input.d.ts +28 -0
- package/dist/media-input.js +127 -0
- package/dist/media-scopes.d.ts +5 -0
- package/dist/media-scopes.js +6 -0
- package/dist/message.d.ts +7 -0
- package/dist/message.js +27 -3
- package/dist/onboarding/agents.d.ts +9 -0
- package/dist/onboarding/agents.js +100 -0
- package/dist/onboarding/controller.d.ts +15 -2
- package/dist/onboarding/controller.js +155 -14
- package/dist/onboarding/host.d.ts +14 -2
- package/dist/onboarding/host.js +49 -32
- package/dist/onboarding/lark-platform.d.ts +6 -0
- package/dist/onboarding/lark-platform.js +15 -4
- package/dist/onboarding/manager.d.ts +32 -0
- package/dist/onboarding/manager.js +129 -0
- package/dist/onboarding/model-check.d.ts +5 -0
- package/dist/onboarding/model-check.js +51 -0
- package/dist/onboarding/protocol.d.ts +51 -1
- package/dist/onboarding/settings-store.d.ts +11 -0
- package/dist/onboarding/settings-store.js +67 -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 +7 -1
- package/dist/session-store.js +44 -11
- 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
package/dist/replies.js
CHANGED
|
@@ -32,13 +32,21 @@ function externalUrl(value) {
|
|
|
32
32
|
return false;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
|
|
35
|
+
function escapeProse(text) {
|
|
36
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
37
|
+
}
|
|
38
|
+
/** Lark takes Markdown hrefs literally: HTML escaping '&' changes their query keys. */
|
|
39
|
+
function linkDestination(url) {
|
|
40
|
+
return url.replace(/&(?=(?:#(?:x[0-9a-f]+|[0-9]+)|[a-z][a-z0-9]*;))/gi, '%26')
|
|
41
|
+
.replace(/["'`]/g, character => '%' + character.charCodeAt(0).toString(16).toUpperCase());
|
|
42
|
+
}
|
|
43
|
+
/** Escape prose and disable unsupported links while retaining safe HTTP(S) destinations. */
|
|
36
44
|
function safeLinks(text) {
|
|
37
45
|
let result = '', cursor = 0;
|
|
38
46
|
while (cursor < text.length) {
|
|
39
47
|
const start = text.indexOf('](', cursor);
|
|
40
48
|
if (start < 0)
|
|
41
|
-
return result + text.slice(cursor);
|
|
49
|
+
return result + escapeProse(text.slice(cursor));
|
|
42
50
|
let end = start + 2, depth = 1;
|
|
43
51
|
for (; end < text.length; end++) {
|
|
44
52
|
if (text[end] === '\\') {
|
|
@@ -51,12 +59,13 @@ function safeLinks(text) {
|
|
|
51
59
|
break;
|
|
52
60
|
}
|
|
53
61
|
if (end >= text.length)
|
|
54
|
-
return result + text.slice(cursor);
|
|
62
|
+
return result + escapeProse(text.slice(cursor));
|
|
55
63
|
const destination = text.slice(start + 2, end).trim();
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
const parts = destination.match(/^(<[^>]+>|\S+?)(\s+["'][\s\S]*["'])?$/);
|
|
65
|
+
const url = parts?.[1]?.replace(/^<|>$/g, '') ?? '';
|
|
66
|
+
result += escapeProse(text.slice(cursor, start)) + (externalUrl(url)
|
|
67
|
+
? `](${linkDestination(url)}${escapeProse(parts?.[2] ?? '')})`
|
|
68
|
+
: `](${escapeProse(destination)})`);
|
|
60
69
|
cursor = end + 1;
|
|
61
70
|
}
|
|
62
71
|
return result;
|
|
@@ -65,10 +74,9 @@ function prose(text) {
|
|
|
65
74
|
let output = text
|
|
66
75
|
.replace(/<(https?:\/\/[^<>\s]+)>/gi, (_match, url) => externalUrl(url) ? `[${url}](${url})` : url)
|
|
67
76
|
.replace(/!\[/g, '[');
|
|
68
|
-
output = safeLinks(output);
|
|
69
77
|
output = output.replace(/^( {0,3}\[[^\]\n]+\]):\s*(<?\S+>?)(.*)$/gm, (line, label, destination) => externalUrl(destination.replace(/^<|>$/g, '')) ? line : line.replace(`${label}:`, `${label}:`));
|
|
70
|
-
//
|
|
71
|
-
output = output
|
|
78
|
+
// Prose still escapes entities so encoded native tags cannot become active.
|
|
79
|
+
output = safeLinks(output);
|
|
72
80
|
return output.replace(/^[ \t]*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?[ \t]*$/gm, line => {
|
|
73
81
|
const cells = line.trim().replace(/^\||\|$/g, '').split('|');
|
|
74
82
|
return `| ${cells.map(() => ':---').join(' | ')} |`;
|
|
@@ -169,7 +177,8 @@ export function renderPanel(panel) {
|
|
|
169
177
|
const metrics = (panel.metrics ?? []).filter(metric => metric.label.trim() || metric.value.trim());
|
|
170
178
|
const fallbackText = safePlainText([panel.title, panel.subtitle, panel.status?.text,
|
|
171
179
|
...metrics.map(metric => [`${metric.label}:${metric.value}`, metric.note].filter(Boolean).join('\n')),
|
|
172
|
-
...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')),
|
|
180
|
+
...panel.sections.map(section => [section.title, section.text].filter(Boolean).join('\n')),
|
|
181
|
+
...(panel.select?.options.map(option => `${option.label}\n/resume ${option.value}`) ?? []), panel.note]
|
|
173
182
|
.filter(Boolean).join('\n\n'));
|
|
174
183
|
const elements = [];
|
|
175
184
|
const actions = panel.actions ?? [];
|
|
@@ -187,6 +196,23 @@ export function renderPanel(panel) {
|
|
|
187
196
|
], { background_style: 'grey', padding: '10px 12px 10px 12px' })),
|
|
188
197
|
});
|
|
189
198
|
}
|
|
199
|
+
if (panel.search)
|
|
200
|
+
elements.push({ tag: 'form', name: 'dsh_sessions_search_form', direction: 'vertical',
|
|
201
|
+
elements: [{ tag: 'column_set', flex_mode: 'none', horizontal_spacing: '8px', margin: '0px', columns: [column([
|
|
202
|
+
{ tag: 'input', name: 'query', input_type: 'text', placeholder: title('搜索话题标题'), default_value: panel.search.query,
|
|
203
|
+
max_length: 80, width: 'fill', disabled: Boolean(panel.search.disabled) },
|
|
204
|
+
]), column([
|
|
205
|
+
{ tag: 'button', name: 'dsh_sessions_search', text: title('搜索'), type: 'default', width: 'default',
|
|
206
|
+
action_type: 'form_submit', form_action_type: 'submit', form_name: 'dsh_sessions_search_form',
|
|
207
|
+
disabled: Boolean(panel.search.disabled), behaviors: [{ type: 'callback', value: { ...panel.search.value } }] },
|
|
208
|
+
], { width: 'auto' })] }] });
|
|
209
|
+
if (panel.select)
|
|
210
|
+
elements.push({ tag: 'select_static', name: 'dsh_session_picker', width: 'fill',
|
|
211
|
+
placeholder: title(panel.select.placeholder), disabled: Boolean(panel.select.disabled),
|
|
212
|
+
...(panel.select.initialOption ? { initial_option: panel.select.initialOption } : {}),
|
|
213
|
+
options: panel.select.options.map(option => ({ text: title(safePlainText(option.label)), value: option.value })),
|
|
214
|
+
behaviors: [{ type: 'callback', value: { ...panel.select.value } }],
|
|
215
|
+
});
|
|
190
216
|
for (const section of panel.sections) {
|
|
191
217
|
if (!section.text.trim() && !section.title?.trim())
|
|
192
218
|
continue;
|
|
@@ -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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface SessionContext {
|
|
2
2
|
ownerKey: string;
|
|
3
3
|
conversationKey: string;
|
|
4
|
+
/** A topic may point at one historical session without changing its siblings' active selection. */
|
|
5
|
+
sessionId?: string;
|
|
4
6
|
}
|
|
5
7
|
export interface SavedSession {
|
|
6
8
|
id: string;
|
|
@@ -8,6 +10,8 @@ export interface SavedSession {
|
|
|
8
10
|
title: string;
|
|
9
11
|
createdAt: number;
|
|
10
12
|
updatedAt: number;
|
|
13
|
+
/** Workspace captured when this native session was created. */
|
|
14
|
+
cwd?: string;
|
|
11
15
|
}
|
|
12
16
|
export type SessionStoreErrorCode = 'INVALID_INPUT' | 'NOT_FOUND' | 'NO_CURRENT' | 'CONFLICT' | 'CAPACITY' | 'CORRUPT' | 'WRITE_FAILED' | 'UNAVAILABLE';
|
|
13
17
|
/** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
|
|
@@ -33,7 +37,9 @@ export declare class SessionStore {
|
|
|
33
37
|
private mutate;
|
|
34
38
|
current(context: SessionContext): Promise<SavedSession | undefined>;
|
|
35
39
|
list(context: SessionContext): Promise<SavedSession[]>;
|
|
36
|
-
add(context: SessionContext, nativeId: string, title?: string): Promise<SavedSession>;
|
|
40
|
+
add(context: SessionContext, nativeId: string, title?: string, cwd?: string): Promise<SavedSession>;
|
|
41
|
+
/** Under the bot's state lock, capture legacy rows before its workspace changes. */
|
|
42
|
+
pinWorkspace(cwd: string): Promise<void>;
|
|
37
43
|
activate(context: SessionContext, id: string): Promise<SavedSession>;
|
|
38
44
|
/** Replace only a confirmed native binding, preserving the user's stable topic. */
|
|
39
45
|
rebindNative(context: SessionContext, id: string, expectedNativeId: string, newNativeId: string): Promise<SavedSession>;
|
package/dist/session-store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, isAbsolute } from 'node:path';
|
|
4
4
|
/** Messages contain no titles, user identifiers, native session IDs, or filesystem details. */
|
|
5
5
|
export class SessionStoreError extends Error {
|
|
6
6
|
code;
|
|
@@ -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) {
|
|
@@ -79,11 +80,14 @@ function parseDocument(value) {
|
|
|
79
80
|
|| session.updatedAt < session.createdAt)
|
|
80
81
|
throw new Error();
|
|
81
82
|
const title = titleText(session.title);
|
|
83
|
+
if (session.cwd !== undefined && (typeof session.cwd !== 'string' || !isAbsolute(session.cwd) || CONTROLS.test(session.cwd)))
|
|
84
|
+
throw new Error();
|
|
82
85
|
if (title !== session.title || sessionIds.has(session.id) || nativeIds.has(session.nativeId.toLowerCase()))
|
|
83
86
|
throw new Error();
|
|
84
87
|
sessionIds.add(session.id);
|
|
85
88
|
nativeIds.add(session.nativeId.toLowerCase());
|
|
86
|
-
return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt
|
|
89
|
+
return { id: session.id, nativeId: session.nativeId, title, createdAt: session.createdAt, updatedAt: session.updatedAt,
|
|
90
|
+
...(session.cwd === undefined ? {} : { cwd: session.cwd }) };
|
|
87
91
|
});
|
|
88
92
|
if (entry.activeId !== null && (typeof entry.activeId !== 'string' || !sessions.some(session => session.id === entry.activeId)))
|
|
89
93
|
throw new Error();
|
|
@@ -175,7 +179,7 @@ export class SessionStore {
|
|
|
175
179
|
}
|
|
176
180
|
mutate(context, update) {
|
|
177
181
|
// Snapshot caller-owned identity before an earlier queued mutation awaits I/O.
|
|
178
|
-
const identity = {
|
|
182
|
+
const identity = { ...context };
|
|
179
183
|
const job = this.tail.then(async () => {
|
|
180
184
|
this.assertAvailable();
|
|
181
185
|
validateContext(identity);
|
|
@@ -196,7 +200,9 @@ export class SessionStore {
|
|
|
196
200
|
await this.tail;
|
|
197
201
|
this.assertAvailable();
|
|
198
202
|
const entry = this.locate(this.document, identity);
|
|
199
|
-
const current = entry?.sessions.find(session => session.id === entry.activeId);
|
|
203
|
+
const current = entry?.sessions.find(session => session.id === (identity.sessionId ?? entry.activeId));
|
|
204
|
+
if (identity.sessionId && !current)
|
|
205
|
+
throw new SessionStoreError('NOT_FOUND');
|
|
200
206
|
return current && { ...current };
|
|
201
207
|
}
|
|
202
208
|
async list(context) {
|
|
@@ -207,11 +213,15 @@ export class SessionStore {
|
|
|
207
213
|
return (this.locate(this.document, identity)?.sessions ?? [])
|
|
208
214
|
.map(session => ({ ...session })).sort((left, right) => right.updatedAt - left.updatedAt || left.id.localeCompare(right.id));
|
|
209
215
|
}
|
|
210
|
-
add(context, nativeId, title = '新会话') {
|
|
216
|
+
add(context, nativeId, title = '新会话', cwd) {
|
|
211
217
|
const identity = { ...context };
|
|
212
218
|
return this.mutate(identity, draft => {
|
|
219
|
+
if (identity.sessionId !== undefined)
|
|
220
|
+
throw new SessionStoreError('INVALID_INPUT');
|
|
213
221
|
if (!NATIVE_ID.test(nativeId))
|
|
214
222
|
throw new SessionStoreError('INVALID_INPUT');
|
|
223
|
+
if (cwd !== undefined && (!isAbsolute(cwd) || CONTROLS.test(cwd)))
|
|
224
|
+
throw new SessionStoreError('INVALID_INPUT');
|
|
215
225
|
const safeTitle = titleText(title);
|
|
216
226
|
let entry = this.locate(draft, identity);
|
|
217
227
|
const owned = draft.contexts.filter(candidate => candidate.ownerKey === identity.ownerKey);
|
|
@@ -226,9 +236,9 @@ export class SessionStore {
|
|
|
226
236
|
id = randomBytes(6).toString('hex');
|
|
227
237
|
} while (usedIds.has(id));
|
|
228
238
|
const time = this.nextTimestamp(draft);
|
|
229
|
-
const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time };
|
|
239
|
+
const session = { id, nativeId, title: safeTitle, createdAt: time, updatedAt: time, ...(cwd === undefined ? {} : { cwd }) };
|
|
230
240
|
if (!entry) {
|
|
231
|
-
entry = {
|
|
241
|
+
entry = { ownerKey: identity.ownerKey, conversationKey: identity.conversationKey, activeId: null, sessions: [] };
|
|
232
242
|
draft.contexts.push(entry);
|
|
233
243
|
}
|
|
234
244
|
entry.sessions.push(session);
|
|
@@ -236,6 +246,29 @@ export class SessionStore {
|
|
|
236
246
|
return { value: session, changed: true };
|
|
237
247
|
});
|
|
238
248
|
}
|
|
249
|
+
/** Under the bot's state lock, capture legacy rows before its workspace changes. */
|
|
250
|
+
pinWorkspace(cwd) {
|
|
251
|
+
const job = this.tail.then(async () => {
|
|
252
|
+
this.assertAvailable();
|
|
253
|
+
if (!isAbsolute(cwd) || CONTROLS.test(cwd))
|
|
254
|
+
throw new SessionStoreError('INVALID_INPUT');
|
|
255
|
+
const draft = structuredClone(this.document);
|
|
256
|
+
let changed = false;
|
|
257
|
+
for (const context of draft.contexts)
|
|
258
|
+
for (const session of context.sessions) {
|
|
259
|
+
if (session.cwd === undefined) {
|
|
260
|
+
session.cwd = cwd;
|
|
261
|
+
changed = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (changed) {
|
|
265
|
+
await this.persist(draft);
|
|
266
|
+
this.document = draft;
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
this.tail = job.catch(() => undefined);
|
|
270
|
+
return job;
|
|
271
|
+
}
|
|
239
272
|
activate(context, id) {
|
|
240
273
|
const identity = { ...context };
|
|
241
274
|
return this.mutate(identity, draft => {
|
|
@@ -275,8 +308,8 @@ export class SessionStore {
|
|
|
275
308
|
const identity = { ...context };
|
|
276
309
|
return this.mutate(identity, draft => {
|
|
277
310
|
const entry = this.locate(draft, identity);
|
|
278
|
-
const changed = Boolean(entry?.activeId);
|
|
279
|
-
if (entry)
|
|
311
|
+
const changed = Boolean(entry?.activeId && (!identity.sessionId || identity.sessionId === entry.activeId));
|
|
312
|
+
if (entry && changed)
|
|
280
313
|
entry.activeId = null;
|
|
281
314
|
return { value: undefined, changed };
|
|
282
315
|
});
|
|
@@ -286,7 +319,7 @@ export class SessionStore {
|
|
|
286
319
|
return this.mutate(identity, draft => {
|
|
287
320
|
const safeTitle = titleText(title);
|
|
288
321
|
const entry = this.locate(draft, identity);
|
|
289
|
-
const session = entry?.sessions.find(candidate => candidate.id === entry.activeId);
|
|
322
|
+
const session = entry?.sessions.find(candidate => candidate.id === (identity.sessionId ?? entry.activeId));
|
|
290
323
|
if (!session)
|
|
291
324
|
throw new SessionStoreError('NO_CURRENT');
|
|
292
325
|
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
|