diff --git a/cli/package.json b/cli/package.json index 94988fea..ede1535e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/smshub", - "version": "1.7.0", + "version": "1.8.0", "description": "Rent a phone number and receive SMS / verification codes from your terminal, scripts and AI agents (CLI, TUI, MCP) via smshub.dev", "type": "module", "bin": { diff --git a/cli/smshub.mjs b/cli/smshub.mjs index 93766bf1..9199585c 100644 --- a/cli/smshub.mjs +++ b/cli/smshub.mjs @@ -12,6 +12,14 @@ // smshub-cli messages [--since ISO] [--limit 20] // smshub-cli otp [--wait 60] print the next code, nothing else // smshub-cli send "" [--from ] [--media ] text someone from your own number +// smshub-cli groups your contact groups +// smshub-cli group create "" [--from ] [--relay] [--member +1415...[=Kim]]... a group (members repeatable) +// smshub-cli group one group and its members (id or external_ref) +// smshub-cli group add +1415...[=Kim] [...] add members +// smshub-cli group remove remove a member +// smshub-cli group send "" [--from ] text every member (STOPped ones skipped) +// smshub-cli group relay on|off members' replies go to the whole group (SMS group chat) +// smshub-cli group rm delete a group // smshub-cli conversations [--line ] [--limit 20] the inbox, optionally one line // smshub-cli thread [--limit 50] messages in one conversation // smshub-cli lines who shares each number (contacts book) @@ -34,7 +42,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { createInterface } from "node:readline"; -const VERSION = "1.7.0"; +const VERSION = "1.8.0"; const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "smshub"); const CONFIG_FILE = join(CONFIG_DIR, "config.json"); @@ -43,10 +51,12 @@ function parseArgs(argv) { for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { - const [k, v] = a.slice(2).split("=", 2); - if (v !== undefined) out[k] = v; - else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) out[k] = argv[++i]; - else out[k] = true; + const eq = a.indexOf("="); + const k = eq === -1 ? a.slice(2) : a.slice(2, eq); + let v = eq === -1 ? undefined : a.slice(eq + 1); + if (v === undefined) v = argv[i + 1] !== undefined && !argv[i + 1].startsWith("--") ? argv[++i] : true; + // A flag given twice (--member a --member b) keeps both. + out[k] = out[k] === undefined ? v : [].concat(out[k], v); } else out._.push(a); } return out; @@ -404,6 +414,59 @@ const commands = { print(args, res, (r) => console.log(`sent ${r.from} -> ${r.to} ${r.message?.id ?? ""}`)); }, + async groups(args) { + const { groups } = await api("/api/v1/groups"); + print(args, { groups }, () => { + if (!groups.length) console.log('No groups. smshub-cli group create "Team" --member +14155550123=Kim'); + for (const g of groups) console.log(`${g.id} ${g.name.padEnd(24)} ${String(g.member_count).padStart(3)} members ${g.from ?? "(no number)"}${g.relay ? " relay" : ""}${g.external_ref ? ` ${g.external_ref}` : ""}`); + }); + }, + + async group(args) { + const sub = args._[1]; + const members = (list) => + list.filter(Boolean).map((m) => { + const [phone, ...name] = String(m).split("="); + return { phone, ...(name.length ? { name: name.join("=") } : {}) }; + }); + const show = (g) => { + console.log(`${g.name} (${g.id})${g.relay ? " relay on" : ""}`); + console.log(`from ${g.from ?? "(none: pass --from when sending)"}${g.external_ref ? ` ref ${g.external_ref}` : ""}`); + for (const m of g.members ?? []) console.log(` ${m.phone} ${m.name ?? ""}${m.opted_out ? " (STOPped)" : ""}`); + }; + const id = (n = 2) => { + const v = args._[n]; + if (!v) throw new ApiError("Which group? smshub-cli groups shows ids"); + return encodeURIComponent(v); + }; + if (sub === "create") { + const name = args._.slice(2).join(" ").trim(); + if (!name) throw new ApiError('Usage: smshub-cli group create "Team" [--from ] [--relay] [--member +1415...=Kim]'); + const list = [].concat(args.member ?? []).filter((m) => m !== true); + const body = { name, members: members(list), relay: args.relay === true }; + if (args.from && args.from !== true) body.from = await resolveLine(String(args.from)); + return print(args, await api("/api/v1/groups", { method: "POST", body }), show); + } + if (sub === "add") return print(args, await api(`/api/v1/groups/${id()}/members`, { method: "POST", body: { members: members(args._.slice(3)) } }), show); + if (sub === "remove") { + if (!args._[3]) throw new ApiError("Usage: smshub-cli group remove "); + return print(args, await api(`/api/v1/groups/${id()}/members/${encodeURIComponent(args._[3])}`, { method: "DELETE" }), show); + } + if (sub === "send") { + const message = args._.slice(3).join(" ").trim(); + if (!message) throw new ApiError('Usage: smshub-cli group send "message"'); + const body = { message }; + if (args.from && args.from !== true) body.from = await resolveLine(String(args.from)); + return print(args, await api(`/api/v1/groups/${id()}/send`, { method: "POST", body }), (r) => + console.log(`sent ${r.sent} from ${r.from}${r.failed ? `, ${r.failed} failed (${r.errors?.[0]?.error ?? ""})` : ""}${r.skipped ? `, ${r.skipped} skipped` : ""}`) + ); + } + if (sub === "relay") return print(args, await api(`/api/v1/groups/${id()}`, { method: "PATCH", body: { relay: args._[3] !== "off" } }), show); + if (sub === "rm" || sub === "delete") return print(args, await api(`/api/v1/groups/${id()}`, { method: "DELETE" }), () => console.log("deleted")); + if (!sub) throw new ApiError("Usage: smshub-cli group | create | add | remove | send | relay | rm"); + return print(args, await api(`/api/v1/groups/${id(1)}`), show); + }, + async thread(args) { const id = args._[1]; if (!id) throw new ApiError("Which conversation? smshub-cli conversations --json shows ids"); diff --git a/package.json b/package.json index 2ca9b74e..32de53a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "smshub", - "version": "0.1.155", + "version": "0.1.156", "private": true, "scripts": { "dev": "bun --bun next dev", diff --git a/src/app/api/v1/groups/[id]/members/[member]/route.ts b/src/app/api/v1/groups/[id]/members/[member]/route.ts new file mode 100644 index 00000000..4f6d5161 --- /dev/null +++ b/src/app/api/v1/groups/[id]/members/[member]/route.ts @@ -0,0 +1,8 @@ +import { groupsHandler } from "@/lib/groups-route"; +import { removeMember } from "@/lib/groups"; + +/** Remove one member, by phone (E.164) or contact id. */ +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string; member: string }> }) { + const { id, member } = await params; + return groupsHandler(request, (db, userId) => removeMember(db, userId, decodeURIComponent(id), decodeURIComponent(member))); +} diff --git a/src/app/api/v1/groups/[id]/members/route.ts b/src/app/api/v1/groups/[id]/members/route.ts new file mode 100644 index 00000000..c8aa4cee --- /dev/null +++ b/src/app/api/v1/groups/[id]/members/route.ts @@ -0,0 +1,8 @@ +import { groupsHandler } from "@/lib/groups-route"; +import { addMembers } from "@/lib/groups"; + +/** Add members: { members: [{ phone, name? }] }. */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return groupsHandler(request, (db, userId, body) => addMembers(db, userId, decodeURIComponent(id), body.members)); +} diff --git a/src/app/api/v1/groups/[id]/route.ts b/src/app/api/v1/groups/[id]/route.ts new file mode 100644 index 00000000..ecbf4e60 --- /dev/null +++ b/src/app/api/v1/groups/[id]/route.ts @@ -0,0 +1,23 @@ +import { groupsHandler } from "@/lib/groups-route"; +import { deleteGroup, getGroup, updateGroup } from "@/lib/groups"; + +type Ctx = { params: Promise<{ id: string }> }; + +/** One group with its members. `id` is the group id or its external_ref. */ +export async function GET(request: Request, { params }: Ctx) { + const { id } = await params; + return groupsHandler(request, (db, userId) => getGroup(db, userId, decodeURIComponent(id))); +} + +/** Change name, description, from, relay; `members` (when given) replaces the member list. */ +export async function PATCH(request: Request, { params }: Ctx) { + const { id } = await params; + return groupsHandler(request, (db, userId, body) => updateGroup(db, userId, decodeURIComponent(id), body)); +} + +export const PUT = PATCH; + +export async function DELETE(request: Request, { params }: Ctx) { + const { id } = await params; + return groupsHandler(request, (db, userId) => deleteGroup(db, userId, decodeURIComponent(id))); +} diff --git a/src/app/api/v1/groups/[id]/send/route.ts b/src/app/api/v1/groups/[id]/send/route.ts new file mode 100644 index 00000000..eb2bbd81 --- /dev/null +++ b/src/app/api/v1/groups/[id]/send/route.ts @@ -0,0 +1,8 @@ +import { groupsHandler } from "@/lib/groups-route"; +import { sendToGroup } from "@/lib/groups"; + +/** Text every member: { message, from?, media_url?, except?: [phones] }. Members who said STOP are skipped. */ +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return groupsHandler(request, (db, userId, body) => sendToGroup(db, userId, decodeURIComponent(id), body)); +} diff --git a/src/app/api/v1/groups/route.ts b/src/app/api/v1/groups/route.ts new file mode 100644 index 00000000..99d506b9 --- /dev/null +++ b/src/app/api/v1/groups/route.ts @@ -0,0 +1,15 @@ +import { groupsHandler } from "@/lib/groups-route"; +import { listGroups, saveGroup } from "@/lib/groups"; + +/** Your contact groups. */ +export async function GET(request: Request) { + return groupsHandler(request, async (db, userId) => ({ groups: await listGroups(db, userId) })); +} + +/** + * Make a group: { name, description?, from?, relay?, members?: [{ phone, name? }], external_ref? }. + * With external_ref already on one of your groups, that group is updated and its members replaced. + */ +export async function POST(request: Request) { + return groupsHandler(request, (db, userId, body) => saveGroup(db, userId, body), 201); +} diff --git a/src/app/groups/page.tsx b/src/app/groups/page.tsx new file mode 100644 index 00000000..e7924b88 --- /dev/null +++ b/src/app/groups/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import Link from "next/link"; + +interface Member { + contact_id: string; + phone: string; + name: string | null; + opted_out: boolean; +} + +interface Group { + id: string; + name: string; + description: string; + from: string | null; + relay: boolean; + external_ref: string | null; + member_count: number; + last_sent_at: string | null; + members?: Member[]; +} + +interface NumberRow { + id: string; + number: string; + managed?: boolean; +} + +const input = "px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"; +const button = "px-3 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-sm disabled:opacity-50"; +const quiet = "px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm"; + +async function call(path: string, init?: { method?: string; body?: unknown }): Promise { + const res = await fetch(path, { + method: init?.method ?? "GET", + headers: init?.body ? { "content-type": "application/json" } : undefined, + body: init?.body ? JSON.stringify(init.body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`); + return data as T; +} + +/** "+14155550123 Kim" or "+14155550123=Kim", one per line or comma separated. */ +function parseMembers(text: string) { + return text + .split(/[\n,]+/) + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + const m = /^(\+?[\d\s().-]{7,})(?:\s*[=\s]\s*(.+))?$/.exec(l); + return m ? { phone: m[1].replace(/[\s().-]/g, ""), ...(m[2] ? { name: m[2].trim() } : {}) } : { phone: l }; + }); +} + +export default function GroupsPage() { + const [groups, setGroups] = useState([]); + const [numbers, setNumbers] = useState([]); + const [open, setOpen] = useState(null); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const [name, setName] = useState(""); + const [from, setFrom] = useState(""); + const [relay, setRelay] = useState(false); + const [membersText, setMembersText] = useState(""); + const [addText, setAddText] = useState(""); + const [message, setMessage] = useState(""); + + const load = useCallback(async () => { + try { + const [{ groups }, nums] = await Promise.all([ + call<{ groups: Group[] }>("/api/v1/groups"), + call<{ numbers?: NumberRow[] }>("/api/v1/numbers").catch(() => ({ numbers: [] as NumberRow[] })), + ]); + setGroups(groups); + setNumbers((nums.numbers ?? []).filter((n) => !n.managed)); + } catch (e) { + setNote((e as Error).message); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const act = async (job: () => Promise) => { + setBusy(true); + setNote(""); + try { + const said = await job(); + if (said) setNote(said); + await load(); + } catch (e) { + setNote((e as Error).message); + } finally { + setBusy(false); + } + }; + + const show = (id: string) => act(async () => setOpen(await call(`/api/v1/groups/${encodeURIComponent(id)}`))); + + return ( +
+
+
+

Groups

+ + ← Back to Inbox + +
+

+ Named lists of people you text together, from your own number. Turn on relay and a member's reply to that + number goes to the whole group, like a group chat. Anyone who replies STOP is skipped. Also from the API + (/api/v1/groups), smshub-cli group and MCP. +

+ {note &&
{note}
} + +
{ + e.preventDefault(); + void act(async () => { + const g = await call("/api/v1/groups", { method: "POST", body: { name, from: from || undefined, relay, members: parseMembers(membersText) } }); + setName(""); + setMembersText(""); + setOpen(g); + return `Made ${g.name} with ${g.member_count} member${g.member_count === 1 ? "" : "s"}.`; + }); + }} + > +

New group

+
+ setName(e.target.value)} placeholder="Name" aria-label="Group name" /> + + +
+