@inetafrica/open-claudia 2.0.5 → 2.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.
@@ -0,0 +1,241 @@
1
+ // Runtime side of the intro flow. Detects unknown chats, walks them
2
+ // through "who are you", and pings the owner with approve/reject
3
+ // buttons. Data lives in core/intros.js; people resolution lives in
4
+ // core/people.js. This module wires both into the inbound router.
5
+
6
+ const people = require("./people");
7
+ const intros = require("./intros");
8
+ const audit = require("./audit");
9
+ const registry = require("./adapter-registry");
10
+ const { bootstrapOwner, loadAuth, saveAuth, updateAuthorizedChatEnv } = require("./access");
11
+ const { CHAT_ID } = require("./config");
12
+
13
+ const MAX_BIO_LEN = 500;
14
+
15
+ function envAdapterId(envelope) {
16
+ return envelope.adapter?.id || envelope.adapter?.type || null;
17
+ }
18
+
19
+ function envAdapterType(envelope) {
20
+ return envelope.adapter?.type || null;
21
+ }
22
+
23
+ function displayNameFromEnvelope(envelope) {
24
+ const raw = envelope.raw || {};
25
+ const from = raw.from || raw.user || {};
26
+ return from.first_name || from.name || from.displayName || envelope.userId || null;
27
+ }
28
+
29
+ function usernameFromEnvelope(envelope) {
30
+ const raw = envelope.raw || {};
31
+ const from = raw.from || raw.user || {};
32
+ return from.username || from.handle || null;
33
+ }
34
+
35
+ function rosterLines() {
36
+ const all = people.list();
37
+ if (all.length === 0) return "(no one is registered yet)";
38
+ return all
39
+ .map((p, i) => `${i + 1}. ${p.name}${p.isOwner ? " (owner)" : ""}`)
40
+ .join("\n");
41
+ }
42
+
43
+ function rosterPrompt() {
44
+ return [
45
+ "Hi, I'm Open Claudia. I don't recognize this chat yet.",
46
+ "",
47
+ "Existing team:",
48
+ rosterLines(),
49
+ "",
50
+ "Reply with the number if that's you, or type \"new <your name>\" if you're new.",
51
+ ].join("\n");
52
+ }
53
+
54
+ function ownerPrimaryAdapterAndChannel() {
55
+ const owner = people.owners()[0];
56
+ if (!owner) return null;
57
+ if (owner.primaryChannel && owner.primaryChannel.adapter && owner.primaryChannel.channelId) {
58
+ return { adapter: owner.primaryChannel.adapter, channelId: owner.primaryChannel.channelId, owner };
59
+ }
60
+ const h = (owner.handles || [])[0];
61
+ if (h) return { adapter: h.adapter, channelId: h.channelId, owner };
62
+ if (CHAT_ID) return { adapter: "telegram", channelId: String(CHAT_ID), owner };
63
+ return null;
64
+ }
65
+
66
+ async function notifyOwnerAboutIntro(intro) {
67
+ const target = ownerPrimaryAdapterAndChannel();
68
+ if (!target) {
69
+ console.warn("intro-flow: no owner record to notify");
70
+ return;
71
+ }
72
+ const adapter = registry.findAdapter(target.adapter) || registry.getAdapters().find((a) => a.type === target.adapter);
73
+ if (!adapter) {
74
+ console.warn(`intro-flow: no live adapter for owner channel ${target.adapter}`);
75
+ return;
76
+ }
77
+
78
+ const claim = intro.claim || {};
79
+ let line;
80
+ if (claim.kind === "existing") {
81
+ const person = people.findById(claim.personId);
82
+ line = `New chat on ${intro.adapter} (${intro.channelId}) — ${intro.displayName || "(no name)"} claims to be ${person ? person.name : claim.personId}.`;
83
+ } else if (claim.kind === "new") {
84
+ line = `New chat on ${intro.adapter} (${intro.channelId}) — wants to join as "${claim.name}".${claim.bio ? "\nBio: " + claim.bio : ""}`;
85
+ } else {
86
+ line = `New chat on ${intro.adapter} (${intro.channelId}) — ${intro.displayName || "(no name)"}`;
87
+ }
88
+ const text = `Intro request:\n\n${line}`;
89
+ try {
90
+ await adapter.send(target.channelId, text, {
91
+ keyboard: { inline_keyboard: [[
92
+ { text: "Approve", callback_data: `intro:approve:${intro.id}` },
93
+ { text: "Reject", callback_data: `intro:reject:${intro.id}` },
94
+ ]] },
95
+ });
96
+ } catch (e) {
97
+ console.warn(`intro-flow: failed to notify owner: ${e.message}`);
98
+ }
99
+ }
100
+
101
+ async function handleInbound(envelope, sendFn) {
102
+ intros.sweep();
103
+ const adapter = envAdapterType(envelope);
104
+ const channelId = String(envelope.channelId);
105
+ if (!adapter) return false;
106
+
107
+ let intro = intros.activeForChannel(adapter, channelId);
108
+ const text = String(envelope.text || "").trim();
109
+
110
+ if (!intro) {
111
+ if (!people.hasOwnerRecord()) {
112
+ people.seedOwnerFromLegacy();
113
+ }
114
+ if (!people.hasOwnerRecord()) {
115
+ const displayName = displayNameFromEnvelope(envelope) || "Owner";
116
+ const person = people.add({ name: displayName, isOwner: true, bio: "Auto-registered on first message" });
117
+ try { people.linkHandle(person.id, { adapter, channelId, displayName, approvedBy: "bootstrap" }); } catch (e) {}
118
+ try { bootstrapOwner({ chatId: channelId, name: displayName, username: usernameFromEnvelope(envelope) }); } catch (e) {}
119
+ audit.log("people.bootstrap-owner", { personId: person.id, adapter, channelId });
120
+ await sendFn(`Welcome, ${displayName}. You're registered as the bot owner. Send /start to begin.`);
121
+ return true;
122
+ }
123
+ intro = intros.start({
124
+ adapter,
125
+ channelId,
126
+ displayName: displayNameFromEnvelope(envelope),
127
+ username: usernameFromEnvelope(envelope),
128
+ });
129
+ intros.markPrompted(intro.id);
130
+ audit.log("intro.started", { introId: intro.id, adapter, channelId });
131
+ await sendFn(rosterPrompt());
132
+ return true;
133
+ }
134
+
135
+ if (intro.step === "awaiting_choice") {
136
+ if (!text) {
137
+ await sendFn("Reply with a number from the list, or \"new <your name>\".");
138
+ return true;
139
+ }
140
+ const num = parseInt(text, 10);
141
+ const all = people.list();
142
+ if (Number.isFinite(num) && num >= 1 && num <= all.length) {
143
+ const person = all[num - 1];
144
+ intros.setClaim(intro.id, { kind: "existing", personId: person.id });
145
+ await sendFn(`Got it. I've asked ${people.owners()[0]?.name || "the owner"} to confirm you're ${person.name}. Hold tight.`);
146
+ const updated = intros.byId(intro.id);
147
+ await notifyOwnerAboutIntro(updated);
148
+ audit.log("intro.claim-existing", { introId: intro.id, personId: person.id });
149
+ return true;
150
+ }
151
+ const m = text.match(/^new\s+(.+)/i);
152
+ if (m) {
153
+ const name = m[1].trim();
154
+ intros.setStep(intro.id, "awaiting_bio", { claim: { kind: "new", name, bio: null } });
155
+ await sendFn(`Got it, ${name}. In one line, tell me who you are / what you do, so I can introduce you to the owner.`);
156
+ audit.log("intro.claim-new", { introId: intro.id, name });
157
+ return true;
158
+ }
159
+ await sendFn("Didn't catch that. Reply with a number from the list, or \"new <your name>\".");
160
+ return true;
161
+ }
162
+
163
+ if (intro.step === "awaiting_bio") {
164
+ const bio = text.slice(0, MAX_BIO_LEN);
165
+ if (!bio) {
166
+ await sendFn("Tell me one line about yourself.");
167
+ return true;
168
+ }
169
+ intros.setClaim(intro.id, { kind: "new", name: intro.claim?.name || "Unknown", bio });
170
+ const updated = intros.byId(intro.id);
171
+ await sendFn("Got it. Pinging the owner now. Hold tight.");
172
+ await notifyOwnerAboutIntro(updated);
173
+ audit.log("intro.bio-collected", { introId: intro.id });
174
+ return true;
175
+ }
176
+
177
+ if (intro.step === "awaiting_owner") {
178
+ await sendFn("Still waiting on owner approval. I'll let you know.");
179
+ return true;
180
+ }
181
+
182
+ return false;
183
+ }
184
+
185
+ async function applyApproval(intro, by) {
186
+ if (intro.step === "approved") return { ok: true, person: people.findByHandle(intro.adapter, intro.channelId) };
187
+ const claim = intro.claim || {};
188
+ let person;
189
+ if (claim.kind === "existing") {
190
+ person = people.findById(claim.personId);
191
+ if (!person) throw new Error(`intro claim references missing person ${claim.personId}`);
192
+ people.linkHandle(person.id, {
193
+ adapter: intro.adapter,
194
+ channelId: intro.channelId,
195
+ displayName: intro.displayName,
196
+ approvedBy: by || null,
197
+ });
198
+ } else if (claim.kind === "new") {
199
+ person = people.add({ name: claim.name, bio: claim.bio || null });
200
+ people.linkHandle(person.id, {
201
+ adapter: intro.adapter,
202
+ channelId: intro.channelId,
203
+ displayName: intro.displayName,
204
+ approvedBy: by || null,
205
+ });
206
+ } else {
207
+ throw new Error("intro has no claim — owner approved a half-finished intro");
208
+ }
209
+ // Also write into auth.json so legacy isChatAuthorized + isChatOwner still work.
210
+ const auth = loadAuth();
211
+ if (!auth.authorized.some((a) => String(a.chatId) === String(intro.channelId))) {
212
+ auth.authorized.push({
213
+ chatId: String(intro.channelId),
214
+ name: intro.displayName || person.name,
215
+ username: intro.username || null,
216
+ isOwner: false,
217
+ authorizedAt: new Date().toISOString(),
218
+ });
219
+ saveAuth(auth);
220
+ if (intro.adapter === "telegram") {
221
+ try { updateAuthorizedChatEnv(auth); } catch (e) {}
222
+ }
223
+ }
224
+ intros.approve(intro.id, by);
225
+ audit.log("intro.approved", { introId: intro.id, personId: person.id, by });
226
+ return { ok: true, person };
227
+ }
228
+
229
+ async function notifyIntroUser(intro, text) {
230
+ const adapter = registry.findAdapter(intro.adapter) || registry.getAdapters().find((a) => a.type === intro.adapter);
231
+ if (!adapter) return;
232
+ try { await adapter.send(intro.channelId, text); } catch (e) {}
233
+ }
234
+
235
+ module.exports = {
236
+ handleInbound,
237
+ applyApproval,
238
+ notifyIntroUser,
239
+ notifyOwnerAboutIntro,
240
+ rosterPrompt,
241
+ };
package/core/intros.js ADDED
@@ -0,0 +1,158 @@
1
+ // Intro flow: when an unknown chat sends a message, the bot asks who
2
+ // they are and lets the owner approve. Approved intros become person
3
+ // records (or new handles on existing people) and the chat gets
4
+ // authorized in auth.json so the rest of the router treats it normally.
5
+ //
6
+ // State machine per pending intro:
7
+ // awaiting_choice -> bot showed roster + "who are you?"
8
+ // awaiting_bio -> user picked "new", bot asked for a one-line bio
9
+ // awaiting_owner -> intro packaged, owner pinged, buttons live
10
+ // approved / rejected — terminal
11
+
12
+ const fs = require("fs");
13
+ const { INTROS_FILE } = require("./config");
14
+
15
+ const AUTO_REJECT_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
16
+
17
+ function nowIso() { return new Date().toISOString(); }
18
+ function nowMs() { return Date.now(); }
19
+ function nextId() {
20
+ return `intro_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
21
+ }
22
+
23
+ function load() {
24
+ try {
25
+ const raw = JSON.parse(fs.readFileSync(INTROS_FILE, "utf-8"));
26
+ return Array.isArray(raw) ? raw : [];
27
+ } catch (e) { return []; }
28
+ }
29
+
30
+ function save(list) {
31
+ const tmp = `${INTROS_FILE}.tmp`;
32
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
33
+ fs.renameSync(tmp, INTROS_FILE);
34
+ }
35
+
36
+ function activeForChannel(adapter, channelId) {
37
+ const a = String(adapter || "").toLowerCase();
38
+ const c = String(channelId || "");
39
+ return load().find((i) =>
40
+ i.adapter === a && String(i.channelId) === c &&
41
+ (i.step === "awaiting_choice" || i.step === "awaiting_bio" || i.step === "awaiting_owner")
42
+ ) || null;
43
+ }
44
+
45
+ function byId(id) { return load().find((i) => i.id === id) || null; }
46
+
47
+ function listPending() {
48
+ return load().filter((i) => i.step !== "approved" && i.step !== "rejected");
49
+ }
50
+
51
+ function start({ adapter, channelId, displayName, username }) {
52
+ const a = String(adapter || "").toLowerCase();
53
+ const c = String(channelId || "");
54
+ const all = load();
55
+ const existing = all.find((i) =>
56
+ i.adapter === a && String(i.channelId) === c &&
57
+ (i.step === "awaiting_choice" || i.step === "awaiting_bio" || i.step === "awaiting_owner")
58
+ );
59
+ if (existing) return existing;
60
+ const intro = {
61
+ id: nextId(),
62
+ adapter: a,
63
+ channelId: c,
64
+ displayName: displayName || null,
65
+ username: username || null,
66
+ step: "awaiting_choice",
67
+ claim: null,
68
+ createdAt: nowIso(),
69
+ updatedAt: nowIso(),
70
+ promptedAt: null,
71
+ resolvedAt: null,
72
+ resolvedBy: null,
73
+ rejectReason: null,
74
+ };
75
+ all.push(intro);
76
+ save(all);
77
+ return intro;
78
+ }
79
+
80
+ function markPrompted(id) {
81
+ const all = load();
82
+ const i = all.find((x) => x.id === id);
83
+ if (!i) return null;
84
+ i.promptedAt = nowIso();
85
+ i.updatedAt = nowIso();
86
+ save(all);
87
+ return i;
88
+ }
89
+
90
+ function setClaim(id, claim) {
91
+ const all = load();
92
+ const i = all.find((x) => x.id === id);
93
+ if (!i) return null;
94
+ i.claim = claim;
95
+ i.step = "awaiting_owner";
96
+ i.updatedAt = nowIso();
97
+ save(all);
98
+ return i;
99
+ }
100
+
101
+ function setStep(id, step, patch = {}) {
102
+ const all = load();
103
+ const i = all.find((x) => x.id === id);
104
+ if (!i) return null;
105
+ i.step = step;
106
+ Object.assign(i, patch);
107
+ i.updatedAt = nowIso();
108
+ save(all);
109
+ return i;
110
+ }
111
+
112
+ function approve(id, by) {
113
+ return setStep(id, "approved", { resolvedAt: nowIso(), resolvedBy: by || null });
114
+ }
115
+
116
+ function reject(id, by, reason = null) {
117
+ return setStep(id, "rejected", { resolvedAt: nowIso(), resolvedBy: by || null, rejectReason: reason });
118
+ }
119
+
120
+ function remove(id) {
121
+ const all = load();
122
+ const idx = all.findIndex((x) => x.id === id);
123
+ if (idx < 0) return null;
124
+ const [removed] = all.splice(idx, 1);
125
+ save(all);
126
+ return removed;
127
+ }
128
+
129
+ // Drop terminal entries older than 7 days; auto-reject stale pending.
130
+ function sweep() {
131
+ const all = load();
132
+ const cutoff = nowMs() - AUTO_REJECT_AFTER_MS;
133
+ let changed = false;
134
+ const kept = [];
135
+ for (const i of all) {
136
+ const t = new Date(i.updatedAt || i.createdAt).getTime();
137
+ if (i.step === "approved" || i.step === "rejected") {
138
+ if (t < cutoff) { changed = true; continue; }
139
+ kept.push(i);
140
+ continue;
141
+ }
142
+ if (t < cutoff) {
143
+ kept.push({ ...i, step: "rejected", resolvedAt: nowIso(), resolvedBy: "system", rejectReason: "auto-reject after 7 days" });
144
+ changed = true;
145
+ } else {
146
+ kept.push(i);
147
+ }
148
+ }
149
+ if (changed) save(kept);
150
+ return kept;
151
+ }
152
+
153
+ module.exports = {
154
+ load, save, listPending, byId,
155
+ activeForChannel, start, markPrompted,
156
+ setClaim, setStep, approve, reject, remove,
157
+ sweep, AUTO_REJECT_AFTER_MS,
158
+ };
package/core/loopback.js CHANGED
@@ -18,6 +18,14 @@ 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 fs2 = require("fs");
27
+ const path2 = require("path");
28
+ const { TRANSCRIPTS_DIR } = require("./config");
21
29
 
22
30
  const INFO_DIR = path.join(CONFIG_DIR, "loopback");
23
31
  const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
@@ -65,8 +73,19 @@ const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
65
73
  const JSON_KINDS = new Set([
66
74
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
67
75
  "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-tree", "task-clear-completed",
76
+ "people-list", "people-show", "people-add", "people-note", "people-link", "people-unlink",
77
+ "people-remove", "people-set-primary",
78
+ "intros-list", "intros-approve", "intros-reject",
79
+ "relay-send", "recent-fetch", "audit-tail",
68
80
  ]);
69
81
 
82
+ function callerIsOwner(payload) {
83
+ const caller = payload?.canonicalUserId;
84
+ if (!caller) return false;
85
+ const person = peopleStore.findByCanonicalUserId(caller);
86
+ return !!(person && person.isOwner);
87
+ }
88
+
70
89
  function readBodyAsString(req, max = 64 * 1024) {
71
90
  return new Promise((resolve, reject) => {
72
91
  let buf = "";
@@ -226,9 +245,210 @@ async function handleJson(req, res, url, kind) {
226
245
  return reply(res, 200, { ok: true, tasks: remaining });
227
246
  }
228
247
 
248
+ if (kind === "people-list") {
249
+ return reply(res, 200, { ok: true, people: peopleStore.roster() });
250
+ }
251
+
252
+ if (kind === "people-show") {
253
+ const ref = payload.id || payload.name || null;
254
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
255
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
256
+ if (!person) return reply(res, 404, { error: "not found" });
257
+ return reply(res, 200, { ok: true, person });
258
+ }
259
+
260
+ if (kind === "people-add") {
261
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
262
+ if (!payload.name) return reply(res, 400, { error: "missing name" });
263
+ try {
264
+ const p = peopleStore.add({ name: payload.name, bio: payload.bio || null, isOwner: !!payload.isOwner });
265
+ audit.log("people.add", { by: payload.canonicalUserId, personId: p.id, name: p.name });
266
+ return reply(res, 200, { ok: true, person: p });
267
+ } catch (e) { return reply(res, 400, { error: e.message }); }
268
+ }
269
+
270
+ if (kind === "people-note") {
271
+ const ref = payload.id || payload.name;
272
+ if (!ref || !payload.text) return reply(res, 400, { error: "missing id/name or text" });
273
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
274
+ if (!person) return reply(res, 404, { error: "not found" });
275
+ const entry = peopleStore.note(person.id, payload.text, { by: payload.canonicalUserId || null });
276
+ audit.log("people.note", { by: payload.canonicalUserId, personId: person.id });
277
+ return reply(res, 200, { ok: true, note: entry });
278
+ }
279
+
280
+ if (kind === "people-link") {
281
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
282
+ const ref = payload.id || payload.name;
283
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
284
+ if (!payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing linkAdapter/linkChannelId" });
285
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
286
+ if (!person) return reply(res, 404, { error: "not found" });
287
+ try {
288
+ const updated = peopleStore.linkHandle(person.id, {
289
+ adapter: payload.linkAdapter,
290
+ channelId: payload.linkChannelId,
291
+ displayName: payload.displayName || null,
292
+ approvedBy: payload.canonicalUserId || null,
293
+ });
294
+ audit.log("people.link", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId });
295
+ return reply(res, 200, { ok: true, person: updated });
296
+ } catch (e) { return reply(res, 400, { error: e.message }); }
297
+ }
298
+
299
+ if (kind === "people-unlink") {
300
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
301
+ const ref = payload.id || payload.name;
302
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
303
+ if (!payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing linkAdapter/linkChannelId" });
304
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
305
+ if (!person) return reply(res, 404, { error: "not found" });
306
+ const updated = peopleStore.unlinkHandle(person.id, { adapter: payload.linkAdapter, channelId: payload.linkChannelId });
307
+ if (!updated) return reply(res, 404, { error: "handle not found on person" });
308
+ audit.log("people.unlink", { by: payload.canonicalUserId, personId: person.id, adapter: payload.linkAdapter, channelId: payload.linkChannelId });
309
+ return reply(res, 200, { ok: true, person: updated });
310
+ }
311
+
312
+ if (kind === "people-set-primary") {
313
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
314
+ const ref = payload.id || payload.name;
315
+ if (!ref || !payload.linkAdapter || !payload.linkChannelId) return reply(res, 400, { error: "missing fields" });
316
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
317
+ if (!person) return reply(res, 404, { error: "not found" });
318
+ try {
319
+ const updated = peopleStore.setPrimary(person.id, { adapter: payload.linkAdapter, channelId: payload.linkChannelId });
320
+ audit.log("people.set-primary", { by: payload.canonicalUserId, personId: person.id });
321
+ return reply(res, 200, { ok: true, person: updated });
322
+ } catch (e) { return reply(res, 400, { error: e.message }); }
323
+ }
324
+
325
+ if (kind === "people-remove") {
326
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
327
+ const ref = payload.id || payload.name;
328
+ if (!ref) return reply(res, 400, { error: "missing id or name" });
329
+ const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
330
+ if (!person) return reply(res, 404, { error: "not found" });
331
+ if (person.isOwner) return reply(res, 400, { error: "refusing to delete owner record" });
332
+ peopleStore.remove(person.id);
333
+ audit.log("people.remove", { by: payload.canonicalUserId, personId: person.id, name: person.name });
334
+ return reply(res, 200, { ok: true, removed: person });
335
+ }
336
+
337
+ if (kind === "intros-list") {
338
+ intros.sweep();
339
+ return reply(res, 200, { ok: true, intros: intros.listPending() });
340
+ }
341
+
342
+ if (kind === "intros-approve") {
343
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
344
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
345
+ const intro = intros.byId(payload.id);
346
+ if (!intro) return reply(res, 404, { error: "intro not found" });
347
+ try {
348
+ const result = await introFlow.applyApproval(intro, payload.canonicalUserId || "owner");
349
+ await introFlow.notifyIntroUser(intro, `You're in. Welcome${result.person ? ", " + result.person.name : ""}.`);
350
+ return reply(res, 200, { ok: true, person: result.person });
351
+ } catch (e) { return reply(res, 400, { error: e.message }); }
352
+ }
353
+
354
+ if (kind === "intros-reject") {
355
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
356
+ if (!payload.id) return reply(res, 400, { error: "missing id" });
357
+ const intro = intros.byId(payload.id);
358
+ if (!intro) return reply(res, 404, { error: "intro not found" });
359
+ intros.reject(intro.id, payload.canonicalUserId || "owner", payload.reason || null);
360
+ await introFlow.notifyIntroUser(intro, "Your access request was denied.");
361
+ audit.log("intro.rejected", { introId: intro.id, by: payload.canonicalUserId });
362
+ return reply(res, 200, { ok: true });
363
+ }
364
+
365
+ if (kind === "relay-send") {
366
+ if (!payload.text) return reply(res, 400, { error: "missing text" });
367
+ try {
368
+ const result = await relay.send({
369
+ text: payload.text,
370
+ from: payload.canonicalUserId || null,
371
+ target: {
372
+ personId: payload.targetPersonId || null,
373
+ personName: payload.targetPersonName || null,
374
+ canonicalUserId: payload.targetCanonicalUserId || null,
375
+ adapter: payload.targetAdapter || null,
376
+ channelId: payload.targetChannelId || null,
377
+ },
378
+ kind: "relay",
379
+ });
380
+ 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 } });
381
+ } catch (e) { return reply(res, 400, { error: e.message }); }
382
+ }
383
+
384
+ if (kind === "recent-fetch") {
385
+ const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
386
+ try {
387
+ let targetAdapter = payload.adapter;
388
+ let targetChannel = payload.channelId;
389
+ if (payload.personName || payload.personId) {
390
+ const person = peopleStore.findById(payload.personId || "") || peopleStore.findByName(payload.personName || "");
391
+ if (!person) return reply(res, 404, { error: "person not found" });
392
+ const handle = person.primaryChannel
393
+ ? (person.handles || []).find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
394
+ : (person.handles || [])[0];
395
+ if (!handle) return reply(res, 404, { error: "person has no handles" });
396
+ targetAdapter = handle.adapter;
397
+ targetChannel = handle.channelId;
398
+ }
399
+ if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
400
+ const entries = readRecentTranscript(targetAdapter, targetChannel, limit);
401
+ return reply(res, 200, { ok: true, adapter: targetAdapter, channelId: targetChannel, entries });
402
+ } catch (e) { return reply(res, 400, { error: e.message }); }
403
+ }
404
+
405
+ if (kind === "audit-tail") {
406
+ if (!callerIsOwner(payload)) return reply(res, 403, { error: "owner only" });
407
+ const n = Math.max(1, Math.min(parseInt(payload.limit || 50, 10) || 50, 500));
408
+ return reply(res, 200, { ok: true, entries: audit.tail(n) });
409
+ }
410
+
229
411
  return reply(res, 404, { error: "not found" });
230
412
  }
231
413
 
414
+ function readRecentTranscript(adapter, channelId, limit) {
415
+ if (!TRANSCRIPTS_DIR) return [];
416
+ try {
417
+ if (!fs2.existsSync(TRANSCRIPTS_DIR)) return [];
418
+ const candidates = fs2.readdirSync(TRANSCRIPTS_DIR)
419
+ .filter((f) => f.endsWith(".jsonl"))
420
+ .map((f) => path2.join(TRANSCRIPTS_DIR, f))
421
+ .map((p) => ({ p, mtime: fs2.statSync(p).mtimeMs }))
422
+ .sort((a, b) => b.mtime - a.mtime);
423
+ const out = [];
424
+ const adapterPrefix = String(adapter).toLowerCase();
425
+ const channelStr = String(channelId);
426
+ for (const { p } of candidates) {
427
+ if (out.length >= limit) break;
428
+ const raw = fs2.readFileSync(p, "utf-8").trim();
429
+ if (!raw) continue;
430
+ const lines = raw.split("\n").slice(-1000);
431
+ for (const line of lines) {
432
+ try {
433
+ const obj = JSON.parse(line);
434
+ const objAdapter = (obj.adapter || obj.adapterType || "").toLowerCase();
435
+ const objChannel = String(obj.channelId || "");
436
+ if (objAdapter && objAdapter !== adapterPrefix) continue;
437
+ if (objChannel && objChannel !== channelStr) continue;
438
+ out.push({
439
+ at: obj.at || obj.timestamp || null,
440
+ role: obj.role || obj.author || null,
441
+ text: typeof obj.text === "string" ? obj.text.slice(0, 600) : null,
442
+ kind: obj.kind || null,
443
+ });
444
+ if (out.length >= limit) break;
445
+ } catch (e) {}
446
+ }
447
+ }
448
+ return out.slice(-limit);
449
+ } catch (e) { return []; }
450
+ }
451
+
232
452
  async function handle(req, res) {
233
453
  try {
234
454
  if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });