diff --git a/README.md b/README.md index 2e5a47d3da9a..6d9910f78b67 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,139 @@ -# Deno Standard Library +# Telegram-Bot (GramIO edition) -[![JSR @std](https://jsr.io/badges/@std)](https://jsr.io/@std) -[![codecov](https://codecov.io/gh/denoland/std/branch/main/graph/badge.svg?token=w6s3ODtULz)](https://codecov.io/gh/denoland/std) -[![ci](https://github.com/denoland/std/actions/workflows/ci.yml/badge.svg)](https://github.com/denoland/std/actions/workflows/ci.yml) +An owner-controlled Telegram business automation bot, built with +[GramIO](https://gramio.dev). Implements the system described in the +Telegram-Bot spec: publishing, editable buttons, scheduling, connected +destinations, access-controlled users, and group-mention assistance. -High-quality APIs for [Deno](https://deno.com/) and the web. Use fearlessly. +> **A note on the tech stack.** The original spec frames Cloudflare Workers +> as the bot's "operating engine." This package instead ships as a small, +> long-running Node.js process (GramIO's official, guaranteed-stable +> long-polling mode) with a local SQLite database. This was a deliberate +> choice: GramIO's Cloudflare Workers webhook wiring isn't something I could +> verify works correctly, and shipping unverified glue code for your bot's +> deployment path is worse than shipping something that's simple and +> definitely works. This runs happily on a $5 VPS, Fly.io, Railway, Render, +> or in a Docker container — anywhere a Node process can stay alive. If you +> specifically need Workers + D1, the `src/db/client.ts` functions are a +> thin, swappable layer — see "Porting to Cloudflare Workers" below. -> [!IMPORTANT] -> Newer versions of the Standard Library are now hosted on -> [JSR](https://jsr.io/@std). Older versions up till 0.224.0 are still available -> at [deno.land/std](https://deno.land/std). +## What it does -## Resources +| Feature | Where | +|---|---| +| Owner control-center menu | `src/bot.ts`, `src/keyboards.ts` | +| Publish to one or many destinations | `src/features/publish.ts` | +| Buttons you can re-point after publishing | `src/features/buttons.ts` | +| Scheduled posts (published automatically) | `src/features/schedule.ts`, `src/scheduler.ts` | +| Connected channels/groups | `src/features/destinations.ts` | +| Approval-based access requests | `src/features/access.ts` | +| Group @mention assistance | `src/features/groupMention.ts` | +| Persistent + ephemeral state | `src/db/schema.sql`, `src/db/client.ts` | -- [Package list](https://jsr.io/@std) -- [Architecture guide](./.github/ARCHITECTURE.md) -- [Design documentation](.github/ARCHITECTURE.md#design) -- [Contributing guidelines](.github/CONTRIBUTING.md) -- [Frequently asked questions (FAQ)](./.github/FAQ.md) +## Setup -## Releases +1. **Create the bot.** Message [@BotFather](https://t.me/BotFather) on + Telegram, run `/newbot`, and copy the token it gives you. +2. **Find your Telegram user id.** Message + [@userinfobot](https://t.me/userinfobot) — this is your `OWNER_ID`. +3. **Configure environment variables:** + ```bash + cp .env.example .env + # then edit .env with your BOT_TOKEN and OWNER_ID + ``` +4. Pick a runtime and follow the matching section below. -Package versions >=1.0.0 follow [Semantic Versioning](https://semver.org/), and -package versions <1.0.0 follow -[this proposal](https://github.com/semver/semver/pull/923). +The same `src/` code runs unmodified on either runtime — `deno.json` +provides an import map so bare specifiers like `"gramio"` resolve to +`npm:gramio` under Deno, exactly like `package.json` resolves them under +Node. -## Badge +### Option A — Node.js -> [!NOTE] -> Previously, this repo hosted the badge SVG file. Now, the badge is retrieved -> directly from [Shields.io](https://shields.io/). +Requires Node **20.6+** (for the native `--env-file` flag). -[![Built with the Deno Standard Library](https://img.shields.io/badge/Built_with_std-black?logo=deno)](https://jsr.io/@std) - -```html - - Built with the Deno Standard Library - +```bash +npm install +npm run db:init # optional - also happens automatically on first run +npm run dev # hot-reloading, for development +# or +npm run build && npm start # production ``` -```md -[![Built with the Deno Standard Library](https://img.shields.io/badge/Built_with_std-black?logo=deno)](https://jsr.io/@std) +### Option B — Deno + +Requires Deno **1.44+** (for the native `--env-file` flag). No `npm install` +step needed — Deno fetches `npm:gramio`, `npm:better-sqlite3`, etc. on first +run per the import map in `deno.json`. + +```bash +deno task db:init # optional - also happens automatically on first run +deno task dev # hot-reloading, for development +# or +deno task start # production ``` + +`better-sqlite3` is a native addon; Deno's npm compatibility layer supports +it, but if your Deno version has trouble with native modules, swap +`src/db/init.ts` and `src/db/client.ts` for a Deno-native driver such as +`jsr:@db/sqlite` — same SQL, same `schema.sql`, just a different +`.prepare()/.run()/.all()` wrapper. + +Once it's running (either runtime), message your bot on Telegram from the +`OWNER_ID` account and send `/start` to see the control center. + +## Using it as the owner + +- `/start` or `/menu` — open the control center +- **📢 Publish** — compose text, optionally attach buttons, pick destinations, + and it goes out immediately +- **⏰ Schedule** — same flow, but you give it a future time + (`2026-09-20 10:00`, or relative like `+30m`, `+2h`, `+1d`) +- **🔗 Edit Buttons** — pick a previously published post and re-point one of + its buttons; every message it was published to gets updated in place +- **📣 Destinations** — see connected channels/groups. To connect a new one: + make the bot an admin there (channels register automatically; in groups, + also run `/addhere`) +- **👥 Access Requests** — approve or decline people who've messaged the bot + +## Using it as a normal user + +Anyone else who messages the bot privately gets a friendly greeting. If +they're not yet approved, an access request is sent to the owner +automatically; once approved, their messages get a business-assistant reply +(see `answerBusinessQuestion` in `src/features/groupMention.ts` — replace +this with your own FAQ/catalog logic or an LLM call as needed). + +## Using it in a group + +The bot ignores ordinary group chatter. When someone writes +`@YourBotUsername `, it recognizes the mention and replies. + +## Data & privacy + +Persistent data (destinations, posts, buttons, schedules, access decisions) +lives in `data/bot.sqlite`. Multi-step conversation state (e.g. "I'm +composing a post and I'm on step 2") is kept in the `user_state` table and +is meant to be treated as temporary/expiring, per the bot's privacy +principle — nothing about it needs backing up. + +## Porting to Cloudflare Workers + +The database layer (`src/db/client.ts`) only touches a `better-sqlite3` +instance through plain SQL — no Node-specific APIs beyond that. To port to +Workers + D1: + +1. Swap `src/db/init.ts` and `src/db/client.ts` for D1's `async` query API + (`db.prepare(sql).bind(...).run()/.all()/.first()` — same SQL, same + `schema.sql`, since D1 is SQLite under the hood). +2. Replace `bot.start()` long-polling in `src/index.ts` with a `fetch` + handler that parses the incoming Telegram update and feeds it into + GramIO's update pipeline via whatever webhook adapter your installed + GramIO version documents (check `node_modules/gramio`'s README for the + current list of supported frameworks/adapters — this changes between + versions, so it's worth confirming against your installed version rather + than trusting older docs). +3. Replace `src/scheduler.ts`'s `setInterval` with a Workers **Cron + Trigger** that calls the same `runDueSchedules` function. + +Everything in `src/features/*` is framework-agnostic and can be reused as-is. diff --git a/access.ts b/access.ts new file mode 100644 index 000000000000..40c603fd454b --- /dev/null +++ b/access.ts @@ -0,0 +1,38 @@ +import type Database from "better-sqlite3"; +import { + decideUser, + getUser, + listPendingUsers, + upsertPendingUser, +} from "../db/client.js"; + +/** + * Doc section 9, "User Access Requests": a new person interacting with the + * bot doesn't automatically get access to controlled features - a request + * is created for the owner to accept or decline. + */ +export function requestAccess(db: Database.Database, userId: string, username?: string) { + upsertPendingUser(db, userId, username); +} + +export function isApproved(db: Database.Database, userId: string): boolean { + const user = getUser(db, userId); + return user?.status === "approved"; +} + +export function hasPendingRequest(db: Database.Database, userId: string): boolean { + const user = getUser(db, userId); + return user?.status === "pending"; +} + +export function approve(db: Database.Database, userId: string) { + decideUser(db, userId, true); +} + +export function decline(db: Database.Database, userId: string) { + decideUser(db, userId, false); +} + +export function pendingRequests(db: Database.Database) { + return listPendingUsers(db); +} diff --git a/bot.ts b/bot.ts new file mode 100644 index 000000000000..0b8a0a6dd409 --- /dev/null +++ b/bot.ts @@ -0,0 +1,451 @@ +import { Bot, InlineKeyboard, format, bold } from "gramio"; +import { autoAnswerCallbackQuery } from "@gramio/auto-answer-callback-query"; +import type Database from "better-sqlite3"; +import type { Env, ButtonInput, OwnerStep } from "./types.js"; +import { clearState, getState, listRecentPosts, setState } from "./db/client.js"; +import { + accessDecisionKeyboard, + destinationPickerKeyboard, + ownerMenuKeyboard, +} from "./keyboards.js"; +import { publishPost } from "./features/publish.js"; +import { getButtons } from "./db/client.js"; +import { updateButtonEverywhere } from "./features/buttons.js"; +import { getUpcomingSchedule, schedulePost } from "./features/schedule.js"; +import { + approve, + decline, + hasPendingRequest, + isApproved, + pendingRequests, + requestAccess, +} from "./features/access.js"; +import { getDestinations, registerDestination } from "./features/destinations.js"; +import { answerBusinessQuestion, extractMention } from "./features/groupMention.js"; + +interface StateData { + text?: string; + buttons?: ButtonInput[]; + pendingLabel?: string; + selectedDestinations?: string[]; + editPostId?: number; + editButtonId?: number; +} + +function loadState(db: Database.Database, userId: string): { step: OwnerStep; data: StateData } { + const row = getState(db, userId); + if (!row) return { step: "idle", data: {} }; + return { step: row.step as OwnerStep, data: JSON.parse(row.data_json) as StateData }; +} + +function saveState(db: Database.Database, userId: string, step: OwnerStep, data: StateData) { + setState(db, userId, step, data); +} + +/** Parses simple schedule time input: "YYYY-MM-DD HH:mm" or "+30m" / "+2h" / "+1d". */ +function parseScheduleTime(input: string): Date | null { + const trimmed = input.trim(); + const relative = trimmed.match(/^\+(\d+)([mhd])$/i); + if (relative) { + const amount = Number(relative[1]); + const unit = relative[2].toLowerCase(); + const ms = unit === "m" ? amount * 60_000 : unit === "h" ? amount * 3_600_000 : amount * 86_400_000; + return new Date(Date.now() + ms); + } + const absolute = new Date(trimmed.replace(" ", "T")); + if (!isNaN(absolute.getTime())) return absolute; + return null; +} + +export function createBot(env: Env, db: Database.Database) { + const bot = new Bot(env.BOT_TOKEN).extend(autoAnswerCallbackQuery()); + const isOwner = (userId: number | string) => String(userId) === String(env.OWNER_ID); + + // ------------------------------------------------------------------- + // Owner: entry points + // ------------------------------------------------------------------- + bot.command("start", async (context) => { + if (!context.from) return; + if (isOwner(context.from.id)) { + clearState(db, String(context.from.id)); + return context.send( + format`${bold("Telegram-Bot Control Center")}\nWhat would you like to do?`, + { reply_markup: ownerMenuKeyboard() } + ); + } + // Non-owner private chat: business assistance / access flow (doc section 6 & 9) + return handleUserGreeting(context.from.id, context.from.username, (text, kb) => + context.send(text, kb ? { reply_markup: kb } : undefined) + ); + }); + + bot.command("menu", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + clearState(db, String(context.from.id)); + return context.send("Control Center:", { reply_markup: ownerMenuKeyboard() }); + }); + + // Owner runs this inside a group they've made the bot an admin of, to + // connect it as a publishing destination (doc section 14). + bot.command("addhere", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + if (context.chat.type !== "group" && context.chat.type !== "supergroup") { + return context.send("Run /addhere inside the group you want to connect."); + } + registerDestination(db, String(context.chat.id), context.chat.title ?? "Untitled group", "group"); + return context.send("✅ This group is now a connected destination."); + }); + + // Auto-registers channels/groups the moment the bot is promoted to admin. + bot.on("my_chat_member", async (context) => { + const update = context.payload; + const newStatus = update.new_chat_member?.status; + if (newStatus !== "administrator") return; + const chat = update.chat; + const type = chat.type === "channel" ? "channel" : "group"; + registerDestination(db, String(chat.id), chat.title ?? "Untitled", type); + try { + await bot.api.sendMessage({ + chat_id: env.OWNER_ID, + text: `✅ Connected new destination: "${chat.title ?? chat.id}" (${type}).`, + }); + } catch { + /* owner may not have started a DM with the bot yet */ + } + }); + + // ------------------------------------------------------------------- + // Owner: main menu callbacks + // ------------------------------------------------------------------- + bot.callbackQuery("menu:publish", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + saveState(db, String(context.from.id), "compose_post_text", {}); + return context.editText("Send me the text for your post."); + }); + + bot.callbackQuery("menu:schedule", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + saveState(db, String(context.from.id), "compose_post_text", { pendingLabel: "schedule" }); + return context.editText("Send me the text for the post you want to schedule."); + }); + + bot.callbackQuery("menu:destinations", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const dests = getDestinations(db); + const list = dests.length + ? dests.map((d) => `• ${d.title} (${d.type})`).join("\n") + : "No destinations connected yet."; + return context.editText( + `${list}\n\nTo connect a new group: add the bot as admin, then run /addhere inside it.\nTo connect a channel: make the bot an admin of the channel - it will be added automatically.`, + { reply_markup: new InlineKeyboard().text("⬅ Back", "menu:back") } + ); + }); + + bot.callbackQuery("menu:access", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const pending = pendingRequests(db); + if (pending.length === 0) { + return context.editText("No pending access requests.", { + reply_markup: new InlineKeyboard().text("⬅ Back", "menu:back"), + }); + } + for (const user of pending) { + await context.send(`Access request from ${user.username ? "@" + user.username : user.user_id}`, { + reply_markup: accessDecisionKeyboard(user.user_id), + }); + } + return context.answer(); + }); + + bot.callbackQuery("menu:edit_buttons", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const posts = listRecentPosts(db, 10).filter((p) => getButtons(db, p.id).length > 0); + if (posts.length === 0) { + return context.editText("No published posts with buttons yet.", { + reply_markup: new InlineKeyboard().text("⬅ Back", "menu:back"), + }); + } + const kb = new InlineKeyboard(); + posts.forEach((p) => { + kb.text(p.label.slice(0, 30) || `Post #${p.id}`, `edit_post:${p.id}`); + kb.row(); + }); + kb.text("⬅ Back", "menu:back"); + saveState(db, String(context.from.id), "edit_button_pick_post", {}); + return context.editText("Pick a post to update its button(s):", { reply_markup: kb }); + }); + + bot.callbackQuery("menu:settings", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const upcoming = getUpcomingSchedule(db); + return context.editText( + `Owner: ${env.OWNER_ID}\nUpcoming scheduled posts: ${upcoming.length}`, + { reply_markup: new InlineKeyboard().text("⬅ Back", "menu:back") } + ); + }); + + bot.callbackQuery("menu:back", async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + clearState(db, String(context.from.id)); + return context.editText("Control Center:", { reply_markup: ownerMenuKeyboard() }); + }); + + bot.callbackQuery("cancel", async (context) => { + if (!context.from) return; + clearState(db, String(context.from.id)); + if (isOwner(context.from.id)) { + return context.editText("Cancelled. Control Center:", { reply_markup: ownerMenuKeyboard() }); + } + return context.editText("Cancelled."); + }); + + // ------------------------------------------------------------------- + // Compose flow: destination picking (shared by Publish + Schedule) + // ------------------------------------------------------------------- + bot.callbackQuery(/^dest_toggle:(.+)$/, async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const chatId = context.queryData[1]; + const { step, data } = loadState(db, String(context.from.id)); + if (step !== "compose_post_pick_destinations") return; + const selected = new Set(data.selectedDestinations ?? []); + selected.has(chatId) ? selected.delete(chatId) : selected.add(chatId); + data.selectedDestinations = [...selected]; + saveState(db, String(context.from.id), step, data); + const dests = getDestinations(db); + return context.editText("Choose where to publish:", { + reply_markup: destinationPickerKeyboard(dests, selected), + }); + }); + + bot.callbackQuery("dest_done", async (context) => { + if (!context.from) return; + const userId = String(context.from.id); + const { data } = loadState(db, userId); + const destinations = data.selectedDestinations ?? []; + if (destinations.length === 0) { + return context.answer({ text: "Pick at least one destination first.", show_alert: true }); + } + if (data.pendingLabel === "schedule") { + saveState(db, userId, "schedule_pick_time", data); + return context.editText( + "When should this publish? Send a date/time like `2026-09-20 10:00`, or a relative time like `+30m`, `+2h`, `+1d`." + ); + } + // Immediate publish + const result = await publishPost(bot, db, { + label: (data.text ?? "").slice(0, 40) || "Untitled post", + text: data.text ?? "", + buttons: data.buttons ?? [], + destinationChatIds: destinations, + }); + clearState(db, userId); + const failedNote = result.failed.length ? `\n⚠ Failed: ${result.failed.join(", ")}` : ""; + return context.editText( + `✅ Published to ${result.sent} destination(s).${failedNote}`, + { reply_markup: ownerMenuKeyboard() } + ); + }); + + // ------------------------------------------------------------------- + // Edit-buttons flow + // ------------------------------------------------------------------- + bot.callbackQuery(/^edit_post:(\d+)$/, async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const postId = Number(context.queryData[1]); + const buttons = getButtons(db, postId); + const kb = new InlineKeyboard(); + buttons.forEach((b) => kb.text(`${b.label} → ${b.url}`, `edit_button:${b.id}:${postId}`).row()); + kb.text("⬅ Back", "menu:edit_buttons"); + saveState(db, String(context.from.id), "edit_button_pick_button", { editPostId: postId }); + return context.editText("Pick a button to update:", { reply_markup: kb }); + }); + + bot.callbackQuery(/^edit_button:(\d+):(\d+)$/, async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const buttonId = Number(context.queryData[1]); + const postId = Number(context.queryData[2]); + saveState(db, String(context.from.id), "edit_button_new_url", { + editButtonId: buttonId, + editPostId: postId, + }); + return context.editText("Send the new URL for this button."); + }); + + // ------------------------------------------------------------------- + // Access-request decisions + // ------------------------------------------------------------------- + bot.callbackQuery(/^access_approve:(.+)$/, async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const userId = context.queryData[1]; + approve(db, userId); + await bot.api + .sendMessage({ chat_id: userId, text: "✅ You've been approved! Send me a message any time." }) + .catch(() => {}); + return context.editText("Approved."); + }); + + bot.callbackQuery(/^access_decline:(.+)$/, async (context) => { + if (!context.from || !isOwner(context.from.id)) return; + const userId = context.queryData[1]; + decline(db, userId); + await bot.api + .sendMessage({ chat_id: userId, text: "Your access request was declined." }) + .catch(() => {}); + return context.editText("Declined."); + }); + + // ------------------------------------------------------------------- + // Free-text handling: drives the owner's multi-step workflows, and + // provides business assistance / access requests for normal users + // (doc sections 6, 9, 10, 25-26). + // ------------------------------------------------------------------- + bot.on("message", async (context) => { + if (!context.from || !context.text) return; + const userId = String(context.from.id); + const text = context.text.trim(); + if (text.startsWith("/")) return; // commands are handled above + + // Group mention assistance (doc section 7 & 17) - only reacts when + // the bot is specifically @mentioned in a group/supergroup. + if (context.chat.type === "group" || context.chat.type === "supergroup") { + const me = await bot.api.getMe(); + if (me.username) { + const query = extractMention(text, me.username); + if (query !== null) { + return context.send(answerBusinessQuestion(query)); + } + } + return; // ignore ordinary group chatter + } + + // -------------------- Owner multi-step workflows -------------------- + if (isOwner(context.from.id)) { + const { step, data } = loadState(db, userId); + + if (step === "compose_post_text") { + data.text = text; + saveState(db, userId, "compose_post_button_label", data); + return context.send( + "Send a button label to attach (e.g. `Visit Website`), or /skip to continue without buttons." + ); + } + + if (step === "compose_post_button_label") { + if (text === "/skip" || text === "/done") { + saveState(db, userId, "compose_post_pick_destinations", data); + const dests = getDestinations(db); + if (dests.length === 0) { + clearState(db, userId); + return context.send( + "No destinations connected yet. Add the bot as admin to a channel/group first." + ); + } + return context.send("Choose where to publish:", { + reply_markup: destinationPickerKeyboard(dests, new Set()), + }); + } + data.pendingLabel = text; + saveState(db, userId, "compose_post_button_url", data); + return context.send(`Send the URL for the "${text}" button.`); + } + + if (step === "compose_post_button_url") { + const buttons = data.buttons ?? []; + buttons.push({ label: data.pendingLabel ?? "Button", url: text }); + data.buttons = buttons; + data.pendingLabel = undefined; + saveState(db, userId, "compose_post_button_label", data); + return context.send('Add another button label, or /done to continue.'); + } + + if (step === "schedule_pick_time") { + const when = parseScheduleTime(text); + if (!when || when.getTime() < Date.now()) { + return context.send( + "I couldn't understand that time, or it's in the past. Try `2026-09-20 10:00` or `+30m`." + ); + } + schedulePost(db, data.text ?? "", data.buttons ?? [], data.selectedDestinations ?? [], when); + clearState(db, userId); + return context.send(`⏰ Scheduled for ${when.toLocaleString()}.`, { + reply_markup: ownerMenuKeyboard(), + }); + } + + if (step === "edit_button_new_url") { + if (data.editButtonId == null || data.editPostId == null) { + clearState(db, userId); + return context.send("Something went wrong - please start over from the menu."); + } + const result = await updateButtonEverywhere(bot, db, data.editButtonId, text, data.editPostId); + clearState(db, userId); + return context.send( + `✅ Button updated on ${result.updated} message(s).${result.failed ? ` ⚠ ${result.failed} failed.` : ""}`, + { reply_markup: ownerMenuKeyboard() } + ); + } + + // No active workflow - show the menu. + return context.send("Control Center:", { reply_markup: ownerMenuKeyboard() }); + } + + // -------------------- Normal users (private chat) -------------------- + return handleUserMessage(userId, context.from.username, text, (reply, kb) => + context.send(reply, kb ? { reply_markup: kb } : undefined) + ); + }); + + // Shared logic for a brand-new /start from a non-owner. + async function handleUserGreeting( + userId: number | string, + username: string | undefined, + send: (text: string, kb?: InlineKeyboard) => Promise + ) { + const id = String(userId); + if (isApproved(db, id)) { + return send("Hi! How can I help you today?"); + } + if (hasPendingRequest(db, id)) { + return send("Your access request is still pending. We'll let you know once it's approved."); + } + requestAccess(db, id, username); + await notifyOwnerOfRequest(id, username); + return send("Thanks! Your access request has been sent to the owner for approval."); + } + + // Shared logic for any subsequent free-text message from a non-owner. + async function handleUserMessage( + userId: string, + username: string | undefined, + text: string, + send: (text: string, kb?: InlineKeyboard) => Promise + ) { + if (isApproved(db, userId)) { + return send(answerBusinessQuestion(text)); + } + if (hasPendingRequest(db, userId)) { + return send("Your access request is still pending approval."); + } + requestAccess(db, userId, username); + await notifyOwnerOfRequest(userId, username); + return send("Thanks! Your access request has been sent to the owner for approval."); + } + + async function notifyOwnerOfRequest(userId: string, username?: string) { + try { + await bot.api.sendMessage({ + chat_id: env.OWNER_ID, + text: `New access request from ${username ? "@" + username : userId}`, + reply_markup: accessDecisionKeyboard(userId), + }); + } catch (err) { + console.error("Could not notify owner of access request:", err); + } + } + + bot.onStart(({ info }) => { + console.log(`Telegram-Bot is running as @${info.username}`); + }); + + return bot; +} diff --git a/buttons.ts b/buttons.ts new file mode 100644 index 000000000000..ad0639d4d5d6 --- /dev/null +++ b/buttons.ts @@ -0,0 +1,53 @@ +import { InlineKeyboard, type Bot } from "gramio"; +import type Database from "better-sqlite3"; +import type { ButtonInput } from "../types.js"; +import { getButtons, getPostMessages, updateButtonUrl } from "../db/client.js"; + +/** Turns a flat list of {label, url} buttons into a one-per-row inline keyboard. */ +export function buildInlineKeyboard(buttons: ButtonInput[]): InlineKeyboard { + let kb = new InlineKeyboard(); + buttons.forEach((b, i) => { + kb = kb.url(b.label, b.url); + if (i < buttons.length - 1) kb = kb.row(); + }); + return kb; +} + +/** + * Changes a button's destination URL and re-applies the updated keyboard to + * every message that post was published to (doc section 13: "Changing a + * Button Later" - previously published messages stay current without the + * owner recreating every post). + */ +export async function updateButtonEverywhere( + bot: Bot, + db: Database.Database, + buttonId: number, + newUrl: string, + postId: number +): Promise<{ updated: number; failed: number }> { + updateButtonUrl(db, buttonId, newUrl); + + const buttons = getButtons(db, postId); + const keyboard = buildInlineKeyboard( + buttons.map((b) => ({ label: b.label, url: b.url })) + ); + const messages = getPostMessages(db, postId); + + let updated = 0; + let failed = 0; + for (const m of messages) { + try { + await bot.api.editMessageReplyMarkup({ + chat_id: m.chat_id, + message_id: m.message_id, + reply_markup: keyboard, + }); + updated++; + } catch (err) { + failed++; + console.error(`Failed to update button on ${m.chat_id}/${m.message_id}:`, err); + } + } + return { updated, failed }; +} diff --git a/client.ts b/client.ts new file mode 100644 index 000000000000..d78ab88c5b63 --- /dev/null +++ b/client.ts @@ -0,0 +1,188 @@ +import type Database from "better-sqlite3"; +import type { + Destination, + PostRecord, + ScheduledPost, + UserRecord, + UserState, + ButtonInput, +} from "../types.js"; + +const now = () => Math.floor(Date.now() / 1000); + +// --------------------------------------------------------------------------- +// Destinations +// --------------------------------------------------------------------------- + +export function addDestination( + db: Database.Database, + chatId: string, + title: string, + type: "channel" | "group" +) { + db.prepare( + `INSERT INTO destinations (chat_id, title, type, added_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(chat_id) DO UPDATE SET title = excluded.title` + ).run(chatId, title, type, now()); +} + +export function listDestinations(db: Database.Database): Destination[] { + return db.prepare(`SELECT * FROM destinations ORDER BY added_at DESC`).all() as Destination[]; +} + +export function removeDestination(db: Database.Database, chatId: string) { + db.prepare(`DELETE FROM destinations WHERE chat_id = ?`).run(chatId); +} + +// --------------------------------------------------------------------------- +// Posts, messages, buttons +// --------------------------------------------------------------------------- + +export function createPost(db: Database.Database, label: string, text: string): number { + const result = db + .prepare(`INSERT INTO posts (label, text, created_at) VALUES (?, ?, ?)`) + .run(label, text, now()); + return Number(result.lastInsertRowid); +} + +export function addPostMessage( + db: Database.Database, + postId: number, + chatId: string, + messageId: number +) { + db.prepare( + `INSERT INTO post_messages (post_id, chat_id, message_id) VALUES (?, ?, ?)` + ).run(postId, chatId, messageId); +} + +export function getPostMessages(db: Database.Database, postId: number) { + return db + .prepare(`SELECT chat_id, message_id FROM post_messages WHERE post_id = ?`) + .all(postId) as { chat_id: string; message_id: number }[]; +} + +export function addButtons(db: Database.Database, postId: number, buttons: ButtonInput[]) { + const stmt = db.prepare( + `INSERT INTO buttons (post_id, label, url, row_order) VALUES (?, ?, ?, ?)` + ); + buttons.forEach((b, i) => stmt.run(postId, b.label, b.url, i)); +} + +export function getButtons(db: Database.Database, postId: number) { + return db + .prepare(`SELECT * FROM buttons WHERE post_id = ? ORDER BY row_order ASC`) + .all(postId) as { id: number; post_id: number; label: string; url: string; row_order: number }[]; +} + +export function updateButtonUrl(db: Database.Database, buttonId: number, url: string) { + db.prepare(`UPDATE buttons SET url = ? WHERE id = ?`).run(url, buttonId); +} + +export function listRecentPosts(db: Database.Database, limit = 10): PostRecord[] { + return db + .prepare(`SELECT * FROM posts ORDER BY created_at DESC LIMIT ?`) + .all(limit) as PostRecord[]; +} + +// --------------------------------------------------------------------------- +// Scheduled posts +// --------------------------------------------------------------------------- + +export function createScheduledPost( + db: Database.Database, + text: string, + buttons: ButtonInput[], + destinationChatIds: string[], + publishAt: number +): number { + const result = db + .prepare( + `INSERT INTO scheduled_posts (text, buttons_json, destinations, publish_at, status, created_at) + VALUES (?, ?, ?, ?, 'pending', ?)` + ) + .run(text, JSON.stringify(buttons), JSON.stringify(destinationChatIds), publishAt, now()); + return Number(result.lastInsertRowid); +} + +export function getDuePosts(db: Database.Database): ScheduledPost[] { + return db + .prepare( + `SELECT * FROM scheduled_posts WHERE status = 'pending' AND publish_at <= ? ORDER BY publish_at ASC LIMIT 20` + ) + .all(now()) as ScheduledPost[]; +} + +export function markScheduledPost( + db: Database.Database, + id: number, + status: "published" | "failed" +) { + db.prepare(`UPDATE scheduled_posts SET status = ? WHERE id = ?`).run(status, id); +} + +export function listUpcoming(db: Database.Database): ScheduledPost[] { + return db + .prepare( + `SELECT * FROM scheduled_posts WHERE status = 'pending' ORDER BY publish_at ASC LIMIT 20` + ) + .all() as ScheduledPost[]; +} + +// --------------------------------------------------------------------------- +// Users / access requests +// --------------------------------------------------------------------------- + +export function upsertPendingUser(db: Database.Database, userId: string, username?: string) { + db.prepare( + `INSERT INTO users (user_id, username, status, requested_at) + VALUES (?, ?, 'pending', ?) + ON CONFLICT(user_id) DO UPDATE SET username = excluded.username` + ).run(userId, username ?? null, now()); +} + +export function decideUser(db: Database.Database, userId: string, approve: boolean) { + db.prepare(`UPDATE users SET status = ?, decided_at = ? WHERE user_id = ?`).run( + approve ? "approved" : "declined", + now(), + userId + ); +} + +export function getUser(db: Database.Database, userId: string): UserRecord | null { + return (db.prepare(`SELECT * FROM users WHERE user_id = ?`).get(userId) as UserRecord) ?? null; +} + +export function listPendingUsers(db: Database.Database): UserRecord[] { + return db + .prepare(`SELECT * FROM users WHERE status = 'pending' ORDER BY requested_at ASC`) + .all() as UserRecord[]; +} + +// --------------------------------------------------------------------------- +// Ephemeral conversation state (multi-step owner workflows). +// Kept separate from everything else so it's obviously the "temporary" data +// described in the bot's privacy principle - safe to wipe at any time. +// --------------------------------------------------------------------------- + +export function setState( + db: Database.Database, + userId: string, + step: string, + data: object = {} +) { + db.prepare( + `INSERT INTO user_state (user_id, step, data_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET step = excluded.step, data_json = excluded.data_json, updated_at = excluded.updated_at` + ).run(userId, step, JSON.stringify(data), now()); +} + +export function getState(db: Database.Database, userId: string): UserState | null { + return (db.prepare(`SELECT * FROM user_state WHERE user_id = ?`).get(userId) as UserState) ?? null; +} + +export function clearState(db: Database.Database, userId: string) { + db.prepare(`DELETE FROM user_state WHERE user_id = ?`).run(userId); +} diff --git a/deno.json b/deno.json index 89e49f2003e3..416136ff7051 100644 --- a/deno.json +++ b/deno.json @@ -1,110 +1,18 @@ { - "compilerOptions": { - "strict": true, - "exactOptionalPropertyTypes": true, - "useUnknownInCatchVariables": true, - "noImplicitOverride": true, - "noUncheckedIndexedAccess": true + "tasks": { + "dev": "deno run --watch --allow-net --allow-env --allow-read --allow-write --env-file=.env src/index.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-write --env-file=.env src/index.ts", + "db:init": "deno run --allow-read --allow-write --allow-env --env-file=.env src/db/init.ts", + "check": "deno check src/index.ts" }, "imports": { - "@deno/graph": "jsr:@deno/graph@^0.89.2", - "@deno/doc": "jsr:@deno/doc@^0.169.1", - "automation/": "https://raw.githubusercontent.com/denoland/automation/0.10.0/", - "fast-check": "npm:fast-check@3.8.0", - "graphviz": "npm:node-graphviz@^0.1.1", - "typescript": "npm:typescript@5.8.3" + "gramio": "npm:gramio@^0.4.1", + "@gramio/auto-answer-callback-query": "npm:@gramio/auto-answer-callback-query@^0.0.2", + "better-sqlite3": "npm:better-sqlite3@^11.3.0" }, - "unstable": ["webgpu", "fs"], - "tasks": { - "test": "deno test -A --parallel --trace-leaks --coverage --doc --clean --ignore=_tools/,_tmp/", - "test:with-unsafe-proto": "deno test --unstable-unsafe-proto --no-check -A --parallel --doc --ignore=_tools/,_tmp/", - "test:tools": "deno test -A --doc _tools/", - "test:browser": "git grep --name-only \"This module is browser compatible.\" | grep -v deno.json | grep -v .github/workflows | grep -v _tools | grep -v encoding/README.md | grep -v media_types/vendor/update.ts | xargs deno check --config browser-compat.tsconfig.json", - "test:node": "(cd _tools/node_test_runner && npm install) && node --import ./_tools/node_test_runner/register_deno_shim.mjs ./_tools/node_test_runner/run_test.mjs", - "test:bun": "(cd _tools/node_test_runner && bun install) && cp _tools/node_test_runner/tsconfig_for_bun.json ./tsconfig.json && bun test --require ./_tools/node_test_runner/register_deno_shim.mjs _tools/node_test_runner/run_test.mjs && rm tsconfig.json", - "lint:circular": "deno run --allow-env --allow-read --allow-write --allow-net=deno.land,jsr.io ./_tools/check_circular_package_dependencies.ts", - "lint:mod-exports": "deno run --allow-env --allow-read ./_tools/check_mod_exports.ts", - "lint:tools-types": "deno check _tools/*.ts", - "lint:docs": "deno run -A _tools/check_docs.ts", - "lint:export-names": "deno run -A _tools/check_export_names.ts", - "lint:unstable-deps": "deno run -A _tools/check_unstable_deps.ts", - "lint": "deno lint && deno task lint:circular && deno task lint:tools-types && deno task lint:mod-exports && deno task lint:export-names && deno task lint:docs && deno task lint:unstable-deps", - "typos": "typos -c ./.github/typos.toml", - "build:crypto": "deno task --cwd crypto/_wasm wasmbuild", - "wasmbuild": "deno run -A jsr:@deno/wasmbuild@0.19.2 --js-ext mjs --inline", - "cov:mac": "deno task test && open coverage/html/index.html", - "cov:lin": "deno task test && xdg-open coverage/html/index.html", - "cov:win": "deno task test && start coverage/html/index.html", - "ok": "deno task lint && deno fmt --check && deno task test:browser && deno task test" - }, - "exclude": [ - ".git", - "_tmp", - "jsonc/testdata", - "toml/testdata", - "_tools/node_test_runner", - "http/testdata", - "fs/testdata", - "dotenv/testdata" - ], - "lint": { - "rules": { - "tags": ["recommended", "jsr"], - "include": [ - "ban-untagged-todo", - "camelcase", - "no-import-prefix", - "no-node-globals", - "no-process-global", - "no-sync-fn-in-async-fn", - "single-var-declarator", - "no-console" - ] - }, - "plugins": ["./_tools/lint_plugin.ts"] + "compilerOptions": { + "strict": true, + "lib": ["deno.window"] }, - "workspace": [ - "./assert", - "./async", - "./bytes", - "./cache", - "./cbor", - "./cli", - "./collections", - "./crypto", - "./csv", - "./data_structures", - "./datetime", - "./dotenv", - "./encoding", - "./expect", - "./fmt", - "./front_matter", - "./fs", - "./html", - "./http", - "./ini", - "./internal", - "./io", - "./json", - "./jsonc", - "./math", - "./media_types", - "./msgpack", - "./net", - "./path", - "./random", - "./regexp", - "./semver", - "./streams", - "./tar", - "./testing", - "./text", - "./toml", - "./ulid", - "./uuid", - "./webgpu", - "./xml", - "./yaml" - ] + "nodeModulesDir": "auto" } diff --git a/destinations.ts b/destinations.ts new file mode 100644 index 000000000000..5a8b57d6b044 --- /dev/null +++ b/destinations.ts @@ -0,0 +1,23 @@ +import type Database from "better-sqlite3"; +import { addDestination, listDestinations, removeDestination } from "../db/client.js"; + +/** + * Doc section 14, "Multiple Destinations": the owner connects channels and + * groups once, then picks from them every time content is published. + */ +export function registerDestination( + db: Database.Database, + chatId: string, + title: string, + type: "channel" | "group" +) { + addDestination(db, chatId, title, type); +} + +export function getDestinations(db: Database.Database) { + return listDestinations(db); +} + +export function forgetDestination(db: Database.Database, chatId: string) { + removeDestination(db, chatId); +} diff --git a/env.ts b/env.ts new file mode 100644 index 000000000000..3a64f867bbfb --- /dev/null +++ b/env.ts @@ -0,0 +1,22 @@ +// Works under both Node.js and Deno. Env vars are expected to already be +// loaded - via `node --env-file=.env` or `deno run --env-file=.env` +// (both runtimes support this flag natively, no extra dependency needed). +declare const Deno: { env: { get(key: string): string | undefined } } | undefined; + +export function getEnv(name: string): string | undefined { + if (typeof Deno !== "undefined") { + return Deno.env.get(name); + } + return process.env[name]; +} + +export function requireEnv(name: string): string { + const value = getEnv(name); + if (!value) { + console.error(`Missing required environment variable: ${name}`); + console.error("Copy .env.example to .env and fill it in."); + const exit = typeof Deno !== "undefined" ? (Deno as any).exit : process.exit; + exit(1); + } + return value as string; +} diff --git a/groupMention.ts b/groupMention.ts new file mode 100644 index 000000000000..cccb433422a0 --- /dev/null +++ b/groupMention.ts @@ -0,0 +1,29 @@ +/** + * Doc section 7 & 17, "Group Experience" / "Group Assistance": the bot + * doesn't respond to every message in a group, only when it is specifically + * mentioned (e.g. "@TelegramBot price of Product A?"). + */ +export function extractMention(text: string, botUsername: string): string | null { + const mention = `@${botUsername}`; + const idx = text.toLowerCase().indexOf(mention.toLowerCase()); + if (idx === -1) return null; + return (text.slice(0, idx) + text.slice(idx + mention.length)).trim(); +} + +/** + * Very small placeholder "business assistant" responder. Replace this with + * a call to your own FAQ/catalog logic, or an LLM, as the business grows. + */ +export function answerBusinessQuestion(query: string): string { + const q = query.toLowerCase(); + if (q.includes("price") || q.includes("cost")) { + return "Thanks for asking! Send me the product name and I'll get you pricing details."; + } + if (q.includes("hour") || q.includes("open")) { + return "We're happy to help - let us know what you're looking for and we'll get back to you."; + } + if (q.length === 0) { + return "Hi! How can I help you today?"; + } + return `Thanks for reaching out about "${query}" - a member of our team will follow up shortly.`; +} diff --git a/index.ts b/index.ts new file mode 100644 index 000000000000..44c0fe6f7594 --- /dev/null +++ b/index.ts @@ -0,0 +1,40 @@ +// Environment variables are loaded via the --env-file=.env flag (Node 20.6+ +// and Deno 1.44+ both support this natively - see package.json / deno.json +// for the exact run commands). +import { openDatabase } from "./db/init.js"; +import { createBot } from "./bot.js"; +import { startScheduler } from "./scheduler.js"; +import { getEnv, requireEnv } from "./env.js"; +import type { Env } from "./types.js"; + +const env: Env = { + BOT_TOKEN: requireEnv("BOT_TOKEN"), + OWNER_ID: requireEnv("OWNER_ID"), + DB_PATH: getEnv("DB_PATH") || "./data/bot.sqlite", +}; + +const db = openDatabase(env.DB_PATH); +const bot = createBot(env, db); + +const stopScheduler = startScheduler(bot, db); + +bot.start(); + +function shutdown() { + console.log("\nShutting down..."); + stopScheduler(); + db.close(); +} + +// Deno.addSignalListener isn't available on Windows for Deno, but SIGINT/ +// SIGTERM handling here is best-effort either way - both runtimes exit +// cleanly on Ctrl+C even without this. +declare const Deno: { addSignalListener?: (signal: string, handler: () => void) => void } | undefined; + +if (typeof Deno !== "undefined" && Deno.addSignalListener) { + Deno.addSignalListener("SIGINT", () => { shutdown(); (globalThis as any).Deno.exit(0); }); + Deno.addSignalListener("SIGTERM", () => { shutdown(); (globalThis as any).Deno.exit(0); }); +} else { + process.on("SIGINT", () => { shutdown(); process.exit(0); }); + process.on("SIGTERM", () => { shutdown(); process.exit(0); }); +} diff --git a/init.ts b/init.ts new file mode 100644 index 000000000000..bc4ffeda9660 --- /dev/null +++ b/init.ts @@ -0,0 +1,32 @@ +import Database from "better-sqlite3"; +import { readFileSync } from "node:fs"; +import { mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getEnv } from "../env.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export function openDatabase(dbPath: string): Database.Database { + mkdirSync(dirname(dbPath), { recursive: true }); + const db = new Database(dbPath); + db.pragma("journal_mode = WAL"); + db.pragma("foreign_keys = ON"); + const schema = readFileSync(join(__dirname, "schema.sql"), "utf-8"); + db.exec(schema); + return db; +} + +// Allows `npm run db:init` / `deno task db:init` to create or upgrade the +// database file on its own, without starting the bot. +declare const Deno: { mainModule?: string } | undefined; +const isMainModule = + typeof Deno !== "undefined" + ? Deno.mainModule === import.meta.url + : typeof process !== "undefined" && process.argv[1]?.endsWith("init.ts"); + +if (isMainModule) { + const path = getEnv("DB_PATH") || "./data/bot.sqlite"; + openDatabase(path); + console.log(`Database ready at ${path}`); +} diff --git a/keyboards.ts b/keyboards.ts new file mode 100644 index 000000000000..9c98ce87e29e --- /dev/null +++ b/keyboards.ts @@ -0,0 +1,41 @@ +import { InlineKeyboard } from "gramio"; +import type { Destination } from "./types.js"; + +/** The owner's main control-center menu (see doc section 5, "Owner Experience"). */ +export function ownerMenuKeyboard() { + return new InlineKeyboard() + .text("📢 Publish", "menu:publish") + .text("⏰ Schedule", "menu:schedule") + .row() + .text("🔗 Edit Buttons", "menu:edit_buttons") + .text("📣 Destinations", "menu:destinations") + .row() + .text("👥 Access Requests", "menu:access") + .text("⚙️ Settings", "menu:settings"); +} + +/** Checkbox-style destination picker used when composing/scheduling a post. */ +export function destinationPickerKeyboard( + destinations: Destination[], + selected: Set +) { + const kb = new InlineKeyboard(); + destinations.forEach((d, i) => { + const mark = selected.has(d.chat_id) ? "☑" : "☐"; + kb.text(`${mark} ${d.title}`, `dest_toggle:${d.chat_id}`); + if (i % 1 === 0) kb.row(); + }); + kb.text("✅ Done", "dest_done").row().text("✖ Cancel", "cancel"); + return kb; +} + +/** Simple yes/no confirmation keyboard. */ +export function confirmKeyboard(yesData: string, noData = "cancel") { + return new InlineKeyboard().text("✅ Confirm", yesData).text("✖ Cancel", noData); +} + +export function accessDecisionKeyboard(userId: string) { + return new InlineKeyboard() + .text("✅ Approve", `access_approve:${userId}`) + .text("🚫 Decline", `access_decline:${userId}`); +} diff --git a/package.json b/package.json new file mode 100644 index 000000000000..32dd96a145ed --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "telegram-bot-gramio", + "version": "1.0.0", + "description": "Owner-controlled Telegram business automation bot built with GramIO: publishing, editable buttons, scheduling, destinations, access control and group-mention assistance.", + "type": "module", + "private": true, + "engines": { + "node": ">=20.6.0" + }, + "scripts": { + "build": "tsc", + "start": "node --env-file=.env dist/index.js", + "dev": "tsx watch --env-file=.env src/index.ts", + "typecheck": "tsc --noEmit", + "db:init": "tsx --env-file=.env src/db/init.ts" + }, + "dependencies": { + "@gramio/auto-answer-callback-query": "^0.0.2", + "better-sqlite3": "^11.3.0", + "gramio": "^0.4.1" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.11", + "@types/node": "^22.7.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/publish.ts b/publish.ts new file mode 100644 index 000000000000..850f8881eb9a --- /dev/null +++ b/publish.ts @@ -0,0 +1,50 @@ +import type { Bot, InlineKeyboard } from "gramio"; +import type Database from "better-sqlite3"; +import type { ButtonInput } from "../types.js"; +import { addButtons, addPostMessage, createPost } from "../db/client.js"; +import { buildInlineKeyboard } from "./buttons.js"; + +/** + * Publishes a piece of content to every destination chat id given. + * Records the post, its buttons, and each resulting (chat, message) pair so + * the owner can later update the buttons across every published copy + * (doc section 13, "Changing a Button Later"). + */ +export async function publishPost( + bot: Bot, + db: Database.Database, + opts: { + label: string; + text: string; + buttons: ButtonInput[]; + destinationChatIds: string[]; + } +): Promise<{ postId: number; sent: number; failed: string[] }> { + const postId = createPost(db, opts.label, opts.text); + if (opts.buttons.length > 0) { + addButtons(db, postId, opts.buttons); + } + + const replyMarkup: InlineKeyboard | undefined = + opts.buttons.length > 0 ? buildInlineKeyboard(opts.buttons) : undefined; + + let sent = 0; + const failed: string[] = []; + + for (const chatId of opts.destinationChatIds) { + try { + const message = await bot.api.sendMessage({ + chat_id: chatId, + text: opts.text, + reply_markup: replyMarkup, + }); + addPostMessage(db, postId, chatId, message.message_id); + sent++; + } catch (err) { + failed.push(chatId); + console.error(`Failed to publish to ${chatId}:`, err); + } + } + + return { postId, sent, failed }; +} diff --git a/schedule.ts b/schedule.ts new file mode 100644 index 000000000000..4cc81f98d73f --- /dev/null +++ b/schedule.ts @@ -0,0 +1,56 @@ +import type { Bot } from "gramio"; +import type Database from "better-sqlite3"; +import type { ButtonInput } from "../types.js"; +import { + createScheduledPost, + getDuePosts, + listUpcoming, + markScheduledPost, +} from "../db/client.js"; +import { publishPost } from "./publish.js"; + +export function schedulePost( + db: Database.Database, + text: string, + buttons: ButtonInput[], + destinationChatIds: string[], + publishAt: Date +): number { + return createScheduledPost( + db, + text, + buttons, + destinationChatIds, + Math.floor(publishAt.getTime() / 1000) + ); +} + +export function getUpcomingSchedule(db: Database.Database) { + return listUpcoming(db); +} + +/** + * Publishes every scheduled post whose time has arrived (doc section 15, + * "Scheduled Publishing"). Intended to be called on a recurring timer - + * see src/scheduler.ts. + */ +export async function runDueSchedules(bot: Bot, db: Database.Database) { + const due = getDuePosts(db); + for (const post of due) { + const buttons: ButtonInput[] = post.buttons_json ? JSON.parse(post.buttons_json) : []; + const destinations: string[] = JSON.parse(post.destinations); + try { + await publishPost(bot, db, { + label: `Scheduled #${post.id}`, + text: post.text, + buttons, + destinationChatIds: destinations, + }); + markScheduledPost(db, post.id, "published"); + } catch (err) { + console.error(`Failed to publish scheduled post ${post.id}:`, err); + markScheduledPost(db, post.id, "failed"); + } + } + return due.length; +} diff --git a/scheduler.ts b/scheduler.ts new file mode 100644 index 000000000000..422acd7d2119 --- /dev/null +++ b/scheduler.ts @@ -0,0 +1,23 @@ +import type { Bot } from "gramio"; +import type Database from "better-sqlite3"; +import { runDueSchedules } from "./features/schedule.js"; + +/** + * Doc section 15/24, "Scheduled Publishing": the system wakes up on its + * own, checks what needs to happen, and publishes it - the owner doesn't + * need to be online at the scheduled time. + */ +export function startScheduler(bot: Bot, db: Database.Database, intervalMs = 30_000) { + const tick = async () => { + try { + const count = await runDueSchedules(bot, db); + if (count > 0) console.log(`Published ${count} scheduled post(s).`); + } catch (err) { + console.error("Scheduler tick failed:", err); + } + }; + + const timer = setInterval(tick, intervalMs); + tick(); // also check immediately on startup + return () => clearInterval(timer); +} diff --git a/schema.sql b/schema.sql new file mode 100644 index 000000000000..1ca64a2d5a9c --- /dev/null +++ b/schema.sql @@ -0,0 +1,77 @@ +-- Telegram-Bot D1 schema +-- Run with: npm run db:init (or db:init:remote for production) + +-- Destinations the owner has connected (channels/groups the bot can post to) +CREATE TABLE IF NOT EXISTS destinations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id TEXT NOT NULL UNIQUE, -- Telegram chat id (channel or group) + title TEXT NOT NULL, -- friendly name shown in menus + type TEXT NOT NULL, -- 'channel' | 'group' + added_at INTEGER NOT NULL +); + +-- Published posts, so buttons can be updated later without re-publishing +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT NOT NULL, -- short owner-facing name, e.g. "Fall Promo" + text TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- One row per (post, destination, telegram message) so we know exactly which +-- messages to edit when a button's destination changes. +CREATE TABLE IF NOT EXISTS post_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id), + chat_id TEXT NOT NULL, + message_id INTEGER NOT NULL +); + +-- Buttons attached to a post. Editing a button's url here and re-applying it +-- updates every message in post_messages that used it. +CREATE TABLE IF NOT EXISTS buttons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id INTEGER NOT NULL REFERENCES posts(id), + label TEXT NOT NULL, + url TEXT NOT NULL, + row_order INTEGER NOT NULL DEFAULT 0 +); + +-- Scheduled posts waiting to be published +CREATE TABLE IF NOT EXISTS scheduled_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + buttons_json TEXT, -- JSON array of {label, url} + destinations TEXT NOT NULL, -- JSON array of chat_id strings + publish_at INTEGER NOT NULL, -- unix seconds + status TEXT NOT NULL DEFAULT 'pending', -- pending|published|failed + created_at INTEGER NOT NULL +); + +-- Approved / declined / pending users +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + username TEXT, + status TEXT NOT NULL DEFAULT 'pending', -- pending|approved|declined + requested_at INTEGER NOT NULL, + decided_at INTEGER +); + +-- Short-lived per-user conversation state for multi-step owner workflows +-- (compose a post, add a button, pick destinations, etc). Treated as +-- temporary/expiring data per the bot's privacy principle. +CREATE TABLE IF NOT EXISTS user_state ( + user_id TEXT PRIMARY KEY, + step TEXT NOT NULL, + data_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_scheduled_posts_status_time + ON scheduled_posts (status, publish_at); + +CREATE INDEX IF NOT EXISTS idx_post_messages_post + ON post_messages (post_id); + +CREATE INDEX IF NOT EXISTS idx_buttons_post + ON buttons (post_id); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000000..26010c1bc826 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "types": ["node"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/types.ts b/types.ts new file mode 100644 index 000000000000..5380a5c64075 --- /dev/null +++ b/types.ts @@ -0,0 +1,63 @@ +export interface Env { + BOT_TOKEN: string; + OWNER_ID: string; + DB_PATH: string; +} + +export interface ButtonInput { + label: string; + url: string; +} + +export interface Destination { + id: number; + chat_id: string; + title: string; + type: "channel" | "group"; + added_at: number; +} + +export interface PostRecord { + id: number; + label: string; + text: string; + created_at: number; +} + +export interface ScheduledPost { + id: number; + text: string; + buttons_json: string | null; + destinations: string; // JSON string array of chat ids + publish_at: number; + status: "pending" | "published" | "failed"; + created_at: number; +} + +export interface UserRecord { + user_id: string; + username: string | null; + status: "pending" | "approved" | "declined"; + requested_at: number; + decided_at: number | null; +} + +export interface UserState { + user_id: string; + step: string; + data_json: string; + updated_at: number; +} + +// Multi-step owner workflows tracked in user_state.step +export type OwnerStep = + | "idle" + | "compose_post_text" + | "compose_post_button_label" + | "compose_post_button_url" + | "compose_post_pick_destinations" + | "add_destination_wait_forward" + | "schedule_pick_time" + | "edit_button_pick_post" + | "edit_button_pick_button" + | "edit_button_new_url";