@controlflow-ai/daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +360 -0
- package/bin/console.js +2 -0
- package/bin/daemon.js +2 -0
- package/bin/pal.js +2 -0
- package/bin/server.js +2 -0
- package/package.json +31 -0
- package/src/agent-runtime.ts +285 -0
- package/src/app.ts +745 -0
- package/src/args.ts +54 -0
- package/src/artifacts.ts +85 -0
- package/src/cli.ts +284 -0
- package/src/client.ts +310 -0
- package/src/coco.ts +52 -0
- package/src/codex.ts +41 -0
- package/src/coding-agent-runtime.ts +20 -0
- package/src/config.ts +106 -0
- package/src/console.ts +349 -0
- package/src/daemon-client.ts +91 -0
- package/src/daemon.ts +580 -0
- package/src/db.ts +2830 -0
- package/src/failure-message.ts +17 -0
- package/src/format.ts +13 -0
- package/src/http.ts +55 -0
- package/src/lark/agent-runtime.ts +142 -0
- package/src/lark/cli.ts +549 -0
- package/src/lark/credentials.ts +105 -0
- package/src/lark/daemon-integration.ts +108 -0
- package/src/lark/dispatcher.ts +374 -0
- package/src/lark/event-router.ts +329 -0
- package/src/lark/inbound-events.ts +131 -0
- package/src/lark/server-integration.ts +445 -0
- package/src/lark/setup.ts +326 -0
- package/src/lark/ws-daemon.ts +224 -0
- package/src/lark-fixture-diagnostics.ts +56 -0
- package/src/lark-fixture.ts +277 -0
- package/src/local-api.ts +155 -0
- package/src/local-auth.ts +45 -0
- package/src/migrations/001_initial.ts +61 -0
- package/src/migrations/002_daemon_deliveries.ts +52 -0
- package/src/migrations/003_sessions_runs.ts +49 -0
- package/src/migrations/004_message_idempotency.ts +21 -0
- package/src/migrations/005_artifacts.ts +24 -0
- package/src/migrations/006_lark_channel_foundation.ts +119 -0
- package/src/migrations/007_agents_a0.ts +17 -0
- package/src/migrations/008_b0_chat_history.ts +31 -0
- package/src/migrations/009_b0_transcript_ingest_seq.ts +35 -0
- package/src/migrations/010_b0_transcript_shadow_external_ids.ts +32 -0
- package/src/migrations/011_b0_channel_conversation_audit_only.ts +27 -0
- package/src/migrations/012_b0_cross_conversation_invariant.ts +45 -0
- package/src/migrations/013_b1_0_eng_inbound_raw_events.ts +56 -0
- package/src/migrations/014_agents_runtime.ts +10 -0
- package/src/migrations/015_agent_runtime_sessions.ts +15 -0
- package/src/migrations/016_room_participants.ts +27 -0
- package/src/migrations/017_unified_room_delivery.ts +203 -0
- package/src/migrations/018_room_display_names.ts +36 -0
- package/src/migrations/019_computer_connections.ts +63 -0
- package/src/migrations/020_computer_agent_assignments.ts +20 -0
- package/src/migrations/021_provider_identity_bindings.ts +32 -0
- package/src/migrations.ts +85 -0
- package/src/neeko.ts +23 -0
- package/src/provider-identity.ts +40 -0
- package/src/runtime-registry.ts +41 -0
- package/src/server-auth.ts +13 -0
- package/src/server.ts +63 -0
- package/src/token-file.ts +57 -0
- package/src/types.ts +408 -0
- package/src/web.ts +565 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { MessageStore, CreateMessageInput } from '../db.js';
|
|
2
|
+
import type { Message } from '../types.js';
|
|
3
|
+
import { palIdentityHandle } from '../provider-identity.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a Lark `im.message.receive_v1` event into a `CreateMessageInput`
|
|
7
|
+
* suitable for `MessageStore.createMessage`.
|
|
8
|
+
*
|
|
9
|
+
* Mapping rules (M2 minimal viable):
|
|
10
|
+
*
|
|
11
|
+
* | Lark field | pal field |
|
|
12
|
+
* |-----------------------------------|---------------------------|
|
|
13
|
+
* | message.chat_id | chatName (prefixed `lark:`) |
|
|
14
|
+
* | message.message_id | idempotencyKey |
|
|
15
|
+
* | sender.sender_id.open_id | sender |
|
|
16
|
+
* | message.mentions[0]?.id.open_id | recipient (first @mention)|
|
|
17
|
+
* | message.content (parsed JSON text)| content |
|
|
18
|
+
* | message.root_id (thread reply) | parentId via lookup |
|
|
19
|
+
*
|
|
20
|
+
* Thread mapping: when `message.root_id` is set (i.e. this is a reply inside
|
|
21
|
+
* a Lark thread/topic), we look up the previously-stored lock message whose
|
|
22
|
+
* idempotency_key matches `root_id`. If found, the new message becomes a
|
|
23
|
+
* topic (depth=1, parent=root). If the root is not yet stored (out-of-order
|
|
24
|
+
* delivery), the reply is stored at top-level under the same chat, with the
|
|
25
|
+
* Lark root_id preserved in the `recipient` field as `root:<id>` so a later
|
|
26
|
+
* backfill can wire it up.
|
|
27
|
+
*
|
|
28
|
+
* Idempotency: Lark `message_id` is guaranteed-unique per tenant; we feed it
|
|
29
|
+
* into `idempotency_key` so any retried delivery gets coalesced by the
|
|
30
|
+
* existing dedupe path in `createMessage`.
|
|
31
|
+
*/
|
|
32
|
+
export interface LarkMessageEnvelope {
|
|
33
|
+
sender?: {
|
|
34
|
+
sender_id?: { open_id?: string; user_id?: string; union_id?: string };
|
|
35
|
+
sender_type?: string;
|
|
36
|
+
};
|
|
37
|
+
message?: {
|
|
38
|
+
message_id?: string;
|
|
39
|
+
chat_id?: string;
|
|
40
|
+
chat_type?: string;
|
|
41
|
+
root_id?: string;
|
|
42
|
+
thread_id?: string;
|
|
43
|
+
parent_id?: string;
|
|
44
|
+
message_type?: string;
|
|
45
|
+
create_time?: string;
|
|
46
|
+
content?: string;
|
|
47
|
+
mentions?: Array<{ id?: { open_id?: string; user_id?: string }; name?: string; key?: string }>;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MapLarkMessageInput {
|
|
52
|
+
/** appId of the receiving bot; used to namespace chat names so multiple bots don't collide. */
|
|
53
|
+
appId: string;
|
|
54
|
+
envelope: LarkMessageEnvelope;
|
|
55
|
+
recipientByMentionOpenId?: Map<string, string>;
|
|
56
|
+
recipientOverride?: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MapLarkMessageResult {
|
|
60
|
+
status: 'ok' | 'skipped';
|
|
61
|
+
reason?: 'missing_message_id' | 'missing_chat_id' | 'missing_sender' | 'unsupported_message_type' | 'empty_text';
|
|
62
|
+
input?: CreateMessageInput;
|
|
63
|
+
/** Lark root_id when present (for thread replies). The router pre-resolves the parentId from it if possible. */
|
|
64
|
+
rootMessageId?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function buildLockChatName(appId: string, chatId: string, chatType?: string): string {
|
|
68
|
+
if (chatType === 'group') {
|
|
69
|
+
return `lark:group:${chatId}`;
|
|
70
|
+
}
|
|
71
|
+
return `lark:${appId}:${chatId}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Extract @mention open_ids from a Lark envelope (may be empty). */
|
|
75
|
+
export function extractMentionOpenIds(envelope: LarkMessageEnvelope): string[] {
|
|
76
|
+
const mentions = envelope.message?.mentions ?? [];
|
|
77
|
+
const ids: string[] = [];
|
|
78
|
+
for (const m of mentions) {
|
|
79
|
+
const id = m.id?.open_id;
|
|
80
|
+
if (typeof id === 'string' && id.length > 0) ids.push(id);
|
|
81
|
+
}
|
|
82
|
+
return ids;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeMappedMentions(values: Array<string | null>): string[] {
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
const seen = new Set<string>();
|
|
88
|
+
for (const value of values) {
|
|
89
|
+
if (!value || seen.has(value)) continue;
|
|
90
|
+
seen.add(value);
|
|
91
|
+
out.push(value);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseLarkTextContent(rawContent: string | undefined, messageType: string | undefined): string {
|
|
97
|
+
if (!rawContent) return '';
|
|
98
|
+
if (messageType !== 'text' && messageType !== undefined) {
|
|
99
|
+
// Non-text messages: keep the raw JSON so downstream agents see *something*.
|
|
100
|
+
return rawContent.length > 4000 ? rawContent.slice(0, 4000) + '…' : rawContent;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const parsed = JSON.parse(rawContent) as { text?: unknown };
|
|
104
|
+
if (typeof parsed.text === 'string') return parsed.text;
|
|
105
|
+
return rawContent;
|
|
106
|
+
} catch {
|
|
107
|
+
return rawContent;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pure mapper: no DB access. Returns a `CreateMessageInput` skeleton (without
|
|
113
|
+
* parentId resolution — that happens in `resolveThreadParent`).
|
|
114
|
+
*/
|
|
115
|
+
export function mapLarkMessageToCreateInput(input: MapLarkMessageInput): MapLarkMessageResult {
|
|
116
|
+
const envelope = input.envelope;
|
|
117
|
+
const msg = envelope.message;
|
|
118
|
+
const sender = envelope.sender;
|
|
119
|
+
if (!msg?.message_id) return { status: 'skipped', reason: 'missing_message_id' };
|
|
120
|
+
if (!msg.chat_id) return { status: 'skipped', reason: 'missing_chat_id' };
|
|
121
|
+
const senderOpenId = sender?.sender_id?.open_id;
|
|
122
|
+
if (!senderOpenId) return { status: 'skipped', reason: 'missing_sender' };
|
|
123
|
+
|
|
124
|
+
const text = parseLarkTextContent(msg.content, msg.message_type);
|
|
125
|
+
if (!text.trim()) return { status: 'skipped', reason: 'empty_text' };
|
|
126
|
+
|
|
127
|
+
const firstMention = msg.mentions?.find((m) => m.id?.open_id)?.id?.open_id;
|
|
128
|
+
const mappedMentions = normalizeMappedMentions(msg.mentions?.map((m) => m.id?.open_id ? input.recipientByMentionOpenId?.get(m.id.open_id) ?? null : null) ?? []);
|
|
129
|
+
const recipient = input.recipientOverride !== undefined
|
|
130
|
+
? input.recipientOverride
|
|
131
|
+
: firstMention ? input.recipientByMentionOpenId?.get(firstMention) ?? firstMention : null;
|
|
132
|
+
const chatName = buildLockChatName(input.appId, msg.chat_id, msg.chat_type);
|
|
133
|
+
const rootMessageId = msg.root_id?.trim() || undefined;
|
|
134
|
+
|
|
135
|
+
const createInput: CreateMessageInput = {
|
|
136
|
+
chatName,
|
|
137
|
+
sender: senderOpenId,
|
|
138
|
+
recipient,
|
|
139
|
+
content: text,
|
|
140
|
+
type: 'message',
|
|
141
|
+
idempotencyKey: msg.message_id,
|
|
142
|
+
provider: 'lark',
|
|
143
|
+
mentions: mappedMentions,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
status: 'ok',
|
|
148
|
+
input: createInput,
|
|
149
|
+
rootMessageId,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve a Lark `root_id` to a previously-stored lock message (by
|
|
155
|
+
* `idempotency_key == root_id`). Returns null if not found.
|
|
156
|
+
*
|
|
157
|
+
* We use `messages.idempotency_key` here because the Lark message_id of the
|
|
158
|
+
* root was stored there when the root was originally ingested. This is
|
|
159
|
+
* deterministic and avoids needing a side-table.
|
|
160
|
+
*/
|
|
161
|
+
export function resolveLarkRootMessage(store: MessageStore, rootLarkMessageId: string): Message | null {
|
|
162
|
+
const row = store.db
|
|
163
|
+
.query(
|
|
164
|
+
`SELECT m.*, c.name AS chat_name
|
|
165
|
+
FROM messages m
|
|
166
|
+
JOIN chats c ON c.id = m.chat_id
|
|
167
|
+
WHERE m.idempotency_key = ? AND m.parent_id IS NULL
|
|
168
|
+
LIMIT 1`,
|
|
169
|
+
)
|
|
170
|
+
.get(rootLarkMessageId) as Record<string, unknown> | null;
|
|
171
|
+
if (!row) return null;
|
|
172
|
+
return rowToMessage(row);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Local copy of rowToMessage shape to avoid importing db.ts internals. Keep
|
|
176
|
+
// in sync with src/db.ts:rowToMessage().
|
|
177
|
+
function rowToMessage(row: Record<string, unknown>): Message {
|
|
178
|
+
return {
|
|
179
|
+
id: Number(row.id),
|
|
180
|
+
chat_id: String(row.chat_id),
|
|
181
|
+
chat_name: String(row.chat_name),
|
|
182
|
+
parent_id: row.parent_id === null || row.parent_id === undefined ? null : Number(row.parent_id),
|
|
183
|
+
depth: Number(row.depth) as 0 | 1,
|
|
184
|
+
sender: String(row.sender),
|
|
185
|
+
recipient: row.recipient === null || row.recipient === undefined ? null : String(row.recipient),
|
|
186
|
+
content: String(row.content),
|
|
187
|
+
type: row.type === 'system' ? 'system' : 'message',
|
|
188
|
+
created_at: String(row.created_at),
|
|
189
|
+
idempotency_key: row.idempotency_key === null || row.idempotency_key === undefined ? null : String(row.idempotency_key),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Full pipeline: map + thread resolution + insert. Returns the resulting
|
|
195
|
+
* Message (either freshly inserted or coalesced via idempotency).
|
|
196
|
+
*
|
|
197
|
+
* Returns null when the event was skipped (non-mappable; e.g. missing fields).
|
|
198
|
+
*/
|
|
199
|
+
export interface IngestLarkMessageInput extends MapLarkMessageInput {
|
|
200
|
+
store: MessageStore;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface IngestLarkMessageResult {
|
|
204
|
+
status: 'ok' | 'skipped';
|
|
205
|
+
reason?: MapLarkMessageResult['reason'];
|
|
206
|
+
message?: Message;
|
|
207
|
+
/** True when the root_id was set but no parent message was found in the local DB. */
|
|
208
|
+
threadOrphan?: boolean;
|
|
209
|
+
/** True when createMessage coalesced via idempotency (i.e. this Lark message_id was already stored). */
|
|
210
|
+
deduped?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function ingestLarkMessage(input: IngestLarkMessageInput): IngestLarkMessageResult {
|
|
214
|
+
const account = input.store.getChannelAccountByAppId(input.appId);
|
|
215
|
+
const providerAccountId = account?.provider_account_id ?? null;
|
|
216
|
+
const senderOpenId = input.envelope.sender?.sender_id?.open_id?.trim() ?? '';
|
|
217
|
+
const senderKind = input.envelope.sender?.sender_type === 'app' ? 'bot' : 'user';
|
|
218
|
+
const senderDisplayName = input.envelope.sender?.sender_id?.union_id ?? input.envelope.sender?.sender_id?.user_id ?? null;
|
|
219
|
+
const senderHandle = providerAccountId && senderOpenId
|
|
220
|
+
? input.store.getOrCreateProviderIdentity({
|
|
221
|
+
provider: 'lark',
|
|
222
|
+
providerAccountId,
|
|
223
|
+
externalType: senderKind,
|
|
224
|
+
externalId: senderOpenId,
|
|
225
|
+
displayName: senderDisplayName,
|
|
226
|
+
}).identity.stable_handle
|
|
227
|
+
: senderOpenId ? palIdentityHandle(senderKind, `lark:${input.appId}:${senderOpenId}`) : '';
|
|
228
|
+
const mentionHandles = new Map<string, string>();
|
|
229
|
+
for (const mention of input.envelope.message?.mentions ?? []) {
|
|
230
|
+
const mentionOpenId = mention.id?.open_id?.trim();
|
|
231
|
+
if (!mentionOpenId) continue;
|
|
232
|
+
if (input.recipientByMentionOpenId?.has(mentionOpenId)) continue;
|
|
233
|
+
const externalType = account?.bot_open_id === mentionOpenId ? 'bot' : 'user';
|
|
234
|
+
const handle = providerAccountId
|
|
235
|
+
? input.store.getOrCreateProviderIdentity({
|
|
236
|
+
provider: 'lark',
|
|
237
|
+
providerAccountId,
|
|
238
|
+
externalType,
|
|
239
|
+
externalId: mentionOpenId,
|
|
240
|
+
displayName: mention.name,
|
|
241
|
+
}).identity.stable_handle
|
|
242
|
+
: palIdentityHandle(externalType, `lark:${input.appId}:${mentionOpenId}`);
|
|
243
|
+
mentionHandles.set(mentionOpenId, handle);
|
|
244
|
+
}
|
|
245
|
+
const recipientByMentionOpenId = new Map(input.recipientByMentionOpenId ?? []);
|
|
246
|
+
for (const [openId, handle] of mentionHandles) {
|
|
247
|
+
recipientByMentionOpenId.set(openId, handle);
|
|
248
|
+
}
|
|
249
|
+
const mapped = mapLarkMessageToCreateInput({
|
|
250
|
+
appId: input.appId,
|
|
251
|
+
envelope: input.envelope,
|
|
252
|
+
recipientByMentionOpenId,
|
|
253
|
+
recipientOverride: input.recipientOverride,
|
|
254
|
+
});
|
|
255
|
+
if (mapped.status !== 'ok' || !mapped.input) {
|
|
256
|
+
return { status: 'skipped', reason: mapped.reason };
|
|
257
|
+
}
|
|
258
|
+
let parentResolved: Message | null = null;
|
|
259
|
+
let threadOrphan = false;
|
|
260
|
+
if (mapped.rootMessageId) {
|
|
261
|
+
parentResolved = resolveLarkRootMessage(input.store, mapped.rootMessageId);
|
|
262
|
+
if (!parentResolved) {
|
|
263
|
+
threadOrphan = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const createInput: CreateMessageInput = parentResolved
|
|
267
|
+
? {
|
|
268
|
+
parentId: parentResolved.id,
|
|
269
|
+
sender: senderHandle || mapped.input.sender,
|
|
270
|
+
recipient: mapped.input.recipient,
|
|
271
|
+
content: mapped.input.content,
|
|
272
|
+
type: mapped.input.type,
|
|
273
|
+
idempotencyKey: mapped.input.idempotencyKey,
|
|
274
|
+
}
|
|
275
|
+
: threadOrphan
|
|
276
|
+
? {
|
|
277
|
+
chatName: mapped.input.chatName,
|
|
278
|
+
sender: senderHandle || mapped.input.sender,
|
|
279
|
+
recipient: `root:${mapped.rootMessageId}`,
|
|
280
|
+
content: mapped.input.content,
|
|
281
|
+
type: mapped.input.type,
|
|
282
|
+
idempotencyKey: mapped.input.idempotencyKey,
|
|
283
|
+
}
|
|
284
|
+
: { ...mapped.input, sender: senderHandle || mapped.input.sender };
|
|
285
|
+
|
|
286
|
+
let deduped = false;
|
|
287
|
+
if (createInput.idempotencyKey) {
|
|
288
|
+
const existing = input.store.db
|
|
289
|
+
.query(
|
|
290
|
+
`SELECT m.*, c.name AS chat_name
|
|
291
|
+
FROM messages m
|
|
292
|
+
JOIN chats c ON c.id = m.chat_id
|
|
293
|
+
WHERE m.idempotency_key = ?`,
|
|
294
|
+
)
|
|
295
|
+
.get(createInput.idempotencyKey) as Record<string, unknown> | null;
|
|
296
|
+
if (existing) {
|
|
297
|
+
deduped = true;
|
|
298
|
+
return {
|
|
299
|
+
status: 'ok',
|
|
300
|
+
message: rowToMessage(existing),
|
|
301
|
+
threadOrphan,
|
|
302
|
+
deduped,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
const message = input.store.createMessage(createInput);
|
|
307
|
+
input.store.upsertRoomParticipant({
|
|
308
|
+
roomId: message.chat_id,
|
|
309
|
+
participantId: message.sender,
|
|
310
|
+
kind: senderKind,
|
|
311
|
+
displayName: senderDisplayName,
|
|
312
|
+
source: 'event',
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
for (const mention of input.envelope.message?.mentions ?? []) {
|
|
316
|
+
const mentionOpenId = mention.id?.open_id;
|
|
317
|
+
if (!mentionOpenId) continue;
|
|
318
|
+
const participantId = input.recipientByMentionOpenId?.get(mentionOpenId) ?? mentionHandles.get(mentionOpenId);
|
|
319
|
+
if (!participantId) continue;
|
|
320
|
+
input.store.upsertRoomParticipant({
|
|
321
|
+
roomId: message.chat_id,
|
|
322
|
+
participantId,
|
|
323
|
+
kind: account?.bot_open_id === mentionOpenId ? 'bot' : 'user',
|
|
324
|
+
displayName: mention.name,
|
|
325
|
+
source: account?.bot_open_id === mentionOpenId ? 'known_bot' : 'event',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
return { status: 'ok', message, threadOrphan, deduped };
|
|
329
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { Database } from 'bun:sqlite';
|
|
2
|
+
|
|
3
|
+
export interface InboundRawEvent {
|
|
4
|
+
id: string;
|
|
5
|
+
received_at: string;
|
|
6
|
+
app_id: string;
|
|
7
|
+
event_type: string;
|
|
8
|
+
event_id: string;
|
|
9
|
+
parse_ok: 0 | 1;
|
|
10
|
+
raw_body_bytes: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface StoreInboundEventInput {
|
|
14
|
+
appId: string;
|
|
15
|
+
rawBody: string | Uint8Array;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StoreInboundEventResult {
|
|
19
|
+
id: string;
|
|
20
|
+
event_id: string;
|
|
21
|
+
event_type: string;
|
|
22
|
+
parse_ok: 0 | 1;
|
|
23
|
+
inserted: boolean;
|
|
24
|
+
duplicate: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ENCODER = new TextEncoder();
|
|
28
|
+
|
|
29
|
+
function toBytes(body: string | Uint8Array): Uint8Array {
|
|
30
|
+
return typeof body === 'string' ? ENCODER.encode(body) : body;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
34
|
+
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
|
35
|
+
const digest = await crypto.subtle.digest('SHA-256', buf);
|
|
36
|
+
const arr = Array.from(new Uint8Array(digest));
|
|
37
|
+
return arr.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ParsedEnvelope {
|
|
41
|
+
event_id: string;
|
|
42
|
+
event_type: string;
|
|
43
|
+
parse_ok: 0 | 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function parseEventEnvelope(rawBytes: Uint8Array): Promise<ParsedEnvelope> {
|
|
47
|
+
try {
|
|
48
|
+
const text = new TextDecoder('utf8', { fatal: false }).decode(rawBytes);
|
|
49
|
+
const json = JSON.parse(text) as Record<string, unknown>;
|
|
50
|
+
const header = (json.header && typeof json.header === 'object') ? json.header as Record<string, unknown> : null;
|
|
51
|
+
const eventId = (header?.event_id ?? json.uuid) as unknown;
|
|
52
|
+
const eventType = (header?.event_type ?? json.type) as unknown;
|
|
53
|
+
if (typeof eventId === 'string' && eventId.length > 0 && typeof eventType === 'string' && eventType.length > 0) {
|
|
54
|
+
return { event_id: eventId, event_type: eventType, parse_ok: 1 };
|
|
55
|
+
}
|
|
56
|
+
const fallbackId = `sha256:${await sha256Hex(rawBytes)}`;
|
|
57
|
+
return {
|
|
58
|
+
event_id: fallbackId,
|
|
59
|
+
event_type: typeof eventType === 'string' ? eventType : 'unknown',
|
|
60
|
+
parse_ok: 0,
|
|
61
|
+
};
|
|
62
|
+
} catch {
|
|
63
|
+
const fallbackId = `sha256:${await sha256Hex(rawBytes)}`;
|
|
64
|
+
return { event_id: fallbackId, event_type: 'unknown', parse_ok: 0 };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Insert a raw Lark event into channel_inbound_raw_events.
|
|
70
|
+
*
|
|
71
|
+
* Deduplication: the partial UNIQUE index on event_id (WHERE event_id IS NOT
|
|
72
|
+
* NULL) backs an ON CONFLICT DO NOTHING insert. A duplicate event_id returns
|
|
73
|
+
* { inserted: false, duplicate: true } so the caller can skip side effects.
|
|
74
|
+
*/
|
|
75
|
+
export async function storeInboundEvent(db: Database, input: StoreInboundEventInput): Promise<StoreInboundEventResult> {
|
|
76
|
+
const rawBytes = toBytes(input.rawBody);
|
|
77
|
+
const envelope = await parseEventEnvelope(rawBytes);
|
|
78
|
+
const id = crypto.randomUUID();
|
|
79
|
+
const result = db
|
|
80
|
+
.query(
|
|
81
|
+
`INSERT INTO channel_inbound_raw_events (id, app_id, event_type, event_id, parse_ok, raw_body_bytes)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
83
|
+
ON CONFLICT(event_id) WHERE event_id IS NOT NULL DO NOTHING
|
|
84
|
+
RETURNING id`,
|
|
85
|
+
)
|
|
86
|
+
.get(id, input.appId, envelope.event_type, envelope.event_id, envelope.parse_ok, rawBytes) as { id: string } | null;
|
|
87
|
+
|
|
88
|
+
if (result) {
|
|
89
|
+
return {
|
|
90
|
+
id: result.id,
|
|
91
|
+
event_id: envelope.event_id,
|
|
92
|
+
event_type: envelope.event_type,
|
|
93
|
+
parse_ok: envelope.parse_ok,
|
|
94
|
+
inserted: true,
|
|
95
|
+
duplicate: false,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const existing = db
|
|
99
|
+
.query('SELECT id FROM channel_inbound_raw_events WHERE event_id = ? LIMIT 1')
|
|
100
|
+
.get(envelope.event_id) as { id: string } | null;
|
|
101
|
+
return {
|
|
102
|
+
id: existing?.id ?? id,
|
|
103
|
+
event_id: envelope.event_id,
|
|
104
|
+
event_type: envelope.event_type,
|
|
105
|
+
parse_ok: envelope.parse_ok,
|
|
106
|
+
inserted: false,
|
|
107
|
+
duplicate: true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function getInboundEvent(db: Database, id: string): InboundRawEvent | null {
|
|
112
|
+
const row = db
|
|
113
|
+
.query('SELECT id, received_at, app_id, event_type, event_id, parse_ok, raw_body_bytes FROM channel_inbound_raw_events WHERE id = ?')
|
|
114
|
+
.get(id) as InboundRawEvent | null;
|
|
115
|
+
return row;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function listRecentInboundEvents(db: Database, limit = 20): InboundRawEvent[] {
|
|
119
|
+
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, id DESC LIMIT ?')
|
|
121
|
+
.all(Math.max(1, Math.min(limit, 500))) as InboundRawEvent[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function countInboundEvents(db: Database, appId?: string): number {
|
|
125
|
+
if (appId) {
|
|
126
|
+
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events WHERE app_id = ?').get(appId) as { n: number };
|
|
127
|
+
return row.n;
|
|
128
|
+
}
|
|
129
|
+
const row = db.query('SELECT COUNT(*) AS n FROM channel_inbound_raw_events').get() as { n: number };
|
|
130
|
+
return row.n;
|
|
131
|
+
}
|