From be0737bf8cb212edb020bde7ecc8b142dd8ad4a6 Mon Sep 17 00:00:00 2001
From: TAKE THE RISK <299915788+angelancajas98-droid@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:57:48 +0800
Subject: [PATCH 1/2] Add files via upload
---
README.md | 162 +++++++++++++++++++++++++++++++++++++++-----------
deno.json | 116 ++++--------------------------------
package.json | 28 +++++++++
tsconfig.json | 15 +++++
4 files changed, 181 insertions(+), 140 deletions(-)
create mode 100644 package.json
create mode 100644 tsconfig.json
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/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/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/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"]
+}
From 723d947e678a606e37220065a14a8c404dcfbe44 Mon Sep 17 00:00:00 2001
From: TAKE THE RISK <299915788+angelancajas98-droid@users.noreply.github.com>
Date: Thu, 24 Sep 2026 18:59:58 +0800
Subject: [PATCH 2/2] Add files via upload
---
access.ts | 38 ++++
bot.ts | 451 ++++++++++++++++++++++++++++++++++++++++++++++++
buttons.ts | 53 ++++++
client.ts | 188 ++++++++++++++++++++
destinations.ts | 23 +++
env.ts | 22 +++
groupMention.ts | 29 ++++
index.ts | 40 +++++
init.ts | 32 ++++
keyboards.ts | 41 +++++
publish.ts | 50 ++++++
schedule.ts | 56 ++++++
scheduler.ts | 23 +++
schema.sql | 77 +++++++++
types.ts | 63 +++++++
15 files changed, 1186 insertions(+)
create mode 100644 access.ts
create mode 100644 bot.ts
create mode 100644 buttons.ts
create mode 100644 client.ts
create mode 100644 destinations.ts
create mode 100644 env.ts
create mode 100644 groupMention.ts
create mode 100644 index.ts
create mode 100644 init.ts
create mode 100644 keyboards.ts
create mode 100644 publish.ts
create mode 100644 schedule.ts
create mode 100644 scheduler.ts
create mode 100644 schema.sql
create mode 100644 types.ts
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/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/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/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";