diff --git a/src/html.ts b/src/html.ts index 87b36f4..39a3cca 100644 --- a/src/html.ts +++ b/src/html.ts @@ -336,11 +336,11 @@ ${ }

Group texts through smshub.dev (optional)

-

Links shared on your calls are texted to everyone on the line. Without this they go from the room number. Link your smshub.dev account and they go as an smshub group (one per room) from your own number, into your smshub inbox; with relay on, replies keep going to the whole group after the call. If smshub cannot send, the room number is used.

+

Links shared on your calls are texted to everyone on the line. Without this they go from the room number. Connect the same CoinPay account from SMSHub Settings and group.call will send as an SMSHub group (one per room), into your SMSHub inbox. If SMSHub cannot send, the room number is used.

${ data.smshub?.linked - ? `

Linked${data.smshub.from ? `, texting from ${esc(data.smshub.from)}` : ''}${data.smshub.relay ? ', relay on' : ''}.

${csrfField(ctx)}
` - : `
${csrfField(ctx)}
` + ? `

Linked${data.smshub.from ? `, texting from ${esc(data.smshub.from)}` : ''}${data.smshub.relay ? ', relay on' : ''}.

Manage or disconnect in SMSHub Settings

` + : '

Connect with CoinPay in SMSHub Settings

' }
@@ -446,7 +446,7 @@ export function helpPage(ctx: Ctx): string {

Agents

An agent joins the way a person does: it places a phone call to ${esc(number)}, keys the code with touch tones, then listens (speech to text) and talks (text to speech) on that call. Our agents (riotcoder and cha0spunk) do this with ElevenLabs voices. Room owners on this instance can invite them from the room page.

Invite by name. Keep a contacts book of people and agents, reached by one of our agent identities, a SIP address, a phone number or email. On the call, just say "invite riotcoder, cha0spunk and Kim": agents dial in, phone contacts are called, email-only contacts get the room number and code, and the room hears what happened. That works for the room's known callers and for people you marked trusted; anyone else saying it changes nothing. Also groupcall invite 4821 riotcoder, Kim, MCP invite, or POST /api/v1/rooms/4821/invite {"names": "riotcoder, Kim"}.

-

Group texts. Share a link on a call (a readm3.com report, a PDF, anything) and everyone on the line gets it by text from ${esc(number)}: text it to ${esc(number)} while you are on the call, use Text everyone on the room page, groupcall text 4821 https://readm3.com/..., MCP text_room, or ask riotcoder on the call. Reply STOP to stop getting them. Optional: link your smshub.dev account on your account page (or groupcall smshub <key>) and your rooms' texts go out as smshub groups from your own number; if smshub cannot send, the room number is used.

+

Group texts. Share a link on a call (a readm3.com report, a PDF, anything) and everyone on the line gets it by text from ${esc(number)}: text it to ${esc(number)} while you are on the call, use Text everyone on the room page, groupcall text 4821 https://readm3.com/..., MCP text_room, or ask riotcoder on the call. Reply STOP to stop getting them. Optional: connect the same CoinPay account from SMSHub Settings and your rooms' texts go out as smshub groups from your own number; if smshub cannot send, the room number is used.

Webhooks. Add a URL on your account (or groupcall webhook add, MCP create_webhook, POST /api/v1/webhooks) and group.call POSTs room.joined, room.left, transcript.line, text.sent, phone.called and recording.ready to it as signed CloudEvents (Standard Webhooks, via @profullstack/webhooks).

A room can also have a greeter: when somebody dials in before the host, riotcoder or cha0spunk joins, says hello, and keeps them company until the host arrives, then hands over and leaves. Set it on the room page (host = a phone number or a name on the line; blank means whoever joins next), with groupcall greeter 4821 riotcoder 408-555-0101, or MCP set_greeter.

Your own agent can do the same from any telephony API (Telnyx, Twilio, ...). To have the room announce it by name, tell us the number it will call from, just before it dials:

diff --git a/src/server.ts b/src/server.ts index b1c5736..c570bb1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -593,6 +593,42 @@ export function createApp(deps: Deps) { } const body = async (c: Context) => ((await c.req.json().catch(() => ({}))) ?? {}) as Record + // SMSHub's Settings > Connect group.call flow sends a fresh CoinPay access + // token server-to-server. CoinPay userinfo verifies it; the token is never + // exposed to the browser or saved here. + const coinpayUser = async (c: Context) => { + const authorization = c.req.header('authorization') ?? '' + const token = /^Bearer\s+(\S+)$/i.exec(authorization.trim())?.[1] + if (!token) return null + try { + return await new CoinPay({ base: config.coinpayBase, token, ...(config.fetch ? { fetch: config.fetch } : {}) }).userinfo() + } catch { + return null + } + } + app.post('/api/integrations/smshub', async (c) => { + const identity = await coinpayUser(c) + if (!identity?.sub) return c.json({ error: 'CoinPay authorization is missing or expired' }, 401) + if (!writeLimit.allow(`smshub-oauth:${identity.sub}`)) return c.json({ error: 'too many changes; wait a few minutes' }, 429) + try { + const b = await body(c) + return c.json(await svc.linkSmshub({ sub: identity.sub }, { + key: String(b.key ?? ''), + from: b.from ? String(b.from) : '', + relay: b.relay === true, + coinpaySub: identity.sub, + })) + } catch (e) { + return c.json({ error: message(e) }, statusOf(e) as 400) + } + }) + app.delete('/api/integrations/smshub', async (c) => { + const identity = await coinpayUser(c) + if (!identity?.sub) return c.json({ error: 'CoinPay authorization is missing or expired' }, 401) + if (!writeLimit.allow(`smshub-oauth:${identity.sub}`)) return c.json({ error: 'too many changes; wait a few minutes' }, 429) + return c.json(svc.unlinkSmshub({ sub: identity.sub })) + }) + // Customer webhooks: the @profullstack/webhooks API, scoped to the key's account. const hookOwner = new WeakMap() const hooksApi = webhooksApi(svc.hooks, { basePath: '/api/v1/webhooks', owner: (req) => hookOwner.get(req) }) diff --git a/src/service.ts b/src/service.ts index 534f20a..e5e4817 100644 --- a/src/service.ts +++ b/src/service.ts @@ -986,7 +986,7 @@ export class GroupCallService { * Link an account's smshub.dev (an smshub_ API key, and optionally which of its numbers to text * from): its rooms' group texts then go out through smshub as a group. The key is checked first. */ - async linkSmshub(user: User, input: { key?: string; from?: string; relay?: boolean }): Promise<{ linked: true; from: string | null; relay: boolean; groups: number }> { + async linkSmshub(user: Pick, input: { key?: string; from?: string; relay?: boolean; coinpaySub?: string }): Promise<{ linked: true; from: string | null; relay: boolean; groups: number }> { const key = String(input.key ?? '').trim() if (!/^smshub_[A-Za-z0-9_-]{8,}$/.test(key)) throw new UserError('Paste an smshub API key (smshub_..., from smshub.dev Settings > API Keys).', 'key') const from = input.from ? normalNumber(String(input.from)) : '' @@ -995,11 +995,19 @@ export class GroupCallService { if (!res || res.status === 401 || res.status === 403) throw new UserError('smshub did not accept that key.', 'key') if (!res.ok) throw new UserError(`smshub answered ${res.status}; try again in a minute.`, 'key', 502) const j = (await res.json().catch(() => ({}))) as { groups?: unknown[] } + if (input.coinpaySub) { + const identityResponse = await (this.config.fetch ?? fetch)(`${base}/api/v1/integrations/groupcall/identity`, { + headers: { authorization: `Bearer ${key}` }, + }).catch(() => null) + if (!identityResponse?.ok) throw new UserError('Connect the same CoinPay account to SMSHub first.', 'key', 403) + const identity = (await identityResponse.json().catch(() => ({}))) as { coinpay_sub?: string } + if (identity.coinpay_sub !== input.coinpaySub) throw new UserError('The SMSHub and group.call CoinPay accounts do not match.', 'key', 403) + } this.store.setSmshubLink(user.sub, { key, from: from || null, relay: input.relay === true }) return { linked: true, from: from || null, relay: input.relay === true, groups: j.groups?.length ?? 0 } } - unlinkSmshub(user: User): { linked: false } { + unlinkSmshub(user: Pick): { linked: false } { this.store.setSmshubLink(user.sub, null) return { linked: false } }