@controlflow-ai/daemon 0.1.2 → 0.1.4
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 +54 -6
- package/bin/daemon.js +6 -1
- package/package.json +3 -1
- 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 +795 -28
- package/src/agent-workspace.ts +183 -0
- package/src/app.ts +1970 -79
- package/src/args.ts +54 -7
- package/src/cli.ts +873 -14
- package/src/client.ts +472 -10
- package/src/coco.ts +9 -40
- package/src/codex.ts +33 -5
- package/src/config.ts +28 -4
- package/src/console.ts +230 -20
- package/src/daemon-client.ts +116 -3
- package/src/daemon.ts +937 -99
- package/src/db.ts +3128 -122
- package/src/delivery-ws.ts +269 -0
- package/src/format.ts +4 -1
- package/src/lark/cli.ts +3 -3
- package/src/lark/event-router.ts +60 -4
- package/src/lark/inbound-events.ts +156 -3
- package/src/lark/server-integration.ts +659 -111
- package/src/lark/ws-daemon.ts +136 -10
- package/src/local-api.ts +545 -15
- package/src/local-auth.ts +33 -1
- package/src/message-attachments.ts +71 -0
- package/src/messaging-cli.ts +741 -0
- package/src/messaging-status.ts +669 -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 +69 -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 +362 -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 {
|
package/src/lark/cli.ts
CHANGED
|
@@ -195,17 +195,17 @@ async function runDaemon(argv: LarkCliArgs, log: NonNullable<RunOptions['log']>)
|
|
|
195
195
|
let resolvedRuntime: string | null = null;
|
|
196
196
|
if (agentSpec) {
|
|
197
197
|
const directRuntime = agentSpec.split(':')[0];
|
|
198
|
-
if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === '
|
|
198
|
+
if (directRuntime === 'neeko' || directRuntime === 'coco' || directRuntime === 'codex') {
|
|
199
199
|
resolvedRuntime = directRuntime;
|
|
200
200
|
} else {
|
|
201
201
|
resolvedRuntime = msgStore.getAgentRuntime(agentSpec);
|
|
202
202
|
if (!resolvedRuntime) {
|
|
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|
|
|
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.`);
|
|
204
204
|
resolvedRuntime = 'neeko';
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
207
|
}
|
|
208
|
-
const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === '
|
|
208
|
+
const isDeliveryAgent = resolvedRuntime === 'neeko' || resolvedRuntime === 'coco' || resolvedRuntime === 'codex';
|
|
209
209
|
|
|
210
210
|
const dispatchers = new Map<string, { dispatcher: ChatDispatcher; periodic: PeriodicQueue | null; chatIdToLarkChatId: Map<string, string> }>();
|
|
211
211
|
if (agentSpec && !isDeliveryAgent) {
|
package/src/lark/event-router.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { palIdentityHandle } from '../provider-identity.js';
|
|
|
14
14
|
* | message.chat_id | chatName (prefixed `lark:`) |
|
|
15
15
|
* | message.message_id | idempotencyKey |
|
|
16
16
|
* | sender.sender_id.open_id | sender |
|
|
17
|
-
* | message.mentions[
|
|
17
|
+
* | message.mentions[].id.open_id | mentions for group delivery; recipient for non-group direct routing |
|
|
18
18
|
* | message.content (parsed JSON text)| content |
|
|
19
19
|
* | message.root_id (thread reply) | parentId via lookup |
|
|
20
20
|
*
|
|
@@ -61,10 +61,16 @@ export interface MapLarkMessageResult {
|
|
|
61
61
|
status: 'ok' | 'skipped';
|
|
62
62
|
reason?: 'missing_message_id' | 'missing_chat_id' | 'missing_sender' | 'unsupported_message_type' | 'empty_text';
|
|
63
63
|
input?: CreateMessageInput;
|
|
64
|
-
/** Lark root_id when
|
|
64
|
+
/** Lark root_id, or parent_id when root_id is absent, used to pre-resolve the local topic parent. */
|
|
65
65
|
rootMessageId?: string;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export interface LarkImageResource {
|
|
69
|
+
fileKey: string;
|
|
70
|
+
filename: string;
|
|
71
|
+
mimeType: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
export function buildLockChatName(appId: string, chatId: string, chatType?: string): string {
|
|
69
75
|
if (chatType === 'group') {
|
|
70
76
|
return `lark:group:${chatId}`;
|
|
@@ -125,6 +131,49 @@ function parseLarkTextContent(rawContent: string | undefined, messageType: strin
|
|
|
125
131
|
}
|
|
126
132
|
}
|
|
127
133
|
|
|
134
|
+
function parseJsonObject(rawContent: string | undefined): Record<string, unknown> | null {
|
|
135
|
+
if (!rawContent) return null;
|
|
136
|
+
try {
|
|
137
|
+
const parsed = JSON.parse(rawContent) as unknown;
|
|
138
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function extractLarkImageResources(envelope: LarkMessageEnvelope): LarkImageResource[] {
|
|
145
|
+
const msg = envelope.message;
|
|
146
|
+
const out: LarkImageResource[] = [];
|
|
147
|
+
const seen = new Set<string>();
|
|
148
|
+
const add = (fileKey: unknown, filename?: unknown) => {
|
|
149
|
+
if (typeof fileKey !== 'string' || !fileKey.trim() || seen.has(fileKey)) return;
|
|
150
|
+
seen.add(fileKey);
|
|
151
|
+
out.push({
|
|
152
|
+
fileKey,
|
|
153
|
+
filename: typeof filename === 'string' && filename.trim() ? filename.trim() : `${fileKey}.jpg`,
|
|
154
|
+
mimeType: 'image/jpeg',
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
const parsed = parseJsonObject(msg?.content);
|
|
158
|
+
if (msg?.message_type === 'image') {
|
|
159
|
+
add(parsed?.image_key ?? parsed?.file_key, parsed?.file_name ?? parsed?.name);
|
|
160
|
+
}
|
|
161
|
+
if (msg?.message_type === 'post' && parsed) {
|
|
162
|
+
const content = parsed.content;
|
|
163
|
+
if (Array.isArray(content)) {
|
|
164
|
+
for (const line of content) {
|
|
165
|
+
if (!Array.isArray(line)) continue;
|
|
166
|
+
for (const item of line) {
|
|
167
|
+
if (!item || typeof item !== 'object') continue;
|
|
168
|
+
const record = item as Record<string, unknown>;
|
|
169
|
+
if (record.tag === 'img' || record.tag === 'image') add(record.image_key ?? record.file_key);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
128
177
|
/**
|
|
129
178
|
* Pure mapper: no DB access. Returns a `CreateMessageInput` skeleton (without
|
|
130
179
|
* parentId resolution — that happens in `resolveThreadParent`).
|
|
@@ -146,11 +195,14 @@ export function mapLarkMessageToCreateInput(input: MapLarkMessageInput): MapLark
|
|
|
146
195
|
...(mentionsAllAgents(envelope) ? [ALL_AGENTS_MENTION] : []),
|
|
147
196
|
...(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []),
|
|
148
197
|
]);
|
|
198
|
+
const firstMappedMention = firstMention ? input.recipientByMentionOpenId?.get(firstMention) : undefined;
|
|
149
199
|
const recipient = input.recipientOverride !== undefined
|
|
150
200
|
? input.recipientOverride
|
|
151
|
-
:
|
|
201
|
+
: msg.chat_type === 'group'
|
|
202
|
+
? null
|
|
203
|
+
: firstMention ? firstMappedMention ?? firstMention : null;
|
|
152
204
|
const chatName = buildLockChatName(input.appId, msg.chat_id, msg.chat_type);
|
|
153
|
-
const rootMessageId = msg.root_id?.trim() || undefined;
|
|
205
|
+
const rootMessageId = msg.root_id?.trim() || msg.parent_id?.trim() || undefined;
|
|
154
206
|
|
|
155
207
|
const createInput: CreateMessageInput = {
|
|
156
208
|
chatName,
|
|
@@ -291,6 +343,8 @@ export function ingestLarkMessage(input: IngestLarkMessageInput): IngestLarkMess
|
|
|
291
343
|
content: mapped.input.content,
|
|
292
344
|
type: mapped.input.type,
|
|
293
345
|
idempotencyKey: mapped.input.idempotencyKey,
|
|
346
|
+
provider: 'lark',
|
|
347
|
+
mentions: mapped.input.mentions,
|
|
294
348
|
}
|
|
295
349
|
: threadOrphan
|
|
296
350
|
? {
|
|
@@ -300,6 +354,8 @@ export function ingestLarkMessage(input: IngestLarkMessageInput): IngestLarkMess
|
|
|
300
354
|
content: mapped.input.content,
|
|
301
355
|
type: mapped.input.type,
|
|
302
356
|
idempotencyKey: mapped.input.idempotencyKey,
|
|
357
|
+
provider: 'lark',
|
|
358
|
+
mentions: mapped.input.mentions,
|
|
303
359
|
}
|
|
304
360
|
: { ...mapped.input, sender: senderHandle || mapped.input.sender };
|
|
305
361
|
|
|
@@ -10,6 +10,8 @@ export interface InboundRawEvent {
|
|
|
10
10
|
raw_body_bytes: Uint8Array;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export type InboundRawEventSummary = Omit<InboundRawEvent, 'raw_body_bytes'>;
|
|
14
|
+
|
|
13
15
|
export interface StoreInboundEventInput {
|
|
14
16
|
appId: string;
|
|
15
17
|
rawBody: string | Uint8Array;
|
|
@@ -24,6 +26,31 @@ export interface StoreInboundEventResult {
|
|
|
24
26
|
duplicate: boolean;
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
export interface RepairInboundEventParseFailuresOptions {
|
|
30
|
+
appId?: string;
|
|
31
|
+
limit?: number;
|
|
32
|
+
dryRun?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RepairInboundEventParseFailuresResult {
|
|
36
|
+
dry_run: boolean;
|
|
37
|
+
scanned: number;
|
|
38
|
+
repaired: number;
|
|
39
|
+
unchanged: number;
|
|
40
|
+
conflicts: number;
|
|
41
|
+
errors: number;
|
|
42
|
+
rows: Array<{
|
|
43
|
+
id: string;
|
|
44
|
+
app_id: string;
|
|
45
|
+
old_event_id: string;
|
|
46
|
+
old_event_type: string;
|
|
47
|
+
new_event_id: string;
|
|
48
|
+
new_event_type: string;
|
|
49
|
+
status: 'repaired' | 'would_repair' | 'unchanged' | 'conflict' | 'error';
|
|
50
|
+
error?: string;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
const ENCODER = new TextEncoder();
|
|
28
55
|
|
|
29
56
|
function toBytes(body: string | Uint8Array): Uint8Array {
|
|
@@ -48,8 +75,9 @@ export async function parseEventEnvelope(rawBytes: Uint8Array): Promise<ParsedEn
|
|
|
48
75
|
const text = new TextDecoder('utf8', { fatal: false }).decode(rawBytes);
|
|
49
76
|
const json = JSON.parse(text) as Record<string, unknown>;
|
|
50
77
|
const header = (json.header && typeof json.header === 'object') ? json.header as Record<string, unknown> : null;
|
|
51
|
-
const
|
|
52
|
-
const
|
|
78
|
+
const event = (json.event && typeof json.event === 'object') ? json.event as Record<string, unknown> : null;
|
|
79
|
+
const eventId = (header?.event_id ?? event?.event_id ?? json.uuid) as unknown;
|
|
80
|
+
const eventType = (header?.event_type ?? event?.event_type ?? json.type) as unknown;
|
|
53
81
|
if (typeof eventId === 'string' && eventId.length > 0 && typeof eventType === 'string' && eventType.length > 0) {
|
|
54
82
|
return { event_id: eventId, event_type: eventType, parse_ok: 1 };
|
|
55
83
|
}
|
|
@@ -117,10 +145,22 @@ export function getInboundEvent(db: Database, id: string): InboundRawEvent | nul
|
|
|
117
145
|
|
|
118
146
|
export function listRecentInboundEvents(db: Database, limit = 20): InboundRawEvent[] {
|
|
119
147
|
return db
|
|
120
|
-
.query('SELECT id, received_at, app_id, event_type, event_id, parse_ok, raw_body_bytes FROM channel_inbound_raw_events ORDER BY received_at DESC,
|
|
148
|
+
.query('SELECT id, received_at, app_id, event_type, event_id, parse_ok, raw_body_bytes FROM channel_inbound_raw_events ORDER BY received_at DESC, rowid DESC LIMIT ?')
|
|
121
149
|
.all(Math.max(1, Math.min(limit, 500))) as InboundRawEvent[];
|
|
122
150
|
}
|
|
123
151
|
|
|
152
|
+
export function latestInboundEventSummary(db: Database, appId: string): InboundRawEventSummary | null {
|
|
153
|
+
return db
|
|
154
|
+
.query("SELECT id, received_at, app_id, event_type, event_id, parse_ok FROM channel_inbound_raw_events WHERE app_id = ? AND event_type NOT LIKE 'pal.probe.%' ORDER BY received_at DESC, rowid DESC LIMIT 1")
|
|
155
|
+
.get(appId) as InboundRawEventSummary | null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function latestProbeInboundEventSummary(db: Database, appId: string): InboundRawEventSummary | null {
|
|
159
|
+
return db
|
|
160
|
+
.query("SELECT id, received_at, app_id, event_type, event_id, parse_ok FROM channel_inbound_raw_events WHERE app_id = ? AND event_type LIKE 'pal.probe.%' ORDER BY received_at DESC, rowid DESC LIMIT 1")
|
|
161
|
+
.get(appId) as InboundRawEventSummary | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
124
164
|
export function countInboundEvents(db: Database, appId?: string): number {
|
|
125
165
|
if (appId) {
|
|
126
166
|
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ?').get(appId) as { n: number };
|
|
@@ -129,3 +169,116 @@ export function countInboundEvents(db: Database, appId?: string): number {
|
|
|
129
169
|
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events').get() as { n: number };
|
|
130
170
|
return row.n;
|
|
131
171
|
}
|
|
172
|
+
|
|
173
|
+
export function countProviderInboundEvents(db: Database, appId?: string): number {
|
|
174
|
+
if (appId) {
|
|
175
|
+
const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND event_type NOT LIKE 'pal.probe.%'").get(appId) as { n: number };
|
|
176
|
+
return row.n;
|
|
177
|
+
}
|
|
178
|
+
const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE event_type NOT LIKE 'pal.probe.%'").get() as { n: number };
|
|
179
|
+
return row.n;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function countProbeInboundEvents(db: Database, appId?: string): number {
|
|
183
|
+
if (appId) {
|
|
184
|
+
const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND event_type LIKE 'pal.probe.%'").get(appId) as { n: number };
|
|
185
|
+
return row.n;
|
|
186
|
+
}
|
|
187
|
+
const row = db.query("SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE event_type LIKE 'pal.probe.%'").get() as { n: number };
|
|
188
|
+
return row.n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function countInboundParseFailures(db: Database, appId?: string): number {
|
|
192
|
+
if (appId) {
|
|
193
|
+
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ? AND parse_ok = 0').get(appId) as { n: number };
|
|
194
|
+
return row.n;
|
|
195
|
+
}
|
|
196
|
+
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE parse_ok = 0').get() as { n: number };
|
|
197
|
+
return row.n;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function repairInboundEventParseFailures(
|
|
201
|
+
db: Database,
|
|
202
|
+
options: RepairInboundEventParseFailuresOptions = {},
|
|
203
|
+
): Promise<RepairInboundEventParseFailuresResult> {
|
|
204
|
+
const limit = Math.max(1, Math.min(options.limit ?? 100, 1000));
|
|
205
|
+
const dryRun = options.dryRun ?? false;
|
|
206
|
+
const rows = options.appId
|
|
207
|
+
? db
|
|
208
|
+
.query('SELECT id, app_id, event_type, event_id, raw_body_bytes FROM channel_inbound_raw_events WHERE app_id = ? AND parse_ok = 0 ORDER BY received_at DESC, rowid DESC LIMIT ?')
|
|
209
|
+
.all(options.appId, limit)
|
|
210
|
+
: db
|
|
211
|
+
.query('SELECT id, app_id, event_type, event_id, raw_body_bytes FROM channel_inbound_raw_events WHERE parse_ok = 0 ORDER BY received_at DESC, rowid DESC LIMIT ?')
|
|
212
|
+
.all(limit);
|
|
213
|
+
const result: RepairInboundEventParseFailuresResult = {
|
|
214
|
+
dry_run: dryRun,
|
|
215
|
+
scanned: rows.length,
|
|
216
|
+
repaired: 0,
|
|
217
|
+
unchanged: 0,
|
|
218
|
+
conflicts: 0,
|
|
219
|
+
errors: 0,
|
|
220
|
+
rows: [],
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const row of rows as Array<{ id: string; app_id: string; event_type: string; event_id: string; raw_body_bytes: Uint8Array }>) {
|
|
224
|
+
const parsed = await parseEventEnvelope(row.raw_body_bytes);
|
|
225
|
+
if (parsed.parse_ok !== 1) {
|
|
226
|
+
result.unchanged += 1;
|
|
227
|
+
result.rows.push({
|
|
228
|
+
id: row.id,
|
|
229
|
+
app_id: row.app_id,
|
|
230
|
+
old_event_id: row.event_id,
|
|
231
|
+
old_event_type: row.event_type,
|
|
232
|
+
new_event_id: parsed.event_id,
|
|
233
|
+
new_event_type: parsed.event_type,
|
|
234
|
+
status: 'unchanged',
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (dryRun) {
|
|
239
|
+
result.repaired += 1;
|
|
240
|
+
result.rows.push({
|
|
241
|
+
id: row.id,
|
|
242
|
+
app_id: row.app_id,
|
|
243
|
+
old_event_id: row.event_id,
|
|
244
|
+
old_event_type: row.event_type,
|
|
245
|
+
new_event_id: parsed.event_id,
|
|
246
|
+
new_event_type: parsed.event_type,
|
|
247
|
+
status: 'would_repair',
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
db
|
|
253
|
+
.query('UPDATE channel_inbound_raw_events SET event_id = ?, event_type = ?, parse_ok = 1 WHERE id = ?')
|
|
254
|
+
.run(parsed.event_id, parsed.event_type, row.id);
|
|
255
|
+
result.repaired += 1;
|
|
256
|
+
result.rows.push({
|
|
257
|
+
id: row.id,
|
|
258
|
+
app_id: row.app_id,
|
|
259
|
+
old_event_id: row.event_id,
|
|
260
|
+
old_event_type: row.event_type,
|
|
261
|
+
new_event_id: parsed.event_id,
|
|
262
|
+
new_event_type: parsed.event_type,
|
|
263
|
+
status: 'repaired',
|
|
264
|
+
});
|
|
265
|
+
} catch (err) {
|
|
266
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
267
|
+
const conflict = message.toLowerCase().includes('unique') || message.toLowerCase().includes('constraint');
|
|
268
|
+
if (conflict) result.conflicts += 1;
|
|
269
|
+
else result.errors += 1;
|
|
270
|
+
result.rows.push({
|
|
271
|
+
id: row.id,
|
|
272
|
+
app_id: row.app_id,
|
|
273
|
+
old_event_id: row.event_id,
|
|
274
|
+
old_event_type: row.event_type,
|
|
275
|
+
new_event_id: parsed.event_id,
|
|
276
|
+
new_event_type: parsed.event_type,
|
|
277
|
+
status: conflict ? 'conflict' : 'error',
|
|
278
|
+
error: message,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return result;
|
|
284
|
+
}
|