Compare commits
10 Commits
ee1113280d
...
f613d83b40
| Author | SHA1 | Date | |
|---|---|---|---|
| f613d83b40 | |||
| 9062ba7e7f | |||
| 83d9bf6e9b | |||
| 43882d5a1b | |||
| 5259f88776 | |||
| 2fdcdb6202 | |||
| 99cece16c0 | |||
| 3c4eedff03 | |||
| 7b0c8c47e2 | |||
| 56fd71a6a0 |
81
README.md
Normal file
81
README.md
Normal 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.
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
252
apps/bot/src/telegram/callbacks.ts
Normal file
252
apps/bot/src/telegram/callbacks.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
248
apps/bot/src/telegram/menus.ts
Normal file
248
apps/bot/src/telegram/menus.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
apps/bot/src/telegram/state.ts
Normal file
43
apps/bot/src/telegram/state.ts
Normal 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;
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
82
apps/bot/src/whatsapp/sender.ts
Normal file
82
apps/bot/src/whatsapp/sender.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
1537
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user