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
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
73 changes: 68 additions & 5 deletions cli/smshub.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
// smshub-cli messages <number> [--since ISO] [--limit 20]
// smshub-cli otp <number> [--wait 60] print the next code, nothing else
// smshub-cli send <to> "<message>" [--from <number>] [--media <url>] text someone from your own number
// smshub-cli groups your contact groups
// smshub-cli group create "<name>" [--from <number>] [--relay] [--member +1415...[=Kim]]... a group (members repeatable)
// smshub-cli group <id> one group and its members (id or external_ref)
// smshub-cli group add <id> +1415...[=Kim] [...] add members
// smshub-cli group remove <id> <phone> remove a member
// smshub-cli group send <id> "<message>" [--from <number>] text every member (STOPped ones skipped)
// smshub-cli group relay <id> on|off members' replies go to the whole group (SMS group chat)
// smshub-cli group rm <id> delete a group
// smshub-cli conversations [--line <number>] [--limit 20] the inbox, optionally one line
// smshub-cli thread <conversation-id> [--limit 50] messages in one conversation
// smshub-cli lines who shares each number (contacts book)
Expand All @@ -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");

Expand All @@ -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;
Expand Down Expand Up @@ -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 <number>] [--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 <id> <phone>");
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 <id> "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 <id> | 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");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "smshub",
"version": "0.1.155",
"version": "0.1.156",
"private": true,
"scripts": {
"dev": "bun --bun next dev",
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/v1/groups/[id]/members/[member]/route.ts
Original file line number Diff line number Diff line change
@@ -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)));
}
8 changes: 8 additions & 0 deletions src/app/api/v1/groups/[id]/members/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}
23 changes: 23 additions & 0 deletions src/app/api/v1/groups/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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)));
}
8 changes: 8 additions & 0 deletions src/app/api/v1/groups/[id]/send/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}
15 changes: 15 additions & 0 deletions src/app/api/v1/groups/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
213 changes: 213 additions & 0 deletions src/app/groups/page.tsx
Original file line number Diff line number Diff line change
@@ -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<T>(path: string, init?: { method?: string; body?: unknown }): Promise<T> {
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<Group[]>([]);
const [numbers, setNumbers] = useState<NumberRow[]>([]);
const [open, setOpen] = useState<Group | null>(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<string | void>) => {
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<Group>(`/api/v1/groups/${encodeURIComponent(id)}`)));

return (
<div className="min-h-screen bg-gray-950 p-8">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Groups</h1>
<Link href="/inbox" className="text-blue-400 hover:text-blue-300 text-sm">
← Back to Inbox
</Link>
</div>
<p className="text-sm text-gray-400">
Named lists of people you text together, from your own number. Turn on relay and a member&apos;s reply to that
number goes to the whole group, like a group chat. Anyone who replies STOP is skipped. Also from the API
(<code>/api/v1/groups</code>), <code>smshub-cli group</code> and MCP.
</p>
{note && <div className="text-sm bg-gray-900 border border-gray-800 rounded-lg p-3">{note}</div>}

<form
className="bg-gray-900 rounded-lg p-4 border border-gray-800 space-y-3"
onSubmit={(e) => {
e.preventDefault();
void act(async () => {
const g = await call<Group>("/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"}.`;
});
}}
>
<h2 className="font-medium">New group</h2>
<div className="flex flex-wrap gap-2">
<input className={input} required value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" aria-label="Group name" />
<select className={input} value={from} onChange={(e) => setFrom(e.target.value)} aria-label="Texts from">
<option value="">Texts from…</option>
{numbers.map((n) => (
<option key={n.id} value={n.id}>
{n.number}
</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={relay} onChange={(e) => setRelay(e.target.checked)} /> Relay replies to the group
</label>
</div>
<textarea className={`${input} w-full`} rows={3} value={membersText} onChange={(e) => setMembersText(e.target.value)} placeholder={"+14155550123 Kim\n+14155550124 Lee"} aria-label="Members" />
<button className={button} disabled={busy}>
Make group
</button>
</form>

<div className="space-y-2">
{groups.length === 0 && <div className="text-center py-8 text-gray-500">No groups yet</div>}
{groups.map((g) => (
<button key={g.id} type="button" onClick={() => void show(g.id)} className="w-full text-left bg-gray-900 rounded-lg p-4 border border-gray-800 hover:border-gray-700">
<div className="flex justify-between">
<span className="font-medium">{g.name}</span>
<span className="text-xs text-gray-500">
{g.member_count} member{g.member_count === 1 ? "" : "s"}
{g.relay ? " · relay" : ""}
{g.external_ref ? ` · ${g.external_ref}` : ""}
</span>
</div>
<div className="text-sm text-gray-400">{g.from ?? "No number set"}</div>
</button>
))}
</div>

{open && (
<div className="bg-gray-900 rounded-lg p-4 border border-gray-700 space-y-3">
<div className="flex justify-between items-center">
<h2 className="font-medium">
{open.name} <span className="text-sm text-gray-500">from {open.from ?? "(choose a number)"}</span>
</h2>
<div className="flex gap-2">
<button type="button" className={quiet} onClick={() => void act(async () => { setOpen(await call<Group>(`/api/v1/groups/${open.id}`, { method: "PATCH", body: { relay: !open.relay } })); })}>
Relay {open.relay ? "on" : "off"}
</button>
<button type="button" className={quiet} onClick={() => void act(async () => { await call(`/api/v1/groups/${open.id}`, { method: "DELETE" }); setOpen(null); return "Group deleted."; })}>
Delete
</button>
</div>
</div>
<ul className="text-sm space-y-1">
{(open.members ?? []).map((m) => (
<li key={m.contact_id} className="flex justify-between">
<span>
{m.name ? `${m.name} · ` : ""}
{m.phone}
{m.opted_out && <span className="text-gray-500"> (STOP)</span>}
</span>
<button type="button" className="text-red-400 text-xs" onClick={() => void act(async () => { setOpen(await call<Group>(`/api/v1/groups/${open.id}/members/${encodeURIComponent(m.phone)}`, { method: "DELETE" })); })}>
remove
</button>
</li>
))}
</ul>
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); void act(async () => { setOpen(await call<Group>(`/api/v1/groups/${open.id}/members`, { method: "POST", body: { members: parseMembers(addText) } })); setAddText(""); }); }}>
<input className={`${input} flex-1`} value={addText} onChange={(e) => setAddText(e.target.value)} placeholder="+14155550125 Sam" aria-label="Add members" />
<button className={quiet} disabled={busy}>Add</button>
</form>
<form className="flex gap-2" onSubmit={(e) => { e.preventDefault(); void act(async () => { const r = await call<{ sent: number; failed: number; skipped: number; errors?: { error?: string }[] }>(`/api/v1/groups/${open.id}/send`, { method: "POST", body: { message } }); setMessage(""); return `Sent ${r.sent}${r.failed ? `, ${r.failed} failed (${r.errors?.[0]?.error ?? ""})` : ""}${r.skipped ? `, ${r.skipped} skipped` : ""}.`; }); }}>
<input className={`${input} flex-1`} required maxLength={1600} value={message} onChange={(e) => setMessage(e.target.value)} placeholder="Text everyone in this group" aria-label="Message" />
<button className={button} disabled={busy}>Send</button>
</form>
</div>
)}
</div>
</div>
);
}
Loading
Loading