@inetafrica/open-claudia 2.0.5 → 2.1.1

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/core/loopback.js CHANGED
@@ -18,6 +18,15 @@ const { CONFIG_DIR } = require("./config");
18
18
  const jobsStore = require("./jobs");
19
19
  const tasksStore = require("./tasks");
20
20
  const scheduler = require("./scheduler");
21
+ const peopleStore = require("./people");
22
+ const intros = require("./intros");
23
+ const introFlow = require("./intro-flow");
24
+ const relay = require("./relay");
25
+ const audit = require("./audit");
26
+ const access = require("./access");
27
+ const fs2 = require("fs");
28
+ const path2 = require("path");
29
+ const { TRANSCRIPTS_DIR } = require("./config");
21
30
 
22
31
  const INFO_DIR = path.join(CONFIG_DIR, "loopback");
23
32
  const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
@@ -65,8 +74,20 @@ const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
65
74
  const JSON_KINDS = new Set([
66
75
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
67
76
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-tree", "task-clear-completed",
77
+ "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
78
+ "people-remove", "people-set-primary",
79
+ "intros-list", "intros-approve", "intros-reject",
80
+ "relay-send", "recent-fetch", "audit-tail",
81
+ "auth-list", "auth-revoke",
68
82
  ]);
69
83
 
