@inetafrica/open-claudia 3.2.3 → 3.3.2
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/CHANGELOG.md +20 -0
- package/bin/loopback-client.js +4 -0
- package/bot.js +10 -1
- package/channels/kazee/adapter.js +5 -0
- package/channels/kazee/call/audio.js +79 -0
- package/channels/kazee/call/manager.js +6 -0
- package/channels/kazee/call/session.js +356 -48
- package/channels/kazee/call/speech-client.js +38 -3
- package/core/actions.js +96 -4
- package/core/enforcer.js +52 -3
- package/core/handlers.js +46 -2
- package/core/intro-flow.js +81 -16
- package/core/loopback.js +37 -0
- package/core/media.js +71 -18
- package/core/pack-review.js +18 -0
- package/core/relationship.js +18 -2
- package/core/router.js +17 -7
- package/core/runner.js +7 -1
- package/core/speaker-attest.js +56 -0
- package/core/system-prompt.js +21 -4
- package/core/verify-contact.js +156 -0
- package/package.json +2 -2
- package/test-enforcer.js +31 -0
- package/test-kazee-call-manager.js +4 -1
- package/test-kazee-call-session.js +100 -4
- package/test-mandate-hygiene.js +23 -5
- package/test-provider-language.js +25 -1
package/core/actions.js
CHANGED
|
@@ -89,7 +89,21 @@ function appendMandate(rec, line) {
|
|
|
89
89
|
function appendMandateLine(rec, lineText, log, opts) {
|
|
90
90
|
const entities = require("./entities");
|
|
91
91
|
const mandates = require("./mandates");
|
|
92
|
-
|
|
92
|
+
// Strictly slug-keyed: resolve through the record's entitySlug, else through
|
|
93
|
+
// the person record by id. NEVER name-keyed — a display name is spoofable,
|
|
94
|
+
// and a name-keyed upsert can land security text on a same-named OTHER
|
|
95
|
+
// person's note (the exact escalation the hashed auto-contact slugs exist
|
|
96
|
+
// to prevent).
|
|
97
|
+
let slug = String(rec.entitySlug || "").trim();
|
|
98
|
+
if (!slug && rec.personId) {
|
|
99
|
+
try {
|
|
100
|
+
const people = require("./people");
|
|
101
|
+
const person = people.findById(String(rec.personId));
|
|
102
|
+
slug = String((person && people.resolveEntitySlug(person)) || "").trim();
|
|
103
|
+
} catch (e) { slug = ""; }
|
|
104
|
+
}
|
|
105
|
+
if (!slug) return { ok: false, reason: "no linked contact record to hold the grant" };
|
|
106
|
+
const ent = entities.readEntity(slug);
|
|
93
107
|
const name = (ent && ent.name) || rec.personName;
|
|
94
108
|
if (!name) return { ok: false, reason: "no contact record" };
|
|
95
109
|
const coreText = mandates.splitExpiry(String(lineText || "")).text;
|
|
@@ -121,14 +135,12 @@ function appendMandateLine(rec, lineText, log, opts) {
|
|
|
121
135
|
return { ok: false, reason: "their mandate is full — trim it with /guardrails" };
|
|
122
136
|
}
|
|
123
137
|
const up = entities.upsertEntity({
|
|
124
|
-
slug
|
|
138
|
+
slug,
|
|
125
139
|
name,
|
|
126
140
|
relationship: (ent && ent.relationship) || "external",
|
|
127
141
|
mandate,
|
|
128
142
|
log,
|
|
129
143
|
});
|
|
130
|
-
// id is slug-scoped, so derive it from the entity the write actually landed
|
|
131
|
-
// on (legacy name-keyed upserts can resolve to a pre-existing slug).
|
|
132
144
|
const res = { ok: true, id: mandates.lineId(up.entity.slug, coreText), expiresAt: expiresAt || null };
|
|
133
145
|
if (refreshed) res.refreshed = true;
|
|
134
146
|
return res;
|
|
@@ -753,6 +765,86 @@ async function handleAction(envelope) {
|
|
|
753
765
|
await send(`Type the rule to apply to ${target}.\nFor example: “may share our office hours and address”, or “never discuss pricing”.\n\nOr send /cancel to skip.`);
|
|
754
766
|
return;
|
|
755
767
|
}
|
|
768
|
+
if (d.startsWith("vfy:")) {
|
|
769
|
+
// "Verify contact…" on an approval card for an unverified auto-added
|
|
770
|
+
// speaker. Owner-only (transport-gated on the callback envelope — the one
|
|
771
|
+
// surface the agent can't forge). Offers the same-name candidate notes as
|
|
772
|
+
// buttons; the actual state change happens in the vfl: pick handler via
|
|
773
|
+
// core/verify-contact, which re-validates everything.
|
|
774
|
+
if (!isChatOwner(envelope.channelId)) return send("Owner only — contact verification is restricted.");
|
|
775
|
+
const id = d.split(":")[1];
|
|
776
|
+
const approvals = require("./approvals");
|
|
777
|
+
const rec = approvals.read(id);
|
|
778
|
+
if (!rec || !rec.personId) return send("That request expired — nothing to verify from it.");
|
|
779
|
+
const people = require("./people");
|
|
780
|
+
const person = people.findById(rec.personId);
|
|
781
|
+
if (!person) return send("I no longer have a contact record for them.");
|
|
782
|
+
if (!person.autoCreated) return send(`${person.name} is already verified.`);
|
|
783
|
+
const candidates = require("./verify-contact").candidateEntitiesFor(person).slice(0, 6);
|
|
784
|
+
// Button payloads are INDEX-based: Telegram caps callback_data at 64
|
|
785
|
+
// bytes and slugs alone can be 60 — the picked index resolves against
|
|
786
|
+
// this snapshot, so a changed entity set can't shift what a tap means.
|
|
787
|
+
state.pendingVerify = {
|
|
788
|
+
aprId: rec.id,
|
|
789
|
+
personId: person.id,
|
|
790
|
+
personName: person.name,
|
|
791
|
+
candidates: candidates.map((c) => ({ slug: c.slug, name: c.name })),
|
|
792
|
+
armedAt: Date.now(),
|
|
793
|
+
};
|
|
794
|
+
saveState();
|
|
795
|
+
const rows = candidates.map((c, i) => ([{ text: `🔗 Link to ${c.slug}`.slice(0, 60), callback_data: `vfl:${rec.id}:${i}` }]));
|
|
796
|
+
rows.push([{ text: "✅ Just verify — this is a new contact", callback_data: `vfl:${rec.id}:self` }]);
|
|
797
|
+
rows.push([{ text: "✖️ Cancel", callback_data: `vfl:${rec.id}:x` }]);
|
|
798
|
+
await send(
|
|
799
|
+
`🪪 Verify ${person.name}?\n` +
|
|
800
|
+
(candidates.length
|
|
801
|
+
? `They share a name with ${candidates.length === 1 ? "an existing note" : `${candidates.length} existing notes`} — link them if this is the same person, or keep them separate.\n`
|
|
802
|
+
: `No existing note matches their name — verifying keeps their current note as canonical.\n`) +
|
|
803
|
+
`Verifying settles WHO they are. It grants no permissions — use “Make a rule” or /guardrails for that.`,
|
|
804
|
+
{ keyboard: { inline_keyboard: rows } }
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (d.startsWith("vfl:")) {
|
|
809
|
+
// Verification pick. Owner-only; one-shot against the armed snapshot with
|
|
810
|
+
// the same 5-minute TTL as rule authoring. verifyContact() re-validates
|
|
811
|
+
// the person and target at execution time, so a stale snapshot can only
|
|
812
|
+
// refuse — never mislink.
|
|
813
|
+
if (!isChatOwner(envelope.channelId)) return send("Owner only — contact verification is restricted.");
|
|
814
|
+
const parts = d.split(":");
|
|
815
|
+
const id = parts[1];
|
|
816
|
+
const pick = parts[2];
|
|
817
|
+
const pend = state.pendingVerify;
|
|
818
|
+
const VERIFY_TTL_MS = 5 * 60 * 1000;
|
|
819
|
+
if (!pend || pend.aprId !== id || (pend.armedAt && Date.now() - pend.armedAt > VERIFY_TTL_MS)) {
|
|
820
|
+
state.pendingVerify = null; saveState();
|
|
821
|
+
return send("That verify prompt is stale — tap “Verify contact…” on the card again.");
|
|
822
|
+
}
|
|
823
|
+
state.pendingVerify = null; saveState();
|
|
824
|
+
if (pick === "x") return send("Okay — left unverified.");
|
|
825
|
+
let linkSlug = null;
|
|
826
|
+
if (pick !== "self") {
|
|
827
|
+
const chosen = pend.candidates[parseInt(pick, 10)];
|
|
828
|
+
if (!chosen) return send("That button is stale — tap “Verify contact…” on the card again.");
|
|
829
|
+
linkSlug = chosen.slug;
|
|
830
|
+
}
|
|
831
|
+
const result = require("./verify-contact").verifyContact({
|
|
832
|
+
personId: pend.personId,
|
|
833
|
+
linkSlug,
|
|
834
|
+
by: String(envelope.channelId),
|
|
835
|
+
via: "approval-card",
|
|
836
|
+
});
|
|
837
|
+
if (!result.ok) {
|
|
838
|
+
return send(result.already
|
|
839
|
+
? `${pend.personName} is already verified.`
|
|
840
|
+
: `Couldn't verify: ${result.reason}`);
|
|
841
|
+
}
|
|
842
|
+
return send(
|
|
843
|
+
`🪪 Verified ${pend.personName}` +
|
|
844
|
+
(linkSlug ? ` — linked to ${result.slug}${result.superseded ? ` (auto-stub ${result.superseded} retired)` : ""}.` : ` — their note is now canonical.`) +
|
|
845
|
+
`\nVerification settles identity only; permissions still come from mandates and rules.`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
756
848
|
if (d.startsWith("grd:")) {
|
|
757
849
|
// Guardrail capture buttons (Track C). Owner-only: standing rules layer
|
|
758
850
|
// above lessons and are never editable by an external speaker.
|
package/core/enforcer.js
CHANGED
|
@@ -336,7 +336,7 @@ function esc(s) {
|
|
|
336
336
|
// Framing: the owner is deciding whether the bot should ACT on a request from
|
|
337
337
|
// this person — the card leads with WHO is asking WHAT, then what the bot
|
|
338
338
|
// proposes to do about it, never with the bot's output as the headline.
|
|
339
|
-
async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
|
|
339
|
+
async function postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate = "" }) {
|
|
340
340
|
const adapter = adapterFor(owner.adapter);
|
|
341
341
|
if (!adapter) return false;
|
|
342
342
|
const who = rec.personName || "An external contact";
|
|
@@ -360,7 +360,28 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
|
|
|
360
360
|
`<pre>${esc(String(rec.command || ""))}</pre>`,
|
|
361
361
|
];
|
|
362
362
|
}
|
|
363
|
-
|
|
363
|
+
// Unverified auto-added speaker → the card carries a one-tap way for the
|
|
364
|
+
// owner to settle WHO this is (link to an existing note, or bless the stub).
|
|
365
|
+
// Computed live from the people record — after verification the row (and
|
|
366
|
+
// the warning line) disappear from subsequent cards on their own.
|
|
367
|
+
let unverifiedLine = "";
|
|
368
|
+
let verifyRow = null;
|
|
369
|
+
try {
|
|
370
|
+
if (rec.personId) {
|
|
371
|
+
const person = require("./people").findById(rec.personId);
|
|
372
|
+
if (person && person.autoCreated && !person.isOwner) {
|
|
373
|
+
const candidates = require("./verify-contact").candidateEntitiesFor(person);
|
|
374
|
+
unverifiedLine = candidates.length
|
|
375
|
+
? `⚠️ Unverified auto-added contact. Possible existing note: <b>${esc(candidates[0].slug)}</b> — NOT linked until you verify.`
|
|
376
|
+
: `⚠️ Unverified auto-added contact — I only know them from this chat.`;
|
|
377
|
+
verifyRow = [{ text: "🪪 Verify contact…", callback_data: `vfy:${rec.id}` }];
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} catch (e) { /* card still posts without the verify affordance */ }
|
|
381
|
+
const lines = [head, ``, ...askBlock, ...body,
|
|
382
|
+
unverifiedLine ? `\n${unverifiedLine}` : ``,
|
|
383
|
+
strandedMandate ? `\n⚠️ ${esc(strandedMandate)}` : ``,
|
|
384
|
+
reason ? `\nWhy I'm checking: ${esc(reason)}` : ``];
|
|
364
385
|
const yes = kind === "triage" ? "✅ Yes, handle it" : "✅ Approve once";
|
|
365
386
|
const no = kind === "triage" ? "⛔ No, decline" : "⛔ Deny";
|
|
366
387
|
const keyboard = { inline_keyboard: [
|
|
@@ -370,6 +391,7 @@ async function postOwnerButtons(rec, owner, { kind, summary, reason }) {
|
|
|
370
391
|
],
|
|
371
392
|
[
|
|
372
393
|
{ text: "📝 Make a rule…", callback_data: `apr:${rec.id}:rule` },
|
|
394
|
+
...(verifyRow || []),
|
|
373
395
|
],
|
|
374
396
|
] };
|
|
375
397
|
try {
|
|
@@ -429,6 +451,33 @@ async function escalate({
|
|
|
429
451
|
channelLabel = String((await originAdapter.channelLabel(String(speaker.channelId))) || "").trim();
|
|
430
452
|
}
|
|
431
453
|
} catch (e) {}
|
|
454
|
+
// Dual-file trap detection: a mandate granted on the name-obvious note while
|
|
455
|
+
// the enforcer reads this person's (hashed) record slug silently does
|
|
456
|
+
// nothing. Detect it at the moment it bites — the escalation the owner
|
|
457
|
+
// thought a grant had already prevented — and say where the grant actually
|
|
458
|
+
// lives. Detection only: the stranded text is NEVER applied (names are
|
|
459
|
+
// spoofable; only the owner links the two, via verify).
|
|
460
|
+
let strandedMandate = "";
|
|
461
|
+
try {
|
|
462
|
+
if (speaker && speaker.person && speaker.slug && !String(speaker.mandate || "").trim()) {
|
|
463
|
+
const entities = require("./entities");
|
|
464
|
+
const sibling = entities.slugify(speaker.person.name || "");
|
|
465
|
+
if (sibling && sibling !== speaker.slug) {
|
|
466
|
+
const other = entities.readEntity(sibling);
|
|
467
|
+
const text = other && other.type === "person"
|
|
468
|
+
? require("./mandates").activeMandateText(String((other.sections && other.sections.Mandate) || "")).trim()
|
|
469
|
+
: "";
|
|
470
|
+
if (text) {
|
|
471
|
+
const people = require("./people");
|
|
472
|
+
const holder = people.list().find((p) => p.id !== speaker.person.id && people.resolveEntitySlug(p) === sibling);
|
|
473
|
+
if (!holder) {
|
|
474
|
+
strandedMandate = `A mandate exists on ${sibling}.md, but ${speaker.person.name}'s record reads ${speaker.slug}.md — those grants have NO effect. Merge them with the Verify button (or /people verify), or re-add via Make a rule.`;
|
|
475
|
+
if (audit) audit.log("enforcer.stranded-mandate", { personId: speaker.person.id, slug: speaker.slug, sibling });
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} catch (e) { strandedMandate = ""; }
|
|
432
481
|
const rec = approvals.create({
|
|
433
482
|
tool: kind === "reply" ? "external-reply" : kind === "triage" ? "external-triage" : "external-action",
|
|
434
483
|
verb: "",
|
|
@@ -459,7 +508,7 @@ async function escalate({
|
|
|
459
508
|
inboundText,
|
|
460
509
|
channelLabel,
|
|
461
510
|
});
|
|
462
|
-
const posted = await postOwnerButtons(rec, owner, { kind, summary, reason });
|
|
511
|
+
const posted = await postOwnerButtons(rec, owner, { kind, summary, reason, strandedMandate });
|
|
463
512
|
if (!posted) {
|
|
464
513
|
approvals.decide(rec.id, "denied", "owner-unreachable");
|
|
465
514
|
if (audit) audit.log("enforcer.escalate.failed", { reason: "could not post to owner", id: rec.id });
|
package/core/handlers.js
CHANGED
|
@@ -2466,7 +2466,7 @@ function formatPersonShort(p) {
|
|
|
2466
2466
|
}
|
|
2467
2467
|
|
|
2468
2468
|
register({
|
|
2469
|
-
name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove ...]",
|
|
2469
|
+
name: "people", description: "Manage team members", args: "[show|note|link|unlink|set-primary|add|remove|verify ...]",
|
|
2470
2470
|
handler: async (env, { tail }) => {
|
|
2471
2471
|
if (!authorized(env)) return;
|
|
2472
2472
|
if (!tail || tail === "list") {
|
|
@@ -2552,7 +2552,51 @@ register({
|
|
|
2552
2552
|
for (const h of handles) { const r = accessStore.revokeChat(h.channelId); if (r.ok) revokedCount += 1; }
|
|
2553
2553
|
return send(`Removed ${person.name}. Deauthed ${revokedCount} chat${revokedCount === 1 ? "" : "s"}.`);
|
|
2554
2554
|
}
|
|
2555
|
-
|
|
2555
|
+
// Verify an auto-added contact: settle their identity, optionally binding
|
|
2556
|
+
// them to an existing note. Owner-only and chat-surface-only by design —
|
|
2557
|
+
// there is deliberately no CLI/loopback equivalent, so identity decisions
|
|
2558
|
+
// can never ride agent-forgeable input. Grants no permissions.
|
|
2559
|
+
const verifyMatch = tail.match(/^verify\s+(.+?)(?:\s+link\s+(\S+))?$/i);
|
|
2560
|
+
if (verifyMatch) {
|
|
2561
|
+
const person = peopleStore.findById(verifyMatch[1].trim()) || peopleStore.findByName(verifyMatch[1].trim());
|
|
2562
|
+
if (!person) return send(`Not found: ${verifyMatch[1].trim()}`);
|
|
2563
|
+
const vc = require("./verify-contact");
|
|
2564
|
+
if (person.autoCreated && !verifyMatch[2]) {
|
|
2565
|
+
const candidates = vc.candidateEntitiesFor(person);
|
|
2566
|
+
if (candidates.length) {
|
|
2567
|
+
return send(
|
|
2568
|
+
`${person.name} shares a name with ${candidates.length === 1 ? "an existing note" : `${candidates.length} existing notes`}:\n` +
|
|
2569
|
+
candidates.map((c) => ` ${c.slug}`).join("\n") +
|
|
2570
|
+
`\n\nLink with /people verify ${person.name} link <slug>, or /people verify-new ${person.name} to keep them separate.`
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
const result = vc.verifyContact({
|
|
2575
|
+
personId: person.id,
|
|
2576
|
+
linkSlug: verifyMatch[2] || null,
|
|
2577
|
+
by: env.canonicalUserId || String(env.channelId),
|
|
2578
|
+
via: "slash-command",
|
|
2579
|
+
});
|
|
2580
|
+
if (!result.ok) return send(result.already ? `${person.name} is already verified.` : `Couldn't verify: ${result.reason}`);
|
|
2581
|
+
return send(
|
|
2582
|
+
`🪪 Verified ${person.name}` +
|
|
2583
|
+
(verifyMatch[2] ? ` — linked to ${result.slug}${result.superseded ? ` (auto-stub ${result.superseded} retired)` : ""}.` : ` — their note is now canonical.`) +
|
|
2584
|
+
`\nVerification settles identity only; permissions still come from mandates and rules.`
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
const verifyNewMatch = tail.match(/^verify-new\s+(.+)$/i);
|
|
2588
|
+
if (verifyNewMatch) {
|
|
2589
|
+
const person = peopleStore.findById(verifyNewMatch[1].trim()) || peopleStore.findByName(verifyNewMatch[1].trim());
|
|
2590
|
+
if (!person) return send(`Not found: ${verifyNewMatch[1].trim()}`);
|
|
2591
|
+
const result = require("./verify-contact").verifyContact({
|
|
2592
|
+
personId: person.id,
|
|
2593
|
+
by: env.canonicalUserId || String(env.channelId),
|
|
2594
|
+
via: "slash-command",
|
|
2595
|
+
});
|
|
2596
|
+
if (!result.ok) return send(result.already ? `${person.name} is already verified.` : `Couldn't verify: ${result.reason}`);
|
|
2597
|
+
return send(`🪪 Verified ${person.name} — their note is now canonical.\nVerification settles identity only; permissions still come from mandates and rules.`);
|
|
2598
|
+
}
|
|
2599
|
+
send("Usage: /people | /people show <name> | /people note <name> <text> | /people add|link|unlink|set-primary|remove|verify ... (owner)");
|
|
2556
2600
|
},
|
|
2557
2601
|
});
|
|
2558
2602
|
|
package/core/intro-flow.js
CHANGED
|
@@ -98,11 +98,80 @@ async function notifyOwnerAboutIntro(intro) {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Legacy auth.json entry for a channel, created — or upgraded in place — so
|
|
102
|
+
// isChatAuthorized/isChatOwner agree with the people record. isOwner flows
|
|
103
|
+
// from people.isOwner: dropping it here is what demoted owners approved onto
|
|
104
|
+
// a second channel (isChatOwner reads this file, not the person).
|
|
105
|
+
function ensureAuthEntry(channelId, { name = null, username = null, isOwner = false } = {}) {
|
|
106
|
+
const auth = loadAuth();
|
|
107
|
+
const id = String(channelId);
|
|
108
|
+
const existing = auth.authorized.find((a) => String(a.chatId) === id);
|
|
109
|
+
if (existing) {
|
|
110
|
+
if (isOwner && existing.isOwner !== true) {
|
|
111
|
+
existing.isOwner = true;
|
|
112
|
+
saveAuth(auth);
|
|
113
|
+
}
|
|
114
|
+
return auth;
|
|
115
|
+
}
|
|
116
|
+
auth.authorized.push({
|
|
117
|
+
chatId: id,
|
|
118
|
+
name: name || "",
|
|
119
|
+
username: username || null,
|
|
120
|
+
isOwner: !!isOwner,
|
|
121
|
+
authorizedAt: new Date().toISOString(),
|
|
122
|
+
});
|
|
123
|
+
saveAuth(auth);
|
|
124
|
+
return auth;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A sender whose transport user id already maps to a person is never
|
|
128
|
+
// interrogated: the id is adapter-attested (the same trust level as the
|
|
129
|
+
// KAZEE_OWNER_USER_ID owner match) — unlike display names, which are
|
|
130
|
+
// spoofable and are never matched. Only 1:1 conversations are adopted: the
|
|
131
|
+
// adapter must attest surface "dm", because auth.json and isChatOwner are
|
|
132
|
+
// per-CHANNEL, so adopting a multi-party room would hand every member the
|
|
133
|
+
// sender's standing. No attestation, no adoption (fail closed).
|
|
134
|
+
function adoptKnownSender(envelope, adapter, channelId) {
|
|
135
|
+
if (envelope.surface !== "dm") return null;
|
|
136
|
+
const senderId = envelope.userId != null ? String(envelope.userId) : "";
|
|
137
|
+
if (!senderId || senderId === channelId) return null;
|
|
138
|
+
const person = people.findByHandle(adapter, senderId);
|
|
139
|
+
if (!person) return null;
|
|
140
|
+
try {
|
|
141
|
+
people.linkHandle(person.id, {
|
|
142
|
+
adapter, channelId,
|
|
143
|
+
displayName: displayNameFromEnvelope(envelope),
|
|
144
|
+
approvedBy: "auto:transport-id",
|
|
145
|
+
});
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return null; // handle conflict — leave the channel to the normal intro flow
|
|
148
|
+
}
|
|
149
|
+
ensureAuthEntry(channelId, {
|
|
150
|
+
name: person.name || displayNameFromEnvelope(envelope),
|
|
151
|
+
username: usernameFromEnvelope(envelope),
|
|
152
|
+
isOwner: person.isOwner === true,
|
|
153
|
+
});
|
|
154
|
+
const pending = intros.activeForChannel(adapter, channelId);
|
|
155
|
+
if (pending) intros.remove(pending.id);
|
|
156
|
+
audit.log("intro.auto-adopted", {
|
|
157
|
+
personId: person.id, adapter, channelId, senderId,
|
|
158
|
+
isOwner: person.isOwner === true,
|
|
159
|
+
resolvedIntroId: pending ? pending.id : null,
|
|
160
|
+
});
|
|
161
|
+
return person;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Returns true when the intro flow consumed the message (the router must not
|
|
165
|
+
// process it further); false ONLY when the sender was recognised by transport
|
|
166
|
+
// id and the channel auto-adopted — the router then handles the turn normally.
|
|
101
167
|
async function handleInbound(envelope, sendFn) {
|
|
102
168
|
intros.sweep();
|
|
103
169
|
const adapter = envAdapterType(envelope);
|
|
104
170
|
const channelId = String(envelope.channelId);
|
|
105
|
-
if (!adapter) return
|
|
171
|
+
if (!adapter) return true;
|
|
172
|
+
|
|
173
|
+
const known = adoptKnownSender(envelope, adapter, channelId);
|
|
174
|
+
if (known) return false;
|
|
106
175
|
|
|
107
176
|
let intro = intros.activeForChannel(adapter, channelId);
|
|
108
177
|
const text = String(envelope.text || "").trim();
|
|
@@ -200,7 +269,7 @@ async function handleInbound(envelope, sendFn) {
|
|
|
200
269
|
return true;
|
|
201
270
|
}
|
|
202
271
|
|
|
203
|
-
return
|
|
272
|
+
return true;
|
|
204
273
|
}
|
|
205
274
|
|
|
206
275
|
async function applyApproval(intro, by) {
|
|
@@ -227,20 +296,16 @@ async function applyApproval(intro, by) {
|
|
|
227
296
|
} else {
|
|
228
297
|
throw new Error("intro has no claim — owner approved a half-finished intro");
|
|
229
298
|
}
|
|
230
|
-
// Also write into auth.json so legacy isChatAuthorized + isChatOwner still
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
})
|
|
240
|
-
saveAuth(auth);
|
|
241
|
-
if (intro.adapter === "telegram") {
|
|
242
|
-
try { updateAuthorizedChatEnv(auth); } catch (e) {}
|
|
243
|
-
}
|
|
299
|
+
// Also write into auth.json so legacy isChatAuthorized + isChatOwner still
|
|
300
|
+
// work. isOwner mirrors the person record: an owner approved onto a new
|
|
301
|
+
// channel keeps owner status there (hardcoding false demoted Kazee owners).
|
|
302
|
+
const auth = ensureAuthEntry(intro.channelId, {
|
|
303
|
+
name: intro.displayName || person.name,
|
|
304
|
+
username: intro.username,
|
|
305
|
+
isOwner: person.isOwner === true,
|
|
306
|
+
});
|
|
307
|
+
if (intro.adapter === "telegram") {
|
|
308
|
+
try { updateAuthorizedChatEnv(auth); } catch (e) {}
|
|
244
309
|
}
|
|
245
310
|
intros.approve(intro.id, by);
|
|
246
311
|
audit.log("intro.approved", { introId: intro.id, personId: person.id, by });
|
package/core/loopback.js
CHANGED
|
@@ -25,6 +25,7 @@ const introFlow = require("./intro-flow");
|
|
|
25
25
|
const relay = require("./relay");
|
|
26
26
|
const audit = require("./audit");
|
|
27
27
|
const access = require("./access");
|
|
28
|
+
const speakerAttest = require("./speaker-attest");
|
|
28
29
|
const fs2 = require("fs");
|
|
29
30
|
const path2 = require("path");
|
|
30
31
|
const { TRANSCRIPTS_DIR } = require("./config");
|
|
@@ -85,9 +86,17 @@ const JSON_KINDS = new Set([
|
|
|
85
86
|
"say", "call", "tts", "transcribe",
|
|
86
87
|
]);
|
|
87
88
|
|
|
89
|
+
// Owner gate for identity-sensitive kinds. The claimed canonicalUserId comes
|
|
90
|
+
// from the CLI's env, which the agent's shell can rewrite — so the claim only
|
|
91
|
+
// counts when it carries the runner-minted attestation (an HMAC over the id
|
|
92
|
+
// with a per-boot, memory-only secret; see core/speaker-attest). No attest,
|
|
93
|
+
// a wrong attest, or an attest for a different id all fail closed: forging
|
|
94
|
+
// `OC_CANONICAL_USER_ID=<owner-id>` from a guarded external turn no longer
|
|
95
|
+
// reaches the people/auth/intros mutations the relationship guard depends on.
|
|
88
96
|
function callerIsOwner(payload) {
|
|
89
97
|
const caller = payload?.canonicalUserId;
|
|
90
98
|
if (!caller) return false;
|
|
99
|
+
if (!speakerAttest.verify(caller, payload?.speakerAttest)) return false;
|
|
91
100
|
const person = peopleStore.findByCanonicalUserId(caller);
|
|
92
101
|
return !!(person && person.isOwner);
|
|
93
102
|
}
|
|
@@ -602,6 +611,24 @@ async function handleJson(req, res, url, kind) {
|
|
|
602
611
|
|
|
603
612
|
if (kind === "relay-send") {
|
|
604
613
|
if (!payload.text) return reply(res, 400, { error: "missing text" });
|
|
614
|
+
// A guarded (external) requester may pass a message TO THE OWNER — that is
|
|
615
|
+
// the "tell them for me" flow — but never use the bot as a relay to third
|
|
616
|
+
// parties: sending as the bot to anyone else is impersonation surface.
|
|
617
|
+
try {
|
|
618
|
+
const relationship = require("./relationship");
|
|
619
|
+
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
620
|
+
if (relationship.guardActive(speaker)) {
|
|
621
|
+
const target =
|
|
622
|
+
(payload.targetPersonId && peopleStore.findById(payload.targetPersonId)) ||
|
|
623
|
+
(payload.targetPersonName && peopleStore.findByName(payload.targetPersonName)) ||
|
|
624
|
+
(payload.targetCanonicalUserId && peopleStore.findByCanonicalUserId(payload.targetCanonicalUserId)) ||
|
|
625
|
+
(payload.targetAdapter && payload.targetChannelId && peopleStore.findByHandle(payload.targetAdapter, payload.targetChannelId)) ||
|
|
626
|
+
null;
|
|
627
|
+
if (!target || !target.isOwner) {
|
|
628
|
+
return reply(res, 403, { error: "relay from an external contact can only go to the owner — messages to anyone else are owner-only" });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
} catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
|
|
605
632
|
try {
|
|
606
633
|
const result = await relay.send({
|
|
607
634
|
text: payload.text,
|
|
@@ -620,6 +647,16 @@ async function handleJson(req, res, url, kind) {
|
|
|
620
647
|
}
|
|
621
648
|
|
|
622
649
|
if (kind === "recent-fetch") {
|
|
650
|
+
// Transcript reads are owner-side memory. A guarded (external, non-owner)
|
|
651
|
+
// requester must not pull other channels' conversations through the CLI —
|
|
652
|
+
// same default-deny as file/voice egress, same fail direction.
|
|
653
|
+
try {
|
|
654
|
+
const relationship = require("./relationship");
|
|
655
|
+
const speaker = relationship.speakerFor(adapter.type, channelId, userId);
|
|
656
|
+
if (relationship.guardActive(speaker)) {
|
|
657
|
+
return reply(res, 403, { error: "transcript access is owner-only — an external contact cannot read other conversations" });
|
|
658
|
+
}
|
|
659
|
+
} catch (e) { /* classification failure → fall through; owner-scoped in practice */ }
|
|
623
660
|
const limit = Math.max(1, Math.min(parseInt(payload.limit || 20, 10) || 20, 200));
|
|
624
661
|
try {
|
|
625
662
|
let targetAdapter = payload.adapter;
|
package/core/media.js
CHANGED
|
@@ -99,34 +99,87 @@ async function elevenLabsToVoice(clean) {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Pack already-cleaned text into gateway-sized pieces. Long inputs used to go
|
|
103
|
+
// to the gateway as ONE request and blow its synthesis window (the abort was
|
|
104
|
+
// the binding limit, not any transport) — sentence-packed chunks keep every
|
|
105
|
+
// request comfortably inside it. Oversized single sentences are hard-split at
|
|
106
|
+
// word boundaries so one run-on sentence can't recreate the timeout.
|
|
107
|
+
const TTS_CHUNK_MAX = 450;
|
|
108
|
+
function chunkForTTS(clean, maxLen = TTS_CHUNK_MAX) {
|
|
109
|
+
if (clean.length <= maxLen) return [clean];
|
|
110
|
+
const sentences = clean.match(/[^.!?]+[.!?]+|\S[^.!?]*$/g) || [clean];
|
|
111
|
+
const chunks = [];
|
|
112
|
+
let buf = "";
|
|
113
|
+
const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
|
|
114
|
+
for (let piece of sentences) {
|
|
115
|
+
piece = piece.trim();
|
|
116
|
+
if (!piece) continue;
|
|
117
|
+
while (piece.length > maxLen) {
|
|
118
|
+
flush();
|
|
119
|
+
let cut = piece.lastIndexOf(" ", maxLen);
|
|
120
|
+
if (cut < maxLen / 2) cut = maxLen;
|
|
121
|
+
chunks.push(piece.slice(0, cut).trim());
|
|
122
|
+
piece = piece.slice(cut).trim();
|
|
123
|
+
}
|
|
124
|
+
if (buf && buf.length + piece.length + 1 > maxLen) flush();
|
|
125
|
+
buf = buf ? `${buf} ${piece}` : piece;
|
|
126
|
+
}
|
|
127
|
+
flush();
|
|
128
|
+
return chunks.filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function pocketFetchWav(input, voice, token) {
|
|
132
|
+
const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify({ input, voice, response_format: "wav" }),
|
|
136
|
+
signal: AbortSignal.timeout(120000),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
139
|
+
return Buffer.from(await res.arrayBuffer());
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
// Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
|
|
103
|
-
// Telegram and voice transports receive a compact Ogg/Opus file.
|
|
143
|
+
// Telegram and voice transports receive a compact Ogg/Opus file. Long text is
|
|
144
|
+
// synthesized chunk-by-chunk and concatenated, so message length can no longer
|
|
145
|
+
// abort the request mid-synthesis.
|
|
104
146
|
async function pocketToVoice(clean, voiceOverride) {
|
|
105
147
|
const token = speechBearerToken();
|
|
106
148
|
if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
|
|
107
|
-
|
|
149
|
+
const voice = voiceOverride || ttsVoice.getVoice();
|
|
150
|
+
const stem = `tts-pocket-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
151
|
+
const oggPath = path.join(TEMP_DIR, `${stem}.ogg`);
|
|
152
|
+
const wavPaths = [];
|
|
153
|
+
let listPath = null;
|
|
108
154
|
try {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
155
|
+
const chunks = chunkForTTS(clean);
|
|
156
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
157
|
+
let buf;
|
|
158
|
+
try {
|
|
159
|
+
buf = await pocketFetchWav(chunks[i], voice, token);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
// one retry: a transient gateway blip shouldn't waste the chunks
|
|
162
|
+
// already synthesized before it
|
|
163
|
+
buf = await pocketFetchWav(chunks[i], voice, token);
|
|
164
|
+
}
|
|
165
|
+
const wavPath = path.join(TEMP_DIR, `${stem}-${i}.wav`);
|
|
166
|
+
fs.writeFileSync(wavPath, buf);
|
|
167
|
+
wavPaths.push(wavPath);
|
|
168
|
+
}
|
|
169
|
+
if (wavPaths.length === 1) {
|
|
170
|
+
execSync(`"${FFMPEG}" -i "${wavPaths[0]}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
|
|
171
|
+
} else {
|
|
172
|
+
listPath = path.join(TEMP_DIR, `${stem}-list.txt`);
|
|
173
|
+
fs.writeFileSync(listPath, wavPaths.map((p) => `file '${p}'`).join("\n"));
|
|
174
|
+
execSync(`"${FFMPEG}" -f concat -safe 0 -i "${listPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 120000 });
|
|
118
175
|
}
|
|
119
|
-
const stem = `tts-pocket-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
120
|
-
wavPath = path.join(TEMP_DIR, `${stem}.wav`);
|
|
121
|
-
const oggPath = path.join(TEMP_DIR, `${stem}.ogg`);
|
|
122
|
-
fs.writeFileSync(wavPath, Buffer.from(await res.arrayBuffer()));
|
|
123
|
-
execSync(`"${FFMPEG}" -i "${wavPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
|
|
124
176
|
return oggPath;
|
|
125
177
|
} catch (e) {
|
|
126
178
|
console.error("Pocket TTS error:", e.message);
|
|
127
179
|
return null;
|
|
128
180
|
} finally {
|
|
129
|
-
|
|
181
|
+
for (const p of wavPaths) { try { fs.unlinkSync(p); } catch (e) {} }
|
|
182
|
+
if (listPath) { try { fs.unlinkSync(listPath); } catch (e) {} }
|
|
130
183
|
}
|
|
131
184
|
}
|
|
132
185
|
|
|
@@ -303,4 +356,4 @@ async function synthSentenceMp3(text) {
|
|
|
303
356
|
}
|
|
304
357
|
}
|
|
305
358
|
|
|
306
|
-
module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
|
|
359
|
+
module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, chunkForTTS, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
|
package/core/pack-review.js
CHANGED
|
@@ -388,7 +388,25 @@ function applyAction(a, activeSet = null) {
|
|
|
388
388
|
|
|
389
389
|
function applyEntityAction(e) {
|
|
390
390
|
if (!e || typeof e !== "object" || !String(e.name || "").trim()) return null;
|
|
391
|
+
// Slug-key through the people roster: name-keyed upserts land on the
|
|
392
|
+
// alphabetically-first same-named entity, but the relationship guard reads
|
|
393
|
+
// the file the person RECORD points at (auto-added contacts get a suffixed
|
|
394
|
+
// stub precisely so they can't shadow an existing note). A unique name
|
|
395
|
+
// match routes the write to the guard-read file; an ambiguous one (two
|
|
396
|
+
// tracked people sharing a name) is skipped outright — enriching the wrong
|
|
397
|
+
// identity's note is worse than writing nothing.
|
|
398
|
+
let slug = "";
|
|
399
|
+
try {
|
|
400
|
+
const people = require("./people");
|
|
401
|
+
const wanted = String(e.name || "").trim().toLowerCase();
|
|
402
|
+
const matches = people.list().filter((p) => (p.name || "").trim().toLowerCase() === wanted);
|
|
403
|
+
if (matches.length === 1) slug = people.resolveEntitySlug(matches[0]) || "";
|
|
404
|
+
else if (matches.length > 1) {
|
|
405
|
+
return { kind: "skip", slug: "", name: e.name, type: e.type || "", note: "ambiguous: multiple tracked people share this name — not writing to either note" };
|
|
406
|
+
}
|
|
407
|
+
} catch (err) { /* roster unavailable → name-keyed fallback below */ }
|
|
391
408
|
const { entity, created } = entities.upsertEntity({
|
|
409
|
+
slug: slug || undefined,
|
|
392
410
|
name: e.name,
|
|
393
411
|
type: e.type,
|
|
394
412
|
aliases: Array.isArray(e.aliases) ? e.aliases.slice(0, 5) : [],
|
package/core/relationship.js
CHANGED
|
@@ -253,10 +253,26 @@ function ensureContact(envelope) {
|
|
|
253
253
|
const suffix = crypto.createHash("sha1").update(`${adapterType}:${userId}`).digest("hex").slice(0, 6);
|
|
254
254
|
const slug = `${entities.slugify(displayName).slice(0, 48) || "contact"}-${suffix}`;
|
|
255
255
|
people.setEntitySlug(person.id, slug);
|
|
256
|
+
|
|
257
|
+
// If a same-named person note already exists, surface it as a HINT only.
|
|
258
|
+
// Deliberately not linked (see invariants above): a name is spoofable, so
|
|
259
|
+
// absorbing the note here would hand the speaker that identity's history.
|
|
260
|
+
// Only the owner can link, via the approval card or /people verify.
|
|
261
|
+
let hint = "";
|
|
262
|
+
try {
|
|
263
|
+
const fresh = people.findById(person.id) || person;
|
|
264
|
+
const candidates = require("./verify-contact").candidateEntitiesFor(fresh);
|
|
265
|
+
if (candidates.length) hint = candidates.map((c) => c.slug).slice(0, 3).join(", ");
|
|
266
|
+
} catch (e) { /* hint is best-effort — the stub is safe without it */ }
|
|
267
|
+
|
|
256
268
|
entities.upsertEntity({
|
|
257
269
|
slug, name: displayName, type: "person", relationship: "external",
|
|
258
|
-
description:
|
|
259
|
-
|
|
270
|
+
description: hint
|
|
271
|
+
? `Auto-added contact (unverified) — first seen in ${label}. Possible existing note: ${hint} — NOT linked; only the owner can verify.`
|
|
272
|
+
: `Auto-added contact (unverified) — first seen in ${label}`,
|
|
273
|
+
log: hint
|
|
274
|
+
? `auto-added as a contact from ${label}; same-named note detected (${hint}) — not linked pending owner verification`
|
|
275
|
+
: `auto-added as a contact from ${label}`,
|
|
260
276
|
});
|
|
261
277
|
|
|
262
278
|
try { require("./audit").log("auto-contact", { personId: person.id, name: displayName, adapter: adapterType, userId, channelId, entitySlug: slug }); } catch (e) {}
|