Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,11 @@ ${
}
<section id="smshub">
<h2>Group texts through smshub.dev (optional)</h2>
<p class="muted small">Links shared on your calls are texted to everyone on the line. Without this they go from the room number. Link your <a href="https://smshub.dev">smshub.dev</a> 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.</p>
<p class="muted small">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 <a href="https://smshub.dev/settings">SMSHub Settings</a> 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.</p>
${
data.smshub?.linked
? `<p>Linked${data.smshub.from ? `, texting from <code>${esc(data.smshub.from)}</code>` : ''}${data.smshub.relay ? ', relay on' : ''}.</p><form method="post" action="/me/smshub/unlink">${csrfField(ctx)}<button class="secondary">Unlink smshub</button></form>`
: `<form method="post" action="/me/smshub/link" class="row">${csrfField(ctx)}<input name="key" required placeholder="smshub_... API key" aria-label="smshub API key" autocomplete="off"><input name="from" type="tel" placeholder="Your smshub number (optional)" aria-label="Number to text from"><label class="small"><input type="checkbox" name="relay" value="1"> relay replies</label><button>Link smshub</button></form>`
? `<p>Linked${data.smshub.from ? `, texting from <code>${esc(data.smshub.from)}</code>` : ''}${data.smshub.relay ? ', relay on' : ''}.</p><p><a class="button secondary" href="https://smshub.dev/settings">Manage or disconnect in SMSHub Settings</a></p>`
: '<p><a class="button" href="https://smshub.dev/settings">Connect with CoinPay in SMSHub Settings</a></p>'
}
</section>
<section id="webhooks">
Expand Down Expand Up @@ -446,7 +446,7 @@ export function helpPage(ctx: Ctx): string {
<h2>Agents</h2>
<p>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.</p>
<p><b>Invite by name.</b> Keep <a href="/me#contacts">a contacts book</a> of people and agents, reached by one of our agent identities, a SIP address, a phone number or email. On the call, just say <b>"invite riotcoder, cha0spunk and Kim"</b>: 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 <code>groupcall invite 4821 riotcoder, Kim</code>, MCP <code>invite</code>, or <code>POST /api/v1/rooms/4821/invite {"names": "riotcoder, Kim"}</code>.</p>
<p><b>Group texts.</b> 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 <b>Text everyone</b> on the room page, <code>groupcall text 4821 https://readm3.com/...</code>, MCP <code>text_room</code>, or ask riotcoder on the call. Reply STOP to stop getting them. Optional: link your <a href="https://smshub.dev">smshub.dev</a> account on <a href="/me#smshub">your account page</a> (or <code>groupcall smshub &lt;key&gt;</code>) and your rooms' texts go out as smshub groups from your own number; if smshub cannot send, the room number is used.</p>
<p><b>Group texts.</b> 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 <b>Text everyone</b> on the room page, <code>groupcall text 4821 https://readm3.com/...</code>, MCP <code>text_room</code>, or ask riotcoder on the call. Reply STOP to stop getting them. Optional: connect the same CoinPay account from <a href="https://smshub.dev/settings">SMSHub Settings</a> and your rooms' texts go out as smshub groups from your own number; if smshub cannot send, the room number is used.</p>
<p><b>Webhooks.</b> Add a URL on <a href="/me#webhooks">your account</a> (or <code>groupcall webhook add</code>, MCP <code>create_webhook</code>, <code>POST /api/v1/webhooks</code>) and group.call POSTs <code>room.joined</code>, <code>room.left</code>, <code>transcript.line</code>, <code>text.sent</code>, <code>phone.called</code> and <code>recording.ready</code> to it as signed CloudEvents (Standard Webhooks, via <code>@profullstack/webhooks</code>).</p>
<p>A room can also have a <b>greeter</b>: 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 <code>groupcall greeter 4821 riotcoder 408-555-0101</code>, or MCP <code>set_greeter</code>.</p>
<p>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:</p>
Expand Down
36 changes: 36 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,42 @@ export function createApp(deps: Deps) {
}
const body = async (c: Context) => ((await c.req.json().catch(() => ({}))) ?? {}) as Record<string, unknown>

// 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<Request, string>()
const hooksApi = webhooksApi(svc.hooks, { basePath: '/api/v1/webhooks', owner: (req) => hookOwner.get(req) })
Expand Down
12 changes: 10 additions & 2 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User, 'sub'>, 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)) : ''
Expand All @@ -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<User, 'sub'>): { linked: false } {
this.store.setSmshubLink(user.sub, null)
return { linked: false }
}
Expand Down
Loading