84
+ function callerIsOwner(payload) {
85
+ const caller = payload?.canonicalUserId;
86
+ if (!caller) return false;
87
+ const person = peopleStore.findByCanonicalUserId(caller);
88
+ return !!(person && person.isOwner);
89
+ }
90
+
70
91
  function readBodyAsString(req, max = 64 * 1024) {
71
92
  return new Promise((resolve, reject) => {
72
93
  let buf = "";
@@ -226,9 +247,247 @@ async function handleJson(req, res, url, kind) {
226
247
  return reply(res, 200, { ok: true, tasks: remaining });
227
248
  }
228
249
 
250
+ if (kind === "people-list") {
251
+ return reply(res, 200, { ok: true, people: peopleStore.roster() });
252
+ }
253
+
254
+ if (kind === "people-show") {
255
+ const ref = payload.id || payload.name || null;
256
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
257
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
258
+ if (!person) return reply(res, 404, { error: "not found" });
259
+ return reply(res, 200, { ok: true, person });
260
+ }
261
+
262
+ if (kind === "people-add") {
263
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
264
+ if (!payload.name) return reply(res, 400, { error: "missing name" });
265
+ try {
266
+ const p = peopleStore.add({ name: payload.name, bio: payload.bio || null, isOwner: !!payload.isOwner });
267
+ audit.log("people.add", { by: payload.canonicalUserId, personId: p.id, name: p.name });
268
+ return reply(res, 200, { ok: true, person: p });
269
+ } catch (e) { return reply(res, 400, { error: e.message }); }
270
+ }
271
+
272
+ if (kind === "people-note") {
273
+ const ref = payload.id || payload.name;
274
+ if (!ref || !payload.text) return reply(res, 400, { error: "missing id/name or text" });
275
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
276
+ if (!person) return reply(res, 404, { error: "not found" });
277
+ const entry = peopleStore.note(person.id, payload.text, { by: payload.canonicalUserId || null });
278
+ audit.log("people.note", { by: payload.canonicalUserId, personId: person.id });
279
+ return reply(res, 200, { ok: true, note: entry });
280
+ }
281
+
282
+ if (kind === "people-link") {
283
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
284
+ const ref = payload.id || payload.name;
285
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
286
+ if (!payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing linkAdapter/linkChannelId" });
287
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
288
+ if (!person) return reply(res, 404, { error: "not found" });
289
+ try {
290
+ const updated = peopleStore.linkHandle(person.id, {
291
+ adapter: payload.linkAdapter,
292
+ channelId: payload.linkChannelId,
293
+ displayName: payload.displayName || null,
294
+ approvedBy: payload.canonicalUserId || null,
295
+ });
296
+ audit.log("people.link", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId });
297
+ return reply(res, 200, { ok: true, person: updated });
298
+ } catch (e) { return reply(res, 400, { error: e.message }); }
299
+ }
300
+
301
+ if (kind === "people-unlink") {
302
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
303
+ const ref = payload.id || payload.name;
304
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
305
+ if (!payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing linkAdapter/linkChannelId" });
306
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
307
+ if (!person) return reply(res, 404, { error: "not found" });
308
+ const updated = peopleStore.unlinkHandle(person.id, { adapter: payload.linkAdapter, channelId: payload.linkChannelId });
309
+ if (!updated) return reply(res, 404, { error: "handle not found on person" });
310
+ let revoked = null;
311
+ if (!person.isOwner) {
312
+ revoked = access.revokeChat(payload.linkChannelId);
313
+ }
314
+ audit.log("people.unlink", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId, authRevoked: revoked });
315
+ return reply(res, 200, { ok: true, person: updated, authRevoked: !!(revoked && revoked.ok) });
316
+ }
317
+
318
+ if (kind === "people-set-primary") {
319
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
320
+ const ref = payload.id || payload.name;
321
+ if (!ref || !payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing fields" });
322
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
323
+ if (!person) return reply(res, 404, { error: "not found" });
324
+ try {
325
+ const updated = peopleStore.setPrimary(person.id, { adapter: payload.linkAdapter, channelId: payload.linkChannelId });
326
+ audit.log("people.set-primary", { by: payload.canonicalUserId, personId: person.id });
327
+ return reply(res, 200, { ok: true, person: updated });
328
+ } catch (e) { return reply(res, 400, { error: e.message }); }
329
+ }
330
+
331
+ if (kind === "people-remove") {
332
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
333
+ const ref = payload.id || payload.name;
334
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
335
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
336
+ if (!person) return reply(res, 404, { error: "not found" });
337
+ if (person.isOwner) return reply(res, 400, { error: "refusing to delete owner record" });
338
+ const handles = (person.handles || []).slice();
339
+ peopleStore.remove(person.id);
340
+ let revokedCount = 0;
341
+ for (const h of handles) {
342
+ const r = access.revokeChat(h.channelId);
343
+ if (r && r.ok) revokedCount += 1;
344
+ }
345
+ audit.log("people.remove", { by: payload.canonicalUserId, personId: person.id, name: person.name, revokedCount, handles });
346
+ return reply(res, 200, { ok: true, removed: person, revokedCount });
347
+ }
348
+
349
+ if (kind === "auth-list") {
350
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
351
+ const raw = access.listAuthorizedRaw();
352
+ const enriched = raw.authorized.map((a) => {
353
+ const person = peopleStore.findByHandle("telegram", a.chatId) || peopleStore.findByHandle("kazee", a.chatId);
354
+ return { ...a, personId: person?.id || null, personName: person?.name || null };
355
+ });
356
+ return reply(res, 200, { ok: true, authorized: enriched, pending: raw.pending });
357
+ }
358
+
359
+ if (kind === "auth-revoke") {
360
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
361
+ const chatId = payload.chatId;
362
+ if (!chatId) return reply(res, 400, { error: "missing chatId" });
363
+ const person = peopleStore.findByHandle("telegram", chatId) || peopleStore.findByHandle("kazee", chatId);
364
+ if (person && person.isOwner) return reply(res, 400, { error: "refusing to revoke owner chat" });
365
+ if (person) {
366
+ const detected = (person.handles || []).find((h) => String(h.channelId) === String(chatId));
367
+ if (detected) {
368
+ peopleStore.unlinkHandle(person.id, { adapter: detected.adapter, channelId: detected.channelId });
369
+ }
370
+ }
371
+ const result = access.revokeChat(chatId);
372
+ audit.log("auth.revoke", { by: payload.canonicalUserId, chatId, personId: person?.id || null, result });
373
+ return reply(res, result.ok ? 200 : 404, { ...result, personId: person?.id || null });
374
+ }
375
+
376
+ if (kind === "intros-list") {
377
+ intros.sweep();
378
+ return reply(res, 200, { ok: true, intros: intros.listPending() });
379
+ }
380
+
381
+ if (kind === "intros-approve") {
382
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
383
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
384
+ const intro = intros.byId(payload.id);
385
+ if (!intro) return reply(res, 404, { error: "intro not found" });
386
+ try {
387
+ const result = await introFlow.applyApproval(intro, payload.canonicalUserId || "owner");
388
+ await introFlow.notifyIntroUser(intro, `You're in. Welcome${result.person ? ", " + result.person.name : ""}.`);
389
+ return reply(res, 200, { ok: true, person: result.person });
390
+ } catch (e) { return reply(res, 400, { error: e.message }); }
391
+ }
392
+
393
+ if (kind === "intros-reject") {
394
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
395
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
396
+ const intro = intros.byId(payload.id);
397
+ if (!intro) return reply(res, 404, { error: "intro not found" });
398
+ intros.reject(intro.id, payload.canonicalUserId || "owner", payload.reason || null);
399
+ await introFlow.notifyIntroUser(intro, "Your access request was denied.");
400
+ audit.log("intro.rejected", { introId: intro.id, by: payload.canonicalUserId });
401
+ return reply(res, 200, { ok: true });
402
+ }
403
+
404
+ if (kind === "relay-send") {
405
+ if (!payload.text) return reply(res, 400, { error: "missing text" });
406
+ try {
407
+ const result = await relay.send({
408
+ text: payload.text,
409
+ from: payload.canonicalUserId || null,
410
+ target: {
411
+ personId: payload.targetPersonId || null,
412
+ personName: payload.targetPersonName || null,
413
+ canonicalUserId: payload.targetCanonicalUserId || null,
414
+ adapter: payload.targetAdapter || null,
415
+ channelId: payload.targetChannelId || null,
416
+ },
417
+ kind: "relay",
418
+ });
419
+ return reply(res, 200, { ok: true, ...result, to: { adapter: result.to.adapter, channelId: result.to.channelId, person: result.to.person ? { id: result.to.person.id, name: result.to.person.name } : null } });
420
+ } catch (e) { return reply(res, 400, { error: e.message }); }
421
+ }
422
+
423
+ if (kind === "recent-fetch") {
424
+ const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
425
+ try {
426
+ let targetAdapter = payload.adapter;
427
+ let targetChannel = payload.channelId;
428
+ if (payload.personName || payload.personId) {
429
+ const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
430
+ if (!person) return reply(res, 404, { error: "person not found" });
431
+ const handle = person.primaryChannel
432
+ ? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
433
+ : (person.handles || [])[0];
434
+ if (!handle) return reply(res, 404, { error: "person has no handles" });
435
+ targetAdapter = handle.adapter;
436
+ targetChannel = handle.channelId;
437
+ }
438
+ if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
439
+ const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
440
+ return reply(res, 200, { ok: true, adapter: targetAdapter, channelId: targetChannel, entries });
441
+ } catch (e) { return reply(res, 400, { error: e.message }); }
442
+ }
443
+
444
+ if (kind === "audit-tail") {
445
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
446
+ const n = Math.max(1, Math.min(parseInt(payload.limit || 50, 10) || 50, 500));
447
+ return reply(res, 200, { ok: true, entries: audit.tail(n) });
448
+ }
449
+
229
450
  return reply(res, 404, { error: "not found" });
230
451
  }
231
452
 
453
+ function readRecentTranscript(adapter, channelId, limit) {
454
+ if (!TRANSCRIPTS_DIR) return [];
455
+ try {
456
+ if (!fs2.existsSync(TRANSCRIPTS_DIR)) return [];
457
+ const candidates = fs2.readdirSync(TRANSCRIPTS_DIR)
458
+ .filter((f) => f.endsWith(".jsonl"))
459
+ .map((f) => path2.join(TRANSCRIPTS_DIR, f))
460
+ .map((p) => ({ p, mtime: fs2.statSync(p).mtimeMs }))
461
+ .sort((a, b) => b.mtime - a.mtime);
462
+ const out = [];
463
+ const adapterPrefix = String(adapter).toLowerCase();
464
+ const channelStr = String(channelId);
465
+ for (const { p } of candidates) {
466
+ if (out.length >= limit) break;
467
+ const raw = fs2.readFileSync(p, "utf-8").trim();
468
+ if (!raw) continue;
469
+ const lines = raw.split("\n").slice(-1000);
470
+ for (const line of lines) {
471
+ try {
472
+ const obj = JSON.parse(line);
473
+ const objAdapter = (obj.adapter || obj.adapterType || "").toLowerCase();
474
+ const objChannel = String(obj.channelId || "");
475
+ if (objAdapter && objAdapter !== adapterPrefix) continue;
476
+ if (objChannel && objChannel !== channelStr) continue;
477
+ out.push({
478
+ at: obj.at || obj.timestamp || null,
479
+ role: obj.role || obj.author || null,
480
+ text: typeof obj.text === "string" ? obj.text.slice(0, 600) : null,
481
+ kind: obj.kind || null,
482
+ });
483
+ if (out.length >= limit) break;
484
+ } catch (e) {}
485
+ }
486
+ }
487
+ return out.slice(-limit);
488
+ } catch (e) { return []; }
489
+ }
490
+
232
491
  async function handle(req, res) {
233
492
  try {
234
493
  if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
package/core/people.js ADDED
@@ -0,0 +1,247 @@
1
+ // People store: the roster of human team members the bot knows about.
2
+ // A person aggregates one or more "handles" (adapter + channelId pairs),
3
+ // optional notes, and a primary channel for outbound messages. Auth
4
+ // (can-this-chat-talk-to-me) stays in core/access.js; this layer adds
5
+ // identity, notes, and cross-channel grouping on top.
6
+ //
7
+ // Owner is just a person with isOwner=true. The owner is auto-seeded
8
+ // on first read from the existing auth.json (the bootstrap owner) and
9
+ // any KAZEE_OWNER_USER_ID in env.
10
+
11
+ const fs = require("fs");
12
+ const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
13
+ const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
14
+
15
+ function nowIso() { return new Date().toISOString(); }
16
+
17
+ function nextId() {
18
+ return `person_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
19
+ }
20
+
21
+ function load() {
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(PEOPLE_FILE, "utf-8"));
24
+ if (!Array.isArray(raw)) return [];
25
+ return raw;
26
+ } catch (e) { return []; }
27
+ }
28
+
29
+ function save(list) {
30
+ const tmp = `${PEOPLE_FILE}.tmp`;
31
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
32
+ fs.renameSync(tmp, PEOPLE_FILE);
33
+ }
34
+
35
+ function findById(id) {
36
+ return load().find((p) => p.id === id) || null;
37
+ }
38
+
39
+ function findByName(name) {
40
+ const needle = String(name || "").trim().toLowerCase();
41
+ if (!needle) return null;
42
+ return load().find((p) => (p.name || "").toLowerCase() === needle) || null;
43
+ }
44
+
45
+ function findByCanonicalUserId(canonicalUserId) {
46
+ const id = String(canonicalUserId || "").toLowerCase();
47
+ if (!id) return null;
48
+ return load().find((p) => (p.handles || []).some((h) => h.canonicalUserId === id)) || null;
49
+ }
50
+
51
+ function findByHandle(adapter, channelId) {
52
+ const a = String(adapter || "").toLowerCase();
53
+ const c = String(channelId || "");
54
+ return load().find((p) => (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c)) || null;
55
+ }
56
+
57
+ function list() { return load(); }
58
+
59
+ function add({ name, isOwner = false, bio = null }) {
60
+ const all = load();
61
+ const p = {
62
+ id: nextId(),
63
+ name: String(name || "").trim(),
64
+ isOwner: !!isOwner,
65
+ bio: bio ? String(bio) : null,
66
+ handles: [],
67
+ notes: [],
68
+ primaryChannel: null,
69
+ createdAt: nowIso(),
70
+ updatedAt: nowIso(),
71
+ };
72
+ if (!p.name) throw new Error("person name is required");
73
+ all.push(p);
74
+ save(all);
75
+ return p;
76
+ }
77
+
78
+ function update(id, patch) {
79
+ const all = load();
80
+ const idx = all.findIndex((p) => p.id === id);
81
+ if (idx < 0) return null;
82
+ all[idx] = { ...all[idx], ...patch, updatedAt: nowIso() };
83
+ save(all);
84
+ return all[idx];
85
+ }
86
+
87
+ function remove(id) {
88
+ const all = load();
89
+ const idx = all.findIndex((p) => p.id === id);
90
+ if (idx < 0) return null;
91
+ const [removed] = all.splice(idx, 1);
92
+ save(all);
93
+ return removed;
94
+ }
95
+
96
+ // Attach a channel handle to a person. Also writes the channel→canonical
97
+ // link in identities.json so the rest of the bot resolves the chat to
98
+ // the same canonical user id as any other handle on this person.
99
+ function linkHandle(personId, { adapter, channelId, displayName = null, approvedBy = null }) {
100
+ const all = load();
101
+ const p = all.find((x) => x.id === personId);
102
+ if (!p) throw new Error(`person not found: ${personId}`);
103
+ const a = String(adapter || "").toLowerCase();
104
+ const c = String(channelId || "");
105
+ if (!a || !c) throw new Error("adapter and channelId are required");
106
+
107
+ if (!Array.isArray(p.handles)) p.handles = [];
108
+ const conflict = all.find((other) =>
109
+ other.id !== p.id &&
110
+ (other.handles || []).some((h) => h.adapter === a && String(h.channelId) === c));
111
+ if (conflict) throw new Error(`handle ${a}:${c} already linked to ${conflict.name} (${conflict.id})`);
112
+
113
+ const canonical = canonicalForChannel(a, c);
114
+ const existing = p.handles.find((h) => h.adapter === a && String(h.channelId) === c);
115
+ if (!existing) {
116
+ p.handles.push({
117
+ adapter: a,
118
+ channelId: c,
119
+ canonicalUserId: canonical,
120
+ displayName,
121
+ approvedBy,
122
+ addedAt: nowIso(),
123
+ });
124
+ } else {
125
+ existing.canonicalUserId = canonical;
126
+ existing.displayName = displayName || existing.displayName;
127
+ }
128
+
129
+ if (!p.primaryChannel) p.primaryChannel = { adapter: a, channelId: c };
130
+ p.updatedAt = nowIso();
131
+ save(all);
132
+
133
+ // Mirror into identities.json so canonicalForChannel resolves cross-handle.
134
+ if (p.handles.length > 1) {
135
+ const primary = p.handles[0];
136
+ const primaryCanonical = primary.canonicalUserId || canonicalForChannel(primary.adapter, primary.channelId);
137
+ try { setIdentityMapping(a, c, primaryCanonical); } catch (e) {}
138
+ }
139
+
140
+ return p;
141
+ }
142
+
143
+ function unlinkHandle(personId, { adapter, channelId }) {
144
+ const all = load();
145
+ const p = all.find((x) => x.id === personId);
146
+ if (!p) return null;
147
+ const a = String(adapter || "").toLowerCase();
148
+ const c = String(channelId || "");
149
+ const before = (p.handles || []).length;
150
+ p.handles = (p.handles || []).filter((h) => !(h.adapter === a && String(h.channelId) === c));
151
+ if (p.primaryChannel && p.primaryChannel.adapter === a && String(p.primaryChannel.channelId) === c) {
152
+ p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
153
+ }
154
+ if (p.handles.length === before) return null;
155
+ p.updatedAt = nowIso();
156
+ save(all);
157
+ return p;
158
+ }
159
+
160
+ function setPrimary(personId, { adapter, channelId }) {
161
+ const all = load();
162
+ const p = all.find((x) => x.id === personId);
163
+ if (!p) return null;
164
+ const a = String(adapter || "").toLowerCase();
165
+ const c = String(channelId || "");
166
+ const has = (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c);
167
+ if (!has) throw new Error(`person ${p.id} has no handle ${a}:${c}`);
168
+ p.primaryChannel = { adapter: a, channelId: c };
169
+ p.updatedAt = nowIso();
170
+ save(all);
171
+ return p;
172
+ }
173
+
174
+ function note(personId, text, { by = null } = {}) {
175
+ const all = load();
176
+ const p = all.find((x) => x.id === personId);
177
+ if (!p) return null;
178
+ if (!Array.isArray(p.notes)) p.notes = [];
179
+ const entry = { at: nowIso(), by, text: String(text || "").trim() };
180
+ if (!entry.text) throw new Error("note text is required");
181
+ p.notes.push(entry);
182
+ p.updatedAt = nowIso();
183
+ save(all);
184
+ return entry;
185
+ }
186
+
187
+ function removeNote(personId, index) {
188
+ const all = load();
189
+ const p = all.find((x) => x.id === personId);
190
+ if (!p || !Array.isArray(p.notes)) return null;
191
+ if (index < 0 || index >= p.notes.length) return null;
192
+ const [removed] = p.notes.splice(index, 1);
193
+ p.updatedAt = nowIso();
194
+ save(all);
195
+ return removed;
196
+ }
197
+
198
+ function owners() { return load().filter((p) => p.isOwner); }
199
+
200
+ function hasOwnerRecord() { return owners().length > 0; }
201
+
202
+ // First-boot seeding: if people.json is empty but auth.json has an
203
+ // owner entry (the legacy bootstrap), promote that owner into a person
204
+ // record so the new system has a baseline before any intros happen.
205
+ function seedOwnerFromLegacy() {
206
+ if (hasOwnerRecord()) return null;
207
+ let legacyOwner = null;
208
+ try {
209
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
210
+ legacyOwner = (auth.authorized || []).find((a) => a.isOwner === true) || null;
211
+ } catch (e) {}
212
+ if (!legacyOwner) return null;
213
+
214
+ const adapter = "telegram";
215
+ const channelId = String(legacyOwner.chatId);
216
+ const name = legacyOwner.name || legacyOwner.username || "Owner";
217
+
218
+ const person = add({ name, isOwner: true, bio: "Seeded from legacy auth.json" });
219
+ try { linkHandle(person.id, { adapter, channelId, displayName: legacyOwner.username || null, approvedBy: "bootstrap" }); } catch (e) {}
220
+
221
+ const kazeeOwnerUserId = (config && config.KAZEE_OWNER_USER_ID) || "";
222
+ if (kazeeOwnerUserId) {
223
+ try { linkHandle(person.id, { adapter: "kazee", channelId: kazeeOwnerUserId, approvedBy: "bootstrap" }); } catch (e) {}
224
+ }
225
+ return person;
226
+ }
227
+
228
+ function roster() {
229
+ return load().map((p) => ({
230
+ id: p.id,
231
+ name: p.name,
232
+ isOwner: !!p.isOwner,
233
+ bio: p.bio || null,
234
+ handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
235
+ primaryChannel: p.primaryChannel || null,
236
+ notes: (p.notes || []).slice(-5),
237
+ }));
238
+ }
239
+
240
+ module.exports = {
241
+ load, save, list, roster,
242
+ add, update, remove,
243
+ findById, findByName, findByCanonicalUserId, findByHandle,
244
+ linkHandle, unlinkHandle, setPrimary,
245
+ note, removeNote,
246
+ owners, hasOwnerRecord, seedOwnerFromLegacy,
247
+ };
package/core/relay.js ADDED
@@ -0,0 +1,61 @@
1
+ // Cross-channel relay: lets the agent (or any authorized caller) send
2
+ // a message to another person on any of their handles, without being
3
+ // inside that chat's async-local context. Resolves person → handle →
4
+ // adapter → send. Every send is audited.
5
+
6
+ const people = require("./people");
7
+ const registry = require("./adapter-registry");
8
+ const audit = require("./audit");
9
+
10
+ function resolveTarget({ personId, personName, canonicalUserId, adapter, channelId }) {
11
+ if (adapter && channelId) {
12
+ return { adapter: String(adapter).toLowerCase(), channelId: String(channelId), person: people.findByHandle(adapter, channelId) };
13
+ }
14
+ let person = null;
15
+ if (personId) person = people.findById(personId);
16
+ if (!person && personName) person = people.findByName(personName);
17
+ if (!person && canonicalUserId) person = people.findByCanonicalUserId(canonicalUserId);
18
+ if (!person) throw new Error("target person not found");
19
+ const handles = person.handles || [];
20
+ if (handles.length === 0) throw new Error(`person ${person.name} has no handles`);
21
+ const primary = person.primaryChannel
22
+ ? handles.find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
23
+ : null;
24
+ const handle = primary || handles[0];
25
+ return { adapter: handle.adapter, channelId: handle.channelId, person };
26
+ }
27
+
28
+ async function send({ text, target, from = null, kind = "relay" }) {
29
+ if (!text || !String(text).trim()) throw new Error("text is required");
30
+ const resolved = resolveTarget(target);
31
+ const adapter = registry.findAdapter(resolved.adapter) || registry.getAdapters().find((a) => a.type === resolved.adapter);
32
+ if (!adapter) throw new Error(`no live adapter for ${resolved.adapter}`);
33
+
34
+ let ok = false;
35
+ let messageId = null;
36
+ let errorMsg = null;
37
+ try {
38
+ const result = await adapter.send(resolved.channelId, String(text));
39
+ if (typeof result === "object" && result && "messageId" in result) { messageId = result.messageId; ok = !!messageId; }
40
+ else { messageId = result; ok = !!result; }
41
+ } catch (e) { errorMsg = e.message; }
42
+
43
+ audit.log(kind, {
44
+ from,
45
+ to: {
46
+ personId: resolved.person?.id || null,
47
+ personName: resolved.person?.name || null,
48
+ adapter: resolved.adapter,
49
+ channelId: resolved.channelId,
50
+ },
51
+ ok,
52
+ messageId,
53
+ textPreview: String(text).slice(0, 200),
54
+ error: errorMsg,
55
+ });
56
+
57
+ if (!ok) throw new Error(errorMsg || "send failed");
58
+ return { ok, messageId, to: { adapter: resolved.adapter, channelId: resolved.channelId, person: resolved.person } };
59
+ }
60
+
61
+ module.exports = { send, resolveTarget };
package/core/router.js CHANGED
@@ -8,6 +8,7 @@ const { runInChat } = require("./context");
8
8
  const { dispatch } = require("./commands");
9
9
  const { handleAction } = require("./actions");
10
10
  const { isChatAuthorized } = require("./access");
11
+ const introFlow = require("./intro-flow");
11
12
  const { send, deleteMessage } = require("./io");
12
13
  const { currentState } = require("./state");
13
14
  const { runClaude } = require("./runner");
@@ -78,7 +79,13 @@ async function onMessage(envelope) {
78
79
  return;
79
80
  }
80
81
 
81
- if (!isChatAuthorized(envelope.channelId)) return;
82
+ if (!isChatAuthorized(envelope.channelId)) {
83
+ if (envelope.type === "text") {
84
+ if (isDuplicate(envelope)) return;
85
+ await introFlow.handleInbound(envelope, (text) => send(text));
86
+ }
87
+ return;
88
+ }
82
89
 
83
90
  if (envelope.type === "voice") return handleVoice(envelope);
84
91
  if (envelope.type === "audio") return handleAudio(envelope);