@controlflow-ai/daemon 0.1.1 → 0.1.3
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 +66 -24
- package/package.json +16 -3
- package/src/agent-avatar.ts +30 -0
- package/src/agent-key.ts +28 -0
- package/src/agent-permissions.ts +359 -0
- package/src/agent-runtime.ts +810 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +2183 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +482 -12
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +460 -26
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +958 -101
- package/src/db.ts +3216 -113
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/app-registration.ts +141 -0
- package/src/lark/cli.ts +7 -137
- package/src/lark/credentials.ts +36 -3
- package/src/lark/event-router.ts +61 -5
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/setup.ts +74 -5
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +611 -14
- package/src/local-auth.ts +36 -3
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -0
- package/src/migrations/023_projects.ts +65 -0
- package/src/migrations/024_agents_model.ts +10 -0
- package/src/migrations/025_room_archive.ts +44 -0
- package/src/migrations/026_project_archive.ts +44 -0
- package/src/migrations/027_agent_permission_profiles.ts +16 -0
- package/src/migrations/028_lark_websocket_restart_state.ts +16 -0
- package/src/migrations/029_held_message_drafts.ts +32 -0
- package/src/migrations/030_agent_room_read_state.ts +25 -0
- package/src/migrations/031_room_tasks.ts +29 -0
- package/src/migrations/032_room_reminders.ts +29 -0
- package/src/migrations/033_room_saved_messages.ts +25 -0
- package/src/migrations/034_agent_activity_events.ts +27 -0
- package/src/migrations/035_agent_avatars.ts +17 -0
- package/src/migrations/036_project_agent_defaults.ts +21 -0
- package/src/migrations/037_message_attachments.ts +36 -0
- package/src/migrations/038_agent_activity_room_scope.ts +64 -0
- package/src/migrations/039_message_attachments_path.ts +34 -0
- package/src/migrations/040_message_attachments_file_schema.ts +80 -0
- package/src/migrations/041_room_system_events.ts +30 -0
- package/src/migrations/042_message_attachment_file_kind.ts +52 -0
- package/src/migrations/043_room_mode_skill_registry.ts +92 -0
- package/src/migrations/044_workflow_runtime.ts +69 -0
- package/src/migrations/045_skill_repository_ownership.ts +64 -0
- package/src/migrations.ts +70 -1
- package/src/neeko.ts +40 -4
- package/src/runtime-env.ts +179 -0
- package/src/runtime-registry.ts +83 -13
- package/src/server.ts +244 -4
- package/src/token-file.ts +13 -6
- package/src/types.ts +394 -0
- package/src/workflow-runtime.ts +275 -0
- package/src/web.ts +0 -904
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from 'bun';
|
|
2
|
+
import { MessageStore } from './db.js';
|
|
3
|
+
import { failure, HttpError } from './http.js';
|
|
4
|
+
import type { MessageDelivery } from './types.js';
|
|
5
|
+
import type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
|
|
6
|
+
|
|
7
|
+
export interface DeliveryWebSocketData {
|
|
8
|
+
kind: 'daemon-delivery';
|
|
9
|
+
computerId: string;
|
|
10
|
+
connectionId: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type DeliveryWebSocketFrame =
|
|
14
|
+
| { type: 'hello'; computer_id: string; connection_id: string }
|
|
15
|
+
| { type: 'delivery'; delivery: Pick<MessageDelivery, 'id' | 'message_id' | 'chat_id' | 'agent' | 'status'> }
|
|
16
|
+
| { type: 'pending'; agent: string; pending?: number }
|
|
17
|
+
| { type: 'pong'; at: string };
|
|
18
|
+
|
|
19
|
+
export interface DeliveryNotifyResult {
|
|
20
|
+
deliveries: number;
|
|
21
|
+
target_connections: number;
|
|
22
|
+
open_sockets: number;
|
|
23
|
+
websocket_frames: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type { DeliveryConnectionStats, DeliveryWebSocketSummary } from './messaging-status.js';
|
|
27
|
+
|
|
28
|
+
export interface DeliveryWebSocketHubOptions {
|
|
29
|
+
logger?: Partial<Pick<Console, 'log' | 'warn'>>;
|
|
30
|
+
staleConnectionTimeoutMs?: number | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface DeliveryConnectionTelemetry {
|
|
34
|
+
computerId: string | null;
|
|
35
|
+
lastOpenAt: string | null;
|
|
36
|
+
lastCloseAt: string | null;
|
|
37
|
+
lastPingAt: string | null;
|
|
38
|
+
lastPongAt: string | null;
|
|
39
|
+
lastCloseCode: number | null;
|
|
40
|
+
lastCloseReason: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function connectionAuthFromRequest(request: Request): { computerId: string; connectionId: string; token: string } {
|
|
44
|
+
const url = new URL(request.url);
|
|
45
|
+
const computerId = request.headers.get('x-pal-computer-id')?.trim() || url.searchParams.get('computer_id')?.trim();
|
|
46
|
+
const connectionId = request.headers.get('x-pal-connection-id')?.trim() || url.searchParams.get('connection_id')?.trim();
|
|
47
|
+
const token = request.headers.get('x-pal-connection-token')?.trim() || url.searchParams.get('token')?.trim();
|
|
48
|
+
if (!computerId || !connectionId || !token) {
|
|
49
|
+
throw new HttpError(401, 'MISSING_CONNECTION_AUTH', 'computer connection auth is required');
|
|
50
|
+
}
|
|
51
|
+
return { computerId, connectionId, token };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sendFrame(ws: ServerWebSocket<DeliveryWebSocketData>, frame: DeliveryWebSocketFrame): boolean {
|
|
55
|
+
try {
|
|
56
|
+
return ws.send(JSON.stringify(frame)) > 0;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class DeliveryWebSocketHub {
|
|
63
|
+
private readonly socketsByConnection = new Map<string, Set<ServerWebSocket<DeliveryWebSocketData>>>();
|
|
64
|
+
private readonly telemetryByConnection = new Map<string, DeliveryConnectionTelemetry>();
|
|
65
|
+
private readonly log: Pick<Console, 'log' | 'warn'>;
|
|
66
|
+
private readonly staleConnectionTimeoutMs: number | null;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly store: MessageStore, options: DeliveryWebSocketHubOptions = {}) {
|
|
69
|
+
this.log = {
|
|
70
|
+
log: options.logger?.log ?? console.log,
|
|
71
|
+
warn: options.logger?.warn ?? console.warn,
|
|
72
|
+
};
|
|
73
|
+
this.staleConnectionTimeoutMs = typeof options.staleConnectionTimeoutMs === 'number' && Number.isFinite(options.staleConnectionTimeoutMs) && options.staleConnectionTimeoutMs >= 0
|
|
74
|
+
? options.staleConnectionTimeoutMs
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pruneStaleConnections(now = new Date()): number {
|
|
79
|
+
if (this.staleConnectionTimeoutMs === null) return 0;
|
|
80
|
+
const closed = this.store.closeStaleComputerConnections(this.staleConnectionTimeoutMs, now);
|
|
81
|
+
if (closed > 0) this.log.warn(`[delivery/ws] closed ${closed} stale computer connection(s)`);
|
|
82
|
+
return closed;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private telemetry(connectionId: string, computerId: string | null): DeliveryConnectionTelemetry {
|
|
86
|
+
let telemetry = this.telemetryByConnection.get(connectionId);
|
|
87
|
+
if (!telemetry) {
|
|
88
|
+
telemetry = {
|
|
89
|
+
computerId,
|
|
90
|
+
lastOpenAt: null,
|
|
91
|
+
lastCloseAt: null,
|
|
92
|
+
lastPingAt: null,
|
|
93
|
+
lastPongAt: null,
|
|
94
|
+
lastCloseCode: null,
|
|
95
|
+
lastCloseReason: null,
|
|
96
|
+
};
|
|
97
|
+
this.telemetryByConnection.set(connectionId, telemetry);
|
|
98
|
+
} else if (computerId) {
|
|
99
|
+
telemetry.computerId = computerId;
|
|
100
|
+
}
|
|
101
|
+
return telemetry;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
handleUpgrade(request: Request, server: Server<DeliveryWebSocketData>): Response | null {
|
|
105
|
+
const url = new URL(request.url);
|
|
106
|
+
if (request.method !== 'GET' || url.pathname !== '/api/daemon/ws') return null;
|
|
107
|
+
try {
|
|
108
|
+
const auth = connectionAuthFromRequest(request);
|
|
109
|
+
const connection = this.store.assertActiveComputerConnection({
|
|
110
|
+
computerId: auth.computerId,
|
|
111
|
+
connectionId: auth.connectionId,
|
|
112
|
+
token: auth.token,
|
|
113
|
+
});
|
|
114
|
+
const upgraded = server.upgrade(request, {
|
|
115
|
+
data: {
|
|
116
|
+
kind: 'daemon-delivery',
|
|
117
|
+
computerId: connection.computer_id,
|
|
118
|
+
connectionId: connection.id,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
return upgraded
|
|
122
|
+
? new Response(null)
|
|
123
|
+
: new Response('websocket upgrade failed', { status: 400 });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
return failure(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
websocket = {
|
|
130
|
+
open: (ws: ServerWebSocket<DeliveryWebSocketData>) => {
|
|
131
|
+
const { connectionId, computerId } = ws.data;
|
|
132
|
+
let sockets = this.socketsByConnection.get(connectionId);
|
|
133
|
+
if (!sockets) {
|
|
134
|
+
sockets = new Set();
|
|
135
|
+
this.socketsByConnection.set(connectionId, sockets);
|
|
136
|
+
}
|
|
137
|
+
sockets.add(ws);
|
|
138
|
+
const telemetry = this.telemetry(connectionId, computerId);
|
|
139
|
+
telemetry.lastOpenAt = new Date().toISOString();
|
|
140
|
+
telemetry.lastCloseAt = null;
|
|
141
|
+
telemetry.lastCloseCode = null;
|
|
142
|
+
telemetry.lastCloseReason = null;
|
|
143
|
+
this.log.log(`[delivery/ws] open computer=${computerId} connection=${connectionId} sockets=${sockets.size}`);
|
|
144
|
+
sendFrame(ws, { type: 'hello', computer_id: computerId, connection_id: connectionId });
|
|
145
|
+
for (const { agent, pending } of this.store.listPendingDeliveryAgentsForConnection(connectionId)) {
|
|
146
|
+
sendFrame(ws, { type: 'pending', agent, pending });
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
message: (ws: ServerWebSocket<DeliveryWebSocketData>, data: string | Buffer) => {
|
|
150
|
+
if (String(data) === 'ping') {
|
|
151
|
+
const at = new Date().toISOString();
|
|
152
|
+
const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
|
|
153
|
+
telemetry.lastPingAt = at;
|
|
154
|
+
if (sendFrame(ws, { type: 'pong', at })) {
|
|
155
|
+
telemetry.lastPongAt = at;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
close: (ws: ServerWebSocket<DeliveryWebSocketData>, code: number, reason: string) => {
|
|
160
|
+
const sockets = this.socketsByConnection.get(ws.data.connectionId);
|
|
161
|
+
if (sockets) {
|
|
162
|
+
sockets.delete(ws);
|
|
163
|
+
if (sockets.size === 0) this.socketsByConnection.delete(ws.data.connectionId);
|
|
164
|
+
}
|
|
165
|
+
const telemetry = this.telemetry(ws.data.connectionId, ws.data.computerId);
|
|
166
|
+
telemetry.lastCloseAt = new Date().toISOString();
|
|
167
|
+
telemetry.lastCloseCode = code;
|
|
168
|
+
telemetry.lastCloseReason = reason || null;
|
|
169
|
+
this.log.log(`[delivery/ws] close computer=${ws.data.computerId} connection=${ws.data.connectionId} code=${code} sockets=${sockets?.size ?? 0}`);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
notifyDeliveries(deliveries: MessageDelivery[]): DeliveryNotifyResult {
|
|
174
|
+
this.pruneStaleConnections();
|
|
175
|
+
const targetConnections = new Set<string>();
|
|
176
|
+
const pendingAgentsByConnection = new Map<string, Set<string>>();
|
|
177
|
+
let pendingDeliveries = 0;
|
|
178
|
+
let openSockets = 0;
|
|
179
|
+
let websocketFrames = 0;
|
|
180
|
+
for (const delivery of deliveries) {
|
|
181
|
+
if (delivery.status !== 'pending') continue;
|
|
182
|
+
pendingDeliveries += 1;
|
|
183
|
+
for (const connectionId of this.store.listOnlineDaemonConnectionsForAgent(delivery.agent)) {
|
|
184
|
+
targetConnections.add(connectionId);
|
|
185
|
+
let pendingAgents = pendingAgentsByConnection.get(connectionId);
|
|
186
|
+
if (!pendingAgents) {
|
|
187
|
+
pendingAgents = new Set();
|
|
188
|
+
pendingAgentsByConnection.set(connectionId, pendingAgents);
|
|
189
|
+
}
|
|
190
|
+
pendingAgents.add(delivery.agent);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const [connectionId, agents] of pendingAgentsByConnection) {
|
|
195
|
+
const sockets = this.socketsByConnection.get(connectionId);
|
|
196
|
+
if (!sockets?.size) continue;
|
|
197
|
+
openSockets += sockets.size;
|
|
198
|
+
const pendingCounts = new Map(
|
|
199
|
+
this.store.listPendingDeliveryAgentsForConnection(connectionId).map((item) => [item.agent, item.pending]),
|
|
200
|
+
);
|
|
201
|
+
for (const agent of agents) {
|
|
202
|
+
for (const ws of sockets) {
|
|
203
|
+
if (sendFrame(ws, { type: 'pending', agent, pending: pendingCounts.get(agent) ?? 0 })) {
|
|
204
|
+
websocketFrames += 1;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
deliveries: pendingDeliveries,
|
|
211
|
+
target_connections: targetConnections.size,
|
|
212
|
+
open_sockets: openSockets,
|
|
213
|
+
websocket_frames: websocketFrames,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
statsForConnection(connectionId: string): DeliveryConnectionStats {
|
|
218
|
+
const connection = this.store.getComputerConnection(connectionId);
|
|
219
|
+
const telemetry = this.telemetryByConnection.get(connectionId);
|
|
220
|
+
return {
|
|
221
|
+
connection_id: connectionId,
|
|
222
|
+
computer_id: connection?.computer_id ?? telemetry?.computerId ?? null,
|
|
223
|
+
open_sockets: this.socketsByConnection.get(connectionId)?.size ?? 0,
|
|
224
|
+
last_open_at: telemetry?.lastOpenAt ?? null,
|
|
225
|
+
last_close_at: telemetry?.lastCloseAt ?? null,
|
|
226
|
+
last_ping_at: telemetry?.lastPingAt ?? null,
|
|
227
|
+
last_pong_at: telemetry?.lastPongAt ?? null,
|
|
228
|
+
last_close_code: telemetry?.lastCloseCode ?? null,
|
|
229
|
+
last_close_reason: telemetry?.lastCloseReason ?? null,
|
|
230
|
+
pending_agents: this.store.listPendingDeliveryAgentsForConnection(connectionId),
|
|
231
|
+
backlog: this.store.listDeliveryBacklogForConnection(connectionId),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
statsAllConnections(): DeliveryWebSocketSummary {
|
|
236
|
+
this.pruneStaleConnections();
|
|
237
|
+
const connections = this.store.listActiveComputerConnections()
|
|
238
|
+
.map((connection) => this.statsForConnection(connection.id));
|
|
239
|
+
return {
|
|
240
|
+
connections,
|
|
241
|
+
totals: connections.reduce((totals, connection) => {
|
|
242
|
+
totals.connections += 1;
|
|
243
|
+
totals.open_sockets += connection.open_sockets;
|
|
244
|
+
for (const item of connection.backlog) {
|
|
245
|
+
totals.pending_deliveries += item.pending;
|
|
246
|
+
totals.claimed_deliveries += item.claimed;
|
|
247
|
+
totals.processing_completed_deliveries += item.processing_completed;
|
|
248
|
+
totals.expired_active_deliveries += item.expired_active;
|
|
249
|
+
}
|
|
250
|
+
return totals;
|
|
251
|
+
}, {
|
|
252
|
+
connections: 0,
|
|
253
|
+
open_sockets: 0,
|
|
254
|
+
pending_deliveries: 0,
|
|
255
|
+
claimed_deliveries: 0,
|
|
256
|
+
processing_completed_deliveries: 0,
|
|
257
|
+
expired_active_deliveries: 0,
|
|
258
|
+
}),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
close(): void {
|
|
263
|
+
for (const sockets of this.socketsByConnection.values()) {
|
|
264
|
+
for (const ws of sockets) ws.close(1001, 'server shutting down');
|
|
265
|
+
}
|
|
266
|
+
this.socketsByConnection.clear();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
}
|
package/src/format.ts
CHANGED
|
@@ -5,7 +5,10 @@ export function formatMessage(message: Message): string {
|
|
|
5
5
|
const chatName = sanitizeProviderIds(message.chat_name);
|
|
6
6
|
const target = message.parent_id === null ? `#${chatName}` : `#${chatName}:${message.parent_id}`;
|
|
7
7
|
const to = message.recipient ? ` -> @${sanitizeProviderIds(message.recipient)}` : '';
|
|
8
|
-
|
|
8
|
+
const mentions = message.mentions?.length
|
|
9
|
+
? ` -> ${message.mentions.map((mention) => `@${sanitizeProviderIds(mention)}`).join(',')}`
|
|
10
|
+
: '';
|
|
11
|
+
return `[${message.id} ${target} ${message.created_at}] @${sanitizeProviderIds(message.sender)}${to || mentions}: ${sanitizeProviderIds(message.content)}`;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
export function formatMessages(messages: Message[]): string {
|
|
@@ -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;
|
|
@@ -325,17 +195,17 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
|
|
|
325
195
|
let resolvedRuntime: string | null = null;
|
|
326
196
|
if (agentSpec) {
|
|
327
197
|
const directRuntime = agentSpec.split(':')[0];
|
|
328
|
-
if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === '
|
|
198
|
+
if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'codex') {
|
|
329
199
|
resolvedRuntime = directRuntime;
|
|
330
200
|
} else {
|
|
331
201
|
resolvedRuntime = msgStore.getAgentRuntime(agentSpec);
|
|
332
202
|
if (!resolvedRuntime) {
|
|
333
|
-
(log.warn ?? console.warn)(`[lark] agent ${agentSpec} has no runtime configured in DB; defaulting to neeko. Run "bun run src/cli.ts agents create --key ${agentSpec} --name <name> --runtime neeko|coco|
|
|
203
|
+
(log.warn ?? console.warn)(`[lark] agent ${agentSpec} has no runtime configured in DB; defaulting to neeko. Run "bun run src/cli.ts agents create --key ${agentSpec} --name <name> --runtime neeko|coco|codex" to configure.`);
|
|
334
204
|
resolvedRuntime = 'neeko';
|
|
335
205
|
}
|
|
336
206
|
}
|
|
337
207
|
}
|
|
338
|
-
const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === '
|
|
208
|
+
const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'codex';
|
|
339
209
|
|
|
340
210
|
const dispatchers = new Map<string, { dispatcher: ChatDispatcher; periodic: PeriodicQueue | null; chatIdToLarkChatId: Map<string, string> }>();
|
|
341
211
|
if (agentSpec && !isDeliveryAgent) {
|
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 {
|