Compare commits

...

10 Commits

Author SHA1 Message Date
f613d83b40 docs: add top-level README 2026-05-09 17:10:48 +08:00
9062ba7e7f fix(bot): drop removed groups during sync
Previously syncGroupsForAccount only upserted, so groups removed from
WhatsApp (deleted, bot was kicked, etc.) lingered in the DB.

Now compute the diff: any whatsapp_groups row for this account whose
wa_group_jid is not in the live fetch result is deleted. Skip the delete
sweep when the fetch returns empty — that's more likely transient than
a genuine "every group gone" signal, and we don't want to nuke valid
data on a hiccup.

Return shape gains a `removed` count alongside `synced`.
2026-05-09 17:08:11 +08:00
83d9bf6e9b fix(bot): upgrade Baileys 6.17.16 → 7.0.0-rc10 for protocol compatibility
The 6.17.x line was returning 406 not-acceptable from WhatsApp's pre-key
endpoint when distributing sender keys to per-device JIDs (e.g.
40471728529510:18@s.whatsapp.net). This blocked every group send
regardless of group size.

Baileys 7.0.0-rc series tracks WhatsApp's current protocol. API is
drop-in compatible — typecheck clean, no source changes needed.

Re-pair required: 6.x signal session files are not portable to 7.x.
2026-05-09 17:01:47 +08:00
43882d5a1b feat(bot): refresh groups list — manual button + auto event listener
Group sync previously only ran once at pairing time, so groups created in
WhatsApp afterwards never showed up.

Two complementary fixes:
- 🔄 Refresh button in the groups list view triggers
  syncGroupsForAccount() on demand and re-renders the menu
- session.ts now subscribes to Baileys 'groups.upsert' and 'groups.update'
  events and re-syncs (debounced 1.5s) so new groups appear without
  manual action
2026-05-09 16:54:55 +08:00
5259f88776 fix(bot): chunk participant pre-key fetches to survive broken JIDs
WhatsApp's pre-key endpoint returns 406 not-acceptable if ANY single JID
in the batch is in a broken state (deleted account, deactivated, etc.).
With Baileys' default behavior of asking for the whole participant list at
once, one stale member poisons the whole group send.

Chunk participant JIDs into batches of 5 and tolerate per-chunk failures.
The send fan-out then works for the participants whose sessions did land,
which covers the vast majority of real-world groups.

Also adds explicit pino logging so we can see which chunks failed during
diagnosis.
2026-05-09 16:52:48 +08:00
2fdcdb6202 fix(bot): explicit assertSessions before group send
groupMetadata alone wasn't enough — Baileys won't establish individual
libsignal sessions lazily during sendMessage, so the first send to a
freshly-paired group fails per-participant. Cast to the internal
assertSessions(jids, force=true) and call it on every participant before
attempting to send.
2026-05-09 16:50:51 +08:00
99cece16c0 fix(bot): pre-fetch group metadata + retry sender on libsignal race
First send to a group after pairing fails with libsignal SessionError
"No sessions" because Baileys hasn't yet established encryption sessions
with all participants. Force-fetch group metadata before sendMessage so
Baileys populates its participant map; if the first send still races,
retry once after a 1.5s delay.
2026-05-09 16:48:42 +08:00
3c4eedff03 feat(bot): tap-to-send test message from groups menu
Each entry in the groups list is now a button. Tapping shows a group detail
view with [📝 Send Test Text]. Operator replies with the message body and
the bot sends it to the selected WhatsApp group via the live Baileys session,
records the action in audit_log, and shows success/failure inline.

This is a small forerunner of the full reminder send pipeline that plan 2
will build out (with media, scheduling, retries). Useful right now to
validate the end-to-end Telegram-to-WhatsApp send path during pairing tests.
2026-05-09 16:46:22 +08:00
7b0c8c47e2 feat(bot): BotFather-style menu navigation
All flows are now reachable from /menu (alias for /start). Single message
edits in place via editMessageText for hierarchical navigation, every leaf
has ⬅ Back / ⬅ Main Menu buttons.

Menu hierarchy:
  /menu → main menu
    📒 Accounts → list (each account is a button)
      📒 <Account> → detail (📂 Groups | 🗑 Unpair | ⬅ back)
        📂 Groups → groups list (⬅ back to account, ⬅ main menu)
        🗑 Unpair → confirm ( yes | ⬅ cancel) → done
    📡 Pair New → prompt for label, operator replies as plain message
     Help → help text + ⬅ Main Menu

Implementation notes:
- New menus.ts module with pure render functions for each view
- New state.ts tracks pending "awaiting pair label" per Telegram user
- bot.on("message:text") consumes the pending label after Pair New
- /pair, /unpair, /groups commands still work for power users; they reuse
  the same handlers behind the scenes (executePairFlow extracted from
  handlePair so the menu and the command share one path)
