Skip to content

Commit d79e1bb

Browse files
ralyodioclaude
andauthored
feat: phone credit (CoinPay), 71 countries, agents on the phone, qc call/phone (0.15.0, qc 0.10.0) (#336)
Anthony: "do it all" (paid credit, international, agents placing calls, qc). - Credit: $5/$10/$25 on CoinPay's hosted checkout (business "qryptchat-web" made with coinpay-onboard; keys in vault + app.env). POST /api/phone/topup -> phone_topups row + checkout session; /api/phone/coinpay-webhook verifies the signature and credits exactly once (phone_credit_topup), by metadata or session. - Billing in micro-dollars: US/Canada free minutes first, then 1 cent per started minute, charged at hang-up; other countries 2x the carrier cost read from Telnyx detail records (telnyx_session_id), settled when the records exist (retried, and on GET /api/phone). phone_charge_call charges once. Integer micros (2 * 0.0072 * 1e6 is 14400.000000000002 in floating point). - International: 71 countries on the Telnyx profile with max_destination_rate 0.10, libphonenumber-js parsing, premium/shared-cost/UAN refused, no free minutes abroad, allowance limited at the worst-case 20.4 cents/min. - Agents: Call Control app "qrypt.chat agent calls" posts to /api/phone/agent-webhook, stored in phone_agent_events; the agent host long-polls /api/phone/agent-events (AGENT_PHONE_SECRET) because it takes no inbound connections. agent-phone (on the host) transcribes with Telnyx transcription_start and speaks with ElevenLabs via a Telnyx integration secret. - qc phone (minutes, credit, recent calls) and qc call <number> (opens the dialer). - /phone: credit and buy buttons, + for international numbers, per-call charges. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d25749a commit d79e1bb

21 files changed

Lines changed: 975 additions & 149 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@ QryptChat implements a **zero-knowledge post-quantum architecture** where:
218218
- [🔒 Encryption Details](./ENCRYPTION.md)
219219
- [✉️ Inviting agents and people](./docs/INVITES.md)
220220
- [🎙️ Voice mode](./docs/VOICE.md)
221+
- [📞 Phone calls](./docs/PHONE.md)
221222

222223
## 🧪 Development
223224

‎bun.lock‎

Lines changed: 153 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/PHONE.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Phone calls
2+
3+
Call ordinary phones from qrypt.chat, the way Skype used to: [qrypt.chat/phone](https://qrypt.chat/phone),
4+
`qc call <number>` (opens the dialer) and `qc phone` (minutes, credit, recent calls).
5+
6+
- **Free:** 10 minutes a day (UTC) to the US and Canada for every signed-in account. Anonymous accounts
7+
get none.
8+
- **Credit:** $5, $10 or $25 on CoinPay's checkout (crypto or PayPal); it never expires. US/Canada past the
9+
free minutes costs 1¢ a minute. Other countries (71 open, in `ALLOWED_COUNTRIES`) cost twice what our
10+
carrier charges us for that call, read from Telnyx's call records; no destination over 10¢/min carrier cost
11+
is allowed, so a call is limited up front as if it cost 20.4¢/min.
12+
- **Not end-to-end encrypted:** calls to phones go over the public phone network. Calls between qrypt.chat
13+
users stay end-to-end encrypted.
14+
- The person you call sees +1 949-284-7328. 911 and other service codes, premium-rate and shared-cost numbers
15+
cannot be called.
16+
17+
## How it works
18+
19+
The browser's line is a per-user Telnyx WebRTC credential that can only *receive* calls. `POST /api/phone/call`
20+
checks the number and the allowance, rings that line through a Call Control app, and when the browser answers
21+
transfers it to the number with `time_limit_secs` = the allowance, so Telnyx itself ends the call. Webhooks
22+
(`/api/phone/webhook`, Ed25519-signed) drive billing; `POST /api/phone/topup` + `/api/phone/coinpay-webhook`
23+
add credit exactly once per checkout. Code: `src/lib/phone/rules.js`, `src/lib/server/phone.js`,
24+
`src/lib/server/phone-credit.js`, `src/lib/server/telnyx-voice.js`.
25+
26+
## Agents on the phone
27+
28+
Anthony's agents can make calls for him (`agent-phone call <number> --goal "..."` on the agent host). Telnyx
29+
transcribes the other side and speaks the agent's lines in its ElevenLabs voice; the agent says it is an AI
30+
assistant in its first line and reports the outcome in the chat. The host takes no inbound connections:
31+
Telnyx posts events to `/api/phone/agent-webhook` and the agent long-polls `/api/phone/agent-events`.

‎package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.14.0",
4+
"version": "0.15.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
@@ -60,6 +60,7 @@
6060
"@noble/ciphers": "^2.0.0",
6161
"@noble/hashes": "^2.0.0",
6262
"@profullstack/autoblog": "github:profullstack/autoblog#75e54af77cbcf61dbd90fb3ed529c1b2dad1ed6f",
63+
"@profullstack/coinpay-onboard": "0.2.0",
6364
"@profullstack/encrypt": "^0.1.0",
6465
"@profullstack/footer": "latest",
6566
"@profullstack/openprofile": "^0.2.0",
@@ -75,6 +76,7 @@
7576
"blessed": "^0.1.81",
7677
"dilithium-crystals": "^1.0.6",
7778
"dotenv": "^17.2.2",
79+
"libphonenumber-js": "1.12.25",
7880
"mlkem": "^2.5.0",
7981
"next": "16.4.3",
8082
"openpgp": "^6.2.0",

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.9.2",
3+
"version": "0.10.0",
44
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
55
"type": "module",
66
"bin": {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import crypto from 'node:crypto';
2+
import { NextResponse } from 'next/server';
3+
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
const WAIT_MS = 20_000;
8+
const STEP_MS = 250;
9+
10+
function authorized(request) {
11+
const secret = process.env.AGENT_PHONE_SECRET;
12+
const given = request.headers.get('x-agent-phone-secret') || '';
13+
if (!secret || given.length !== secret.length) return false;
14+
return crypto.timingSafeEqual(Buffer.from(given), Buffer.from(secret));
15+
}
16+
17+
/**
18+
* GET /api/phone/agent-events?session=<call_session_id>&after=<id>: the Telnyx
19+
* events for one agent call, waiting up to 20 s for new ones (long poll).
20+
* Only the agent host holds AGENT_PHONE_SECRET.
21+
*/
22+
export async function GET(request) {
23+
if (!authorized(request)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
24+
const url = new URL(request.url);
25+
const session = url.searchParams.get('session') || '';
26+
const after = Number(url.searchParams.get('after') || 0);
27+
if (!session) return NextResponse.json({ error: 'session is required' }, { status: 400 });
28+
const db = getServiceRoleClient();
29+
const deadline = Date.now() + WAIT_MS;
30+
while (true) {
31+
const { data, error } = await db
32+
.from('phone_agent_events')
33+
.select('id, event')
34+
.eq('session_id', session)
35+
.gt('id', after)
36+
.order('id', { ascending: true })
37+
.limit(100);
38+
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
39+
if (data?.length || Date.now() >= deadline || request.signal?.aborted) {
40+
return NextResponse.json({ events: data || [] }, { headers: { 'Cache-Control': 'no-store' } });
41+
}
42+
await new Promise((r) => setTimeout(r, STEP_MS));
43+
}
44+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextResponse } from 'next/server';
2+
import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
3+
import { telnyxConfig, verifyWebhook } from '@/lib/server/telnyx-voice.js';
4+
5+
export const dynamic = 'force-dynamic';
6+
7+
/**
8+
* POST /api/phone/agent-webhook: Telnyx events for phone calls AI agents place
9+
* (the "qrypt.chat agent calls" Call Control app). Stored for the agent to pick
10+
* up from /api/phone/agent-events; old rows are pruned as new ones arrive.
11+
*/
12+
export async function POST(request) {
13+
const body = await request.text();
14+
const ok = verifyWebhook({
15+
body,
16+
signature: request.headers.get('telnyx-signature-ed25519'),
17+
timestamp: request.headers.get('telnyx-timestamp'),
18+
publicKey: telnyxConfig().publicKey,
19+
});
20+
if (!ok) return NextResponse.json({ error: 'Bad signature' }, { status: 401 });
21+
let event;
22+
try {
23+
event = JSON.parse(body)?.data;
24+
} catch {
25+
return NextResponse.json({ error: 'Bad JSON' }, { status: 400 });
26+
}
27+
const sessionId = event?.payload?.call_session_id;
28+
if (!sessionId) return NextResponse.json({ ok: true, ignored: 'no session' });
29+
const db = getServiceRoleClient();
30+
const { error } = await db.from('phone_agent_events').insert({ session_id: sessionId, event });
31+
if (error) {
32+
console.error('[phone/agent-webhook]', error.message);
33+
return NextResponse.json({ error: 'failed' }, { status: 500 });
34+
}
35+
if (Math.random() < 0.02) {
36+
await db.from('phone_agent_events').delete().lt('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
37+
}
38+
return NextResponse.json({ ok: true });
39+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { NextResponse } from 'next/server';
2+
import { PhoneError } from '@/lib/server/phone.js';
3+
import { handleCoinpayWebhook } from '@/lib/server/phone-credit.js';
4+
import { phoneContext } from '@/lib/server/phone-route.js';
5+
6+
export const dynamic = 'force-dynamic';
7+
8+
/** POST /api/phone/coinpay-webhook: CoinPay payment events; a paid top-up adds phone credit once. */
9+
export async function POST(request) {
10+
const rawBody = await request.text();
11+
try {
12+
const { store } = phoneContext();
13+
const result = await handleCoinpayWebhook({
14+
store,
15+
rawBody,
16+
signature: request.headers.get('x-coinpay-signature'),
17+
secret: process.env.COINPAY_WEBHOOK_SECRET,
18+
});
19+
return NextResponse.json({ ok: true, ...result });
20+
} catch (error) {
21+
if (error instanceof PhoneError) return NextResponse.json({ error: error.message }, { status: error.status });
22+
console.error('[phone/coinpay-webhook]', error?.message);
23+
return NextResponse.json({ error: 'failed' }, { status: 500 });
24+
}
25+
}

‎src/app/api/phone/route.js‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,31 @@
11
import { NextResponse } from 'next/server';
22
import { withAuth } from '@/lib/api/middleware/auth.js';
3-
import { accountState } from '@/lib/server/phone.js';
3+
import { accountState, settlePending } from '@/lib/server/phone.js';
4+
import { coinpayConfigured } from '@/lib/server/phone-credit.js';
45
import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
5-
import { CENTS_PER_MINUTE, FREE_SECONDS_PER_DAY } from '@/lib/phone/rules.js';
6+
import { ALLOWED_COUNTRIES, FREE_COUNTRIES, FREE_SECONDS_PER_DAY, INTL_MAX_MICROS_PER_MINUTE, TOPUP_DOLLARS, US_CA_MICROS_PER_MINUTE } from '@/lib/phone/rules.js';
67

78
export const dynamic = 'force-dynamic';
89

9-
/** GET /api/phone: today's free minutes, recent calls, caller ID. */
10+
/** GET /api/phone: free minutes, credit, prices, recent calls, caller ID. */
1011
export const GET = withAuth(async ({ locals }) => {
1112
try {
12-
const { db, store, config } = phoneContext();
13+
const { db, store, telnyx, config } = phoneContext();
1314
const user = await phoneUser(db, locals.user.id);
15+
await settlePending({ store, telnyx, userId: user.id });
1416
const account = await accountState({ store, user });
1517
const calls = (await store.recentCalls(user.id)) || [];
1618
return NextResponse.json(
17-
{ ...account, freeSecondsPerDay: FREE_SECONDS_PER_DAY, centsPerMinute: CENTS_PER_MINUTE, callerId: config.callerId, calls },
19+
{
20+
...account,
21+
freeSecondsPerDay: FREE_SECONDS_PER_DAY,
22+
freeCountries: FREE_COUNTRIES,
23+
countries: ALLOWED_COUNTRIES,
24+
prices: { usCaMicrosPerMinute: US_CA_MICROS_PER_MINUTE, intlMaxMicrosPerMinute: INTL_MAX_MICROS_PER_MINUTE, intl: 'twice the carrier cost' },
25+
topups: coinpayConfigured() ? TOPUP_DOLLARS : [],
26+
callerId: config.callerId,
27+
calls,
28+
},
1829
{ headers: { 'Cache-Control': 'no-store' } }
1930
);
2031
} catch (error) {

‎src/app/api/phone/topup/route.js‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { NextResponse } from 'next/server';
2+
import { withAuth } from '@/lib/api/middleware/auth.js';
3+
import { RateLimiter, applyRateLimitForKey } from '@/lib/server/rate-limiter.js';
4+
import { PhoneError } from '@/lib/server/phone.js';
5+
import { coinpayClient, coinpayConfigured, createTopup } from '@/lib/server/phone-credit.js';
6+
import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
7+
8+
const limiter = new RateLimiter({ maxRequests: 10, windowMs: 10 * 60 * 1000 });
9+
10+
/** POST /api/phone/topup { dollars }: a CoinPay checkout for phone credit; returns { url }. */
11+
export const POST = withAuth(async ({ request, locals }) => {
12+
const limited = applyRateLimitForKey(limiter, locals.user.id);
13+
if (limited) return limited;
14+
try {
15+
if (!coinpayConfigured()) throw new PhoneError('Buying credit is not set up on this server.', 503);
16+
const body = await request.json().catch(() => ({}));
17+
const { db, store } = phoneContext();
18+
const user = await phoneUser(db, locals.user.id);
19+
const result = await createTopup({ store, coinpay: coinpayClient(), user, email: locals.user.email, dollars: body?.dollars });
20+
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } });
21+
} catch (error) {
22+
if (error?.name === 'CoinPayError') {
23+
console.error('[phone/topup] coinpay', error.status, error.message);
24+
return NextResponse.json({ error: 'CoinPay could not start the checkout.' }, { status: 502 });
25+
}
26+
return phoneError(error, 'topup');
27+
}
28+
});

0 commit comments

Comments
 (0)