Skip to content

Commit d25749a

Browse files
ralyodioclaude
andauthored
feat: call ordinary phones from qrypt.chat, 10 free minutes a day (0.14.0) (#335)
Anthony: "rebuild so it works like skype used to" (after Suparelay), and "let people make ten minutes of phone calls a day for free". /phone (Navbar "Phone"): a dialer for US and Canadian numbers on Telnyx. - The browser's line is a per-user Telnyx telephony credential on a credential connection with NO outbound voice profile: a WebRTC token cannot dial the phone network. POST /api/phone/call checks the number and the allowance, rings that line through a Call Control app, and on call.answered transfers it to the number with time_limit_secs = the allowance, so Telnyx itself ends the call. - call.answered / call.hangup on the phone leg (signed Ed25519 webhooks at /api/phone/webhook, raw 32-byte or PEM key) bill answer -> hang-up seconds. - 10 free minutes a day (UTC) for signed-in accounts, none for anonymous ones, operators (PHONE_OPERATOR_IDS) an hour a call. 911/N11, 900/976 and fictional 555-01XX numbers are refused; one live call per person. - Caller ID is forced to PHONE_CALLER_ID by the connection's ANI override. The outbound profile allows US/CA only with a $10/day spend cap. - The page says plainly that calls to phones are not end-to-end encrypted. Tables phone_lines and phone_calls (service role only), migration applied on dev2. Tested against the real phone network before building: browser rang, server transferred to NIST's time line (+1 303-499-7111), audio transcribed as "At the tone, 12 hours, 43 minutes, coordinated universal time", and Telnyx ended the call at the time limit (hangup_cause time_limit). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a9f98ab commit d25749a

17 files changed

Lines changed: 940 additions & 7 deletions

File tree

‎bun.lock‎

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

‎package.json‎

Lines changed: 2 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.13.4",
4+
"version": "0.14.0",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",
@@ -71,6 +71,7 @@
7171
"@simplewebauthn/server": "^14.0.3",
7272
"@supabase/ssr": "^0.7.0",
7373
"@supabase/supabase-js": "^2.56.0",
74+
"@telnyx/webrtc": "2.28.0",
7475
"blessed": "^0.1.81",
7576
"dilithium-crystals": "^1.0.6",
7677
"dotenv": "^17.2.2",

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
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 { startCall } from '@/lib/server/phone.js';
5+
import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
6+
7+
const perMinute = new RateLimiter({ maxRequests: 10, windowMs: 60 * 1000 });
8+
const perDay = new RateLimiter({ maxRequests: 200, windowMs: 24 * 60 * 60 * 1000 });
9+
10+
/**
11+
* POST /api/phone/call { to }: call a US or Canadian phone. Rings this user's
12+
* browser line first; the number is dialled once the browser answers.
13+
*/
14+
export const POST = withAuth(async ({ request, locals }) => {
15+
const limited = applyRateLimitForKey(perMinute, locals.user.id) || applyRateLimitForKey(perDay, locals.user.id);
16+
if (limited) return limited;
17+
try {
18+
const body = await request.json().catch(() => ({}));
19+
const ctx = phoneContext();
20+
const user = await phoneUser(ctx.db, locals.user.id);
21+
const result = await startCall({ ...ctx, user, to: body?.to });
22+
return NextResponse.json(result, { headers: { 'Cache-Control': 'no-store' } });
23+
} catch (error) {
24+
return phoneError(error, 'call');
25+
}
26+
});

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { NextResponse } from 'next/server';
2+
import { withAuth } from '@/lib/api/middleware/auth.js';
3+
import { hangupCall } from '@/lib/server/phone.js';
4+
import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
5+
6+
/** POST /api/phone/hangup { callId }: end this user's call on both legs. */
7+
export const POST = withAuth(async ({ request, locals }) => {
8+
try {
9+
const body = await request.json().catch(() => ({}));
10+
const ctx = phoneContext();
11+
const user = await phoneUser(ctx.db, locals.user.id);
12+
return NextResponse.json(await hangupCall({ ...ctx, user, callId: String(body?.callId || '') }));
13+
} catch (error) {
14+
return phoneError(error, 'hangup');
15+
}
16+
});

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NextResponse } from 'next/server';
2+
import { withAuth } from '@/lib/api/middleware/auth.js';
3+
import { accountState } from '@/lib/server/phone.js';
4+
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+
7+
export const dynamic = 'force-dynamic';
8+
9+
/** GET /api/phone: today's free minutes, recent calls, caller ID. */
10+
export const GET = withAuth(async ({ locals }) => {
11+
try {
12+
const { db, store, config } = phoneContext();
13+
const user = await phoneUser(db, locals.user.id);
14+
const account = await accountState({ store, user });
15+
const calls = (await store.recentCalls(user.id)) || [];
16+
return NextResponse.json(
17+
{ ...account, freeSecondsPerDay: FREE_SECONDS_PER_DAY, centsPerMinute: CENTS_PER_MINUTE, callerId: config.callerId, calls },
18+
{ headers: { 'Cache-Control': 'no-store' } }
19+
);
20+
} catch (error) {
21+
return phoneError(error, 'status');
22+
}
23+
});

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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 { loginToken } from '@/lib/server/phone.js';
5+
import { phoneContext, phoneError, phoneUser } from '@/lib/server/phone-route.js';
6+
7+
const limiter = new RateLimiter({ maxRequests: 20, windowMs: 60 * 1000 });
8+
9+
/**
10+
* POST /api/phone/token: a short-lived login for the browser's phone line
11+
* (Telnyx WebRTC). The line can only receive calls; the server places them.
12+
*/
13+
export const POST = withAuth(async ({ locals }) => {
14+
const limited = applyRateLimitForKey(limiter, locals.user.id);
15+
if (limited) return limited;
16+
try {
17+
const ctx = phoneContext();
18+
const user = await phoneUser(ctx.db, locals.user.id);
19+
const { token } = await loginToken({ ...ctx, user });
20+
return NextResponse.json({ token }, { headers: { 'Cache-Control': 'no-store' } });
21+
} catch (error) {
22+
return phoneError(error, 'token');
23+
}
24+
});

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { NextResponse } from 'next/server';
2+
import { handleEvent } from '@/lib/server/phone.js';
3+
import { phoneContext } from '@/lib/server/phone-route.js';
4+
import { telnyxConfig, verifyWebhook } from '@/lib/server/telnyx-voice.js';
5+
6+
export const dynamic = 'force-dynamic';
7+
8+
/**
9+
* POST /api/phone/webhook: Telnyx Call Control events for qrypt.chat calls
10+
* (answered, hangup, ...). Signed with Ed25519; anything unsigned is refused.
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+
try {
28+
const result = await handleEvent({ ...phoneContext(), event });
29+
return NextResponse.json({ ok: true, ...result });
30+
} catch (error) {
31+
console.error('[phone/webhook]', event?.event_type, error?.message);
32+
// 500 makes Telnyx retry, which handleEvent tolerates.
33+
return NextResponse.json({ error: 'failed' }, { status: 500 });
34+
}
35+
}

0 commit comments

Comments
 (0)