2026-05-09 16:42:44 +08:00
56fd71a6a0 feat(bot): inline keyboards + Telegram slash menu
UX improvements driven by live testing:
- setMyCommands populates Telegram's '/' picker with all commands and
  descriptions, so the operator gets autocomplete instead of guessing
  syntax.
- /start replies with an inline keyboard ([📒 Accounts] [📡 How to Pair]
  [ Help]) — quick navigation without typing.
- /accounts emits one message per account with [📂 Groups] [🗑 Unpair]
  inline buttons. Tapping triggers a callback (no typed labels needed).
- New callbacks module wires the buttons. Unpair shows a confirm/cancel
  prompt before acting.

/pair still requires a typed label since the value is operator-defined
content rather than a selection from existing data.
2026-05-09 16:35:28 +08:00
13 changed files with 1313 additions and 1172 deletions

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# cm WhatsApp Reminder Bot
Self-hosted WhatsApp reminder bot. Pairs multiple WhatsApp accounts via Telegram-delivered QR codes and sends scheduled reminders to groups.
## Status
**Plan 1 complete.** Foundation, DB schema, and Telegram-driven WhatsApp pairing are working end-to-end. Reminder scheduling, the web dashboard, and production deploy are upcoming plans (`docs/superpowers/plans/`).
What's working today:
- Single-operator Telegram bot with a whitelist + audit log of every command.
- BotFather-style menu navigation: `/menu` opens a single message that edits in place as you navigate.
- Pair a new WhatsApp account with `/menu` → 📡 Pair New → reply with a label. QR is delivered to Telegram and refreshed in place as it expires.
- Browse paired accounts with 📒 Accounts. Tap an account → see groups, send a test text message, or unpair.
- Group sync runs at pairing and on every Baileys `groups.upsert` / `groups.update` event, plus a manual 🔄 Refresh button. Removed groups are pruned automatically.
- Auto-reconnect on transient drops; restart-survival via Baileys `useMultiFileAuthState` (no QR rescan needed across container restarts as long as WhatsApp hasn't logged the device out).
## Host requirements
Only Docker. No host Node, pnpm, or any other language toolchain — everything runs in containers via the long-lived `tools` service.
## Architecture in one paragraph
Two app containers and one external dependency. `bot` (Node.js) holds the live Baileys WhatsApp sessions, the grammy Telegram bot, and (in plan 2) a pg-boss scheduler. `web` (Next.js, plan 3) is stateless UI + API. `tools` is a long-running Node 22 + pnpm sidecar used for installs/tests/typechecks/migrations so the host doesn't need a Node toolchain. Postgres lives external at `192.168.0.210` in a `wabot` database. All cross-service communication goes through Postgres (`LISTEN/NOTIFY` for events, table writes for state).
Full design spec: [`docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md`](docs/superpowers/specs/2026-05-03-whatsapp-bot-design.md)
## Quick start (dev)
Prerequisites: Docker, the `wabot` database + `waBot` role on `192.168.0.210` (with a `pg_hba.conf` line permitting `192.168.0.0/24`), and a Telegram bot token from `@BotFather`.
```bash
# 1. Configure env
cp envs/.env.example .env.development
# edit .env.development: real DATABASE_URL, TELEGRAM_BOT_TOKEN, your TG user ID
scripts/gen_auth_secret.sh --write
# 2. Bring up the tools container, install deps
NO_SUDO=1 scripts/dev.sh up
NO_SUDO=1 scripts/dev.sh pnpm install
# 3. Apply migrations and seed your operator row
NO_SUDO=1 scripts/db.sh migrate
NO_SUDO=1 scripts/db.sh seed
# 4. Watch the bot service
NO_SUDO=1 scripts/dev.sh logs bot
```
In Telegram, message your dev bot `/menu`, tap **📡 Pair New**, reply with a label, scan the QR.
`NO_SUDO=1` is the right setting if your user is in the `docker` group (the default for this repo). Drop it if you need `sudo docker`.
## Layout
- `apps/bot/` — Node service: Baileys WhatsApp + grammy Telegram + (later) pg-boss scheduler
- `apps/web/` — Next.js dashboard (plan 3)
- `packages/db/` — Drizzle schema and migrations
- `packages/shared/` — cross-app helpers (rrule, media paths, timezones)
- `docs/superpowers/specs/` — design specs and manual test runbooks
- `docs/superpowers/plans/` — implementation plans
- `docker/` — Dockerfiles (`tools.Dockerfile`, `bot.Dockerfile`, `web.Dockerfile` placeholder)
- `scripts/``dev.sh`, `db.sh`, `gen_auth_secret.sh`, plus stubs for plans 2/4
## Scripts
All `pnpm`/`tsx`/`drizzle-kit` invocations run inside the `tools` container, so no host Node is needed.
| Script | Purpose |
|---|---|
| `scripts/dev.sh up\|down\|logs\|status\|build\|exec\|pnpm\|shell\|restart-bot` | Stack lifecycle and tools-container shell |
| `scripts/db.sh migrate\|generate\|studio\|seed\|reset` | Drizzle migration helper |
| `scripts/gen_auth_secret.sh [--write]` | Generate `AUTH_SECRET` (host-only, no Node needed) |
| `scripts/publish.sh` | Push to Gitea registry — implemented in plan 4 |
| `scripts/link-account.sh` | CLI pairing without Telegram — implemented in plan 2 |
Set `NO_SUDO=1` if your user is in the docker group (recommended).
## Next plan
`docs/superpowers/plans/<next-date>-reminder-scheduling.md` — pg-boss, reminder CRUD via Telegram, fire-reminder handler, sender (text/image/video), retry policy, run history.

View File

@ -15,7 +15,7 @@
"dependencies": {
"@cmbot/db": "workspace:*",
"@cmbot/shared": "workspace:*",
"@whiskeysockets/baileys": "^6.7.7",
"@whiskeysockets/baileys": "7.0.0-rc10",
"grammy": "^1.31.0",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",

View File

@ -3,12 +3,30 @@ import { env } from "../env.js";
import { logger } from "../logger.js";
import { makeWhitelistMiddleware } from "./middleware/whitelist.js";
import { auditMiddleware } from "./middleware/audit.js";
import { handleStart } from "./commands/start.js";
import { handleHelp } from "./commands/help.js";
import { handlePair } from "./commands/pair.js";
import { handlePair, executePairFlow } from "./commands/pair.js";
import { handleUnpair } from "./commands/unpair.js";
import { handleAccounts } from "./commands/accounts.js";
import { handleGroups } from "./commands/groups.js";
import {
showMainMenu,
showHelpMenu,
showAccountsMenu,
showAccountDetail,
showGroupsList,
showUnpairConfirm,
executeUnpair,
showPairPrompt,
showGroupDetail,
showSendTestPrompt,
executeSendTest,
refreshGroupsList,
} from "./callbacks.js";
import {
consumePendingPairLabel,
clearPendingPairLabel,
consumePendingSendToGroup,
clearPendingSendToGroup,
} from "./state.js";
export function createTelegramBot(): Bot {
const bot = new Bot(env.TELEGRAM_BOT_TOKEN);
@ -16,16 +34,110 @@ export function createTelegramBot(): Bot {
bot.use(makeWhitelistMiddleware(env.TELEGRAM_OPERATOR_WHITELIST));
bot.use(auditMiddleware);
bot.command("start", handleStart);
// Slash commands. /start and /menu both open the main menu.
bot.command(["start", "menu"], async (ctx) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await showMainMenu(ctx);
});
bot.command("help", handleHelp);
bot.command("pair", handlePair);
bot.command("unpair", handleUnpair);
bot.command("accounts", handleAccounts);
bot.command("accounts", async (ctx) => {
// Backward-compatible: /accounts now opens the accounts menu in the same chat.
await showAccountsMenu(ctx);
});
bot.command("groups", handleGroups);
// Inline keyboard callbacks. Prefixes keep callback_data well under 64 bytes.
bot.callbackQuery("m:main", async (ctx) => {
const tgId = ctx.from?.id;
if (tgId !== undefined) {
clearPendingPairLabel(tgId);
clearPendingSendToGroup(tgId);
}
await ctx.answerCallbackQuery();
await showMainMenu(ctx);
});
bot.callbackQuery("m:accounts", showAccountsMenu);
bot.callbackQuery("m:help", showHelpMenu);
bot.callbackQuery("m:pair", showPairPrompt);
bot.callbackQuery(/^acc:(.+)$/, async (ctx) => {
await showAccountDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^g:(.+)$/, async (ctx) => {
await showGroupsList(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^u:(.+)$/, async (ctx) => {
await showUnpairConfirm(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^uc:(.+)$/, async (ctx) => {
await executeUnpair(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^gr:(.+)$/, async (ctx) => {
await showGroupDetail(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^st:(.+)$/, async (ctx) => {
await showSendTestPrompt(ctx, ctx.match[1]!);
});
bot.callbackQuery(/^rs:(.+)$/, async (ctx) => {
await refreshGroupsList(ctx, ctx.match[1]!);
});
// Plain-text messages: if the operator is in the "pending pair label" state
// (because they tapped 📡 Pair New), treat their next non-command message as
// the label. Otherwise, gently nudge them toward /menu.
bot.on("message:text", async (ctx) => {
const text = ctx.message?.text ?? "";
if (text.startsWith("/")) return; // commands are handled above
const tgId = ctx.from?.id;
if (tgId === undefined) return;
// Pending "Pair New" label
if (consumePendingPairLabel(tgId)) {
const label = text.trim().replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply("That label is empty. Tap /menu and try again.");
return;
}
await executePairFlow(ctx, label);
return;
}
// Pending "Send Test" message body
const pendingGroupId = consumePendingSendToGroup(tgId);
if (pendingGroupId) {
const body = text.trim();
if (!body) {
await ctx.reply("Empty message. Tap /menu and try again.");
return;
}
await executeSendTest(ctx, pendingGroupId, body);
return;
}
await ctx.reply("Tap /menu to see what I can do.");
});
bot.catch((err) => {
logger.error({ err }, "telegram error");
});
// Populate Telegram's slash menu with our commands.
void bot.api
.setMyCommands([
{ command: "menu", description: "Open the main menu" },
{ command: "start", description: "Open the main menu" },
{ command: "accounts", description: "List paired WhatsApp accounts" },
{ command: "pair", description: "Pair a new account (usage: /pair Label)" },
{ command: "unpair", description: "Unpair an account (usage: /unpair Label)" },
{ command: "groups", description: "List groups for an account (usage: /groups Label)" },
{ command: "help", description: "Show command help" },
])
.catch((err) => logger.warn({ err }, "setMyCommands failed"));
return bot;
}

View File

@ -0,0 +1,252 @@
import type { Context } from "grammy";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { eq } from "drizzle-orm";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../db.js";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import { writeAuditLog } from "../audit.js";
import { setPendingPairLabel, setPendingSendToGroup } from "./state.js";
import { sendTextToGroup } from "../whatsapp/sender.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import {
mainMenu,
helpMenu,
pairPromptMenu,
accountsMenu,
accountDetailMenu,
groupsListMenu,
groupDetailMenu,
sendTestPromptMenu,
sendTestDoneMenu,
unpairConfirmMenu,
unpairDoneMenu,
type MenuView,
} from "./menus.js";
async function findOperator(ctx: Context) {
const tgId = ctx.from?.id;
if (!tgId) return null;
return db.query.operators.findFirst({
where: (o, { eq }) => eq(o.telegramUserId, tgId),
});
}
// Edit the current message to render a new menu view. Falls back to a fresh
// reply if the previous message can't be edited (e.g. a photo message — Telegram
// won't let us turn it back into a text message).
async function showMenu(ctx: Context, view: MenuView): Promise<void> {
try {
await ctx.editMessageText(view.text, {
reply_markup: view.keyboard,
parse_mode: "Markdown",
});
} catch (err) {
logger.debug({ err }, "showMenu: edit failed, sending fresh message");
await ctx.reply(view.text, {
reply_markup: view.keyboard,
parse_mode: "Markdown",
});
}
}
export async function showMainMenu(ctx: Context): Promise<void> {
await showMenu(ctx, mainMenu());
}
export async function showHelpMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
await showMenu(ctx, helpMenu());
}
export async function showAccountsMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await accountsMenu(op.id);
await showMenu(ctx, view);
}
export async function showAccountDetail(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await accountDetailMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function showGroupsList(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await groupsListMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function refreshGroupsList(ctx: Context, accountId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
const session = sessionManager.getSession(accountId);
if (!session) {
await ctx.answerCallbackQuery({
text: "Account not connected. Re-pair first.",
show_alert: true,
});
return;
}
await ctx.answerCallbackQuery({ text: "Refreshing…" });
try {
const result = await syncGroupsForAccount(accountId, session.socket);
logger.info({ accountId, count: result.synced }, "refreshGroupsList: ok");
} catch (err) {
logger.error({ err, accountId }, "refreshGroupsList: failed");
}
const view = await groupsListMenu(op.id, accountId);
if (view) await showMenu(ctx, view);
}
export async function showUnpairConfirm(ctx: Context, accountId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await unpairConfirmMenu(op.id, accountId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function executeUnpair(ctx: Context, accountId: string): Promise<void> {
const op = await findOperator(ctx);
if (!op) {
await ctx.answerCallbackQuery();
return;
}
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Account not found.", show_alert: true });
return;
}
await sessionManager.stop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "logged_out", phoneNumber: null })
.where(eq(whatsappAccounts.id, accountId));
await writeAuditLog(db, {
operatorId: op.id,
source: "telegram",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: account.label, via: "menu" },
});
await ctx.answerCallbackQuery({ text: "Unpaired." });
await showMenu(ctx, unpairDoneMenu(account.label));
}
export async function showPairPrompt(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const userId = ctx.from?.id;
if (userId) setPendingPairLabel(userId);
await showMenu(ctx, pairPromptMenu());
}
export async function showGroupDetail(ctx: Context, groupId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const view = await groupDetailMenu(op.id, groupId);
if (!view) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
await showMenu(ctx, view);
}
export async function showSendTestPrompt(ctx: Context, groupId: string): Promise<void> {
await ctx.answerCallbackQuery();
const op = await findOperator(ctx);
if (!op) return;
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
// Verify the group's account belongs to this operator before stashing state.
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, op.id)),
});
if (!account) {
await ctx.answerCallbackQuery({ text: "Group not found.", show_alert: true });
return;
}
const userId = ctx.from?.id;
if (userId) setPendingSendToGroup(userId, groupId);
await showMenu(ctx, sendTestPromptMenu(group.name));
}
export async function executeSendTest(
ctx: Context,
groupId: string,
text: string,
): Promise<void> {
const op = await findOperator(ctx);
if (!op) return;
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) {
await ctx.reply("Group not found.");
return;
}
const session = sessionManager.getSession(group.accountId);
if (!session) {
await ctx.reply("That account isn't currently connected. Re-pair it first.", {
reply_markup: sendTestDoneMenu(group.name, false, "session not connected").keyboard,
});
return;
}
try {
const result = await sendTextToGroup(session.socket, group.waGroupJid, text);
await writeAuditLog(db, {
operatorId: op.id,
source: "telegram",
action: "group.send_test",
targetType: "whatsapp_group",
targetId: groupId,
payload: { groupName: group.name, length: text.length, waMessageId: result.messageId ?? null },
});
const view = sendTestDoneMenu(group.name, true);
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
} catch (err) {
logger.error({ err, groupId }, "send-test: failed");
const view = sendTestDoneMenu(group.name, false, (err as Error).message);
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
}
}

View File

@ -1,4 +1,5 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
import { db } from "../../db.js";
import { sessionManager } from "../../whatsapp/session-manager.js";
@ -17,14 +18,19 @@ export async function handleAccounts(ctx: Context): Promise<void> {
});
if (accounts.length === 0) {
await ctx.reply('No accounts paired yet. Use /pair "Label" to add one.');
await ctx.reply('No accounts paired yet. Send /pair YourLabel to add one.');
return;
}
const lines = accounts.map((a) => {
// One message per account so each gets its own action buttons. Keeps
// callback_data short and avoids hitting Telegram's per-message limits.
for (const a of accounts) {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
return `${a.label}${phone} — db:${a.status} live:${live}`;
});
await ctx.reply(`📒 Paired accounts:\n${lines.join("\n")}`);
const text = `📒 ${a.label}${phone}\nstatus: ${a.status} (live: ${live})`;
const kb = new InlineKeyboard()
.text("📂 Groups", `g:${a.id}`)
.text("🗑 Unpair", `u:${a.id}`);
await ctx.reply(text, { reply_markup: kb });
}
}

