@controlflow-ai/daemon 0.1.0 → 0.1.2
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 +21 -19
- package/package.json +14 -3
- package/src/agent-runtime.ts +15 -0
- package/src/app.ts +314 -3
- package/src/client.ts +11 -3
- package/src/console.ts +397 -18
- package/src/daemon.ts +25 -6
- package/src/db.ts +181 -6
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +4 -134
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +22 -2
- package/src/lark/server-integration.ts +9 -16
- package/src/lark/setup.ts +74 -5
- package/src/local-api.ts +69 -2
- package/src/local-auth.ts +4 -3
- package/src/migrations/022_lark_authorized_users.ts +16 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations.ts +3 -1
- package/src/network.ts +24 -0
- package/src/server.ts +21 -7
- package/src/types.ts +40 -0
- package/src/web.ts +368 -29
package/src/db.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { Database, type SQLQueryBindings } from 'bun:sqlite';
|
|
2
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
3
4
|
import { ensureParentDir } from './config.js';
|
|
4
5
|
import { runMigrations } from './migrations.js';
|
|
5
6
|
import { artifactExpiry, generateArtifactToken, hashArtifactToken, validateArtifactContent } from './artifacts.js';
|
|
6
7
|
import { palIdentityHandle } from './provider-identity.js';
|
|
7
|
-
import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
|
|
8
|
+
import type { AgentDefinition, AgentRun, AgentSession, AgentValidationResult, Artifact, ArtifactMetadata, ChannelAccount, ChannelConversation, ChannelMessageMapping, ChannelOutboxRecord, Chat, ChatKind, Computer, ComputerAgentAssignment, ComputerConnection, DaemonInstance, LarkAuthorizedUser, LarkGroupRoomMapping, LockProviderEvidence, LockTranscriptMessage, Message, MessageDelivery, MessageType, AgentRoomSubscription, AgentRoomSubscriptionMode, PendingInboundEvent, Project, ProvisionedComputer, ProviderExternalType, ProviderIdentityBinding, PalIdentity, RoomChannel, RoomParticipant, RoomParticipantKind, RoomParticipantSource, RoomProvider, RunAction, RunStatus, TranscriptReadModel, WorkbenchArtifact } from './types.js';
|
|
9
|
+
|
|
10
|
+
export const ALL_AGENTS_MENTION = '__pal_all_agents__';
|
|
8
11
|
|
|
9
12
|
export interface CreateMessageInput {
|
|
10
13
|
chatId?: string;
|
|
@@ -83,6 +86,7 @@ export interface ConnectComputerResult {
|
|
|
83
86
|
computer: Computer;
|
|
84
87
|
connection: ComputerConnection;
|
|
85
88
|
token: string;
|
|
89
|
+
localControlToken: string;
|
|
86
90
|
daemon: DaemonInstance;
|
|
87
91
|
agents: ComputerAgentAssignment[];
|
|
88
92
|
}
|
|
@@ -354,6 +358,19 @@ function rowToComputerAgentAssignment(row: Record<string, unknown>): ComputerAge
|
|
|
354
358
|
};
|
|
355
359
|
}
|
|
356
360
|
|
|
361
|
+
function rowToProject(row: Record<string, unknown>): Project {
|
|
362
|
+
return {
|
|
363
|
+
id: String(row.id),
|
|
364
|
+
name: String(row.name),
|
|
365
|
+
computer_id: String(row.computer_id),
|
|
366
|
+
computer_name: row.computer_name === null || row.computer_name === undefined ? null : String(row.computer_name),
|
|
367
|
+
root_path: String(row.root_path),
|
|
368
|
+
room_count: Number(row.room_count ?? 0),
|
|
369
|
+
created_at: String(row.created_at),
|
|
370
|
+
updated_at: String(row.updated_at),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
357
374
|
function rowToMessage(row: Record<string, unknown>): Message {
|
|
358
375
|
return {
|
|
359
376
|
id: Number(row.id),
|
|
@@ -529,6 +546,16 @@ function rowToChannelAccount(row: Record<string, unknown>): ChannelAccount {
|
|
|
529
546
|
};
|
|
530
547
|
}
|
|
531
548
|
|
|
549
|
+
function rowToLarkAuthorizedUser(row: Record<string, unknown>): LarkAuthorizedUser {
|
|
550
|
+
return {
|
|
551
|
+
id: String(row.id),
|
|
552
|
+
user_id: String(row.user_id),
|
|
553
|
+
display_name: row.display_name === null || row.display_name === undefined ? null : String(row.display_name),
|
|
554
|
+
created_at: String(row.created_at),
|
|
555
|
+
updated_at: String(row.updated_at),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
532
559
|
function rowToPalIdentity(row: Record<string, unknown>): PalIdentity {
|
|
533
560
|
return {
|
|
534
561
|
id: String(row.id),
|
|
@@ -800,6 +827,68 @@ export class MessageStore {
|
|
|
800
827
|
return this.getChatById(id)!;
|
|
801
828
|
}
|
|
802
829
|
|
|
830
|
+
createProject(input: { name: string; computerId: string; rootPath: string }): Project {
|
|
831
|
+
const name = input.name.trim();
|
|
832
|
+
const computerId = input.computerId.trim();
|
|
833
|
+
const rootPath = input.rootPath.trim();
|
|
834
|
+
if (!name) throw new Error('project name is required');
|
|
835
|
+
if (!computerId) throw new Error('computer_id is required');
|
|
836
|
+
if (!rootPath) throw new Error('root_path is required');
|
|
837
|
+
if (!isAbsolute(rootPath)) throw new Error('root_path must be absolute');
|
|
838
|
+
const computer = this.getComputer(computerId);
|
|
839
|
+
if (!computer) throw new Error(`computer ${computerId} not found`);
|
|
840
|
+
|
|
841
|
+
const id = crypto.randomUUID();
|
|
842
|
+
this.db.query(`
|
|
843
|
+
INSERT INTO projects (id, name, computer_id, root_path, created_at, updated_at)
|
|
844
|
+
VALUES (?, ?, ?, ?, datetime('now'), datetime('now'))
|
|
845
|
+
`).run(id, name, computerId, rootPath);
|
|
846
|
+
return this.getProject(id)!;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
getProject(id: string): Project | null {
|
|
850
|
+
const row = this.db.query(`
|
|
851
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
852
|
+
FROM projects p
|
|
853
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
854
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
855
|
+
WHERE p.id = ?
|
|
856
|
+
GROUP BY p.id
|
|
857
|
+
`).get(id.trim()) as Record<string, unknown> | null;
|
|
858
|
+
return row ? rowToProject(row) : null;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
listProjects(limit = 50): Project[] {
|
|
862
|
+
const rows = this.db.query(`
|
|
863
|
+
SELECT p.*, c.name AS computer_name, COUNT(ch.id) AS room_count
|
|
864
|
+
FROM projects p
|
|
865
|
+
LEFT JOIN computers c ON c.id = p.computer_id
|
|
866
|
+
LEFT JOIN chats ch ON ch.project_id = p.id
|
|
867
|
+
GROUP BY p.id
|
|
868
|
+
ORDER BY p.updated_at DESC, p.created_at DESC
|
|
869
|
+
LIMIT ?
|
|
870
|
+
`).all(limitValue(limit, 50)) as Record<string, unknown>[];
|
|
871
|
+
return rows.map(rowToProject);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
createProjectRoom(input: { projectId: string; name: string; kind?: ChatKind }): Chat {
|
|
875
|
+
const project = this.getProject(input.projectId);
|
|
876
|
+
if (!project) throw new Error(`project ${input.projectId} was not found`);
|
|
877
|
+
const kind = input.kind ?? 'group';
|
|
878
|
+
if (kind !== 'group' && kind !== 'dm') throw new Error('kind must be group or dm');
|
|
879
|
+
const chatName = normalizeChatName(input.name);
|
|
880
|
+
if (!chatName) throw new Error('room name is required');
|
|
881
|
+
if (this.getChatByName(chatName)) throw new Error(`room ${chatName} already exists`);
|
|
882
|
+
|
|
883
|
+
const id = crypto.randomUUID();
|
|
884
|
+
this.db.query(`
|
|
885
|
+
INSERT INTO chats (id, name, kind, provider, capabilities_json, project_id)
|
|
886
|
+
VALUES (?, ?, ?, 'web', ?, ?)
|
|
887
|
+
`).run(id, chatName, kind, kind === 'dm' ? '{"topics":"unsupported"}' : '{"topics":"native"}', project.id);
|
|
888
|
+
this.db.query(`UPDATE projects SET updated_at = datetime('now') WHERE id = ?`).run(project.id);
|
|
889
|
+
return this.getChatById(id)!;
|
|
890
|
+
}
|
|
891
|
+
|
|
803
892
|
updateRoomDisplayName(roomId: string, displayName: string | null): Chat {
|
|
804
893
|
const room = this.getChatById(roomId);
|
|
805
894
|
if (!room) throw new Error(`room ${roomId} was not found`);
|
|
@@ -852,6 +941,43 @@ export class MessageStore {
|
|
|
852
941
|
return this.db.query('SELECT * FROM chat_stats ORDER BY COALESCE(last_message_at, created_at) DESC').all() as Chat[];
|
|
853
942
|
}
|
|
854
943
|
|
|
944
|
+
listLarkAuthorizedUsers(): LarkAuthorizedUser[] {
|
|
945
|
+
const rows = this.db.query('SELECT * FROM lark_authorized_users ORDER BY COALESCE(display_name, user_id), user_id').all() as Record<string, unknown>[];
|
|
946
|
+
return rows.map(rowToLarkAuthorizedUser);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
upsertLarkAuthorizedUser(input: { userId: string; displayName?: string | null }): LarkAuthorizedUser {
|
|
950
|
+
const userId = input.userId.trim();
|
|
951
|
+
if (!userId) throw new Error('lark user_id is required');
|
|
952
|
+
const id = crypto.randomUUID();
|
|
953
|
+
this.db.query(`
|
|
954
|
+
INSERT INTO lark_authorized_users (id, user_id, display_name, created_at, updated_at)
|
|
955
|
+
VALUES (?, ?, ?, datetime('now'), datetime('now'))
|
|
956
|
+
ON CONFLICT(user_id) DO UPDATE SET
|
|
957
|
+
display_name = excluded.display_name,
|
|
958
|
+
updated_at = datetime('now')
|
|
959
|
+
`).run(id, userId, input.displayName?.trim() || null);
|
|
960
|
+
return this.getLarkAuthorizedUser(userId)!;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
getLarkAuthorizedUser(userId: string): LarkAuthorizedUser | null {
|
|
964
|
+
const normalized = userId.trim();
|
|
965
|
+
if (!normalized) return null;
|
|
966
|
+
const row = this.db.query('SELECT * FROM lark_authorized_users WHERE user_id = ?').get(normalized) as Record<string, unknown> | null;
|
|
967
|
+
return row ? rowToLarkAuthorizedUser(row) : null;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
isLarkAuthorizedUser(userId: string | null | undefined): boolean {
|
|
971
|
+
if (!userId?.trim()) return false;
|
|
972
|
+
return this.getLarkAuthorizedUser(userId) !== null;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
deleteLarkAuthorizedUser(userId: string): boolean {
|
|
976
|
+
const normalized = userId.trim();
|
|
977
|
+
if (!normalized) return false;
|
|
978
|
+
return this.db.query('DELETE FROM lark_authorized_users WHERE user_id = ?').run(normalized).changes > 0;
|
|
979
|
+
}
|
|
980
|
+
|
|
855
981
|
listLarkGroupRoomMappings(): LarkGroupRoomMapping[] {
|
|
856
982
|
const rows = this.db.query(`
|
|
857
983
|
SELECT DISTINCT
|
|
@@ -2146,7 +2272,7 @@ export class MessageStore {
|
|
|
2146
2272
|
VALUES (?, ?, ?, 'offline', NULL, NULL, datetime('now'))
|
|
2147
2273
|
`).run(id, name, hashSecret(apiKey));
|
|
2148
2274
|
|
|
2149
|
-
const packageName = input.packageName?.trim() || '@
|
|
2275
|
+
const packageName = input.packageName?.trim() || '@controlflow-ai/daemon@latest';
|
|
2150
2276
|
const serverUrl = input.serverUrl?.trim() || 'http://127.0.0.1:4127';
|
|
2151
2277
|
const command = `npx ${packageName} --server-url ${serverUrl} --api-key ${apiKey} # ${name}`;
|
|
2152
2278
|
return { computer: this.getComputer(id)!, api_key: apiKey, command };
|
|
@@ -2167,6 +2293,7 @@ export class MessageStore {
|
|
|
2167
2293
|
|
|
2168
2294
|
const connectionId = crypto.randomUUID();
|
|
2169
2295
|
const token = generateConnectionToken();
|
|
2296
|
+
const localControlToken = generateConnectionToken();
|
|
2170
2297
|
const tokenHash = hashSecret(token);
|
|
2171
2298
|
const revokedRows = this.db.query(`
|
|
2172
2299
|
SELECT id FROM computer_connections
|
|
@@ -2221,9 +2348,9 @@ export class MessageStore {
|
|
|
2221
2348
|
}
|
|
2222
2349
|
}
|
|
2223
2350
|
this.db.query(`
|
|
2224
|
-
INSERT INTO computer_connections (id, computer_id, token_hash, epoch, status, connected_at, last_heartbeat_at)
|
|
2225
|
-
VALUES (?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
|
|
2226
|
-
`).run(connectionId, computerId, tokenHash, epoch);
|
|
2351
|
+
INSERT INTO computer_connections (id, computer_id, token_hash, local_control_token, epoch, status, connected_at, last_heartbeat_at)
|
|
2352
|
+
VALUES (?, ?, ?, ?, ?, 'active', datetime('now'), datetime('now'))
|
|
2353
|
+
`).run(connectionId, computerId, tokenHash, localControlToken, epoch);
|
|
2227
2354
|
this.db.query(`
|
|
2228
2355
|
INSERT INTO daemon_instances (id, name, host, local_url, server_url, status, last_seen_at)
|
|
2229
2356
|
VALUES (?, ?, ?, ?, ?, 'online', datetime('now'))
|
|
@@ -2254,11 +2381,30 @@ export class MessageStore {
|
|
|
2254
2381
|
computer: this.getComputer(computerId)!,
|
|
2255
2382
|
connection: this.getComputerConnection(connectionId)!,
|
|
2256
2383
|
token,
|
|
2384
|
+
localControlToken,
|
|
2257
2385
|
daemon: this.getDaemon(connectionId)!,
|
|
2258
2386
|
agents: this.listComputerAgentAssignments(computerId),
|
|
2259
2387
|
};
|
|
2260
2388
|
}
|
|
2261
2389
|
|
|
2390
|
+
getComputerLocalControl(computerId: string): { computer: Computer; connection: ComputerConnection; daemon: DaemonInstance; token: string } | null {
|
|
2391
|
+
const computer = this.getComputer(computerId);
|
|
2392
|
+
if (!computer || computer.status !== 'online' || !computer.active_connection_id) return null;
|
|
2393
|
+
const connection = this.getComputerConnection(computer.active_connection_id);
|
|
2394
|
+
if (!connection || connection.status !== 'active') return null;
|
|
2395
|
+
const row = this.db.query(`
|
|
2396
|
+
SELECT local_control_token
|
|
2397
|
+
FROM computer_connections
|
|
2398
|
+
WHERE id = ? AND computer_id = ? AND status = 'active'
|
|
2399
|
+
LIMIT 1
|
|
2400
|
+
`).get(connection.id, computer.id) as { local_control_token: string | null } | null;
|
|
2401
|
+
const token = row?.local_control_token?.trim();
|
|
2402
|
+
if (!token) return null;
|
|
2403
|
+
const daemon = this.getDaemon(connection.id);
|
|
2404
|
+
if (!daemon || daemon.status !== 'online' || !daemon.local_url.trim()) return null;
|
|
2405
|
+
return { computer, connection, daemon, token };
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2262
2408
|
closeStaleComputerConnections(timeoutMs: number, now = new Date()): number {
|
|
2263
2409
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error('timeoutMs must be non-negative');
|
|
2264
2410
|
const cutoff = new Date(now.getTime() - timeoutMs).toISOString();
|
|
@@ -2431,7 +2577,11 @@ export class MessageStore {
|
|
|
2431
2577
|
if (!room) throw new Error(`room ${message.chat_id} was not found`);
|
|
2432
2578
|
const candidates = new Set<string>();
|
|
2433
2579
|
if (message.recipient && this.getAgent(message.recipient)) candidates.add(message.recipient);
|
|
2580
|
+
if ((message.mentions ?? []).includes(ALL_AGENTS_MENTION)) {
|
|
2581
|
+
for (const agent of this.listRoomAgents(room)) candidates.add(agent);
|
|
2582
|
+
}
|
|
2434
2583
|
for (const mention of message.mentions ?? []) {
|
|
2584
|
+
if (mention === ALL_AGENTS_MENTION) continue;
|
|
2435
2585
|
if (this.getAgent(mention)) candidates.add(mention);
|
|
2436
2586
|
}
|
|
2437
2587
|
if (room.kind === 'dm') {
|
|
@@ -2460,9 +2610,34 @@ export class MessageStore {
|
|
|
2460
2610
|
return deliveries;
|
|
2461
2611
|
}
|
|
2462
2612
|
|
|
2613
|
+
private listRoomAgents(room: Chat): string[] {
|
|
2614
|
+
if (room.provider === 'web') {
|
|
2615
|
+
const rows = this.db.query(`
|
|
2616
|
+
SELECT participant_id AS agent
|
|
2617
|
+
FROM room_participants
|
|
2618
|
+
WHERE room_id = ? AND kind = 'agent' AND status = 'active'
|
|
2619
|
+
`).all(room.id) as Array<{ agent: string }>;
|
|
2620
|
+
return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
const rows = this.db.query(`
|
|
2624
|
+
SELECT DISTINCT pa.agent AS agent
|
|
2625
|
+
FROM provider_accounts pa
|
|
2626
|
+
INNER JOIN provider_conversations pc ON pc.provider_account_id = pa.id
|
|
2627
|
+
WHERE pa.provider = ? AND pa.status = 'active' AND pc.room_id = ?
|
|
2628
|
+
UNION
|
|
2629
|
+
SELECT DISTINCT ca.agent AS agent
|
|
2630
|
+
FROM channel_accounts ca
|
|
2631
|
+
INNER JOIN channel_conversations cc ON cc.channel_account_id = ca.id
|
|
2632
|
+
WHERE ca.channel = ? AND ca.status = 'active' AND cc.lock_chat_id = ? AND ca.agent IS NOT NULL AND ca.agent != ''
|
|
2633
|
+
`).all(room.provider, room.id, room.provider, room.id) as Array<{ agent: string }>;
|
|
2634
|
+
return rows.map((row) => row.agent).filter((agent) => Boolean(this.getAgent(agent)));
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2463
2637
|
private shouldCreateDeliveryForAgent(message: Message, room: Chat, agent: string): boolean {
|
|
2464
2638
|
if (!this.canAgentParticipateInRoom(agent, room)) return false;
|
|
2465
|
-
const
|
|
2639
|
+
const allAgentsMentioned = (message.mentions ?? []).includes(ALL_AGENTS_MENTION);
|
|
2640
|
+
const direct = allAgentsMentioned || message.recipient === agent || (message.mentions ?? []).includes(agent);
|
|
2466
2641
|
if (room.kind === 'dm') return direct || message.recipient === null;
|
|
2467
2642
|
const subscription = this.getAgentRoomSubscription(agent, room.id);
|
|
2468
2643
|
const mode = subscription?.mode ?? 'mentions';
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
export type LarkRegistrationTenantBrand = 'feishu' | 'lark';
|
|
2
|
+
|
|
3
|
+
export interface LarkRegistrationBegin {
|
|
4
|
+
deviceCode: string;
|
|
5
|
+
url: string;
|
|
6
|
+
expiresIn: number;
|
|
7
|
+
interval: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LarkRegistrationComplete {
|
|
11
|
+
appId: string;
|
|
12
|
+
appSecret: string;
|
|
13
|
+
tenantBrand: LarkRegistrationTenantBrand;
|
|
14
|
+
userOpenId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type LarkRegistrationPollResult =
|
|
18
|
+
| { status: 'pending' }
|
|
19
|
+
| { status: 'slow_down'; interval: number }
|
|
20
|
+
| { status: 'complete'; registration: LarkRegistrationComplete }
|
|
21
|
+
| { status: 'denied' | 'expired' | 'error'; message: string };
|
|
22
|
+
|
|
23
|
+
export type RegistrationFetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
24
|
+
|
|
25
|
+
const FEISHU_ACCOUNTS_ORIGIN = 'https://accounts.feishu.cn';
|
|
26
|
+
const LARK_ACCOUNTS_ORIGIN = 'https://accounts.larksuite.com';
|
|
27
|
+
const REGISTRATION_PATH = '/oauth/v1/app/registration';
|
|
28
|
+
|
|
29
|
+
interface RegistrationBeginBody {
|
|
30
|
+
device_code?: unknown;
|
|
31
|
+
verification_uri_complete?: unknown;
|
|
32
|
+
expires_in?: unknown;
|
|
33
|
+
interval?: unknown;
|
|
34
|
+
error?: unknown;
|
|
35
|
+
error_description?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface RegistrationPollBody {
|
|
39
|
+
client_id?: unknown;
|
|
40
|
+
client_secret?: unknown;
|
|
41
|
+
user_info?: { tenant_brand?: unknown; open_id?: unknown };
|
|
42
|
+
error?: unknown;
|
|
43
|
+
error_description?: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeMessage(message: unknown): string {
|
|
47
|
+
return String(message ?? 'unknown').replace(/[A-Za-z0-9_-]{30,}/g, '***');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function postRegistration(origin: string, params: Record<string, string>, fetchImpl: RegistrationFetchLike): Promise<unknown> {
|
|
51
|
+
const response = await fetchImpl(`${origin}${REGISTRATION_PATH}`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
54
|
+
body: new URLSearchParams(params).toString(),
|
|
55
|
+
});
|
|
56
|
+
const body = await response.json().catch(() => ({}));
|
|
57
|
+
if (!response.ok && !(body && typeof body === 'object' && 'error' in body)) {
|
|
58
|
+
throw new Error(`registration request failed: HTTP ${response.status}`);
|
|
59
|
+
}
|
|
60
|
+
return body;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function beginLarkAppRegistration(options: {
|
|
64
|
+
fetchImpl?: RegistrationFetchLike;
|
|
65
|
+
source?: string;
|
|
66
|
+
} = {}): Promise<LarkRegistrationBegin> {
|
|
67
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
68
|
+
const body = await postRegistration(FEISHU_ACCOUNTS_ORIGIN, {
|
|
69
|
+
action: 'begin',
|
|
70
|
+
archetype: 'PersonalAgent',
|
|
71
|
+
auth_method: 'client_secret',
|
|
72
|
+
request_user_info: 'open_id',
|
|
73
|
+
}, fetchImpl) as RegistrationBeginBody;
|
|
74
|
+
|
|
75
|
+
if (typeof body.error === 'string') {
|
|
76
|
+
throw new Error(sanitizeMessage(body.error_description ?? body.error));
|
|
77
|
+
}
|
|
78
|
+
if (typeof body.device_code !== 'string' || typeof body.verification_uri_complete !== 'string') {
|
|
79
|
+
throw new Error('registration begin response did not include device_code and verification_uri_complete');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = new URL(body.verification_uri_complete);
|
|
83
|
+
url.searchParams.set('from', 'sdk');
|
|
84
|
+
url.searchParams.set('source', `node-sdk/${options.source ?? 'pal'}`);
|
|
85
|
+
url.searchParams.set('tp', 'sdk');
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
deviceCode: body.device_code,
|
|
89
|
+
url: url.toString(),
|
|
90
|
+
expiresIn: typeof body.expires_in === 'number' ? body.expires_in : 600,
|
|
91
|
+
interval: typeof body.interval === 'number' ? body.interval : 5,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function pollLarkAppRegistration(options: {
|
|
96
|
+
deviceCode: string;
|
|
97
|
+
fetchImpl?: RegistrationFetchLike;
|
|
98
|
+
origin?: 'feishu' | 'lark';
|
|
99
|
+
}): Promise<LarkRegistrationPollResult> {
|
|
100
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
101
|
+
const origin = options.origin === 'lark' ? LARK_ACCOUNTS_ORIGIN : FEISHU_ACCOUNTS_ORIGIN;
|
|
102
|
+
let body: RegistrationPollBody;
|
|
103
|
+
try {
|
|
104
|
+
body = await postRegistration(origin, {
|
|
105
|
+
action: 'poll',
|
|
106
|
+
device_code: options.deviceCode,
|
|
107
|
+
}, fetchImpl) as RegistrationPollBody;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return {
|
|
110
|
+
status: 'error',
|
|
111
|
+
message: error instanceof Error ? sanitizeMessage(error.message) : sanitizeMessage(error),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof body.client_id === 'string' && typeof body.client_secret === 'string') {
|
|
116
|
+
const tenantBrand: LarkRegistrationTenantBrand = body.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
|
|
117
|
+
const openId = typeof body.user_info?.open_id === 'string' && body.user_info.open_id.startsWith('ou_')
|
|
118
|
+
? body.user_info.open_id
|
|
119
|
+
: undefined;
|
|
120
|
+
return {
|
|
121
|
+
status: 'complete',
|
|
122
|
+
registration: {
|
|
123
|
+
appId: body.client_id,
|
|
124
|
+
appSecret: body.client_secret,
|
|
125
|
+
tenantBrand,
|
|
126
|
+
userOpenId: openId,
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (body.user_info?.tenant_brand === 'lark' && options.origin !== 'lark') {
|
|
132
|
+
return pollLarkAppRegistration({ ...options, origin: 'lark' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const error = typeof body.error === 'string' ? body.error : '';
|
|
136
|
+
if (error === 'authorization_pending' || !error) return { status: 'pending' };
|
|
137
|
+
if (error === 'slow_down') return { status: 'slow_down', interval: 10 };
|
|
138
|
+
if (error === 'access_denied') return { status: 'denied', message: 'user denied app registration' };
|
|
139
|
+
if (error === 'expired_token') return { status: 'expired', message: 'registration QR code expired' };
|
|
140
|
+
return { status: 'error', message: sanitizeMessage(body.error_description ?? error) };
|
|
141
|
+
}
|
package/src/lark/cli.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { defaultDbPath
|
|
2
|
+
import { defaultDbPath } from '../config.js';
|
|
3
3
|
import { MessageStore } from '../db.js';
|
|
4
4
|
import {
|
|
5
5
|
boundAgents,
|
|
@@ -11,12 +11,6 @@ import { countInboundEvents, listRecentInboundEvents } from './inbound-events.js
|
|
|
11
11
|
import { extractMentionOpenIds, ingestLarkMessage, type LarkMessageEnvelope } from './event-router.js';
|
|
12
12
|
import { ChatDispatcher, chatKeyOf, parseReceivePolicy, PeriodicQueue, shouldAcceptForAgent, type DispatchInput, type ReceivePolicy } from './dispatcher.js';
|
|
13
13
|
import { parseRuntimeSpec } from './agent-runtime.js';
|
|
14
|
-
import {
|
|
15
|
-
formatLarkSetupNextSteps,
|
|
16
|
-
persistLarkCredential,
|
|
17
|
-
resolveLarkBotInfo,
|
|
18
|
-
runInteractiveLarkSetup,
|
|
19
|
-
} from './setup.js';
|
|
20
14
|
import { createLarkApiClient, sendTextMessage, startLarkDaemon } from './ws-daemon.js';
|
|
21
15
|
|
|
22
16
|
export interface LarkCliArgs {
|
|
@@ -44,69 +38,6 @@ function flagBool(flags: Record<string, unknown>, key: string): boolean {
|
|
|
44
38
|
return flags[key] === true;
|
|
45
39
|
}
|
|
46
40
|
|
|
47
|
-
async function postJson(url: string, body: unknown): Promise<{ ok: boolean; status: number; text: string }> {
|
|
48
|
-
try {
|
|
49
|
-
const response = await fetch(url, {
|
|
50
|
-
method: 'POST',
|
|
51
|
-
headers: { 'content-type': 'application/json' },
|
|
52
|
-
body: JSON.stringify(body),
|
|
53
|
-
});
|
|
54
|
-
return { ok: response.ok, status: response.status, text: await response.text() };
|
|
55
|
-
} catch (error) {
|
|
56
|
-
return { ok: false, status: 0, text: error instanceof Error ? error.message : String(error) };
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
async function listAgentsForSetup(serverUrl: string): Promise<Array<{ agent_key: string; display_name: string; runtime?: string | null }>> {
|
|
61
|
-
const response = await fetch(`${serverUrl.replace(/\/$/, '')}/api/agents`);
|
|
62
|
-
const payload = await response.json() as { ok?: boolean; data?: { agents?: Array<{ agent_key?: unknown; display_name?: unknown; runtime?: unknown }> }; message?: string };
|
|
63
|
-
if (!response.ok || payload.ok === false) {
|
|
64
|
-
throw new Error(payload.message ?? `agent list failed: ${response.status}`);
|
|
65
|
-
}
|
|
66
|
-
return (payload.data?.agents ?? [])
|
|
67
|
-
.filter((agent) => typeof agent.agent_key === 'string' && typeof agent.display_name === 'string')
|
|
68
|
-
.map((agent) => ({
|
|
69
|
-
agent_key: String(agent.agent_key),
|
|
70
|
-
display_name: String(agent.display_name),
|
|
71
|
-
runtime: agent.runtime === null || typeof agent.runtime === 'string' ? agent.runtime : undefined,
|
|
72
|
-
}));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function onboardAgentForLark(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>, agent: string | undefined): Promise<boolean> {
|
|
76
|
-
if (!flagBool(flags, 'create-agent')) return true;
|
|
77
|
-
if (!agent) {
|
|
78
|
-
(log.error ?? console.error)('lark setup --create-agent requires --agent');
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
|
|
82
|
-
const displayName = flagString(flags, 'agent-name') ?? agent;
|
|
83
|
-
const runtime = flagString(flags, 'runtime') ?? 'codex';
|
|
84
|
-
const computerId = flagString(flags, 'computer-id');
|
|
85
|
-
const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/agents/onboard`, {
|
|
86
|
-
agent_key: agent,
|
|
87
|
-
display_name: displayName,
|
|
88
|
-
runtime,
|
|
89
|
-
computer_id: computerId,
|
|
90
|
-
});
|
|
91
|
-
if (!result.ok) {
|
|
92
|
-
(log.error ?? console.error)(`agent onboard failed (${result.status || 'network'}): ${result.text}`);
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
(log.log ?? console.log)(`[lark setup] agent onboarded: ${agent}`);
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function reloadLarkIntegration(flags: Record<string, unknown>, log: NonNullable<RunOptions['log']>): Promise<void> {
|
|
100
|
-
if (flagBool(flags, 'no-reload')) return;
|
|
101
|
-
const serverUrl = flagString(flags, 'server') ?? defaultServerUrl();
|
|
102
|
-
const result = await postJson(`${serverUrl.replace(/\/$/, '')}/api/lark/reload`, {});
|
|
103
|
-
if (result.ok) {
|
|
104
|
-
(log.log ?? console.log)('[lark setup] server lark integration reloaded');
|
|
105
|
-
} else {
|
|
106
|
-
(log.warn ?? console.warn)(`[lark setup] saved config, but server reload failed (${result.status || 'network'}): ${result.text}`);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
41
|
export async function runLarkCli(options: RunOptions): Promise<number> {
|
|
111
42
|
const { argv } = options;
|
|
112
43
|
const log = options.log ?? {};
|
|
@@ -118,58 +49,8 @@ export async function runLarkCli(options: RunOptions): Promise<number> {
|
|
|
118
49
|
}
|
|
119
50
|
|
|
120
51
|
if (sub === 'setup') {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const label = flagString(argv.flags, 'label');
|
|
124
|
-
const agent = flagString(argv.flags, 'agent');
|
|
125
|
-
const path = flagString(argv.flags, 'config') ?? defaultLarkConfigPath();
|
|
126
|
-
if ('agents' in argv.flags) {
|
|
127
|
-
(log.error ?? console.error)('lark setup no longer supports --agents; bind one bot to one agent with --agent');
|
|
128
|
-
return 2;
|
|
129
|
-
}
|
|
130
|
-
if (!appId && !appSecret) {
|
|
131
|
-
const result = await runInteractiveLarkSetup({
|
|
132
|
-
configPath: path,
|
|
133
|
-
log: {
|
|
134
|
-
log: log.log ?? console.log,
|
|
135
|
-
warn: log.warn ?? console.warn,
|
|
136
|
-
error: log.error ?? console.error,
|
|
137
|
-
},
|
|
138
|
-
ask: options.setupAsk,
|
|
139
|
-
listAgents: () => listAgentsForSetup(flagString(argv.flags, 'server') ?? defaultServerUrl()),
|
|
140
|
-
});
|
|
141
|
-
if (result) await reloadLarkIntegration(argv.flags, log);
|
|
142
|
-
return result ? 0 : 2;
|
|
143
|
-
}
|
|
144
|
-
if (!appId || !appSecret) {
|
|
145
|
-
(log.error ?? console.error)('lark setup requires --app-id and --app-secret');
|
|
146
|
-
return 2;
|
|
147
|
-
}
|
|
148
|
-
if (!(await onboardAgentForLark(argv.flags, log, agent))) return 2;
|
|
149
|
-
const botInfo = await resolveLarkBotInfo(appId, appSecret);
|
|
150
|
-
if (!botInfo.ok) {
|
|
151
|
-
(log.error ?? console.error)(`lark setup could not resolve bot open_id (${botInfo.error}): ${botInfo.message}`);
|
|
152
|
-
return 2;
|
|
153
|
-
}
|
|
154
|
-
const result = persistLarkCredential({
|
|
155
|
-
appId,
|
|
156
|
-
appSecret,
|
|
157
|
-
label,
|
|
158
|
-
agent,
|
|
159
|
-
botOpenId: botInfo.openId,
|
|
160
|
-
configPath: path,
|
|
161
|
-
});
|
|
162
|
-
if (result.replaced) {
|
|
163
|
-
(log.warn ?? console.warn)(`[lark setup] overwrote existing credential for appId=${appId} in ${path}`);
|
|
164
|
-
}
|
|
165
|
-
if (flagBool(argv.flags, 'next-steps')) {
|
|
166
|
-
(log.log ?? console.log)(formatLarkSetupNextSteps(result));
|
|
167
|
-
await reloadLarkIntegration(argv.flags, log);
|
|
168
|
-
return 0;
|
|
169
|
-
}
|
|
170
|
-
printJson(result, { log: log.log });
|
|
171
|
-
await reloadLarkIntegration(argv.flags, log);
|
|
172
|
-
return 0;
|
|
52
|
+
(log.error ?? console.error)('lark setup has been removed. Bind or rebind bots with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret> [--rebind-lark]".');
|
|
53
|
+
return 2;
|
|
173
54
|
}
|
|
174
55
|
|
|
175
56
|
if (sub === 'list') {
|
|
@@ -223,17 +104,6 @@ function printLarkUsage(log: NonNullable<RunOptions['log']>): void {
|
|
|
223
104
|
(log.log ?? console.log)(`pal lark <subcommand> [flags]
|
|
224
105
|
|
|
225
106
|
Subcommands:
|
|
226
|
-
setup [--app-id <id> --app-secret <secret>] [--label <name>] [--agent <agent-key>] [--config <path>] [--next-steps]
|
|
227
|
-
[--create-agent --agent-name <name> --runtime codex --computer-id <machine>] [--server <url>] [--no-reload]
|
|
228
|
-
Persist a (appId, appSecret) credential pair to ~/.pal/lark.json (0600).
|
|
229
|
-
With no app-id/app-secret flags, starts an interactive setup wizard that
|
|
230
|
-
validates credentials before writing the config.
|
|
231
|
-
Overwrites if appId already present and logs a warning.
|
|
232
|
-
Setup resolves the bot open_id through Feishu's bot info API before
|
|
233
|
-
writing config. --agent binds this bot to a logical agent key. --create-agent creates or
|
|
234
|
-
updates that agent through the Pal server before writing lark.json.
|
|
235
|
-
By default setup asks the running server to reload Lark integration.
|
|
236
|
-
|
|
237
107
|
list [--config <path>]
|
|
238
108
|
List configured bots (secrets redacted).
|
|
239
109
|
|
|
@@ -281,7 +151,7 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
|
|
|
281
151
|
}
|
|
282
152
|
const store = loadLarkCredentials(configPath);
|
|
283
153
|
if (store.bots.length === 0) {
|
|
284
|
-
(log.error ?? console.error)(`No bots configured in ${configPath}.
|
|
154
|
+
(log.error ?? console.error)(`No bots configured in ${configPath}. Bind a bot with "bun run console -- agents update --key <agent> --lark-app-id <id> --lark-app-secret <secret>" first.`);
|
|
285
155
|
return 2;
|
|
286
156
|
}
|
|
287
157
|
let targets = store.bots;
|
package/src/lark/credentials.ts
CHANGED
|
@@ -40,18 +40,51 @@ export function saveLarkCredentials(store: LarkCredentialStore, path: string = d
|
|
|
40
40
|
export interface AddCredentialResult {
|
|
41
41
|
store: LarkCredentialStore;
|
|
42
42
|
replaced: boolean;
|
|
43
|
+
unbound: LarkCredential[];
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export function
|
|
46
|
+
export function findCredentialByAgent(store: LarkCredentialStore, agent: string): LarkCredential | undefined {
|
|
47
|
+
const key = agent.trim();
|
|
48
|
+
if (!key) return undefined;
|
|
49
|
+
return store.bots.find((bot) => bot.agent?.trim() === key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function unbindCredentialAgent(store: LarkCredentialStore, agent: string): { store: LarkCredentialStore; changed: boolean; unbound?: LarkCredential } {
|
|
53
|
+
const key = agent.trim();
|
|
54
|
+
if (!key) throw new Error('agent is required');
|
|
55
|
+
const index = store.bots.findIndex((bot) => bot.agent?.trim() === key);
|
|
56
|
+
if (index === -1) return { store: { bots: [...store.bots] }, changed: false };
|
|
57
|
+
const next: LarkCredentialStore = { bots: [...store.bots] };
|
|
58
|
+
const existing = next.bots[index]!;
|
|
59
|
+
const { agent: _agent, ...withoutAgent } = existing;
|
|
60
|
+
next.bots[index] = withoutAgent;
|
|
61
|
+
return { store: next, changed: true, unbound: existing };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function upsertCredential(store: LarkCredentialStore, credential: LarkCredential, options: { rebind?: boolean } = {}): AddCredentialResult {
|
|
46
65
|
validateCredential(credential);
|
|
47
66
|
const existingIndex = store.bots.findIndex((bot) => bot.appId === credential.appId);
|
|
48
67
|
const next: LarkCredentialStore = { bots: [...store.bots] };
|
|
68
|
+
const unbound: LarkCredential[] = [];
|
|
69
|
+
const agent = credential.agent?.trim();
|
|
70
|
+
if (agent) {
|
|
71
|
+
const existingAgentIndex = next.bots.findIndex((bot, index) => index !== existingIndex && bot.agent?.trim() === agent);
|
|
72
|
+
if (existingAgentIndex !== -1) {
|
|
73
|
+
const existing = next.bots[existingAgentIndex]!;
|
|
74
|
+
if (!options.rebind) {
|
|
75
|
+
throw new Error(`agent ${agent} is already bound to Lark app ${existing.appId}; pass --rebind-lark to move the binding`);
|
|
76
|
+
}
|
|
77
|
+
const { agent: _agent, ...withoutAgent } = existing;
|
|
78
|
+
next.bots[existingAgentIndex] = withoutAgent;
|
|
79
|
+
unbound.push(existing);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
49
82
|
if (existingIndex === -1) {
|
|
50
83
|
next.bots.push(credential);
|
|
51
|
-
return { store: next, replaced: false };
|
|
84
|
+
return { store: next, replaced: false, unbound };
|
|
52
85
|
}
|
|
53
86
|
next.bots[existingIndex] = credential;
|
|
54
|
-
return { store: next, replaced: true };
|
|
87
|
+
return { store: next, replaced: true, unbound };
|
|
55
88
|
}
|
|
56
89
|
|
|
57
90
|
export function findCredential(store: LarkCredentialStore, appId: string): LarkCredential | undefined {
|