@gonzih/meet-the-one-ai 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +41 -0
- package/.node-version +1 -0
- package/basis/BERNAYS.md +233 -0
- package/basis/FOUNDING_TRANSCRIPT.md +218 -0
- package/basis/TECH_SPEC.md +303 -0
- package/basis/VALS.md +255 -0
- package/basis/layers/L1_IDENTITY_AUTH.md +78 -0
- package/basis/layers/L2_CONVERSATION.md +159 -0
- package/basis/layers/L3_RECORDING_STORE.md +104 -0
- package/basis/layers/L4_ANALYSIS_PIPELINE.md +257 -0
- package/basis/layers/L5_MATCHING_ENGINE.md +164 -0
- package/basis/layers/L6_CONSENT_INTRODUCTION.md +143 -0
- package/basis/layers/L7_PORTABLE_IDENTITY.md +139 -0
- package/basis/layers/STACK.md +64 -0
- package/basis/schema.sql +203 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +114 -0
- package/dist/agent.js.map +1 -0
- package/dist/api/routes/auth.d.ts +2 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +79 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/identity.d.ts +2 -0
- package/dist/api/routes/identity.d.ts.map +1 -0
- package/dist/api/routes/identity.js +92 -0
- package/dist/api/routes/identity.js.map +1 -0
- package/dist/api/routes/text-submission.d.ts +2 -0
- package/dist/api/routes/text-submission.d.ts.map +1 -0
- package/dist/api/routes/text-submission.js +56 -0
- package/dist/api/routes/text-submission.js.map +1 -0
- package/dist/api/webhooks/twilio.d.ts +2 -0
- package/dist/api/webhooks/twilio.d.ts.map +1 -0
- package/dist/api/webhooks/twilio.js +144 -0
- package/dist/api/webhooks/twilio.js.map +1 -0
- package/dist/api/webhooks/vapi.d.ts +2 -0
- package/dist/api/webhooks/vapi.d.ts.map +1 -0
- package/dist/api/webhooks/vapi.js +177 -0
- package/dist/api/webhooks/vapi.js.map +1 -0
- package/dist/bot.d.ts +3 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +39 -0
- package/dist/bot.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/compact-identity.d.ts +2 -0
- package/dist/jobs/compact-identity.d.ts.map +1 -0
- package/dist/jobs/compact-identity.js +159 -0
- package/dist/jobs/compact-identity.js.map +1 -0
- package/dist/jobs/consent-call.d.ts +2 -0
- package/dist/jobs/consent-call.d.ts.map +1 -0
- package/dist/jobs/consent-call.js +70 -0
- package/dist/jobs/consent-call.js.map +1 -0
- package/dist/jobs/export-identity.d.ts +2 -0
- package/dist/jobs/export-identity.d.ts.map +1 -0
- package/dist/jobs/export-identity.js +129 -0
- package/dist/jobs/export-identity.js.map +1 -0
- package/dist/jobs/introduction-call.d.ts +2 -0
- package/dist/jobs/introduction-call.d.ts.map +1 -0
- package/dist/jobs/introduction-call.js +86 -0
- package/dist/jobs/introduction-call.js.map +1 -0
- package/dist/jobs/reanalyze-identity.d.ts +2 -0
- package/dist/jobs/reanalyze-identity.d.ts.map +1 -0
- package/dist/jobs/reanalyze-identity.js +56 -0
- package/dist/jobs/reanalyze-identity.js.map +1 -0
- package/dist/jobs/run-matching.d.ts +2 -0
- package/dist/jobs/run-matching.d.ts.map +1 -0
- package/dist/jobs/run-matching.js +200 -0
- package/dist/jobs/run-matching.js.map +1 -0
- package/dist/jobs/scheduled-matching.d.ts +2 -0
- package/dist/jobs/scheduled-matching.d.ts.map +1 -0
- package/dist/jobs/scheduled-matching.js +44 -0
- package/dist/jobs/scheduled-matching.js.map +1 -0
- package/dist/jobs/transcribe-session.d.ts +2 -0
- package/dist/jobs/transcribe-session.d.ts.map +1 -0
- package/dist/jobs/transcribe-session.js +66 -0
- package/dist/jobs/transcribe-session.js.map +1 -0
- package/dist/lib/anthropic.d.ts +4 -0
- package/dist/lib/anthropic.d.ts.map +1 -0
- package/dist/lib/anthropic.js +32 -0
- package/dist/lib/anthropic.js.map +1 -0
- package/dist/lib/config.d.ts +57 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +73 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/deepgram.d.ts +15 -0
- package/dist/lib/deepgram.d.ts.map +1 -0
- package/dist/lib/deepgram.js +37 -0
- package/dist/lib/deepgram.js.map +1 -0
- package/dist/lib/inngest.d.ts +42 -0
- package/dist/lib/inngest.d.ts.map +1 -0
- package/dist/lib/inngest.js +7 -0
- package/dist/lib/inngest.js.map +1 -0
- package/dist/lib/openai.d.ts +3 -0
- package/dist/lib/openai.d.ts.map +1 -0
- package/dist/lib/openai.js +13 -0
- package/dist/lib/openai.js.map +1 -0
- package/dist/lib/prompts.d.ts +8 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +258 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/lib/r2.d.ts +7 -0
- package/dist/lib/r2.d.ts.map +1 -0
- package/dist/lib/r2.js +49 -0
- package/dist/lib/r2.js.map +1 -0
- package/dist/lib/session-helpers.d.ts +8 -0
- package/dist/lib/session-helpers.d.ts.map +1 -0
- package/dist/lib/session-helpers.js +31 -0
- package/dist/lib/session-helpers.js.map +1 -0
- package/dist/lib/supabase.d.ts +2 -0
- package/dist/lib/supabase.d.ts.map +1 -0
- package/dist/lib/supabase.js +11 -0
- package/dist/lib/supabase.js.map +1 -0
- package/dist/lib/twilio.d.ts +7 -0
- package/dist/lib/twilio.d.ts.map +1 -0
- package/dist/lib/twilio.js +34 -0
- package/dist/lib/twilio.js.map +1 -0
- package/dist/lib/vapi.d.ts +4 -0
- package/dist/lib/vapi.d.ts.map +1 -0
- package/dist/lib/vapi.js +59 -0
- package/dist/lib/vapi.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +177 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +28 -0
- package/railway.json +14 -0
- package/src/agent.ts +123 -0
- package/src/api/routes/auth.ts +95 -0
- package/src/api/routes/identity.ts +112 -0
- package/src/api/routes/text-submission.ts +64 -0
- package/src/api/webhooks/twilio.ts +181 -0
- package/src/api/webhooks/vapi.ts +219 -0
- package/src/bot.ts +44 -0
- package/src/index.ts +11 -0
- package/src/jobs/compact-identity.ts +211 -0
- package/src/jobs/consent-call.ts +87 -0
- package/src/jobs/export-identity.ts +166 -0
- package/src/jobs/introduction-call.ts +101 -0
- package/src/jobs/reanalyze-identity.ts +65 -0
- package/src/jobs/run-matching.ts +243 -0
- package/src/jobs/scheduled-matching.ts +59 -0
- package/src/jobs/transcribe-session.ts +77 -0
- package/src/lib/anthropic.ts +37 -0
- package/src/lib/config.ts +81 -0
- package/src/lib/deepgram.ts +57 -0
- package/src/lib/inngest.ts +33 -0
- package/src/lib/openai.ts +14 -0
- package/src/lib/prompts.ts +266 -0
- package/src/lib/r2.ts +79 -0
- package/src/lib/session-helpers.ts +37 -0
- package/src/lib/supabase.ts +15 -0
- package/src/lib/twilio.ts +49 -0
- package/src/lib/vapi.ts +80 -0
- package/src/mcp-server.ts +195 -0
- package/src/types/index.ts +146 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +384 -0
- package/supabase/migrations/20260303000000_initial_schema.sql +203 -0
- package/supabase/migrations/20260304000000_brand_consents.sql +13 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { supabase } from "../../lib/supabase.js";
|
|
4
|
+
import { inngest } from "../../lib/inngest.js";
|
|
5
|
+
|
|
6
|
+
export const identity_router = Router();
|
|
7
|
+
|
|
8
|
+
const UserParams = z.object({ user_id: z.string().uuid() });
|
|
9
|
+
|
|
10
|
+
// GET /api/identity/:user_id/export
|
|
11
|
+
// Triggers async export — user gets SMS when ready
|
|
12
|
+
identity_router.get("/:user_id/export", async (req, res) => {
|
|
13
|
+
const parsed = UserParams.safeParse(req.params);
|
|
14
|
+
if (!parsed.success) {
|
|
15
|
+
res.status(400).json({ error: "Invalid user_id" });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { user_id } = parsed.data;
|
|
20
|
+
|
|
21
|
+
// Verify user exists and has an identity
|
|
22
|
+
const { data: identity } = await supabase
|
|
23
|
+
.from("identities")
|
|
24
|
+
.select("signal_completeness_score, session_count")
|
|
25
|
+
.eq("user_id", user_id)
|
|
26
|
+
.single();
|
|
27
|
+
|
|
28
|
+
if (!identity) {
|
|
29
|
+
res.status(404).json({ error: "No identity found — complete at least one session first" });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await inngest.send({ name: "identity/export", data: { user_id } });
|
|
34
|
+
|
|
35
|
+
res.json({
|
|
36
|
+
ok: true,
|
|
37
|
+
message: "Export started — you'll receive a download link via SMS shortly",
|
|
38
|
+
completeness: identity.signal_completeness_score,
|
|
39
|
+
sessions: identity.session_count,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// GET /api/identity/:user_id
|
|
44
|
+
// Returns current identity profile (for web portal)
|
|
45
|
+
identity_router.get("/:user_id", async (req, res) => {
|
|
46
|
+
const parsed = UserParams.safeParse(req.params);
|
|
47
|
+
if (!parsed.success) {
|
|
48
|
+
res.status(400).json({ error: "Invalid user_id" });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { data, error } = await supabase
|
|
53
|
+
.from("identities")
|
|
54
|
+
.select("base_profile, modality_weights, signal_completeness_score, session_count, ready_for_matching, last_updated")
|
|
55
|
+
.eq("user_id", parsed.data.user_id)
|
|
56
|
+
.single();
|
|
57
|
+
|
|
58
|
+
if (error || !data) {
|
|
59
|
+
res.status(404).json({ error: "Identity not found" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
res.json(data);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// DELETE /api/identity/:user_id
|
|
67
|
+
// GDPR hard delete — removes all user data
|
|
68
|
+
identity_router.delete("/:user_id", async (req, res) => {
|
|
69
|
+
const parsed = UserParams.safeParse(req.params);
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
res.status(400).json({ error: "Invalid user_id" });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { user_id } = parsed.data;
|
|
76
|
+
|
|
77
|
+
// Verify user exists
|
|
78
|
+
const { data: user } = await supabase
|
|
79
|
+
.from("users")
|
|
80
|
+
.select("id")
|
|
81
|
+
.eq("id", user_id)
|
|
82
|
+
.single();
|
|
83
|
+
|
|
84
|
+
if (!user) {
|
|
85
|
+
res.status(404).json({ error: "User not found" });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Cascade delete — all child records deleted via ON DELETE CASCADE:
|
|
90
|
+
// sessions, identities, identity_modality_embeddings, matches (partial — see note)
|
|
91
|
+
// matches where user is user_b won't cascade — delete explicitly
|
|
92
|
+
await Promise.all([
|
|
93
|
+
supabase.from("matches").delete().eq("user_a_id", user_id),
|
|
94
|
+
supabase.from("matches").delete().eq("user_b_id", user_id),
|
|
95
|
+
supabase.from("declined_pairs").delete().eq("user_a_id", user_id),
|
|
96
|
+
supabase.from("declined_pairs").delete().eq("user_b_id", user_id),
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
// Delete user — cascades to sessions, identities, embeddings
|
|
100
|
+
const { error } = await supabase.from("users").delete().eq("id", user_id);
|
|
101
|
+
|
|
102
|
+
if (error) {
|
|
103
|
+
console.error("GDPR delete error", error);
|
|
104
|
+
res.status(500).json({ error: "Deletion failed — contact support" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Note: R2 audio/transcript files are NOT deleted here — add async cleanup job
|
|
109
|
+
// for production. R2 lifecycle rules can auto-expire after 30 days.
|
|
110
|
+
|
|
111
|
+
res.json({ ok: true, message: "All data deleted" });
|
|
112
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { supabase } from "../../lib/supabase.js";
|
|
4
|
+
import { inngest } from "../../lib/inngest.js";
|
|
5
|
+
import { create_session } from "../../lib/session-helpers.js";
|
|
6
|
+
|
|
7
|
+
export const text_submission_router = Router();
|
|
8
|
+
|
|
9
|
+
const TextBody = z.object({
|
|
10
|
+
user_id: z.string().uuid(),
|
|
11
|
+
text: z.string().min(10).max(10000),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// POST /api/session/text
|
|
15
|
+
// Accepts a free-text submission (alternative to voice intake)
|
|
16
|
+
text_submission_router.post("/text", async (req, res) => {
|
|
17
|
+
const parsed = TextBody.safeParse(req.body);
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
res.status(400).json({ error: parsed.error.flatten() });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { user_id, text } = parsed.data;
|
|
24
|
+
|
|
25
|
+
// Verify user exists
|
|
26
|
+
const { data: user, error: user_error } = await supabase
|
|
27
|
+
.from("users")
|
|
28
|
+
.select("id")
|
|
29
|
+
.eq("id", user_id)
|
|
30
|
+
.single();
|
|
31
|
+
|
|
32
|
+
if (user_error || !user) {
|
|
33
|
+
res.status(404).json({ error: "User not found" });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let session_id: string;
|
|
38
|
+
try {
|
|
39
|
+
session_id = await create_session(user_id, "text", { raw_text: text });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error("create_session error", err);
|
|
42
|
+
res.status(500).json({ error: "Failed to create session" });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fire Inngest event — triggers compact-identity pipeline
|
|
47
|
+
// Text sessions skip transcription and go straight to analysis
|
|
48
|
+
await inngest.send({
|
|
49
|
+
name: "session/transcribed",
|
|
50
|
+
data: {
|
|
51
|
+
user_id,
|
|
52
|
+
session_id,
|
|
53
|
+
transcript_r2_key: "", // empty — compact-identity reads raw_text instead
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Update last_active
|
|
58
|
+
await supabase
|
|
59
|
+
.from("users")
|
|
60
|
+
.update({ last_active: new Date().toISOString() })
|
|
61
|
+
.eq("id", user_id);
|
|
62
|
+
|
|
63
|
+
res.json({ ok: true, session_id });
|
|
64
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { supabase } from "../../lib/supabase.js";
|
|
3
|
+
import { inngest } from "../../lib/inngest.js";
|
|
4
|
+
import { normalize_phone, send_sms } from "../../lib/twilio.js";
|
|
5
|
+
|
|
6
|
+
export const twilio_webhook_router = Router();
|
|
7
|
+
|
|
8
|
+
// POST /api/webhooks/twilio/sms
|
|
9
|
+
// Handles inbound SMS — consent responses and export triggers
|
|
10
|
+
twilio_webhook_router.post("/sms", async (req, res) => {
|
|
11
|
+
// Twilio sends form-encoded body
|
|
12
|
+
const from: string = req.body.From ?? "";
|
|
13
|
+
const body: string = (req.body.Body ?? "").trim();
|
|
14
|
+
|
|
15
|
+
// Always respond with empty TwiML to suppress Twilio error
|
|
16
|
+
res.set("Content-Type", "text/xml");
|
|
17
|
+
res.send(`<?xml version="1.0" encoding="UTF-8"?><Response></Response>`);
|
|
18
|
+
|
|
19
|
+
if (!from || !body) return;
|
|
20
|
+
|
|
21
|
+
const phone = normalize_phone(from);
|
|
22
|
+
const text = body.toUpperCase();
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await handle_sms(phone, text, body);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
console.error("twilio sms webhook error", err);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// POST /api/webhooks/twilio/conference
|
|
32
|
+
// Status callback when conference ends — no action needed yet
|
|
33
|
+
twilio_webhook_router.post("/conference", (_req, res) => {
|
|
34
|
+
res.sendStatus(204);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
async function handle_sms(phone: string, text: string, raw_body: string) {
|
|
38
|
+
// Look up user by phone
|
|
39
|
+
const { data: user } = await supabase
|
|
40
|
+
.from("users")
|
|
41
|
+
.select("id, phone, brand")
|
|
42
|
+
.eq("phone", phone)
|
|
43
|
+
.single();
|
|
44
|
+
|
|
45
|
+
if (!user) {
|
|
46
|
+
console.warn(`Inbound SMS from unknown phone: ${phone}`);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ── EXPORT trigger ────────────────────────────────────────────────────────
|
|
51
|
+
if (text === "EXPORT" || text === "EXPORT IDENTITY" || text === "SEND ME MY IDENTITY") {
|
|
52
|
+
await inngest.send({
|
|
53
|
+
name: "identity/export",
|
|
54
|
+
data: { user_id: user.id },
|
|
55
|
+
});
|
|
56
|
+
await send_sms(
|
|
57
|
+
phone,
|
|
58
|
+
"Preparing your identity export — you'll receive a download link shortly.",
|
|
59
|
+
user.brand as Parameters<typeof send_sms>[2]
|
|
60
|
+
);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── DELETE trigger ────────────────────────────────────────────────────────
|
|
65
|
+
if (text === "DELETE MY DATA" || text === "DELETE") {
|
|
66
|
+
// Fire deletion via API — use internal fetch to keep logic in one place
|
|
67
|
+
// In production replace with direct service call
|
|
68
|
+
await supabase.from("users").delete().eq("id", user.id);
|
|
69
|
+
// Note: cascades handle sessions, identities, embeddings
|
|
70
|
+
// matches deleted separately
|
|
71
|
+
await Promise.all([
|
|
72
|
+
supabase.from("matches").delete().eq("user_a_id", user.id),
|
|
73
|
+
supabase.from("matches").delete().eq("user_b_id", user.id),
|
|
74
|
+
supabase.from("declined_pairs").delete().eq("user_a_id", user.id),
|
|
75
|
+
supabase.from("declined_pairs").delete().eq("user_b_id", user.id),
|
|
76
|
+
]);
|
|
77
|
+
// Can't send SMS after deletion since user no longer exists — no reply needed
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── Consent responses (ACCEPT / DECLINE with match reference) ────────────
|
|
82
|
+
// Format: "ACCEPT [match:uuid]" or just "ACCEPT" (applies to oldest pending)
|
|
83
|
+
const match_ref = raw_body.match(/\[match:([a-f0-9-]{36})\]/i);
|
|
84
|
+
const match_id_from_msg = match_ref?.[1];
|
|
85
|
+
|
|
86
|
+
if (text.startsWith("ACCEPT") || text.startsWith("YES")) {
|
|
87
|
+
await handle_consent_sms(user.id, "accepted", match_id_from_msg ?? null);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (text.startsWith("DECLINE") || text.startsWith("NO") || text.startsWith("PASS")) {
|
|
92
|
+
await handle_consent_sms(user.id, "declined", match_id_from_msg ?? null);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unknown — send help
|
|
97
|
+
await send_sms(
|
|
98
|
+
phone,
|
|
99
|
+
"Reply ACCEPT or DECLINE to respond to a match. Reply EXPORT to get your identity file.",
|
|
100
|
+
user.brand as Parameters<typeof send_sms>[2]
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function handle_consent_sms(
|
|
105
|
+
user_id: string,
|
|
106
|
+
decision: "accepted" | "declined",
|
|
107
|
+
explicit_match_id: string | null
|
|
108
|
+
) {
|
|
109
|
+
// Find the relevant pending match
|
|
110
|
+
let match_id = explicit_match_id;
|
|
111
|
+
|
|
112
|
+
if (!match_id) {
|
|
113
|
+
// Find oldest pending_consent match for this user
|
|
114
|
+
const { data } = await supabase
|
|
115
|
+
.from("matches")
|
|
116
|
+
.select("id, user_a_id, user_b_id, status")
|
|
117
|
+
.or(`user_a_id.eq.${user_id},user_b_id.eq.${user_id}`)
|
|
118
|
+
.eq("status", "pending_consent")
|
|
119
|
+
.order("created_at", { ascending: true })
|
|
120
|
+
.limit(1)
|
|
121
|
+
.single();
|
|
122
|
+
|
|
123
|
+
if (!data) return; // no pending match
|
|
124
|
+
match_id = data.id;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { data: match } = await supabase
|
|
128
|
+
.from("matches")
|
|
129
|
+
.select("id, user_a_id, user_b_id, status")
|
|
130
|
+
.eq("id", match_id)
|
|
131
|
+
.single();
|
|
132
|
+
|
|
133
|
+
if (!match) return;
|
|
134
|
+
|
|
135
|
+
const is_user_a = user_id === match.user_a_id;
|
|
136
|
+
|
|
137
|
+
if (decision === "declined") {
|
|
138
|
+
await supabase.from("declined_pairs").upsert(
|
|
139
|
+
{
|
|
140
|
+
user_a_id: match.user_a_id,
|
|
141
|
+
user_b_id: match.user_b_id,
|
|
142
|
+
declined_by: user_id,
|
|
143
|
+
reason: "sms_declined",
|
|
144
|
+
created_at: new Date().toISOString(),
|
|
145
|
+
},
|
|
146
|
+
{ onConflict: "user_a_id,user_b_id" }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
await supabase
|
|
150
|
+
.from("matches")
|
|
151
|
+
.update({
|
|
152
|
+
status: is_user_a ? "a_declined" : "b_declined",
|
|
153
|
+
updated_at: new Date().toISOString(),
|
|
154
|
+
})
|
|
155
|
+
.eq("id", match_id);
|
|
156
|
+
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// accepted
|
|
161
|
+
const current_status = match.status;
|
|
162
|
+
let new_status: string;
|
|
163
|
+
|
|
164
|
+
if (is_user_a) {
|
|
165
|
+
new_status = current_status === "b_accepted" ? "both_accepted" : "a_accepted";
|
|
166
|
+
} else {
|
|
167
|
+
new_status = current_status === "a_accepted" ? "both_accepted" : "b_accepted";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await supabase
|
|
171
|
+
.from("matches")
|
|
172
|
+
.update({ status: new_status, updated_at: new Date().toISOString() })
|
|
173
|
+
.eq("id", match_id);
|
|
174
|
+
|
|
175
|
+
if (new_status === "both_accepted") {
|
|
176
|
+
await inngest.send({
|
|
177
|
+
name: "match/both-accepted",
|
|
178
|
+
data: { match_id },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { supabase } from "../../lib/supabase.js";
|
|
3
|
+
import { inngest } from "../../lib/inngest.js";
|
|
4
|
+
import { upload_buffer } from "../../lib/r2.js";
|
|
5
|
+
|
|
6
|
+
export const vapi_webhook_router = Router();
|
|
7
|
+
|
|
8
|
+
// POST /api/webhooks/vapi
|
|
9
|
+
vapi_webhook_router.post("/", async (req, res) => {
|
|
10
|
+
const event = req.body as VapiEvent;
|
|
11
|
+
res.json({ ok: true });
|
|
12
|
+
try {
|
|
13
|
+
await handle_vapi_event(event);
|
|
14
|
+
} catch (err) {
|
|
15
|
+
console.error("vapi webhook handler error", err);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
async function handle_vapi_event(event: VapiEvent) {
|
|
20
|
+
switch (event.message?.type) {
|
|
21
|
+
case "end-of-call-report":
|
|
22
|
+
await handle_call_ended(event);
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function handle_call_ended(event: VapiEvent) {
|
|
30
|
+
const { call, recordingUrl, durationSeconds } = event.message ?? {};
|
|
31
|
+
if (!call?.id) return;
|
|
32
|
+
|
|
33
|
+
const vars = call.assistantOverrides?.variableValues ?? {};
|
|
34
|
+
const user_id = vars.user_id as string | undefined;
|
|
35
|
+
const match_id = vars.match_id as string | undefined;
|
|
36
|
+
|
|
37
|
+
// ── Consent call outcome ──────────────────────────────────────────────────
|
|
38
|
+
if (match_id) {
|
|
39
|
+
await handle_consent_outcome(call, match_id);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Intake call — create session ─────────────────────────────────────────
|
|
44
|
+
if (!user_id) {
|
|
45
|
+
console.warn("vapi call ended but no user_id or match_id in variableValues", call.id);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { data: session, error } = await supabase
|
|
50
|
+
.from("sessions")
|
|
51
|
+
.insert({
|
|
52
|
+
user_id,
|
|
53
|
+
source: "vapi",
|
|
54
|
+
duration_seconds: durationSeconds ? Math.round(durationSeconds) : null,
|
|
55
|
+
analysis_status: "pending",
|
|
56
|
+
})
|
|
57
|
+
.select("id")
|
|
58
|
+
.single();
|
|
59
|
+
|
|
60
|
+
if (error || !session) {
|
|
61
|
+
console.error("Failed to create session for vapi call", error);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const session_id = session.id;
|
|
66
|
+
|
|
67
|
+
if (recordingUrl) {
|
|
68
|
+
try {
|
|
69
|
+
const audio_res = await fetch(recordingUrl);
|
|
70
|
+
const audio_buf = Buffer.from(await audio_res.arrayBuffer());
|
|
71
|
+
const audio_r2_key = `audio/${user_id}/${session_id}.wav`;
|
|
72
|
+
|
|
73
|
+
await upload_buffer(audio_r2_key, audio_buf, "audio/wav");
|
|
74
|
+
await supabase.from("sessions").update({ audio_r2_key }).eq("id", session_id);
|
|
75
|
+
|
|
76
|
+
await inngest.send({
|
|
77
|
+
name: "session/completed",
|
|
78
|
+
data: { user_id, session_id },
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.error("Failed to store audio for session", session_id, err);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const transcript_text = call.transcript ?? "";
|
|
85
|
+
if (transcript_text) {
|
|
86
|
+
await supabase.from("sessions").update({ raw_text: transcript_text }).eq("id", session_id);
|
|
87
|
+
await inngest.send({
|
|
88
|
+
name: "session/transcribed",
|
|
89
|
+
data: { user_id, session_id, transcript_r2_key: "" },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await supabase
|
|
95
|
+
.from("users")
|
|
96
|
+
.update({ last_active: new Date().toISOString() })
|
|
97
|
+
.eq("id", user_id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function handle_consent_outcome(
|
|
101
|
+
call: NonNullable<VapiEvent["message"]>["call"],
|
|
102
|
+
match_id: string
|
|
103
|
+
) {
|
|
104
|
+
if (!call) return;
|
|
105
|
+
|
|
106
|
+
// Vapi assistant sets call.summary or transcript to indicate decision
|
|
107
|
+
// Convention: assistant ends call with structured summary JSON
|
|
108
|
+
// e.g. {"decision": "accepted"} or {"decision": "declined"}
|
|
109
|
+
const transcript = call.transcript ?? "";
|
|
110
|
+
const summary = call.summary ?? "";
|
|
111
|
+
|
|
112
|
+
const decision = extract_decision(summary || transcript);
|
|
113
|
+
if (!decision) {
|
|
114
|
+
console.warn(`No consent decision found for match ${match_id}, call ${call.id}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Load match to determine which user this call was for
|
|
119
|
+
const { data: match, error } = await supabase
|
|
120
|
+
.from("matches")
|
|
121
|
+
.select("id, user_a_id, user_b_id, status")
|
|
122
|
+
.eq("id", match_id)
|
|
123
|
+
.single();
|
|
124
|
+
|
|
125
|
+
if (error || !match) {
|
|
126
|
+
console.error(`Match not found for consent outcome: ${match_id}`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Identify which user answered (match call.id to stored call reference — simplified:
|
|
131
|
+
// use variableValues.user_id if present, else infer from status)
|
|
132
|
+
const vars = call.assistantOverrides?.variableValues ?? {};
|
|
133
|
+
const answering_user_id = vars.user_id as string | undefined;
|
|
134
|
+
|
|
135
|
+
if (!answering_user_id) {
|
|
136
|
+
console.warn(`Cannot identify consenting user for match ${match_id}`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const is_user_a = answering_user_id === match.user_a_id;
|
|
141
|
+
|
|
142
|
+
if (decision === "declined") {
|
|
143
|
+
// Record decline — insert to declined_pairs and update match
|
|
144
|
+
await supabase.from("declined_pairs").upsert(
|
|
145
|
+
{
|
|
146
|
+
user_a_id: match.user_a_id,
|
|
147
|
+
user_b_id: match.user_b_id,
|
|
148
|
+
declined_by: answering_user_id,
|
|
149
|
+
reason: "consent_call_declined",
|
|
150
|
+
created_at: new Date().toISOString(),
|
|
151
|
+
},
|
|
152
|
+
{ onConflict: "user_a_id,user_b_id" }
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const new_status = is_user_a ? "a_declined" : "b_declined";
|
|
156
|
+
await supabase
|
|
157
|
+
.from("matches")
|
|
158
|
+
.update({ status: new_status, updated_at: new Date().toISOString() })
|
|
159
|
+
.eq("id", match_id);
|
|
160
|
+
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// decision === "accepted"
|
|
165
|
+
const current_status = match.status;
|
|
166
|
+
let new_status: string;
|
|
167
|
+
|
|
168
|
+
if (is_user_a) {
|
|
169
|
+
new_status = current_status === "b_accepted" ? "both_accepted" : "a_accepted";
|
|
170
|
+
} else {
|
|
171
|
+
new_status = current_status === "a_accepted" ? "both_accepted" : "b_accepted";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await supabase
|
|
175
|
+
.from("matches")
|
|
176
|
+
.update({ status: new_status, updated_at: new Date().toISOString() })
|
|
177
|
+
.eq("id", match_id);
|
|
178
|
+
|
|
179
|
+
if (new_status === "both_accepted") {
|
|
180
|
+
await inngest.send({
|
|
181
|
+
name: "match/both-accepted",
|
|
182
|
+
data: { match_id },
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Extract accept/decline from Vapi call summary or transcript
|
|
188
|
+
function extract_decision(text: string): "accepted" | "declined" | null {
|
|
189
|
+
// Try structured JSON first (Vapi assistant summary)
|
|
190
|
+
try {
|
|
191
|
+
const parsed = JSON.parse(text);
|
|
192
|
+
if (parsed.decision === "accepted") return "accepted";
|
|
193
|
+
if (parsed.decision === "declined") return "declined";
|
|
194
|
+
} catch {
|
|
195
|
+
// not JSON — fall through to keyword search
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const lower = text.toLowerCase();
|
|
199
|
+
if (/\b(accept|yes|interested|connect)\b/.test(lower)) return "accepted";
|
|
200
|
+
if (/\b(decline|no|pass|not interested)\b/.test(lower)) return "declined";
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ─── Vapi event types ─────────────────────────────────────────────────────────
|
|
205
|
+
interface VapiEvent {
|
|
206
|
+
message?: {
|
|
207
|
+
type: string;
|
|
208
|
+
call?: {
|
|
209
|
+
id: string;
|
|
210
|
+
transcript?: string;
|
|
211
|
+
summary?: string;
|
|
212
|
+
assistantOverrides?: {
|
|
213
|
+
variableValues?: Record<string, unknown>;
|
|
214
|
+
};
|
|
215
|
+
};
|
|
216
|
+
recordingUrl?: string;
|
|
217
|
+
durationSeconds?: number;
|
|
218
|
+
};
|
|
219
|
+
}
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Telegraf } from "telegraf";
|
|
2
|
+
import { sendMessage } from "./agent.js";
|
|
3
|
+
|
|
4
|
+
const TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
5
|
+
|
|
6
|
+
export function createBot(): Telegraf {
|
|
7
|
+
if (!TOKEN) throw new Error("TELEGRAM_BOT_TOKEN env var is required");
|
|
8
|
+
|
|
9
|
+
const bot = new Telegraf(TOKEN);
|
|
10
|
+
|
|
11
|
+
bot.start(async (ctx) => {
|
|
12
|
+
const userId = String(ctx.from.id);
|
|
13
|
+
|
|
14
|
+
await ctx.reply(
|
|
15
|
+
"Welcome to Meet The One AI! I'm your personal AI matchmaker.\n\n" +
|
|
16
|
+
"I'll help you find someone truly compatible — not just based on looks, " +
|
|
17
|
+
"but on values, goals, and what matters most to you.\n\n" +
|
|
18
|
+
"Let's start by getting to know you. What's your name?"
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await sendMessage(userId, "I just joined. Please help me set up my profile.");
|
|
23
|
+
if (response) await ctx.reply(response);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(`[bot] /start error for ${userId}:`, err);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
bot.on("text", async (ctx) => {
|
|
30
|
+
const userId = String(ctx.from.id);
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await sendMessage(userId, ctx.message.text);
|
|
34
|
+
if (response) await ctx.reply(response);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`[bot] message error for ${userId}:`, err);
|
|
37
|
+
await ctx
|
|
38
|
+
.reply("I ran into a technical issue. Please try again in a moment.")
|
|
39
|
+
.catch(() => {});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return bot;
|
|
44
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { createBot } from "./bot.js";
|
|
3
|
+
|
|
4
|
+
const bot = createBot();
|
|
5
|
+
|
|
6
|
+
bot.launch().then(() => {
|
|
7
|
+
console.log("meet-the-one-ai is running...");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
process.once("SIGTERM", () => bot.stop("SIGTERM"));
|
|
11
|
+
process.once("SIGINT", () => bot.stop("SIGINT"));
|