View File

@ -10,6 +10,8 @@ import { sessionManager } from "../../whatsapp/session-manager.js";
import { renderQrPng } from "../../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../../whatsapp/group-sync.js";
import { writeAuditLog } from "../../audit.js";
import { setPendingPairLabel } from "../state.js";
import { InlineKeyboard } from "grammy";
// Per-account state for the pairing flow. Re-running /pair for the same
// account tears down the previous flow before starting a new one so we never
@ -40,10 +42,23 @@ export async function handlePair(ctx: Context): Promise<void> {
.trim()
.replace(/^["'“”‘’]|["'“”‘’]$/g, "");
if (!label) {
await ctx.reply('Usage: /pair "Account Label"');
// No label after /pair — set pending state and prompt the operator to
// reply with a label as a regular message.
const tgId = ctx.from?.id;
if (tgId !== undefined) setPendingPairLabel(tgId);
const kb = new InlineKeyboard().text("⬅ Cancel", "m:main");
await ctx.reply(
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.",
{ reply_markup: kb, parse_mode: "Markdown" },
);
return;
}
await executePairFlow(ctx, label);
}
export async function executePairFlow(ctx: Context, label: string): Promise<void> {
const operatorId = ctx.from?.id;
if (!operatorId) return;
@ -114,9 +129,6 @@ export async function handlePair(ctx: Context): Promise<void> {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
await ctx.reply(
`✅ "${label}" connected${event.phoneNumber ? ` as +${event.phoneNumber}` : ""}.`,
);
await writeAuditLog(db, {
operatorId: operatorRow.id,
source: "telegram",
@ -126,16 +138,27 @@ export async function handlePair(ctx: Context): Promise<void> {
payload: { label },
});
const session = sessionManager.getSession(id);
let syncedCount = 0;
if (session) {
const result = await syncGroupsForAccount(id, session.socket);
await ctx.reply(`Synced ${result.synced} groups. Ready to send reminders.`);
syncedCount = result.synced;
}
const phoneText = event.phoneNumber ? ` as +${event.phoneNumber}` : "";
const kb = new InlineKeyboard()
.text("📂 View Groups", `g:${id}`)
.row()
.text("⬅ Main Menu", "m:main");
await ctx.reply(
`✅ *${label}* connected${phoneText}.\n\nSynced ${syncedCount} group${syncedCount === 1 ? "" : "s"}.`,
{ reply_markup: kb, parse_mode: "Markdown" },
);
off();
} else if (event.type === "close" && event.loggedOut) {
qrMessageIdByAccount.delete(id);
lastQrPayloadByAccount.delete(id);
offByAccount.delete(id);
await ctx.reply(`⚠️ Pairing failed (logged out).`);
const kb = new InlineKeyboard().text("⬅ Main Menu", "m:main");
await ctx.reply(`⚠️ Pairing failed (logged out).`, { reply_markup: kb });
off();
}
} catch (err) {

View File

@ -1,8 +1,13 @@
import type { Context } from "grammy";
import { InlineKeyboard } from "grammy";
export async function handleStart(ctx: Context): Promise<void> {
await ctx.reply(
"👋 cm WhatsApp Reminder Bot is online.\n\n" +
"Type /help to see available commands.",
);
const kb = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📡 How to Pair", "m:pair")
.row()
.text("❓ Help", "m:help");
await ctx.reply("👋 cm WhatsApp Reminder Bot is online.\n\nWhat would you like to do?", {
reply_markup: kb,
});
}

View File

@ -0,0 +1,248 @@
import { InlineKeyboard } from "grammy";
import { db } from "../db.js";
import { sessionManager } from "../whatsapp/session-manager.js";
// BotFather-style navigation: every leaf has a way home, every branch shows
// you where you are. All callbacks edit the same message.
// Callback data scheme (kept short to stay under Telegram's 64-byte limit):
// m:main — top-level menu
// m:accounts — accounts list
// m:help — help text
// m:pair — prompt for new account label
// acc:<id> — single account view
// g:<id> — groups list for account
// u:<id> — unpair confirm prompt
// uc:<id> — unpair execute
// ux:<id> — cancel unpair, go back to account view
export type MenuView = {
text: string;
keyboard: InlineKeyboard;
};
export function mainMenu(): MenuView {
const keyboard = new InlineKeyboard()
.text("📒 Accounts", "m:accounts")
.text("📡 Pair New", "m:pair")
.row()
.text("❓ Help", "m:help");
return {
text:
"👋 *cm WhatsApp Reminder Bot*\n\n" +
"What would you like to do?",
keyboard,
};
}
export function helpMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
return {
text:
"*Available actions:*\n\n" +
"📒 *Accounts* — list paired WhatsApp accounts and act on each one\n" +
"📡 *Pair New* — link a new WhatsApp account via QR code\n" +
"❓ *Help* — this screen\n\n" +
"Type /start or /menu anytime to come back here.",
keyboard,
};
}
export function pairPromptMenu(): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
"📡 *Pair a new account*\n\n" +
"What name should I give this WhatsApp account?\n\n" +
"Reply to this message with a short label, e.g. `Sales 1`.\n\n" +
"(Or tap *Cancel* to go back.)",
keyboard,
};
}
export async function accountsMenu(operatorId: string): Promise<MenuView> {
const accounts = await db.query.whatsappAccounts.findMany({
where: (a, { eq }) => eq(a.operatorId, operatorId),
orderBy: (a, { asc }) => [asc(a.label)],
});
if (accounts.length === 0) {
const keyboard = new InlineKeyboard()
.text("📡 Pair New", "m:pair")
.row()
.text("⬅ Main Menu", "m:main");
return {
text: "📒 *Accounts*\n\nNo accounts paired yet.",
keyboard,
};
}
const keyboard = new InlineKeyboard();
for (const a of accounts) {
keyboard.text(`📒 ${a.label}`, `acc:${a.id}`).row();
}
keyboard.text("📡 Pair New", "m:pair").row().text("⬅ Main Menu", "m:main");
const lines = accounts.map((a) => {
const live = sessionManager.getState(a.id);
const phone = a.phoneNumber ? ` (+${a.phoneNumber})` : "";
return `• *${a.label}*${phone}${a.status} (live: ${live})`;
});
return {
text: `📒 *Paired accounts:*\n\n${lines.join("\n")}\n\nTap an account to view its actions.`,
keyboard,
};
}
export async function accountDetailMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const live = sessionManager.getState(accountId);
const phone = account.phoneNumber ? ` (+${account.phoneNumber})` : "";
const keyboard = new InlineKeyboard()
.text("📂 Groups", `g:${accountId}`)
.text("🗑 Unpair", `u:${accountId}`)
.row()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text:
`📒 *${account.label}*${phone}\n\n` +
`db status: \`${account.status}\`\n` +
`live status: \`${live}\`\n\n` +
"What would you like to do?",
keyboard,
};
}
export async function groupsListMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const groups = await db.query.whatsappGroups.findMany({
where: (g, { eq }) => eq(g.accountId, accountId),
orderBy: (g, { asc }) => [asc(g.name)],
});
const keyboard = new InlineKeyboard();
// One button per group (truncate to 30 to stay under Telegram's 100-button
// ceiling and keep the message readable). Group name truncated to 32 chars.
const visible = groups.slice(0, 30);
for (const g of visible) {
const name = g.name.length > 32 ? `${g.name.slice(0, 31)}` : g.name;
keyboard.text(`👥 ${name}`, `gr:${g.id}`).row();
}
keyboard
.text("🔄 Refresh", `rs:${accountId}`)
.row()
.text("⬅ Account", `acc:${accountId}`)
.text("⬅ Main Menu", "m:main");
if (groups.length === 0) {
return {
text: `👥 *Groups in ${account.label}*\n\nNo groups synced yet. Tap *Refresh* to pull the latest list.`,
keyboard,
};
}
const overflow = groups.length > 30 ? `\n\n_…${groups.length - 30} more not shown_` : "";
return {
text: `👥 *Groups in ${account.label}*\n\nTap a group to send a test message, or *Refresh* to pick up new groups.${overflow}`,
keyboard,
};
}
export async function groupDetailMenu(
operatorId: string,
groupId: string,
): Promise<MenuView | null> {
const group = await db.query.whatsappGroups.findFirst({
where: (g, { eq }) => eq(g.id, groupId),
});
if (!group) return null;
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, group.accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("📝 Send Test Text", `st:${groupId}`)
.row()
.text("⬅ Groups", `g:${group.accountId}`)
.text("⬅ Main Menu", "m:main");
return {
text:
`👥 *${group.name}*\n\n` +
`Account: ${account.label}\n` +
`Members: ${group.participantCount}\n\n` +
"What would you like to do?",
keyboard,
};
}
export function sendTestPromptMenu(groupName: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Cancel", "m:main");
return {
text:
`📝 *Send a test message to ${groupName}*\n\n` +
"Reply to this message with the text you want to send.\n\n" +
"(Or tap *Cancel*.)",
keyboard,
};
}
export function sendTestDoneMenu(groupName: string, ok: boolean, errorMsg?: string): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Main Menu", "m:main");
if (ok) {
return {
text: `✅ Test message sent to *${groupName}*.`,
keyboard,
};
}
return {
text: `❌ Failed to send to *${groupName}*.\n\n\`${errorMsg ?? "unknown error"}\``,
keyboard,
};
}
export async function unpairConfirmMenu(
operatorId: string,
accountId: string,
): Promise<MenuView | null> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq, and }) => and(eq(a.id, accountId), eq(a.operatorId, operatorId)),
});
if (!account) return null;
const keyboard = new InlineKeyboard()
.text("✅ Yes, unpair", `uc:${accountId}`)
.text("⬅ Cancel", `acc:${accountId}`);
return {
text:
`🗑 *Unpair ${account.label}?*\n\n` +
"The session files will be deleted and you'll need to re-scan a QR code if you want this account back.",
keyboard,
};
}
export function unpairDoneMenu(label: string): MenuView {
const keyboard = new InlineKeyboard()
.text("⬅ Accounts", "m:accounts")
.text("⬅ Main Menu", "m:main");
return {
text: `🗑 *${label}* unpaired. Session files deleted.`,
keyboard,
};
}

