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)
-[](https://jsr.io/@std)
-[](https://codecov.io/gh/denoland/std)
-[](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).
-[](https://jsr.io/@std)
-
-```html
-
-
-
+```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
-[](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";