@controlflow-ai/daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +360 -0
- package/bin/console.js +2 -0
- package/bin/daemon.js +2 -0
- package/bin/pal.js +2 -0
- package/bin/server.js +2 -0
- package/package.json +31 -0
- package/src/agent-runtime.ts +285 -0
- package/src/app.ts +745 -0
- package/src/args.ts +54 -0
- package/src/artifacts.ts +85 -0
- package/src/cli.ts +284 -0
- package/src/client.ts +310 -0
- package/src/coco.ts +52 -0
- package/src/codex.ts +41 -0
- package/src/coding-agent-runtime.ts +20 -0
- package/src/config.ts +106 -0
- package/src/console.ts +349 -0
- package/src/daemon-client.ts +91 -0
- package/src/daemon.ts +580 -0
- package/src/db.ts +2830 -0
- package/src/failure-message.ts +17 -0
- package/src/format.ts +13 -0
- package/src/http.ts +55 -0
- package/src/lark/agent-runtime.ts +142 -0
- package/src/lark/cli.ts +549 -0
- package/src/lark/credentials.ts +105 -0
- package/src/lark/daemon-integration.ts +108 -0
- package/src/lark/dispatcher.ts +374 -0
- package/src/lark/event-router.ts +329 -0
- package/src/lark/inbound-events.ts +131 -0
- package/src/lark/server-integration.ts +445 -0
- package/src/lark/setup.ts +326 -0
- package/src/lark/ws-daemon.ts +224 -0
- package/src/lark-fixture-diagnostics.ts +56 -0
- package/src/lark-fixture.ts +277 -0
- package/src/local-api.ts +155 -0
- package/src/local-auth.ts +45 -0
- package/src/migrations/001_initial.ts +61 -0
- package/src/migrations/002_daemon_deliveries.ts +52 -0
- package/src/migrations/003_sessions_runs.ts +49 -0
- package/src/migrations/004_message_idempotency.ts +21 -0
- package/src/migrations/005_artifacts.ts +24 -0
- package/src/migrations/006_lark_channel_foundation.ts +119 -0
- package/src/migrations/007_agents_a0.ts +17 -0
- package/src/migrations/008_b0_chat_history.ts +31 -0
- package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
- package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
- package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
- package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
- package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
- package/src/migrations/014_agents_runtime.ts +10 -0
- package/src/migrations/015_agent_runtime_sessions.ts +15 -0
- package/src/migrations/016_room_participants.ts +27 -0
- package/src/migrations/017_unified_room_delivery.ts +203 -0
- package/src/migrations/018_room_display_names.ts +36 -0
- package/src/migrations/019_computer_connections.ts +63 -0
- package/src/migrations/020_computer_agent_assignments.ts +20 -0
- package/src/migrations/021_provider_identity_bindings.ts +32 -0
- package/src/migrations.ts +85 -0
- package/src/neeko.ts +23 -0
- package/src/provider-identity.ts +40 -0
- package/src/runtime-registry.ts +41 -0
- package/src/server-auth.ts +13 -0
- package/src/server.ts +63 -0
- package/src/token-file.ts +57 -0
- package/src/types.ts +408 -0
- package/src/web.ts +565 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import type { LarkCredential } from './credentials.js';
|
|
3
|
+
import { loadLarkCredentials, saveLarkCredentials, upsertCredential } from './credentials.js';
|
|
4
|
+
|
|
5
|
+
export interface CredentialValidationOk {
|
|
6
|
+
ok: true;
|
|
7
|
+
tokenExpiresIn: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CredentialValidationFail {
|
|
11
|
+
ok: false;
|
|
12
|
+
error: 'invalid_credentials' | 'network' | 'unknown';
|
|
13
|
+
message: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type CredentialValidationResult = CredentialValidationOk | CredentialValidationFail;
|
|
17
|
+
|
|
18
|
+
export interface LarkBotInfoOk {
|
|
19
|
+
ok: true;
|
|
20
|
+
openId: string;
|
|
21
|
+
appName?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LarkBotInfoFail {
|
|
25
|
+
ok: false;
|
|
26
|
+
error: 'invalid_credentials' | 'network' | 'unknown';
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LarkBotInfoResult = LarkBotInfoOk | LarkBotInfoFail;
|
|
31
|
+
|
|
32
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
33
|
+
|
|
34
|
+
export interface PersistLarkCredentialInput {
|
|
35
|
+
appId: string;
|
|
36
|
+
appSecret: string;
|
|
37
|
+
label?: string;
|
|
38
|
+
agent?: string;
|
|
39
|
+
botOpenId?: string;
|
|
40
|
+
configPath: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PersistLarkCredentialResult {
|
|
44
|
+
path: string;
|
|
45
|
+
appId: string;
|
|
46
|
+
agent: string | null;
|
|
47
|
+
botOpenId: string | null;
|
|
48
|
+
replaced: boolean;
|
|
49
|
+
bots: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LarkSetupAgentOption {
|
|
53
|
+
agent_key: string;
|
|
54
|
+
display_name: string;
|
|
55
|
+
runtime?: string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface InteractiveSetupOptions {
|
|
59
|
+
configPath: string;
|
|
60
|
+
log: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
61
|
+
ask?: (question: string) => Promise<string>;
|
|
62
|
+
listAgents?: () => Promise<LarkSetupAgentOption[]>;
|
|
63
|
+
validateCredentials?: typeof validateLarkCredentials;
|
|
64
|
+
resolveBotInfo?: typeof resolveLarkBotInfo;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function clean(value: string | undefined): string | undefined {
|
|
68
|
+
const trimmed = value?.trim();
|
|
69
|
+
return trimmed ? trimmed : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function chooseAgent(options: InteractiveSetupOptions, ask: (question: string) => Promise<string>): Promise<string | null> {
|
|
73
|
+
if (!options.listAgents) {
|
|
74
|
+
return clean(await ask('Bind to agent key [codex]: ')) ?? 'codex';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let agents: LarkSetupAgentOption[];
|
|
78
|
+
try {
|
|
79
|
+
agents = await options.listAgents();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
options.log.error(`Could not load agent list: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
options.log.error('Set up an agent first, then rerun lark setup.');
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (agents.length === 0) {
|
|
87
|
+
options.log.error('No agents found. Set up an agent first with "bun run console -- agents onboard".');
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
options.log.log('Choose the Pal agent this Feishu bot should deliver to:');
|
|
92
|
+
agents.forEach((agent, index) => {
|
|
93
|
+
options.log.log(` ${index + 1}. ${agent.display_name} (${agent.agent_key}) runtime=${agent.runtime ?? '-'}`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
while (true) {
|
|
97
|
+
const answer = clean(await ask(agents.length === 1 ? 'Agent [1]: ' : 'Agent: ')) ?? (agents.length === 1 ? '1' : undefined);
|
|
98
|
+
const selected = answer ? agents[Number(answer) - 1] ?? agents.find((agent) => agent.agent_key === answer) : undefined;
|
|
99
|
+
if (selected) return selected.agent_key;
|
|
100
|
+
options.log.error('Choose a listed number or agent key.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
export async function validateLarkCredentials(
|
|
106
|
+
appId: string,
|
|
107
|
+
appSecret: string,
|
|
108
|
+
options: { budgetMs?: number; fetchImpl?: FetchLike } = {},
|
|
109
|
+
): Promise<CredentialValidationResult> {
|
|
110
|
+
const budgetMs = options.budgetMs ?? 10_000;
|
|
111
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), budgetMs);
|
|
114
|
+
let response: Response;
|
|
115
|
+
let body: { code?: unknown; msg?: unknown; expire?: unknown; tenant_access_token?: unknown };
|
|
116
|
+
try {
|
|
117
|
+
response = await fetchImpl('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
118
|
+
method: 'POST',
|
|
119
|
+
headers: { 'content-type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
});
|
|
123
|
+
body = await response.json();
|
|
124
|
+
} catch (err) {
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
const aborted = controller.signal.aborted || (err instanceof Error && err.name === 'AbortError');
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: 'network',
|
|
130
|
+
message: aborted ? `request timed out after ${budgetMs}ms` : `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
|
|
135
|
+
if (body.code === 0 && typeof body.tenant_access_token === 'string') {
|
|
136
|
+
return { ok: true, tokenExpiresIn: typeof body.expire === 'number' ? body.expire : 7200 };
|
|
137
|
+
}
|
|
138
|
+
if (body.code === 10003 || body.code === 10012 || body.code === 99991663) {
|
|
139
|
+
return { ok: false, error: 'invalid_credentials', message: `invalid credentials (code=${body.code})` };
|
|
140
|
+
}
|
|
141
|
+
return { ok: false, error: 'unknown', message: `code=${String(body.code ?? '?')} msg=${String(body.msg ?? '')}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchTenantAccessToken(
|
|
145
|
+
appId: string,
|
|
146
|
+
appSecret: string,
|
|
147
|
+
options: { budgetMs?: number; fetchImpl?: FetchLike } = {},
|
|
148
|
+
): Promise<{ ok: true; token: string; expire: number } | CredentialValidationFail> {
|
|
149
|
+
const budgetMs = options.budgetMs ?? 10_000;
|
|
150
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), budgetMs);
|
|
153
|
+
let response: Response;
|
|
154
|
+
let body: { code?: unknown; msg?: unknown; expire?: unknown; tenant_access_token?: unknown };
|
|
155
|
+
try {
|
|
156
|
+
response = await fetchImpl('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
headers: { 'content-type': 'application/json' },
|
|
159
|
+
body: JSON.stringify({ app_id: appId, app_secret: appSecret }),
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
});
|
|
162
|
+
body = await response.json();
|
|
163
|
+
} catch (err) {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
const aborted = controller.signal.aborted || (err instanceof Error && err.name === 'AbortError');
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
error: 'network',
|
|
169
|
+
message: aborted ? `request timed out after ${budgetMs}ms` : `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
|
|
174
|
+
if (body.code === 0 && typeof body.tenant_access_token === 'string') {
|
|
175
|
+
return { ok: true, token: body.tenant_access_token, expire: typeof body.expire === 'number' ? body.expire : 7200 };
|
|
176
|
+
}
|
|
177
|
+
if (body.code === 10003 || body.code === 10012 || body.code === 99991663) {
|
|
178
|
+
return { ok: false, error: 'invalid_credentials', message: `invalid credentials (code=${body.code})` };
|
|
179
|
+
}
|
|
180
|
+
return { ok: false, error: 'unknown', message: `code=${String(body.code ?? '?')} msg=${String(body.msg ?? '')}` };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function resolveLarkBotInfo(
|
|
184
|
+
appId: string,
|
|
185
|
+
appSecret: string,
|
|
186
|
+
options: { budgetMs?: number; fetchImpl?: FetchLike } = {},
|
|
187
|
+
): Promise<LarkBotInfoResult> {
|
|
188
|
+
const budgetMs = options.budgetMs ?? 10_000;
|
|
189
|
+
const token = await fetchTenantAccessToken(appId, appSecret, options);
|
|
190
|
+
if (!token.ok) return token;
|
|
191
|
+
|
|
192
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
193
|
+
const controller = new AbortController();
|
|
194
|
+
const timer = setTimeout(() => controller.abort(), budgetMs);
|
|
195
|
+
try {
|
|
196
|
+
const response = await fetchImpl('https://open.feishu.cn/open-apis/bot/v3/info', {
|
|
197
|
+
method: 'GET',
|
|
198
|
+
headers: { authorization: `Bearer ${token.token}` },
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
});
|
|
201
|
+
const body = await response.json() as { code?: unknown; msg?: unknown; bot?: { open_id?: unknown; app_name?: unknown } };
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
if (body.code === 0 && typeof body.bot?.open_id === 'string' && body.bot.open_id.trim()) {
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
openId: body.bot.open_id,
|
|
207
|
+
appName: typeof body.bot.app_name === 'string' ? body.bot.app_name : undefined,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
return { ok: false, error: 'unknown', message: `code=${String(body.code ?? '?')} msg=${String(body.msg ?? '')}` };
|
|
211
|
+
} catch (err) {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
const aborted = controller.signal.aborted || (err instanceof Error && err.name === 'AbortError');
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
error: 'network',
|
|
217
|
+
message: aborted ? `request timed out after ${budgetMs}ms` : `network error: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function persistLarkCredential(input: PersistLarkCredentialInput): PersistLarkCredentialResult {
|
|
223
|
+
const credential: LarkCredential = {
|
|
224
|
+
appId: input.appId,
|
|
225
|
+
appSecret: input.appSecret,
|
|
226
|
+
};
|
|
227
|
+
if (clean(input.label)) credential.label = clean(input.label);
|
|
228
|
+
if (clean(input.agent)) credential.agent = clean(input.agent);
|
|
229
|
+
if (clean(input.botOpenId)) credential.botOpenId = clean(input.botOpenId);
|
|
230
|
+
|
|
231
|
+
const store = loadLarkCredentials(input.configPath);
|
|
232
|
+
const { store: next, replaced } = upsertCredential(store, credential);
|
|
233
|
+
saveLarkCredentials(next, input.configPath);
|
|
234
|
+
return {
|
|
235
|
+
path: input.configPath,
|
|
236
|
+
appId: input.appId,
|
|
237
|
+
agent: credential.agent ?? null,
|
|
238
|
+
botOpenId: credential.botOpenId ?? null,
|
|
239
|
+
replaced,
|
|
240
|
+
bots: next.bots.length,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function formatLarkSetupNextSteps(result: PersistLarkCredentialResult): string {
|
|
245
|
+
return [
|
|
246
|
+
'',
|
|
247
|
+
'Lark bot saved.',
|
|
248
|
+
` Config: ${result.path}`,
|
|
249
|
+
` App ID: ${result.appId}`,
|
|
250
|
+
` Agent binding: ${result.agent ?? '(none)'}`,
|
|
251
|
+
` Bot open_id: ${result.botOpenId ?? '(not set)'}`,
|
|
252
|
+
'',
|
|
253
|
+
'Next steps:',
|
|
254
|
+
' 1. In Feishu Open Platform, enable bot capability and event subscriptions:',
|
|
255
|
+
' - im.message.receive_v1',
|
|
256
|
+
' - card.action.trigger',
|
|
257
|
+
' Use long connection / WebSocket event delivery.',
|
|
258
|
+
' 2. Grant the message, chat, contact, and reaction scopes required by Pal.',
|
|
259
|
+
' 3. Confirm the bound agent exists, has runtime=codex, and is assigned to one computer.',
|
|
260
|
+
' 4. Reload Lark integration or keep the default setup reload enabled.',
|
|
261
|
+
' 5. Verify with: bun run console -- lark list && bun run console -- lark events --limit 20',
|
|
262
|
+
'',
|
|
263
|
+
].join('\n');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export async function runInteractiveLarkSetup(options: InteractiveSetupOptions): Promise<PersistLarkCredentialResult | null> {
|
|
267
|
+
const closeable = options.ask
|
|
268
|
+
? null
|
|
269
|
+
: createInterface({ input: process.stdin, output: process.stdout });
|
|
270
|
+
const ask = options.ask ?? ((question: string) => new Promise<string>((resolve) => closeable!.question(question, resolve)));
|
|
271
|
+
try {
|
|
272
|
+
options.log.log('Pal Lark setup');
|
|
273
|
+
options.log.log(`Config path: ${options.configPath}`);
|
|
274
|
+
options.log.log('');
|
|
275
|
+
const agent = await chooseAgent(options, ask);
|
|
276
|
+
if (!agent) return null;
|
|
277
|
+
options.log.log('');
|
|
278
|
+
options.log.log('Paste an existing Feishu app credential. Pal will validate it before writing lark.json.');
|
|
279
|
+
const appId = clean(await ask('App ID (cli_xxx): '));
|
|
280
|
+
const appSecret = clean(await ask('App Secret: '));
|
|
281
|
+
if (!appId || !appSecret) {
|
|
282
|
+
options.log.error('App ID and App Secret are required; no config was written.');
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
const label = clean(await ask('Bot label / display name [optional]: '));
|
|
286
|
+
let botOpenId: string | undefined;
|
|
287
|
+
|
|
288
|
+
options.log.log('');
|
|
289
|
+
options.log.log('Validating credentials...');
|
|
290
|
+
const validate = options.validateCredentials ?? validateLarkCredentials;
|
|
291
|
+
const validation = await validate(appId, appSecret);
|
|
292
|
+
if (!validation.ok) {
|
|
293
|
+
options.log.error(`Credential validation failed (${validation.error}): ${validation.message}`);
|
|
294
|
+
options.log.error('No config was written.');
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
options.log.log('Credentials validated.');
|
|
298
|
+
options.log.log('Resolving bot open_id...');
|
|
299
|
+
const resolveBotInfo = options.resolveBotInfo ?? resolveLarkBotInfo;
|
|
300
|
+
const botInfo = await resolveBotInfo(appId, appSecret);
|
|
301
|
+
if (botInfo.ok) {
|
|
302
|
+
botOpenId = botInfo.openId;
|
|
303
|
+
options.log.log(`Bot open_id resolved${botInfo.appName ? ` for ${botInfo.appName}` : ''}.`);
|
|
304
|
+
} else {
|
|
305
|
+
options.log.error(`Bot open_id lookup failed (${botInfo.error}): ${botInfo.message}`);
|
|
306
|
+
options.log.error('No config was written.');
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const result = persistLarkCredential({
|
|
311
|
+
appId,
|
|
312
|
+
appSecret,
|
|
313
|
+
label,
|
|
314
|
+
botOpenId,
|
|
315
|
+
agent,
|
|
316
|
+
configPath: options.configPath,
|
|
317
|
+
});
|
|
318
|
+
if (result.replaced) {
|
|
319
|
+
options.log.warn(`[lark setup] overwrote existing credential for appId=${appId} in ${options.configPath}`);
|
|
320
|
+
}
|
|
321
|
+
options.log.log(formatLarkSetupNextSteps(result));
|
|
322
|
+
return result;
|
|
323
|
+
} finally {
|
|
324
|
+
closeable?.close();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as Lark from '@larksuiteoapi/node-sdk';
|
|
2
|
+
import type { Database } from 'bun:sqlite';
|
|
3
|
+
import { storeInboundEvent, type StoreInboundEventResult } from './inbound-events.js';
|
|
4
|
+
|
|
5
|
+
export type LarkEventCallback = (event: {
|
|
6
|
+
appId: string;
|
|
7
|
+
envelope: 'im.message.receive_v1' | 'card.action.trigger' | string;
|
|
8
|
+
data: unknown;
|
|
9
|
+
rawBody: string;
|
|
10
|
+
storeResult: StoreInboundEventResult;
|
|
11
|
+
}) => void | Promise<void>;
|
|
12
|
+
|
|
13
|
+
export interface StartLarkDaemonOptions {
|
|
14
|
+
appId: string;
|
|
15
|
+
appSecret: string;
|
|
16
|
+
db: Database;
|
|
17
|
+
onEvent?: LarkEventCallback;
|
|
18
|
+
logger?: Partial<Pick<Console, 'info' | 'warn' | 'error'>>;
|
|
19
|
+
loggerLevel?: Lark.LoggerLevel;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LarkDaemonHandle {
|
|
23
|
+
appId: string;
|
|
24
|
+
wsClient: Lark.WSClient;
|
|
25
|
+
stop(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SUPPORTED_EVENTS = ['im.message.receive_v1', 'card.action.trigger'] as const;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Start a Lark WSClient that subscribes to im.message.receive_v1 +
|
|
32
|
+
* card.action.trigger and persists every event into channel_inbound_raw_events
|
|
33
|
+
* with dedupe. Callers can pass an onEvent callback for downstream processing
|
|
34
|
+
* (e.g. routing to internal LockClient chats).
|
|
35
|
+
*
|
|
36
|
+
* Returns a handle whose stop() closes the WSClient.
|
|
37
|
+
*/
|
|
38
|
+
export function startLarkDaemon(options: StartLarkDaemonOptions): LarkDaemonHandle {
|
|
39
|
+
const log = {
|
|
40
|
+
info: options.logger?.info ?? console.log,
|
|
41
|
+
warn: options.logger?.warn ?? console.warn,
|
|
42
|
+
error: options.logger?.error ?? console.error,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const handlers: Record<string, (data: unknown) => Promise<unknown>> = {};
|
|
46
|
+
for (const eventName of SUPPORTED_EVENTS) {
|
|
47
|
+
handlers[eventName] = async (data) => {
|
|
48
|
+
const rawBody = JSON.stringify({ schema: '2.0', header: { event_type: eventName }, event: data });
|
|
49
|
+
try {
|
|
50
|
+
const storeResult = await storeInboundEvent(options.db, { appId: options.appId, rawBody });
|
|
51
|
+
if (storeResult.duplicate) {
|
|
52
|
+
log.info?.(`[lark/${options.appId}] dup ${storeResult.event_id} (${eventName})`);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (options.onEvent) {
|
|
56
|
+
await options.onEvent({
|
|
57
|
+
appId: options.appId,
|
|
58
|
+
envelope: eventName,
|
|
59
|
+
data,
|
|
60
|
+
rawBody,
|
|
61
|
+
storeResult,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
log.error?.(`[lark/${options.appId}] handler error on ${eventName}:`, err);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const eventDispatcher = new Lark.EventDispatcher({}).register(handlers);
|
|
73
|
+
|
|
74
|
+
const wsClient = new Lark.WSClient({
|
|
75
|
+
appId: options.appId,
|
|
76
|
+
appSecret: options.appSecret,
|
|
77
|
+
loggerLevel: options.loggerLevel ?? Lark.LoggerLevel.warn,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
wsClient.start({ eventDispatcher });
|
|
81
|
+
log.info?.(`[lark/${options.appId}] WSClient started`);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
appId: options.appId,
|
|
85
|
+
wsClient,
|
|
86
|
+
stop(): void {
|
|
87
|
+
try {
|
|
88
|
+
const anyClient = wsClient as unknown as { stop?: () => void; close?: () => void };
|
|
89
|
+
if (typeof anyClient.stop === 'function') {
|
|
90
|
+
anyClient.stop();
|
|
91
|
+
} else if (typeof anyClient.close === 'function') {
|
|
92
|
+
anyClient.close();
|
|
93
|
+
}
|
|
94
|
+
log.info?.(`[lark/${options.appId}] WSClient stopped`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log.warn?.(`[lark/${options.appId}] stop error:`, err);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function createLarkApiClient(appId: string, appSecret: string): Lark.Client {
|
|
103
|
+
return new Lark.Client({
|
|
104
|
+
appId,
|
|
105
|
+
appSecret,
|
|
106
|
+
loggerLevel: Lark.LoggerLevel.warn,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface SendTextMessageInput {
|
|
111
|
+
client: Lark.Client;
|
|
112
|
+
receiveIdType: 'chat_id' | 'open_id' | 'union_id' | 'email' | 'user_id';
|
|
113
|
+
receiveId: string;
|
|
114
|
+
text: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SendTextMessageResult {
|
|
118
|
+
messageId?: string;
|
|
119
|
+
raw: unknown;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function sendTextMessage(input: SendTextMessageInput): Promise<SendTextMessageResult> {
|
|
123
|
+
const response = await input.client.im.message.create({
|
|
124
|
+
params: { receive_id_type: input.receiveIdType },
|
|
125
|
+
data: {
|
|
126
|
+
receive_id: input.receiveId,
|
|
127
|
+
msg_type: 'text',
|
|
128
|
+
content: JSON.stringify({ text: input.text }),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const messageId = (response?.data as { message_id?: string } | undefined)?.message_id;
|
|
132
|
+
return { messageId, raw: response };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface AddMessageReactionInput {
|
|
136
|
+
client: Lark.Client;
|
|
137
|
+
messageId: string;
|
|
138
|
+
emojiType: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface AddMessageReactionResult {
|
|
142
|
+
reactionId?: string;
|
|
143
|
+
raw: unknown;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function addMessageReaction(input: AddMessageReactionInput): Promise<AddMessageReactionResult> {
|
|
147
|
+
const response = await input.client.im.messageReaction.create({
|
|
148
|
+
path: { message_id: input.messageId },
|
|
149
|
+
data: {
|
|
150
|
+
reaction_type: { emoji_type: input.emojiType },
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const reactionId = (response?.data as { reaction_id?: string } | undefined)?.reaction_id;
|
|
154
|
+
return { reactionId, raw: response };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface GetChatInfoInput {
|
|
158
|
+
client: Lark.Client;
|
|
159
|
+
chatId: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface GetChatInfoResult {
|
|
163
|
+
name?: string;
|
|
164
|
+
chatMode?: string;
|
|
165
|
+
raw: unknown;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function getChatInfo(input: GetChatInfoInput): Promise<GetChatInfoResult> {
|
|
169
|
+
const response = await input.client.im.chat.get({
|
|
170
|
+
path: { chat_id: input.chatId },
|
|
171
|
+
});
|
|
172
|
+
const data = response?.data as { name?: string; chat_mode?: string } | undefined;
|
|
173
|
+
return { name: data?.name, chatMode: data?.chat_mode, raw: response };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface GetChatMembersInput {
|
|
177
|
+
client: Lark.Client;
|
|
178
|
+
chatId: string;
|
|
179
|
+
pageSize?: number;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export interface LarkChatMember {
|
|
183
|
+
memberId: string;
|
|
184
|
+
memberIdType?: string;
|
|
185
|
+
name?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface GetChatMembersResult {
|
|
189
|
+
members: LarkChatMember[];
|
|
190
|
+
raw: unknown[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function getChatMembers(input: GetChatMembersInput): Promise<GetChatMembersResult> {
|
|
194
|
+
const members: LarkChatMember[] = [];
|
|
195
|
+
const raw: unknown[] = [];
|
|
196
|
+
let pageToken: string | undefined;
|
|
197
|
+
do {
|
|
198
|
+
const response = await input.client.im.chatMembers.get({
|
|
199
|
+
path: { chat_id: input.chatId },
|
|
200
|
+
params: {
|
|
201
|
+
member_id_type: 'open_id',
|
|
202
|
+
page_size: input.pageSize ?? 100,
|
|
203
|
+
page_token: pageToken,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
raw.push(response);
|
|
207
|
+
const data = response?.data as {
|
|
208
|
+
items?: Array<{ member_id?: string; member_id_type?: string; name?: string }>;
|
|
209
|
+
page_token?: string;
|
|
210
|
+
has_more?: boolean;
|
|
211
|
+
} | undefined;
|
|
212
|
+
for (const item of data?.items ?? []) {
|
|
213
|
+
const memberId = item.member_id?.trim();
|
|
214
|
+
if (!memberId) continue;
|
|
215
|
+
members.push({
|
|
216
|
+
memberId,
|
|
217
|
+
memberIdType: item.member_id_type,
|
|
218
|
+
name: item.name,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
pageToken = data?.has_more ? data.page_token : undefined;
|
|
222
|
+
} while (pageToken);
|
|
223
|
+
return { members, raw };
|
|
224
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ApplyFixtureResult, NormalizedLarkFixtureEvent } from './lark-fixture.js';
|
|
2
|
+
|
|
3
|
+
export type FixtureDiagnosticState = 'observed' | 'missing' | 'ignored' | 'rejected' | 'unmapped';
|
|
4
|
+
|
|
5
|
+
export interface FixtureDiagnostic {
|
|
6
|
+
fixture_id: string;
|
|
7
|
+
scope: NormalizedLarkFixtureEvent['scope'];
|
|
8
|
+
conversation_key_state: FixtureDiagnosticState;
|
|
9
|
+
audit_status: NormalizedLarkFixtureEvent['audit_status'];
|
|
10
|
+
normalized_recipient_hint_state: FixtureDiagnosticState;
|
|
11
|
+
rejection_reason: string | null;
|
|
12
|
+
transcript_state: FixtureDiagnosticState;
|
|
13
|
+
mapping_state: FixtureDiagnosticState;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FixtureDiagnosticsSummary {
|
|
17
|
+
total: number;
|
|
18
|
+
by_audit_status: Record<string, number>;
|
|
19
|
+
by_scope: Record<string, number>;
|
|
20
|
+
diagnostics: FixtureDiagnostic[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildFixtureDiagnostics(results: ApplyFixtureResult[]): FixtureDiagnosticsSummary {
|
|
24
|
+
const diagnostics = results
|
|
25
|
+
.map(toDiagnostic)
|
|
26
|
+
.sort((left, right) => left.fixture_id.localeCompare(right.fixture_id));
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
total: diagnostics.length,
|
|
30
|
+
by_audit_status: countBy(diagnostics, (item) => item.audit_status),
|
|
31
|
+
by_scope: countBy(diagnostics, (item) => item.scope),
|
|
32
|
+
diagnostics,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toDiagnostic(result: ApplyFixtureResult): FixtureDiagnostic {
|
|
37
|
+
return {
|
|
38
|
+
fixture_id: result.normalized.fixture_id,
|
|
39
|
+
scope: result.normalized.scope,
|
|
40
|
+
conversation_key_state: result.normalized.conversation_key ? 'observed' : 'missing',
|
|
41
|
+
audit_status: result.normalized.audit_status,
|
|
42
|
+
normalized_recipient_hint_state: result.normalized.delivery_recipient ? 'observed' : 'missing',
|
|
43
|
+
rejection_reason: result.normalized.rejection_reason,
|
|
44
|
+
transcript_state: result.transcriptId ? 'observed' : result.normalized.audit_status === 'mapped' ? 'unmapped' : result.normalized.audit_status,
|
|
45
|
+
mapping_state: result.mappingId ? 'observed' : 'missing',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function countBy<T>(items: T[], key: (item: T) => string): Record<string, number> {
|
|
50
|
+
const counts: Record<string, number> = {};
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
const value = key(item);
|
|
53
|
+
counts[value] = (counts[value] ?? 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
return counts;
|
|
56
|
+
}
|