View File

@ -0,0 +1,43 @@
// Per-user conversation state for menu-driven flows.
// Currently tracks: "operator clicked Pair New, waiting for them to type the label".
// In-memory only — fine for a single-instance bot. If we ever scale horizontally,
// move this to Postgres.
const PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes
const pendingPairLabel = new Map<number, number>(); // userId → expires_at_ms
export function setPendingPairLabel(userId: number): void {
pendingPairLabel.set(userId, Date.now() + PENDING_TTL_MS);
}
export function clearPendingPairLabel(userId: number): void {
pendingPairLabel.delete(userId);
}
export function consumePendingPairLabel(userId: number): boolean {
const expiresAt = pendingPairLabel.get(userId);
if (!expiresAt) return false;
pendingPairLabel.delete(userId);
return Date.now() < expiresAt;
}
// "Send a test message to this WhatsApp group" pending state.
type PendingSend = { groupId: string; expiresAt: number };
const pendingSendToGroup = new Map<number, PendingSend>();
export function setPendingSendToGroup(userId: number, groupId: string): void {
pendingSendToGroup.set(userId, { groupId, expiresAt: Date.now() + PENDING_TTL_MS });
}
export function clearPendingSendToGroup(userId: number): void {
pendingSendToGroup.delete(userId);
}
export function consumePendingSendToGroup(userId: number): string | null {
const pending = pendingSendToGroup.get(userId);
if (!pending) return null;
pendingSendToGroup.delete(userId);
if (Date.now() >= pending.expiresAt) return null;
return pending.groupId;
}

