@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/bin/auth.js +62 -0
- package/bin/cli.js +32 -2
- package/bin/intros.js +75 -0
- package/bin/people.js +159 -0
- package/bin/recent.js +46 -0
- package/bin/send-to.js +52 -0
- package/core/access.js +42 -1
- package/core/actions.js +34 -0
- package/core/audit.js +40 -0
- package/core/config.js +4 -0
- package/core/handlers.js +181 -3
- package/core/intro-flow.js +241 -0
- package/core/intros.js +158 -0
- package/core/loopback.js +259 -0
- package/core/people.js +247 -0
- package/core/relay.js +61 -0
- package/core/router.js +8 -1
- package/core/system-prompt.js +57 -2
- package/package.json +1 -1
package/core/handlers.js
CHANGED
|
@@ -121,6 +121,7 @@ register({
|
|
|
121
121
|
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
122
122
|
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
123
123
|
"Identity: /whoami /link",
|
|
124
|
+
"Team: /people /intros /auth (owner)",
|
|
124
125
|
"Automation: /cron /vault /soul",
|
|
125
126
|
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
126
127
|
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
@@ -133,9 +134,41 @@ register({
|
|
|
133
134
|
});
|
|
134
135
|
|
|
135
136
|
register({
|
|
136
|
-
name: "auth", description: "Request access
|
|
137
|
+
name: "auth", description: "Request access (or list/revoke as owner)", args: "[list | revoke <chatId>]",
|
|
137
138
|
authRequired: false,
|
|
138
|
-
handler: async (env) => {
|
|
139
|
+
handler: async (env, { tail }) => {
|
|
140
|
+
if (tail) {
|
|
141
|
+
if (!authorized(env)) return;
|
|
142
|
+
if (!ownerEnv(env)) return send("Owner only — auth list/revoke are restricted.");
|
|
143
|
+
if (tail === "list") {
|
|
144
|
+
const raw = accessStore.listAuthorizedRaw();
|
|
145
|
+
const lines = ["Authorized chats:"];
|
|
146
|
+
for (const a of raw.authorized) {
|
|
147
|
+
const person = peopleStore.findByHandle("telegram", a.chatId) || peopleStore.findByHandle("kazee", a.chatId);
|
|
148
|
+
lines.push(` ${a.chatId}${a.isOwner ? " (owner)" : ""} ${a.name || a.username || ""}${person ? " → " + person.name : " → (no person)"}`);
|
|
149
|
+
}
|
|
150
|
+
if (raw.authorized.length === 0) lines.push(" (none)");
|
|
151
|
+
if (raw.pending.length > 0) {
|
|
152
|
+
lines.push("", "Pending:");
|
|
153
|
+
for (const p of raw.pending) lines.push(` ${p.chatId} ${p.name || p.username || ""}`);
|
|
154
|
+
}
|
|
155
|
+
return send(lines.join("\n"));
|
|
156
|
+
}
|
|
157
|
+
const revokeMatch = tail.match(/^revoke\s+(\S+)$/i);
|
|
158
|
+
if (revokeMatch) {
|
|
159
|
+
const chatId = revokeMatch[1];
|
|
160
|
+
const person = peopleStore.findByHandle("telegram", chatId) || peopleStore.findByHandle("kazee", chatId);
|
|
161
|
+
if (person && person.isOwner) return send("Refusing to revoke owner chat.");
|
|
162
|
+
if (person) {
|
|
163
|
+
const detected = (person.handles || []).find((h) => String(h.channelId) === String(chatId));
|
|
164
|
+
if (detected) peopleStore.unlinkHandle(person.id, { adapter: detected.adapter, channelId: detected.channelId });
|
|
165
|
+
}
|
|
166
|
+
const result = accessStore.revokeChat(chatId);
|
|
167
|
+
if (!result.ok) return send(`Revoke failed: ${result.reason}`);
|
|
168
|
+
return send(`Revoked ${chatId}${person ? ` (was ${person.name})` : ""}.`);
|
|
169
|
+
}
|
|
170
|
+
return send("Usage: /auth | /auth list | /auth revoke <chatId>");
|
|
171
|
+
}
|
|
139
172
|
if (authorized(env)) {
|
|
140
173
|
send("You're already authorized.");
|
|
141
174
|
return;
|
|
@@ -1001,4 +1034,149 @@ register({
|
|
|
1001
1034
|
},
|
|
1002
1035
|
});
|
|
1003
1036
|
|
|
1004
|
-
|
|
1037
|
+
// ── Team (people / intros / auth) ─────────────────────────────────
|
|
1038
|
+
|
|
1039
|
+
const peopleStore = require("./people");
|
|
1040
|
+
const introsStore = require("./intros");
|
|
1041
|
+
const introFlow = require("./intro-flow");
|
|
1042
|
+
const accessStore = require("./access");
|
|
1043
|
+
|
|
1044
|
+
function formatPersonShort(p) {
|
|
1045
|
+
const handles = (p.handles || []).map((h) => `${h.adapter}:${h.channelId}`).join(", ") || "(no handles)";
|
|
1046
|
+
return `${p.name}${p.isOwner ? " (owner)" : ""} — ${handles}`;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
register({
|
|
1050
|
+
name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove ...]",
|
|
1051
|
+
handler: async (env, { tail }) => {
|
|
1052
|
+
if (!authorized(env)) return;
|
|
1053
|
+
if (!tail || tail === "list") {
|
|
1054
|
+
const all = peopleStore.list();
|
|
1055
|
+
if (all.length === 0) return send("No people yet.");
|
|
1056
|
+
const lines = ["Team:"];
|
|
1057
|
+
for (const p of all) lines.push(` ${formatPersonShort(p)}`);
|
|
1058
|
+
lines.push("", "More: /people show <name>, /people note <name> <text>");
|
|
1059
|
+
return send(lines.join("\n"));
|
|
1060
|
+
}
|
|
1061
|
+
const showMatch = tail.match(/^show\s+(.+)$/i);
|
|
1062
|
+
if (showMatch) {
|
|
1063
|
+
const ref = showMatch[1].trim();
|
|
1064
|
+
const person = peopleStore.findById(ref) || peopleStore.findByName(ref);
|
|
1065
|
+
if (!person) return send(`Not found: ${ref}`);
|
|
1066
|
+
const handles = (person.handles || []).map((h) => ` ${h.adapter}:${h.channelId}${person.primaryChannel && person.primaryChannel.adapter === h.adapter && String(person.primaryChannel.channelId) === String(h.channelId) ? " (primary)" : ""}`).join("\n");
|
|
1067
|
+
const notes = (person.notes || []).slice(-5).map((n) => ` - [${(n.at || "").slice(0, 10)}] ${n.text}`).join("\n");
|
|
1068
|
+
return send([
|
|
1069
|
+
`${person.name}${person.isOwner ? " (owner)" : ""} id=${person.id}`,
|
|
1070
|
+
person.bio ? `Bio: ${person.bio}` : null,
|
|
1071
|
+
"Handles:",
|
|
1072
|
+
handles || " (none)",
|
|
1073
|
+
notes ? "Notes:" : null,
|
|
1074
|
+
notes || null,
|
|
1075
|
+
].filter((x) => x !== null && x !== "").join("\n"));
|
|
1076
|
+
}
|
|
1077
|
+
const noteMatch = tail.match(/^note\s+(\S+)\s+(.+)$/i);
|
|
1078
|
+
if (noteMatch) {
|
|
1079
|
+
const person = peopleStore.findById(noteMatch[1]) || peopleStore.findByName(noteMatch[1]);
|
|
1080
|
+
if (!person) return send(`Not found: ${noteMatch[1]}`);
|
|
1081
|
+
const entry = peopleStore.note(person.id, noteMatch[2], { by: env.canonicalUserId || null });
|
|
1082
|
+
return send(`Noted on ${person.name}: ${entry.text}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (!ownerEnv(env)) return send("Owner only — add/link/unlink/set-primary/remove.");
|
|
1085
|
+
const addMatch = tail.match(/^add\s+(.+)$/i);
|
|
1086
|
+
if (addMatch) {
|
|
1087
|
+
try {
|
|
1088
|
+
const p = peopleStore.add({ name: addMatch[1].trim() });
|
|
1089
|
+
return send(`Added ${p.name} (${p.id}). Link a channel with /people link ${p.name} <adapter> <channelId>.`);
|
|
1090
|
+
} catch (e) { return send(`Failed: ${e.message}`); }
|
|
1091
|
+
}
|
|
1092
|
+
const linkMatch = tail.match(/^link\s+(\S+)\s+(\S+)\s+(\S+)$/i);
|
|
1093
|
+
if (linkMatch) {
|
|
1094
|
+
const person = peopleStore.findById(linkMatch[1]) || peopleStore.findByName(linkMatch[1]);
|
|
1095
|
+
if (!person) return send(`Not found: ${linkMatch[1]}`);
|
|
1096
|
+
try {
|
|
1097
|
+
peopleStore.linkHandle(person.id, { adapter: linkMatch[2], channelId: linkMatch[3], approvedBy: env.canonicalUserId });
|
|
1098
|
+
return send(`Linked ${linkMatch[2]}:${linkMatch[3]} → ${person.name}`);
|
|
1099
|
+
} catch (e) { return send(`Failed: ${e.message}`); }
|
|
1100
|
+
}
|
|
1101
|
+
const unlinkMatch = tail.match(/^unlink\s+(\S+)\s+(\S+)\s+(\S+)$/i);
|
|
1102
|
+
if (unlinkMatch) {
|
|
1103
|
+
const person = peopleStore.findById(unlinkMatch[1]) || peopleStore.findByName(unlinkMatch[1]);
|
|
1104
|
+
if (!person) return send(`Not found: ${unlinkMatch[1]}`);
|
|
1105
|
+
const updated = peopleStore.unlinkHandle(person.id, { adapter: unlinkMatch[2], channelId: unlinkMatch[3] });
|
|
1106
|
+
if (!updated) return send("Handle not found on that person.");
|
|
1107
|
+
if (!person.isOwner) accessStore.revokeChat(unlinkMatch[3]);
|
|
1108
|
+
return send(`Unlinked ${unlinkMatch[2]}:${unlinkMatch[3]} from ${person.name}.`);
|
|
1109
|
+
}
|
|
1110
|
+
const setPrimaryMatch = tail.match(/^set-primary\s+(\S+)\s+(\S+)\s+(\S+)$/i);
|
|
1111
|
+
if (setPrimaryMatch) {
|
|
1112
|
+
const person = peopleStore.findById(setPrimaryMatch[1]) || peopleStore.findByName(setPrimaryMatch[1]);
|
|
1113
|
+
if (!person) return send(`Not found: ${setPrimaryMatch[1]}`);
|
|
1114
|
+
try {
|
|
1115
|
+
peopleStore.setPrimary(person.id, { adapter: setPrimaryMatch[2], channelId: setPrimaryMatch[3] });
|
|
1116
|
+
return send(`Primary for ${person.name} → ${setPrimaryMatch[2]}:${setPrimaryMatch[3]}`);
|
|
1117
|
+
} catch (e) { return send(`Failed: ${e.message}`); }
|
|
1118
|
+
}
|
|
1119
|
+
const removeMatch = tail.match(/^remove\s+(.+)$/i);
|
|
1120
|
+
if (removeMatch) {
|
|
1121
|
+
const person = peopleStore.findById(removeMatch[1]) || peopleStore.findByName(removeMatch[1]);
|
|
1122
|
+
if (!person) return send(`Not found: ${removeMatch[1]}`);
|
|
1123
|
+
if (person.isOwner) return send("Refusing to remove the owner.");
|
|
1124
|
+
const handles = (person.handles || []).slice();
|
|
1125
|
+
peopleStore.remove(person.id);
|
|
1126
|
+
let revokedCount = 0;
|
|
1127
|
+
for (const h of handles) { const r = accessStore.revokeChat(h.channelId); if (r.ok) revokedCount += 1; }
|
|
1128
|
+
return send(`Removed ${person.name}. Deauthed ${revokedCount} chat${revokedCount === 1 ? "" : "s"}.`);
|
|
1129
|
+
}
|
|
1130
|
+
send("Usage: /people | /people show <name> | /people note <name> <text> | /people add|link|unlink|set-primary|remove ... (owner)");
|
|
1131
|
+
},
|
|
1132
|
+
});
|
|
1133
|
+
|
|
1134
|
+
register({
|
|
1135
|
+
name: "intros", description: "Pending intros from unknown chats", args: "[approve|reject <id>]", ownerOnly: true,
|
|
1136
|
+
handler: async (env, { tail }) => {
|
|
1137
|
+
if (!authorized(env)) return;
|
|
1138
|
+
if (!ownerEnv(env)) return send("Owner only.");
|
|
1139
|
+
introsStore.sweep();
|
|
1140
|
+
if (!tail || tail === "list") {
|
|
1141
|
+
const pending = introsStore.listPending();
|
|
1142
|
+
if (pending.length === 0) return send("No pending intros.");
|
|
1143
|
+
const lines = ["Pending intros:"];
|
|
1144
|
+
const buttons = [];
|
|
1145
|
+
for (const i of pending) {
|
|
1146
|
+
const claim = i.claim || {};
|
|
1147
|
+
let claimLine = "no claim yet";
|
|
1148
|
+
if (claim.kind === "existing") {
|
|
1149
|
+
const p = peopleStore.findById(claim.personId);
|
|
1150
|
+
claimLine = `claims to be ${p ? p.name : claim.personId}`;
|
|
1151
|
+
} else if (claim.kind === "new") {
|
|
1152
|
+
claimLine = `new: ${claim.name}${claim.bio ? " — " + claim.bio : ""}`;
|
|
1153
|
+
}
|
|
1154
|
+
lines.push(` ${i.id} ${i.adapter}:${i.channelId} ${claimLine}`);
|
|
1155
|
+
buttons.push([
|
|
1156
|
+
{ text: `Approve ${i.id.slice(-6)}`, callback_data: `intro:approve:${i.id}` },
|
|
1157
|
+
{ text: `Reject ${i.id.slice(-6)}`, callback_data: `intro:reject:${i.id}` },
|
|
1158
|
+
]);
|
|
1159
|
+
}
|
|
1160
|
+
return send(lines.join("\n"), buttons.length ? { keyboard: { inline_keyboard: buttons } } : undefined);
|
|
1161
|
+
}
|
|
1162
|
+
const approveMatch = tail.match(/^approve\s+(\S+)$/i);
|
|
1163
|
+
if (approveMatch) {
|
|
1164
|
+
const intro = introsStore.byId(approveMatch[1]);
|
|
1165
|
+
if (!intro) return send(`No intro: ${approveMatch[1]}`);
|
|
1166
|
+
try {
|
|
1167
|
+
const result = await introFlow.applyApproval(intro, env.from?.name || env.canonicalUserId || "owner");
|
|
1168
|
+
await introFlow.notifyIntroUser(intro, `You're in. Welcome${result.person ? ", " + result.person.name : ""}.`);
|
|
1169
|
+
return send(`Approved. Person: ${result.person?.name || "?"}`);
|
|
1170
|
+
} catch (e) { return send(`Failed: ${e.message}`); }
|
|
1171
|
+
}
|
|
1172
|
+
const rejectMatch = tail.match(/^reject\s+(\S+)(?:\s+(.+))?$/i);
|
|
1173
|
+
if (rejectMatch) {
|
|
1174
|
+
const intro = introsStore.byId(rejectMatch[1]);
|
|
1175
|
+
if (!intro) return send(`No intro: ${rejectMatch[1]}`);
|
|
1176
|
+
introsStore.reject(intro.id, env.from?.name || env.canonicalUserId || "owner", rejectMatch[2] || null);
|
|
1177
|
+
await introFlow.notifyIntroUser(intro, "Your access request was denied.");
|
|
1178
|
+
return send(`Rejected ${intro.id}.`);
|
|
1179
|
+
}
|
|
1180
|
+
send("Usage: /intros | /intros approve <id> | /intros reject <id> [reason]");
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
@@ -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
|
+
};
|