View File

@ -1,4 +1,4 @@
import { sql } from "drizzle-orm";
import { and, eq, notInArray, sql } from "drizzle-orm";
import type { WASocket } from "@whiskeysockets/baileys";
import { whatsappGroups } from "@cmbot/db";
import { db } from "../db.js";
@ -7,13 +7,35 @@ import { logger } from "../logger.js";
export async function syncGroupsForAccount(
accountId: string,
socket: WASocket,
): Promise<{ synced: number }> {
): Promise<{ synced: number; removed: number }> {
const meta = await socket.groupFetchAllParticipating();
const entries = Object.values(meta);
const liveJids = entries.map((g) => g.id);
// Remove DB rows for groups that are no longer in the live participant list
// (group was deleted, bot was removed, etc.). Only run the delete when we
// got at least one live group back — an empty result is more likely a
// transient WA fetch failure than a genuine "all groups gone" signal, and
// we don't want to nuke valid data on a hiccup.
let removed: { id: string }[] = [];
if (liveJids.length > 0) {
removed = await db
.delete(whatsappGroups)
.where(
and(
eq(whatsappGroups.accountId, accountId),
notInArray(whatsappGroups.waGroupJid, liveJids),
),
)
.returning({ id: whatsappGroups.id });
}
if (entries.length === 0) {
logger.info({ accountId }, "group-sync: no groups");
return { synced: 0 };
logger.info(
{ accountId },
"group-sync: empty fetch — skipping delete sweep (treating as transient)",
);
return { synced: 0, removed: 0 };
}
const rows = entries.map((g) => ({
@ -37,6 +59,9 @@ export async function syncGroupsForAccount(
},
});
logger.info({ accountId, count: rows.length }, "group-sync: synced");
return { synced: rows.length };
logger.info(
{ accountId, count: rows.length, removed: removed.length },
"group-sync: synced",
);
return { synced: rows.length, removed: removed.length };
}

View File

@ -0,0 +1,82 @@
import type { WASocket } from "@whiskeysockets/baileys";
import pino from "pino";
const logger = pino({ name: "sender" });
// Internal Baileys method used to fetch pre-key bundles and establish individual
// libsignal sessions for a list of JIDs. Not part of the public type, but it's
// the only way to avoid "No sessions" on the first group send after pairing.
type SocketWithAssertSessions = WASocket & {
assertSessions?: (jids: string[], force: boolean) => Promise<boolean>;
};
const CHUNK_SIZE = 5;
async function chunked<T>(items: T[], size: number): Promise<T[][]> {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) {
out.push(items.slice(i, i + size));
}
return out;
}
/**
* Establish per-participant libsignal sessions in small chunks. WhatsApp's
* pre-key endpoint returns 406 "not-acceptable" if any single JID in the
* batch is in a broken state (deleted account, deactivated, etc.) so we
* chunk the work and tolerate per-chunk failures rather than letting one
* bad participant poison the whole send.
*/
async function ensureSessionsForGroup(
socket: WASocket,
groupJid: string,
): Promise<{ ok: number; failed: number; total: number }> {
const metadata = await socket.groupMetadata(groupJid);
const participantJids = metadata.participants.map((p) => p.id);
const internal = socket as SocketWithAssertSessions;
if (typeof internal.assertSessions !== "function") {
return { ok: 0, failed: 0, total: participantJids.length };
}
let ok = 0;
let failed = 0;
const chunks = await chunked(participantJids, CHUNK_SIZE);
for (const chunk of chunks) {
try {
await internal.assertSessions(chunk, true);
ok += chunk.length;
} catch (err) {
failed += chunk.length;
logger.warn(
{ groupJid, chunkSize: chunk.length, err: (err as Error).message },
"assertSessions chunk failed; continuing",
);
}
}
logger.info(
{ groupJid, ok, failed, total: participantJids.length },
"ensureSessionsForGroup: done",
);
return { ok, failed, total: participantJids.length };
}
export async function sendTextToGroup(
socket: WASocket,
groupJid: string,
text: string,
): Promise<{ messageId: string | undefined }> {
await ensureSessionsForGroup(socket, groupJid);
try {
const result = await socket.sendMessage(groupJid, { text });
return { messageId: result?.key?.id ?? undefined };
} catch (err) {
const message = (err as Error)?.message ?? "";
if (message.includes("No sessions")) {
await new Promise((resolve) => setTimeout(resolve, 2000));
await ensureSessionsForGroup(socket, groupJid);
const result = await socket.sendMessage(groupJid, { text });
return { messageId: result?.key?.id ?? undefined };
}
throw err;
}
}

View File

@ -11,6 +11,7 @@ import {
} from "@whiskeysockets/baileys";
import { logger } from "../logger.js";
import { env } from "../env.js";
import { syncGroupsForAccount } from "./group-sync.js";
export type SessionEvent =
| { type: "qr"; payload: string }
@ -50,6 +51,22 @@ export async function startSession(params: {
socket.ev.on("creds.update", () => void saveCreds());
// Keep `whatsapp_groups` in sync as Baileys discovers new groups or updates.
// Debounced so a flurry of upsert events from the initial sync collapses
// into a single DB write.
let groupsSyncTimer: NodeJS.Timeout | null = null;
const scheduleGroupsSync = (): void => {
if (groupsSyncTimer) return;
groupsSyncTimer = setTimeout(() => {
groupsSyncTimer = null;
void syncGroupsForAccount(accountId, socket).catch((err) =>
logger.warn({ err, accountId }, "auto group sync failed"),
);
}, 1500);
};
socket.ev.on("groups.upsert", scheduleGroupsSync);
socket.ev.on("groups.update", scheduleGroupsSync);
socket.ev.on("connection.update", (update: Partial<ConnectionState>) => {
if (update.qr) {
void onEvent({ type: "qr", payload: update.qr });

1537
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff