diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..9983ba4
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,11 @@
+# Telegram bot token from @BotFather
+BOT_TOKEN=123456789:AAExampleTokenReplaceMe
+
+# Telegram numeric user ID of the owner (get from @userinfobot)
+OWNER_ID=123456789
+
+# Secret path segment used to validate incoming webhook requests from Telegram
+WEBHOOK_SECRET=change-this-to-a-long-random-string
+
+# Public HTTPS base URL of this deployment (e.g. https://your-app.deno.dev)
+PUBLIC_URL=https://your-app.deno.dev
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..157a691
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,101 @@
+# For most projects, this workflow file will not need changing; you simply need
+# to commit it to your repository.
+#
+# You may wish to alter this file to override the set of languages analyzed,
+# or to provide custom queries or build logic.
+#
+# ******** NOTE ********
+# We have attempted to detect the languages in your repository. Please check
+# the `language` matrix defined below to confirm you have the correct set of
+# supported CodeQL languages.
+#
+name: "CodeQL Advanced"
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+ schedule:
+ - cron: '15 21 * * 3'
+
+jobs:
+ analyze:
+ name: Analyze (${{ matrix.language }})
+ # Runner size impacts CodeQL analysis time. To learn more, please see:
+ # - https://gh.io/recommended-hardware-resources-for-running-codeql
+ # - https://gh.io/supported-runners-and-hardware-resources
+ # - https://gh.io/using-larger-runners (GitHub.com only)
+ # Consider using larger runners or machines with greater resources for possible analysis time improvements.
+ runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
+ permissions:
+ # required for all workflows
+ security-events: write
+
+ # required to fetch internal or private CodeQL packs
+ packages: read
+
+ # only required for workflows in private repositories
+ actions: read
+ contents: read
+
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - language: actions
+ build-mode: none
+ - language: javascript-typescript
+ build-mode: none
+ # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
+ # Use `c-cpp` to analyze code written in C, C++ or both
+ # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
+ # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
+ # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
+ # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
+ # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
+ # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v7
+
+ # Add any setup steps before running the `github/codeql-action/init` action.
+ # This includes steps like installing compilers or runtimes (`actions/setup-node`
+ # or others). This is typically only required for manual builds.
+ # - name: Setup runtime (example)
+ # uses: actions/setup-example@v1
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v4
+ with:
+ languages: ${{ matrix.language }}
+ build-mode: ${{ matrix.build-mode }}
+ # If you wish to specify custom queries, you can do so here or in a config file.
+ # By default, queries listed here will override any specified in a config file.
+ # Prefix the list here with "+" to use these queries and those in the config file.
+
+ # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
+ # queries: security-extended,security-and-quality
+
+ # If the analyze step fails for one of the languages you are analyzing with
+ # "We were unable to automatically build your code", modify the matrix above
+ # to set the build mode to "manual" for that language. Then modify this step
+ # to build your code.
+ # ā¹ļø Command-line programs to run using the OS shell.
+ # š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
+ - name: Run manual build steps
+ if: matrix.build-mode == 'manual'
+ shell: bash
+ run: |
+ echo 'If you are using a "manual" build mode for one or more of the' \
+ 'languages you are analyzing, replace this with the commands to build' \
+ 'your code, for example:'
+ echo ' make bootstrap'
+ echo ' make release'
+ exit 1
+
+ - name: Perform CodeQL Analysis
+ uses: github/codeql-action/analyze@v4
+ with:
+ category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml
new file mode 100644
index 0000000..67be9b0
--- /dev/null
+++ b/.github/workflows/jekyll-gh-pages.yml
@@ -0,0 +1,51 @@
+# Sample workflow for building and deploying a Jekyll site to GitHub Pages
+name: Deploy Jekyll with GitHub Pages dependencies preinstalled
+
+on:
+ # Runs on pushes targeting the default branch
+ push:
+ branches: ["main"]
+
+ # Allows you to run this workflow manually from the Actions tab
+ workflow_dispatch:
+
+# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
+# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
+concurrency:
+ group: "pages"
+ cancel-in-progress: false
+
+jobs:
+ # Build job
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+ - name: Build with Jekyll
+ uses: actions/jekyll-build-pages@v1
+ with:
+ source: ./
+ destination: ./_site
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+
+ # Deployment job
+ deploy:
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v5
diff --git a/README.md b/README.md
index 11e97bc..00123ca 100644
--- a/README.md
+++ b/README.md
@@ -1,113 +1,98 @@
-# wrappergram
+# Telegram-Bot ā Production Build
-
+A Deno/V8-based Telegram business management, communication, and automation
+system, implementing the owner/users/groups model described in the system
+overview: publishing, editable buttons, scheduling, destination management,
+access control, and group-mention assistance.
-[](https://core.telegram.org/bots/api)
-[](https://www.npmjs.org/package/wrappergram)
-[](https://www.npmjs.org/package/wrappergram)
-[](https://jsr.io/@gramio/wrappergram)
-[](https://jsr.io/@gramio/wrappergram)
-[![bundlejs]()](https://bundlejs.com/?q=wrappergram&treeshake=%5B*%5D&text=%22const+telegram+%3D+new+Telegram%28process.env.BOT_TOKEN+as+string%29%3B%5Cn%5Cntelegram.api.sendMessage%28%7B%5Cn++++chat_id%3A+617580375%2C%5Cn++++text%3A+%5C%22Hello%21%5C%22%2C%5Cn%7D%29%3B%22)
+## Stack
-
+- **Runtime:** Deno (webhook server via `Deno.serve`, background jobs via `Deno.cron`)
+- **Telegram framework:** [grammY](https://grammy.dev)
+- **Storage:** Deno KV (built-in, zero external dependency; swap for Postgres/etc. by reimplementing `src/db.ts` if you outgrow it)
+- **Hosting:** Deno Deploy (recommended) or any host that runs Deno with `--unstable-kv --unstable-cron`
-Simple and tiny code-generated **Telegram Bot API** wrapper for TypeScript/JavaScript with [file upload](https://core.telegram.org/bots/api#sending-files) support.
+## Project layout
-š **Multi-runtime** - Works on [Node.js](https://nodejs.org/), [Bun](https://bun.sh/) and [Deno](https://deno.com/)
-
-āļø **Code-generated** - For example, [code-generated and auto-published Telegram Bot API types](https://github.com/gramiojs/types))
-
-š”ļø **Type-safe** - Written in TypeScript with love ā¤ļø
-
-š¤ **Tiny** - Simple `sendMessage` call cost some [](https://bundlejs.com/?q=wrappergram&treeshake=%5B*%5D&text=%22const+telegram+%3D+new+Telegram%28process.env.BOT_TOKEN+as+string%29%3B%5Cn%5Cntelegram.api.sendMessage%28%7B%5Cn++++chat_id%3A+617580375%2C%5Cn++++text%3A+%5C%22Hello%21%5C%22%2C%5Cn%7D%29%3B%22) in bundle size. So it is a good choice for browser/serverless environments
-
-But if you need a more complete framework, then please look to [`GramIO`](https://gramio.dev/).
-
-### Usage
-
-```ts
-import { Telegram, getUpdates } from "wrappergram";
-
-const telegram = new Telegram(process.env.BOT_TOKEN as string);
-
-telegram.api.sendMessage({
- chat_id: 617580375,
- text: "Hello!",
-});
-
-for await (const update of getUpdates(telegram)) {
- console.log(update);
-
- if (update.message?.from) {
- telegram.api.sendMessage({
- chat_id: update.message.from.id,
- text: "Hi! Thanks for the message",
- });
- }
-}
+```
+telegram-bot/
+āāā main.ts # webhook server + cron entry point
+āāā deno.json # tasks + import map
+āāā .env.example # required environment variables
+āāā scripts/set_webhook.ts # one-time webhook registration
+āāā src/
+ āāā config.ts # env var loading
+ āāā types.ts # domain types
+ āāā db.ts # Deno KV persistence layer
+ āāā bot.ts # command/message routing
+ āāā middleware/auth.ts # owner-only / approved-only gates
+ āāā features/
+ āāā destinations.ts # connect/list/remove channels & groups
+ āāā publish.ts # multi-destination publish + wizard
+ āāā buttons.ts # update a button's URL across old posts
+ āāā scheduling.ts # /schedule + cron-driven publication
+ āāā access.ts # request/approve/decline access
+ āāā groupMention.ts # respond only when @mentioned in groups
+ āāā assistance.ts # simple FAQ-style business assistance
```
-This example on bundlejs cost [](https://bundlejs.com/?q=wrappergram&treeshake=%5B*%5D&share=MYewdgzgLgBFCmAbeBzATgQwLYwLwzHgHcYAVJVTLACgAc0Rh4IIA6eMAN1YCEB5UgH1SfANIBRAHIBKANwAoeQmTpsrDLQCWrCBwAmAWWYQMKeNQDe8mDZjAAFhiiDNegFwwAbAEYA7AFYADgAGAGYAgBprWwQADygPACIACSREEABCRKiAXzlFADMQNBgMIgxNWGpQSFgAV1o9J3gYEAKYMygAVUbmiGplSmxpaRgrWztwCBBkVnSUagamhHzom012xd6EVixjU3gAflYChixR8YmYilUsdS0dfSMWA8s1q4mHJxd3GCXm3b7MwnM6sVxRD4fOIJGApTQZMiOMAAawgMCKJSg9haexeZmy7wmeQURPkOSAA)
-
-> [!IMPORTANT]
-> Use `getUpdates` only **once** in your code otherwise it will cause double calls to [getUpdates](https://core.telegram.org/bots/api#getupdates)
+## Setup
-### Call api
+1. **Create the bot** with [@BotFather](https://t.me/BotFather) and copy the token.
+2. **Get your Telegram user id** from [@userinfobot](https://t.me/userinfobot) ā this becomes `OWNER_ID`.
+3. Copy `.env.example` to `.env` and fill in `BOT_TOKEN`, `OWNER_ID`, a random `WEBHOOK_SECRET`, and `PUBLIC_URL` (set after your first deploy).
+4. Install the [Deno CLI](https://deno.com) if running locally.
-You can send requests to Telegram Bot API Methods via `telegram.api` with full type-safety!
+## Local development (polling is simplest locally, but this build uses webhooks)
-```ts
-const response = await telegram.api.sendMessage({
- chat_id: "@gramio_forum",
- text: "Hello, world!",
-});
+For local testing, the quickest path is a tunnel (e.g. `deno task dev` behind `ngrok http 8000`), setting `PUBLIC_URL` to the tunnel's HTTPS URL, then running:
-if (!response.ok) console.error("Something went wrong");
-else console.log(`New message id is ${response.result.message_id}`);
+```
+deno task setwebhook
+deno task dev
```
-### Send keyboards
-
-For keyboards you need to install [`@gramio/keyboard`](https://www.npmjs.com/package/@gramio/keyboards) library and just use it!
+## Deploying to Deno Deploy
-```ts
-import { Keyboard } from "@gramio/keyboards";
+1. Push this project to a GitHub repo.
+2. Create a new Deno Deploy project linked to the repo, entry point `main.ts`.
+3. Add the environment variables from `.env.example` in the Deno Deploy dashboard.
+4. Set `PUBLIC_URL` to your `*.deno.dev` URL, redeploy, then run `deno task setwebhook` locally (pointing at the same env vars) to register the webhook.
-// telegram init
-telegram.api.sendMessage({
- chat_id: "@gramio_forum",
- text: "Hello, world!",
- reply_markup: new InlineKeyboard().url(
- "GitHub",
- "https://github.com/gramiojs/wrappergram"
- ),
-});
-```
+## Using the bot
-This example cost - [![bundlejs]()](https://bundlejs.com/?q=wrappergram%2C%40gramio%2Fkeyboards&treeshake=%5B*%5D%2C%5B*%5D&text=%22const+telegram+%3D+new+Telegram%28process.env.BOT_TOKEN%29%3B%5Cn%5Cntelegram.api.sendMessage%28%7B%5Cn++chat_id%3A+617580375%2C%5Cn++text%3A+%5C%22Hello%21%5C%22%2C%5Cn++reply_markup%3A+new+InlineKeyboard%28%29.url%28%5C%22GitHub%5C%22%2C+%5C%22https%3A%2F%2Fgithub.com%2Fgramiojs%2Fwrappergram%5C%22%29%5Cn%7D%29%3B%22)
+**Owner commands** (only work for the Telegram account matching `OWNER_ID`):
-[Read more here](https://gramio.dev/keyboards/overview)
+| Command | Purpose |
+|---|---|
+| `/adddestination ` | Run inside a channel/group to connect it |
+| `/listdestinations` | List connected destinations and their ids |
+| `/removedestination ` | Disconnect a destination |
+| `/publish` | Start the guided publish flow (text ā buttons ā destinations) |
+| `/schedule \| \| ` | Schedule a post, e.g. `/schedule 2026-09-10T10:00 \| main-channel,promo \| Big sale tomorrow!` |
+| `/changebutton ` | Update a button's destination across every post that used it |
+| `/requests` | Review and approve/decline pending access requests |
-### Send files
+**User commands:**
-[`@gramio/files`](https://gramio.dev/files/overview) already used under the hood so you don't need to install it
+| Command | Purpose |
+|---|---|
+| `/start` | Greeting and basic guidance |
+| `/requestaccess` | Ask the owner for approval to use gated features |
+| plain text | Answered by the FAQ-style business assistance handler |
-```ts
-import { MediaUpload } from "wrappergram";
+**Groups:** the bot only responds when explicitly mentioned, e.g. `@YourBot price of Product A?`.
-telegram.api.sendPhoto({
- chat_id: "@gramio_forum",
- text: "Hello, world!",
- photo: MediaUpload.path("./cute-cat.png"),
-});
+## Extending
-telegram.api.sendDocument({
- chat_id: "@gramio_forum",
- text: "Hello, world!",
- photo: Bun.file("README.md"), // you can use File instance to upload files
-});
-```
+- **Business assistance:** `src/features/assistance.ts` has a small keyword-matched FAQ table ā swap the `answerBusinessQuestion` function for an LLM call or CMS lookup as needed.
+- **Gated features for approved users:** wrap any handler with the `approvedOnly` middleware from `src/middleware/auth.ts`.
+- **Storage:** all persistence goes through `src/db.ts`; replace its internals to move off Deno KV without touching feature code.
-This example cost - [![bundlejs]()](https://bundlejs.com/?q=wrappergram&treeshake=%5B*%5D&text=%22const+telegram+%3D+new+Telegram%28process.env.BOT_TOKEN%29%3B+%5Cn%5Cntelegram.api.sendPhoto%28%7B+%5Cn++chat_id%3A+%5C%22%40gramio_forum%5C%22%2C+%5Cn++text%3A+%5C%22Hello%2C+world%21%5C%22%2C+%5Cn++photo%3A+MediaUpload.path%28%5C%22.%2Fcute-cat.png%5C%22%29%2C+%5Cn%7D%29%3B%22)
+## Data retention
-[Read more here](https://gramio.dev/files/overview)
+Following the privacy principle in the system overview, only functionally
+necessary data is stored permanently (destinations, published posts + button
+index, schedules, access decisions). In-progress conversation state (the
+publish wizard) is stored with a 30-minute TTL and expires automatically if
+abandoned.
diff --git a/access.ts b/access.ts
new file mode 100644
index 0000000..883fe5c
--- /dev/null
+++ b/access.ts
@@ -0,0 +1,91 @@
+import type { Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+import { config } from "../config.ts";
+import { getAccessRequest, listPendingRequests, saveAccessRequest } from "../db.ts";
+import type { AccessRequest } from "../types.ts";
+
+export async function handleRequestAccess(ctx: Context): Promise {
+ const userId = ctx.from?.id;
+ if (!userId) return;
+
+ const existing = await getAccessRequest(userId);
+ if (existing?.status === "approved") {
+ await ctx.reply("You already have access.");
+ return;
+ }
+ if (existing?.status === "pending") {
+ await ctx.reply("Your access request is still pending owner approval.");
+ return;
+ }
+
+ const req: AccessRequest = {
+ userId,
+ username: ctx.from?.username,
+ firstName: ctx.from?.first_name,
+ status: "pending",
+ requestedAt: Date.now(),
+ };
+ await saveAccessRequest(req);
+
+ await ctx.reply("Access request sent. You'll be notified once the owner reviews it.");
+
+ const label = req.username ? `@${req.username}` : req.firstName ?? `user ${userId}`;
+ const keyboard = new InlineKeyboard()
+ .text("Approve", `access:approve:${userId}`)
+ .text("Decline", `access:decline:${userId}`);
+ await ctx.api.sendMessage(
+ config.ownerId,
+ `New access request from ${label} (id: ${userId}).`,
+ { reply_markup: keyboard },
+ );
+}
+
+export async function handleListRequests(ctx: Context): Promise {
+ const pending = await listPendingRequests();
+ if (pending.length === 0) {
+ await ctx.reply("No pending access requests.");
+ return;
+ }
+ for (const req of pending) {
+ const label = req.username ? `@${req.username}` : req.firstName ?? `user ${req.userId}`;
+ const keyboard = new InlineKeyboard()
+ .text("Approve", `access:approve:${req.userId}`)
+ .text("Decline", `access:decline:${req.userId}`);
+ await ctx.reply(`${label} (id: ${req.userId})`, { reply_markup: keyboard });
+ }
+}
+
+// Handles the Approve/Decline inline button callback from the owner.
+export async function handleAccessDecision(ctx: Context): Promise {
+ const data = ctx.callbackQuery?.data ?? "";
+ const [, decision, userIdRaw] = data.split(":");
+ const userId = Number(userIdRaw);
+ if (!userId || (decision !== "approve" && decision !== "decline")) {
+ await ctx.answerCallbackQuery();
+ return;
+ }
+
+ const req = await getAccessRequest(userId);
+ if (!req) {
+ await ctx.answerCallbackQuery({ text: "Request no longer exists." });
+ return;
+ }
+
+ req.status = decision === "approve" ? "approved" : "declined";
+ req.decidedAt = Date.now();
+ await saveAccessRequest(req);
+
+ await ctx.answerCallbackQuery({ text: `Marked as ${req.status}.` });
+ await ctx.editMessageText(
+ `${req.username ? "@" + req.username : "User " + userId} ā ${req.status}.`,
+ );
+
+ const message = req.status === "approved"
+ ? "You've been approved. You can now use the bot's available features."
+ : "Your access request was declined.";
+ try {
+ await ctx.api.sendMessage(userId, message);
+ } catch {
+ // user may have blocked the bot; nothing more we can do
+ }
+}
diff --git a/assistance.ts b/assistance.ts
new file mode 100644
index 0000000..8d03526
--- /dev/null
+++ b/assistance.ts
@@ -0,0 +1,54 @@
+import type { Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+
+export interface AssistanceReply {
+ reply: string;
+ buttons?: { text: string; url: string }[];
+}
+
+// A minimal keyword-matched FAQ. This is intentionally simple and data-driven
+// so the owner can extend it without touching bot logic ā swap this for a
+// call to an LLM or a CMS lookup as the business grows.
+const FAQ: { keywords: string[]; reply: string; buttons?: { text: string; url: string }[] }[] = [
+ {
+ keywords: ["price", "cost", "how much"],
+ reply: "Here's our current pricing information.",
+ buttons: [{ text: "View pricing", url: "https://example.com/pricing" }],
+ },
+ {
+ keywords: ["info", "information", "more information", "details"],
+ reply: "Happy to help ā here's more information about us.",
+ buttons: [{ text: "Learn more", url: "https://example.com/about" }],
+ },
+ {
+ keywords: ["contact", "support", "help me", "human"],
+ reply: "You can reach our support team directly here.",
+ buttons: [{ text: "Contact us", url: "https://example.com/contact" }],
+ },
+ {
+ keywords: ["hello", "hi", "hey"],
+ reply: "Hello! Ask me about pricing, our services, or how to get started.",
+ },
+];
+
+export function answerBusinessQuestion(question: string): AssistanceReply {
+ const q = question.toLowerCase();
+ for (const entry of FAQ) {
+ if (entry.keywords.some((kw) => q.includes(kw))) {
+ return { reply: entry.reply, buttons: entry.buttons };
+ }
+ }
+ return {
+ reply: "Thanks for your message! Ask about pricing, more information, or contact support.",
+ };
+}
+
+// Handles a plain private-chat message from a normal (non-owner) user.
+export async function handleUserMessage(ctx: Context): Promise {
+ const text = ctx.message?.text ?? "";
+ const { reply, buttons } = answerBusinessQuestion(text);
+ const keyboard = buttons?.length
+ ? buttons.reduce((kb, b) => kb.url(b.text, b.url).row(), new InlineKeyboard())
+ : undefined;
+ await ctx.reply(reply, { reply_markup: keyboard });
+}
diff --git a/auth.ts b/auth.ts
new file mode 100644
index 0000000..8acf3c9
--- /dev/null
+++ b/auth.ts
@@ -0,0 +1,37 @@
+// Enforces section 9's core principle: only the owner controls
+// administrative functions, verified by Telegram user id (not a claim
+// the user can type). Approved users get access to permitted features;
+// everyone else can only request access.
+
+import type { Context, NextFunction } from "grammy";
+import { config } from "../config.ts";
+import { isApprovedUser } from "../db.ts";
+
+export function isOwner(ctx: Context): boolean {
+ return ctx.from?.id === config.ownerId;
+}
+
+// Use as bot.command("publish", ownerOnly, handler)
+export async function ownerOnly(ctx: Context, next: NextFunction): Promise {
+ if (!isOwner(ctx)) {
+ await ctx.reply("This action is restricted to the bot owner.");
+ return;
+ }
+ await next();
+}
+
+// Use for features gated behind an approved access request.
+export async function approvedOnly(ctx: Context, next: NextFunction): Promise {
+ const userId = ctx.from?.id;
+ if (userId === config.ownerId) {
+ await next(); // owner is implicitly approved for everything
+ return;
+ }
+ if (userId && (await isApprovedUser(userId))) {
+ await next();
+ return;
+ }
+ await ctx.reply(
+ "You don't have access to this yet. Send /requestaccess to ask the owner for approval.",
+ );
+}
diff --git a/bot.ts b/bot.ts
new file mode 100644
index 0000000..e35ad73
--- /dev/null
+++ b/bot.ts
@@ -0,0 +1,89 @@
+import { Bot } from "grammy";
+import { config } from "./config.ts";
+import { isOwner, ownerOnly } from "./middleware/auth.ts";
+// `approvedOnly` (from ./middleware/auth.ts) is available to gate any
+// future feature behind the access-request flow ā see section 10.
+import {
+ handleAddDestination,
+ handleListDestinations,
+ handleRemoveDestination,
+} from "./features/destinations.ts";
+import {
+ cancelWizard,
+ continuePublishWizard,
+ startPublishWizard,
+} from "./features/publish.ts";
+import { handleChangeButton } from "./features/buttons.ts";
+import { handleSchedule } from "./features/scheduling.ts";
+import {
+ handleAccessDecision,
+ handleListRequests,
+ handleRequestAccess,
+} from "./features/access.ts";
+import { maybeHandleGroupMention } from "./features/groupMention.ts";
+import { handleUserMessage } from "./features/assistance.ts";
+
+export const bot = new Bot(config.botToken);
+
+// ---- Owner: control center (sections 6, 31) --------------------------------
+bot.command("publish", ownerOnly, startPublishWizard);
+bot.command("cancel", ownerOnly, cancelWizard);
+bot.command("changebutton", ownerOnly, handleChangeButton);
+bot.command("schedule", ownerOnly, handleSchedule);
+
+bot.command("adddestination", ownerOnly, handleAddDestination);
+bot.command("listdestinations", ownerOnly, handleListDestinations);
+bot.command("removedestination", ownerOnly, handleRemoveDestination);
+
+bot.command("requests", ownerOnly, handleListRequests);
+bot.callbackQuery(/^access:(approve|decline):\d+$/, async (ctx) => {
+ if (!isOwner(ctx)) {
+ await ctx.answerCallbackQuery({ text: "Owner only." });
+ return;
+ }
+ await handleAccessDecision(ctx);
+});
+
+// ---- Users: access requests + business assistance (sections 7, 3.8, 3.9) ---
+bot.command("requestaccess", handleRequestAccess);
+
+bot.command("start", async (ctx) => {
+ if (isOwner(ctx)) {
+ await ctx.reply(
+ "Welcome back. Commands: /publish /schedule /changebutton /adddestination " +
+ "/listdestinations /removedestination /requests",
+ );
+ return;
+ }
+ await ctx.reply(
+ "Hi! Ask me about pricing or how to get started, or send /requestaccess for extended features.",
+ );
+});
+
+// ---- Group mention assistance (sections 3.10, 8, 18) -----------------------
+bot.on("message:text", async (ctx, next) => {
+ if (await maybeHandleGroupMention(ctx)) return;
+ await next();
+});
+
+// ---- Fallback text routing: owner wizard steps, then plain user chat -------
+bot.on("message:text", async (ctx) => {
+ if (ctx.chat.type !== "private") return; // group noise already filtered above
+
+ if (isOwner(ctx)) {
+ const handledByWizard = await continuePublishWizard(ctx);
+ if (handledByWizard) return;
+ // Owner sent plain text outside of any flow ā no-op, avoid noisy replies.
+ return;
+ }
+
+ // Basic business assistance (section 7) is open to anyone in a private
+ // chat, matching the doc: users don't need approval just to say "hello".
+ // Wrap specific commands with `approvedOnly` if you add gated features
+ // that should require the access-request flow first.
+ await handleUserMessage(ctx);
+});
+
+bot.catch((err) => {
+ console.error("Bot error:", err.error);
+});
diff --git a/buttons.ts b/buttons.ts
new file mode 100644
index 0000000..e71c40e
--- /dev/null
+++ b/buttons.ts
@@ -0,0 +1,52 @@
+import type { Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+import { findPostsForButton, savePost } from "../db.ts";
+import type { InlineButtonRef, Post } from "../types.ts";
+
+function rebuildKeyboard(buttons: InlineButtonRef[]): InlineKeyboard {
+ const kb = new InlineKeyboard();
+ for (const b of buttons) kb.url(b.text, b.url).row();
+ return kb;
+}
+
+// /changebutton
+// Finds every published message that used this button and edits its
+// reply markup in place, so old posts stay current without recreating them.
+export async function handleChangeButton(ctx: Context): Promise {
+ const args = ctx.match?.toString().trim().split(/\s+/) ?? [];
+ const [buttonId, newUrl] = args;
+ if (!buttonId || !newUrl) {
+ await ctx.reply("Usage: /changebutton ");
+ return;
+ }
+
+ const posts = await findPostsForButton(buttonId);
+ if (posts.length === 0) {
+ await ctx.reply(`No published posts found using button id "${buttonId}".`);
+ return;
+ }
+
+ let updatedMessages = 0;
+ for (const post of posts) {
+ const button = post.buttons.find((b) => b.id === buttonId);
+ if (!button) continue;
+ button.url = newUrl;
+
+ const keyboard = rebuildKeyboard(post.buttons);
+ for (const ref of post.publishedMessages) {
+ try {
+ await ctx.api.editMessageReplyMarkup(ref.chatId, ref.messageId, {
+ reply_markup: keyboard,
+ });
+ updatedMessages++;
+ } catch (err) {
+ console.error(`Failed to update message ${ref.messageId} in ${ref.chatId}:`, err);
+ }
+ }
+ await savePost(post as Post);
+ }
+
+ await ctx.reply(
+ `Updated button "${buttonId}" to ${newUrl} across ${updatedMessages} message(s) in ${posts.length} post(s).`,
+ );
+}
diff --git a/config.ts b/config.ts
new file mode 100644
index 0000000..3a3033f
--- /dev/null
+++ b/config.ts
@@ -0,0 +1,17 @@
+// Central place for environment configuration. Fails fast if required
+// variables are missing so misconfiguration is caught at boot, not at runtime.
+
+function required(name: string): string {
+ const value = Deno.env.get(name);
+ if (!value) {
+ throw new Error(`Missing required environment variable: ${name}`);
+ }
+ return value;
+}
+
+export const config = {
+ botToken: required("BOT_TOKEN"),
+ ownerId: Number(required("OWNER_ID")),
+ webhookSecret: required("WEBHOOK_SECRET"),
+ publicUrl: Deno.env.get("PUBLIC_URL") ?? "",
+};
diff --git a/db.ts b/db.ts
new file mode 100644
index 0000000..a778cc0
--- /dev/null
+++ b/db.ts
@@ -0,0 +1,132 @@
+// Persistence layer built on Deno KV.
+//
+// Key design follows the doc's privacy principle (section 11/26/27):
+// only durable, functionally necessary data is stored permanently.
+// Conversation state is stored too, but with a short TTL via `expireIn`
+// so idle flows self-clean instead of accumulating forever.
+
+import type {
+ AccessRequest,
+ ConversationState,
+ Destination,
+ Post,
+ ScheduledPost,
+} from "./types.ts";
+
+const kv = await Deno.openKv();
+
+const CONVERSATION_TTL_MS = 30 * 60 * 1000; // 30 minutes of inactivity
+
+// ---- Destinations ---------------------------------------------------------
+
+export async function addDestination(dest: Destination): Promise {
+ await kv.set(["destinations", dest.id], dest);
+}
+
+export async function removeDestination(id: string): Promise {
+ await kv.delete(["destinations", id]);
+}
+
+export async function listDestinations(): Promise {
+ const out: Destination[] = [];
+ for await (const entry of kv.list({ prefix: ["destinations"] })) {
+ out.push(entry.value);
+ }
+ return out;
+}
+
+// ---- Posts (published) -----------------------------------------------------
+
+export async function savePost(post: Post): Promise {
+ await kv.set(["posts", post.id], post);
+}
+
+export async function getPost(id: string): Promise {
+ const res = await kv.get(["posts", id]);
+ return res.value;
+}
+
+// Every button gets an index entry so "change this button's URL" can find
+// every post/message it appears in, across destinations, in one lookup.
+export async function indexButton(buttonId: string, postId: string): Promise {
+ await kv.set(["button_index", buttonId, postId], true);
+}
+
+export async function findPostsForButton(buttonId: string): Promise {
+ const posts: Post[] = [];
+ for await (const entry of kv.list({ prefix: ["button_index", buttonId] })) {
+ const postId = entry.key[2] as string;
+ const post = await getPost(postId);
+ if (post) posts.push(post);
+ }
+ return posts;
+}
+
+// ---- Scheduled posts --------------------------------------------------------
+
+export async function saveScheduledPost(sp: ScheduledPost): Promise {
+ await kv.set(["scheduled", sp.id], sp);
+}
+
+export async function listDuePosts(now: number): Promise {
+ const due: ScheduledPost[] = [];
+ for await (const entry of kv.list({ prefix: ["scheduled"] })) {
+ const sp = entry.value;
+ if (!sp.published && sp.publishAt <= now) due.push(sp);
+ }
+ return due;
+}
+
+export async function markScheduledPublished(id: string): Promise {
+ const res = await kv.get(["scheduled", id]);
+ if (res.value) {
+ res.value.published = true;
+ await kv.set(["scheduled", id], res.value);
+ }
+}
+
+// ---- Access requests ---------------------------------------------------------
+
+export async function saveAccessRequest(req: AccessRequest): Promise {
+ await kv.set(["access", req.userId], req);
+}
+
+export async function getAccessRequest(userId: number): Promise {
+ const res = await kv.get(["access", userId]);
+ return res.value;
+}
+
+export async function isApprovedUser(userId: number): Promise {
+ const req = await getAccessRequest(userId);
+ return req?.status === "approved";
+}
+
+export async function listPendingRequests(): Promise {
+ const out: AccessRequest[] = [];
+ for await (const entry of kv.list({ prefix: ["access"] })) {
+ if (entry.value.status === "pending") out.push(entry.value);
+ }
+ return out;
+}
+
+// ---- Conversation state (short-lived, per user) ------------------------------
+
+export async function setConversationState(
+ userId: number,
+ state: ConversationState,
+): Promise {
+ await kv.set(["conversation", userId], state, { expireIn: CONVERSATION_TTL_MS });
+}
+
+export async function getConversationState(
+ userId: number,
+): Promise {
+ const res = await kv.get(["conversation", userId]);
+ return res.value;
+}
+
+export async function clearConversationState(userId: number): Promise {
+ await kv.delete(["conversation", userId]);
+}
+
+export { kv };
diff --git a/deno.json b/deno.json
index 6ac45bc..be0bb00 100644
--- a/deno.json
+++ b/deno.json
@@ -1,15 +1,14 @@
{
- "name": "@gramio/wrappergram",
- "version": "2.0.0",
- "exports": "./src/index.ts",
- "publish": {
- "include": [
- "src",
- "README.md",
- "LICENSE"
- ]
- },
- "imports": {
- "@gramio/types": "jsr:@gramio/types@^10.3.1"
- }
-}
\ No newline at end of file
+ "tasks": {
+ "dev": "deno run --allow-net --allow-env --unstable-kv --unstable-cron --watch main.ts",
+ "start": "deno run --allow-net --allow-env --unstable-kv --unstable-cron main.ts",
+ "setwebhook": "deno run --allow-net --allow-env scripts/set_webhook.ts"
+ },
+ "imports": {
+ "grammy": "https://deno.land/x/grammy@v1.30.0/mod.ts",
+ "grammy/types": "https://deno.land/x/grammy@v1.30.0/types.ts"
+ },
+ "compilerOptions": {
+ "strict": true
+ }
+}
diff --git a/destinations.ts b/destinations.ts
new file mode 100644
index 0000000..547ad2c
--- /dev/null
+++ b/destinations.ts
@@ -0,0 +1,41 @@
+import type { Context } from "grammy";
+import { addDestination, listDestinations, removeDestination } from "../db.ts";
+import type { Destination } from "../types.ts";
+
+// /adddestination -- run this command *inside* the channel/group
+// you want to connect (as a forward, or by adding the bot as admin there
+// and using it there directly).
+export async function handleAddDestination(ctx: Context): Promise {
+ const label = ctx.match?.toString().trim();
+ if (!label) {
+ await ctx.reply("Usage: /adddestination \nRun this inside the channel or group you want to connect.");
+ return;
+ }
+ const chatId = ctx.chat?.id;
+ if (!chatId) return;
+
+ const id = label.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || `dest-${Date.now()}`;
+ const dest: Destination = { id, chatId, label, addedAt: Date.now() };
+ await addDestination(dest);
+ await ctx.reply(`Connected "${label}" as destination id "${id}".`);
+}
+
+export async function handleListDestinations(ctx: Context): Promise {
+ const dests = await listDestinations();
+ if (dests.length === 0) {
+ await ctx.reply("No destinations connected yet. Use /adddestination inside a channel or group.");
+ return;
+ }
+ const lines = dests.map((d) => `⢠${d.label} (id: ${d.id}, chat: ${d.chatId})`);
+ await ctx.reply(`Connected destinations:\n${lines.join("\n")}`);
+}
+
+export async function handleRemoveDestination(ctx: Context): Promise {
+ const id = ctx.match?.toString().trim();
+ if (!id) {
+ await ctx.reply("Usage: /removedestination ");
+ return;
+ }
+ await removeDestination(id);
+ await ctx.reply(`Removed destination "${id}".`);
+}
diff --git a/groupMention.ts b/groupMention.ts
new file mode 100644
index 0000000..e2d3887
--- /dev/null
+++ b/groupMention.ts
@@ -0,0 +1,32 @@
+import type { Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+import { answerBusinessQuestion } from "./assistance.ts";
+
+// Only responds in group/supergroup chats, and only when the bot's own
+// @username is actually mentioned in the message text ā it deliberately
+// ignores the rest of group conversation (section 8: "does not need to
+// respond to every conversation").
+export async function maybeHandleGroupMention(ctx: Context): Promise {
+ const chatType = ctx.chat?.type;
+ if (chatType !== "group" && chatType !== "supergroup") return false;
+
+ const text = ctx.message?.text ?? "";
+ const botUsername = ctx.me?.username;
+ if (!botUsername || !text.toLowerCase().includes(`@${botUsername.toLowerCase()}`)) {
+ return false;
+ }
+
+ const question = text.replace(new RegExp(`@${botUsername}`, "ig"), "").trim();
+ const { reply, buttons } = answerBusinessQuestion(question);
+
+ const keyboard = buttons?.length
+ ? buttons.reduce((kb, b) => kb.url(b.text, b.url).row(), new InlineKeyboard())
+ : undefined;
+
+ const replyToId = ctx.message?.message_id;
+ await ctx.reply(reply, {
+ reply_markup: keyboard,
+ ...(replyToId ? { reply_parameters: { message_id: replyToId } } : {}),
+ });
+ return true;
+}
diff --git a/main.ts b/main.ts
new file mode 100644
index 0000000..d9421e4
--- /dev/null
+++ b/main.ts
@@ -0,0 +1,41 @@
+// Entry point. Runs an HTTPS-reachable webhook server (deployable to Deno
+// Deploy or any Deno host) and a background cron job that publishes due
+// scheduled posts (sections 16, 17, 20, 25).
+
+import { webhookCallback } from "grammy";
+import { bot } from "./src/bot.ts";
+import { config } from "./src/config.ts";
+import { runDuePublications } from "./src/features/scheduling.ts";
+
+const handleUpdate = webhookCallback(bot, "std/http");
+
+// Check scheduled posts every minute. Deno.cron keeps this running even
+// on serverless platforms that would otherwise sleep between requests.
+Deno.cron("publish due scheduled posts", "* * * * *", async () => {
+ try {
+ await runDuePublications(bot);
+ } catch (err) {
+ console.error("Scheduled publish check failed:", err);
+ }
+});
+
+Deno.serve(async (req: Request) => {
+ const url = new URL(req.url);
+
+ // Webhook path includes the secret so only Telegram (who we told the
+ // secret) can trigger updates ā see section 21, "permanent bot address".
+ if (url.pathname === `/webhook/${config.webhookSecret}` && req.method === "POST") {
+ try {
+ return await handleUpdate(req);
+ } catch (err) {
+ console.error("Webhook handling error:", err);
+ return new Response("error", { status: 500 });
+ }
+ }
+
+ if (url.pathname === "/" || url.pathname === "/health") {
+ return new Response("Telegram-Bot is running.", { status: 200 });
+ }
+
+ return new Response("Not found", { status: 404 });
+});
diff --git a/publish.ts b/publish.ts
new file mode 100644
index 0000000..ab3cb90
--- /dev/null
+++ b/publish.ts
@@ -0,0 +1,145 @@
+import type { Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+import { indexButton, listDestinations, savePost } from "../db.ts";
+import {
+ clearConversationState,
+ getConversationState,
+ setConversationState,
+} from "../db.ts";
+import type { InlineButtonRef, Post, PublishedMessageRef } from "../types.ts";
+
+// Builds a grammY InlineKeyboard from our button refs, one per row for clarity.
+function buildKeyboard(buttons: InlineButtonRef[]): InlineKeyboard {
+ const kb = new InlineKeyboard();
+ for (const b of buttons) {
+ kb.url(b.text, b.url).row();
+ }
+ return kb;
+}
+
+// Publishes text + buttons to every destination id given, recording exactly
+// where each message landed so buttons can be updated later (section 14).
+export async function publishToDestinations(
+ ctx: Context,
+ text: string,
+ buttons: InlineButtonRef[],
+ destinationIds: string[],
+): Promise {
+ const allDestinations = await listDestinations();
+ const targets = allDestinations.filter((d) => destinationIds.includes(d.id));
+
+ const publishedMessages: PublishedMessageRef[] = [];
+ const keyboard = buttons.length > 0 ? buildKeyboard(buttons) : undefined;
+
+ for (const dest of targets) {
+ const sent = await ctx.api.sendMessage(dest.chatId, text, {
+ reply_markup: keyboard,
+ });
+ publishedMessages.push({ chatId: dest.chatId, messageId: sent.message_id });
+ }
+
+ const post: Post = {
+ id: `post-${Date.now()}`,
+ text,
+ buttons,
+ destinations: destinationIds,
+ publishedMessages,
+ createdAt: Date.now(),
+ };
+ await savePost(post);
+
+ for (const b of buttons) {
+ await indexButton(b.id, post.id);
+ }
+
+ return post;
+}
+
+// --- Simple conversational publish wizard -----------------------------------
+// /publish starts a short guided flow: text -> buttons (optional) -> destinations.
+
+export async function startPublishWizard(ctx: Context): Promise {
+ const userId = ctx.from!.id;
+ await setConversationState(userId, {
+ step: "awaiting_text",
+ data: {},
+ updatedAt: Date.now(),
+ });
+ await ctx.reply(
+ "Let's publish something. Send the message text now (or /cancel to stop).",
+ );
+}
+
+export async function cancelWizard(ctx: Context): Promise {
+ await clearConversationState(ctx.from!.id);
+ await ctx.reply("Cancelled.");
+}
+
+// Called from the main message handler when a conversation is in progress.
+export async function continuePublishWizard(ctx: Context): Promise {
+ const userId = ctx.from!.id;
+ const state = await getConversationState(userId);
+ if (!state || !state.step.startsWith("awaiting_")) return false;
+
+ const text = ctx.message?.text ?? "";
+
+ if (state.step === "awaiting_text") {
+ state.data.text = text;
+ state.step = "awaiting_buttons";
+ state.updatedAt = Date.now();
+ await setConversationState(userId, state);
+ await ctx.reply(
+ 'Add a button? Send "Label | https://example.com", multiple lines for multiple buttons, or send "no" to skip.',
+ );
+ return true;
+ }
+
+ if (state.step === "awaiting_buttons") {
+ const buttons: InlineButtonRef[] = [];
+ if (text.trim().toLowerCase() !== "no") {
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
+ for (const line of lines) {
+ const [label, url] = line.split("|").map((p) => p.trim());
+ if (label && url) {
+ buttons.push({ id: `btn-${Date.now()}-${buttons.length}`, text: label, url });
+ }
+ }
+ }
+ state.data.buttons = buttons;
+ state.step = "awaiting_destinations";
+ state.updatedAt = Date.now();
+ await setConversationState(userId, state);
+
+ const dests = await listDestinations();
+ const list = dests.map((d) => `${d.id}`).join(", ") || "(none connected)";
+ await ctx.reply(
+ `Which destinations? Comma-separated ids from: ${list}\n(or "all")`,
+ );
+ return true;
+ }
+
+ if (state.step === "awaiting_destinations") {
+ const dests = await listDestinations();
+ let destinationIds: string[];
+ if (text.trim().toLowerCase() === "all") {
+ destinationIds = dests.map((d) => d.id);
+ } else {
+ destinationIds = text.split(",").map((s) => s.trim()).filter(Boolean);
+ }
+
+ const post = await publishToDestinations(
+ ctx,
+ state.data.text as string,
+ state.data.buttons as InlineButtonRef[],
+ destinationIds,
+ );
+
+ await clearConversationState(userId);
+ await ctx.reply(
+ `Published to ${post.publishedMessages.length} location(s). Post id: ${post.id}`,
+ );
+ return true;
+ }
+
+ return false;
+}
diff --git a/scheduling.ts b/scheduling.ts
new file mode 100644
index 0000000..845df11
--- /dev/null
+++ b/scheduling.ts
@@ -0,0 +1,94 @@
+import type { Bot, Context } from "grammy";
+import { InlineKeyboard } from "grammy";
+import {
+ indexButton,
+ listDestinations,
+ listDuePosts,
+ markScheduledPublished,
+ saveScheduledPost,
+ savePost,
+} from "../db.ts";
+import type { InlineButtonRef, Post, PublishedMessageRef, ScheduledPost } from "../types.ts";
+
+// /schedule | |
+// Kept as a single-line command (rather than a wizard) since schedules are
+// often prepared ahead of time and copy-pasted from notes.
+export async function handleSchedule(ctx: Context): Promise {
+ const raw = ctx.match?.toString() ?? "";
+ const [whenRaw, destRaw, ...rest] = raw.split("|").map((p) => p.trim());
+ const text = rest.join("|").trim();
+
+ if (!whenRaw || !destRaw || !text) {
+ await ctx.reply(
+ "Usage: /schedule | | ",
+ );
+ return;
+ }
+
+ const publishAt = new Date(whenRaw).getTime();
+ if (Number.isNaN(publishAt)) {
+ await ctx.reply("Couldn't parse that date/time. Use format 2026-09-10T10:00");
+ return;
+ }
+
+ const destinationIds = destRaw.split(",").map((s) => s.trim()).filter(Boolean);
+
+ const sp: ScheduledPost = {
+ id: `sched-${Date.now()}`,
+ text,
+ buttons: [], // extend here if scheduled posts need buttons too
+ destinations: destinationIds,
+ publishAt,
+ published: false,
+ createdAt: Date.now(),
+ };
+ await saveScheduledPost(sp);
+
+ await ctx.reply(
+ `Scheduled for ${new Date(publishAt).toLocaleString()}. Id: ${sp.id}`,
+ );
+}
+
+// Publishes anything whose time has come. Called by the cron job in main.ts.
+export async function runDuePublications(bot: Bot): Promise {
+ const due = await listDuePosts(Date.now());
+ if (due.length === 0) return;
+
+ const allDestinations = await listDestinations();
+
+ for (const sp of due) {
+ const targets = allDestinations.filter((d) => sp.destinations.includes(d.id));
+ const publishedMessages: PublishedMessageRef[] = [];
+ const keyboard = sp.buttons.length > 0 ? buildKeyboard(sp.buttons) : undefined;
+
+ for (const dest of targets) {
+ try {
+ const sent = await bot.api.sendMessage(dest.chatId, sp.text, {
+ reply_markup: keyboard,
+ });
+ publishedMessages.push({ chatId: dest.chatId, messageId: sent.message_id });
+ } catch (err) {
+ console.error(`Scheduled publish failed for ${sp.id} -> ${dest.chatId}:`, err);
+ }
+ }
+
+ const post: Post = {
+ id: `post-${sp.id}`,
+ text: sp.text,
+ buttons: sp.buttons,
+ destinations: sp.destinations,
+ publishedMessages,
+ createdAt: Date.now(),
+ };
+ await savePost(post);
+ for (const b of sp.buttons) await indexButton(b.id, post.id);
+
+ await markScheduledPublished(sp.id);
+ }
+}
+
+function buildKeyboard(buttons: InlineButtonRef[]): InlineKeyboard {
+ const kb = new InlineKeyboard();
+ for (const b of buttons) kb.url(b.text, b.url).row();
+ return kb;
+}
diff --git a/set_webhook.ts b/set_webhook.ts
new file mode 100644
index 0000000..edafcbf
--- /dev/null
+++ b/set_webhook.ts
@@ -0,0 +1,23 @@
+// Run once after deploying: deno task setwebhook
+// Tells Telegram where to POST updates for this bot.
+
+import { config } from "../src/config.ts";
+
+if (!config.publicUrl) {
+ console.error("Set PUBLIC_URL in your environment before running this script.");
+ Deno.exit(1);
+}
+
+const webhookUrl = `${config.publicUrl}/webhook/${config.webhookSecret}`;
+
+const res = await fetch(
+ `https://api.telegram.org/bot${config.botToken}/setWebhook`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url: webhookUrl }),
+ },
+);
+
+const data = await res.json();
+console.log(data.ok ? `Webhook set to ${webhookUrl}` : data);
diff --git a/src/.env.example b/src/.env.example
new file mode 100644
index 0000000..a6449b7
--- /dev/null
+++ b/src/.env.example
@@ -0,0 +1,9 @@
+# Telegram bot token from @BotFather
+BOT_TOKEN=123456789:AAExampleTokenReplaceMe
+
+# Your numeric Telegram user id (the owner / admin of the bot).
+# You can get this from @userinfobot on Telegram.
+OWNER_ID=123456789
+
+# Path to the SQLite database file (created automatically on first run)
+DB_PATH=./data/bot.sqlite
diff --git a/src/README.md b/src/README.md
new file mode 100644
index 0000000..6d9910f
--- /dev/null
+++ b/src/README.md
@@ -0,0 +1,139 @@
+# Telegram-Bot (GramIO edition)
+
+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.
+
+> **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.
+
+## What it does
+
+| 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` |
+
+## Setup
+
+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.
+
+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.
+
+### Option A ā Node.js
+
+Requires Node **20.6+** (for the native `--env-file` flag).
+
+```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
+```
+
+### 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/src/access.ts b/src/access.ts
new file mode 100644
index 0000000..40c603f
--- /dev/null
+++ b/src/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/src/bot.ts b/src/bot.ts
new file mode 100644
index 0000000..0b8a0a6
--- /dev/null
+++ b/src/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/src/buttons.ts b/src/buttons.ts
new file mode 100644
index 0000000..ad0639d
--- /dev/null
+++ b/src/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/src/client.ts b/src/client.ts
new file mode 100644
index 0000000..d78ab88
--- /dev/null
+++ b/src/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/src/deno.json b/src/deno.json
new file mode 100644
index 0000000..416136f
--- /dev/null
+++ b/src/deno.json
@@ -0,0 +1,18 @@
+{
+ "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": {
+ "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"
+ },
+ "compilerOptions": {
+ "strict": true,
+ "lib": ["deno.window"]
+ },
+ "nodeModulesDir": "auto"
+}
diff --git a/src/destinations.ts b/src/destinations.ts
new file mode 100644
index 0000000..5a8b57d
--- /dev/null
+++ b/src/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/src/env.ts b/src/env.ts
new file mode 100644
index 0000000..3a64f86
--- /dev/null
+++ b/src/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/src/gitignore b/src/gitignore
new file mode 100644
index 0000000..6493df6
--- /dev/null
+++ b/src/gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+data/
+.env
+*.sqlite
+*.sqlite-journal
+*.sqlite-wal
+*.sqlite-shm
diff --git a/src/groupMention.ts b/src/groupMention.ts
new file mode 100644
index 0000000..cccb433
--- /dev/null
+++ b/src/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/src/index.ts b/src/index.ts
index 0e7b78f..44c0fe6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,226 +1,40 @@
-/**
- * Simple and tiny Telegram Bot API wrapper with middleware chain, configurable fetch, and opt-in file/format support
- * @module
- */
-import type {
- APIMethodParams,
- APIMethodReturn,
- APIMethods,
- TelegramAPIResponse,
-} from "@gramio/types";
-import { TelegramError } from "./errors.ts";
-import type {
- MaybeSuppressedParams,
- Middleware,
- MiddlewareContext,
- RequestOptions,
- SuppressedAPIMethods,
-} from "./types.ts";
-import { simplifyObject } from "./utils.ts";
-
-export { TelegramError } from "./errors.ts";
-export { getUpdates, withRetries } from "./utils.ts";
-export type * from "./types.ts";
-export type * from "@gramio/types";
-
-/**
- * Options for {@link Telegram}
- */
-export interface TelegramOptions {
- /**
- * URL which will be used to send requests to.
- * @default "https://api.telegram.org/bot"
- */
- baseURL?: string;
- /**
- * Global fetch options applied to every request.
- * Can be overridden per-request via the second argument to API methods.
- *
- * {@link https://developer.mozilla.org/en-US/docs/Web/API/RequestInit | MDN}
- */
- fetchOptions?: RequestOptions;
- /**
- * Middleware chain that wraps every API call.
- * Each middleware receives `(context, next)` ā mutate params before `next()`,
- * set `context.formData` for file uploads, handle results after, or catch errors.
- *
- * @example
- * ```ts
- * import { Telegram } from "wrappergram";
- * import { filesMiddleware } from "@gramio/files/middleware";
- * import { formatMiddleware } from "@gramio/format/middleware";
- *
- * const telegram = new Telegram("BOT_TOKEN", {
- * middlewares: [formatMiddleware, filesMiddleware],
- * });
- * ```
- */
- middlewares?: Middleware[];
+// 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();
}
-/**
- * Main class of the library.
- *
- * Use {@link Telegram.api | api} to call Telegram Bot API methods.
- * Pass middlewares via {@link TelegramOptions.middlewares | options.middlewares}.
- *
- * Without middlewares it's just `fetch` + `JSON.stringify` ā zero overhead.
- *
- * @example
- * ```ts
- * import { Telegram } from "wrappergram";
- *
- * const telegram = new Telegram("BOT_TOKEN");
- *
- * const result = await telegram.api.sendMessage({
- * chat_id: "@gramio_forum",
- * text: "Hello, world!"
- * });
- * ```
- */
-export class Telegram {
- /** Bot token */
- token: string;
- /** Class {@link TelegramOptions | options} */
- options: TelegramOptions & { baseURL: string };
-
- private middlewares: Middleware[];
-
- /** Create new instance */
- constructor(token: string, options?: TelegramOptions) {
- this.token = token;
- this.options = { baseURL: "https://api.telegram.org/bot", ...options };
- this.middlewares = options?.middlewares ?? [];
- }
-
- /**
- * Send requests to Telegram Bot API.
- *
- * Returns the API result directly (unwrapped from `{ ok, result }`).
- * Throws {@link TelegramError} on failure, unless `suppress: true` is passed.
- *
- * @example
- * ```ts
- * const message = await telegram.api.sendMessage({
- * chat_id: "@gramio_forum",
- * text: "Hello, world!"
- * });
- *
- * // With error suppression
- * const result = await telegram.api.sendMessage({
- * suppress: true,
- * chat_id: "@not_found",
- * text: "test"
- * });
- * if (result instanceof TelegramError) console.error(result.message);
- *
- * // With per-request fetch options (second argument)
- * await telegram.api.sendMessage(
- * { chat_id: 123, text: "hi" },
- * { signal: AbortSignal.timeout(5000) },
- * );
- * ```
- */
- readonly api: SuppressedAPIMethods = new Proxy({} as SuppressedAPIMethods, {
- get: (
- _target: SuppressedAPIMethods,
- method: T,
- ) =>
- // biome-ignore lint/suspicious/noAssignInExpressions: cache the function
- (_target[method] ??= ((
- args: MaybeSuppressedParams,
- requestOptions?: RequestOptions,
- ) => {
- const callSite = new Error();
- const captureStackTrace = (
- Error as ErrorConstructor & {
- captureStackTrace?: (
- targetObject: object,
- constructorOpt?: Function,
- ) => void;
- }
- ).captureStackTrace;
- captureStackTrace?.(callSite, _target[method] as Function);
- return this._callApi(method, args, requestOptions, callSite);
- }) as SuppressedAPIMethods[T]),
- });
-
- private async _callApi(
- method: T,
- params: MaybeSuppressedParams = {} as MaybeSuppressedParams,
- perRequestOptions?: RequestOptions,
- callSite?: Error,
- ): Promise | TelegramError> {
- // Extract suppress flag, keep only API params
- const suppress = (params as Record)?.suppress as
- | boolean
- | undefined;
-
- const apiParams = { ...params } as Record;
- delete apiParams.suppress;
-
- // Shared mutable context for middleware chain
- const context = { method, params: apiParams } as MiddlewareContext;
-
- const executeCall = async (): Promise<
- APIMethodReturn | TelegramError
- > => {
- let url = `${this.options.baseURL}${this.token}/${context.method}`;
-
- // Merge fetch options: global ā per-request
- const reqOptions: RequestInit = {
- method: "POST",
- ...this.options.fetchOptions,
- ...perRequestOptions,
- headers: new Headers({
- ...((this.options.fetchOptions?.headers as Record) ??
- {}),
- ...((perRequestOptions?.headers as Record) ?? {}),
- }),
- };
-
- // Build request body
- if (context.formData) {
- reqOptions.body = context.formData;
-
- if (context.params && Object.keys(context.params).length) {
- url += `?${new URLSearchParams(simplifyObject(context.params as Record)).toString()}`;
- }
- } else {
- reqOptions.body = JSON.stringify(context.params);
- (reqOptions.headers as Headers).set(
- "Content-Type",
- "application/json",
- );
- }
-
- const response = await fetch(url, reqOptions);
- const data = (await response.json()) as TelegramAPIResponse;
-
- if (!data.ok) {
- const err = new TelegramError(
- data,
- method,
- context.params as APIMethodParams,
- callSite,
- );
-
- if (!suppress) throw err;
- return err;
- }
-
- return data.result;
- };
-
- // Compose middleware chain
- if (!this.middlewares.length) return executeCall();
-
- let fn: () => Promise = executeCall;
- for (const mw of [...this.middlewares].reverse()) {
- const prev = fn;
- fn = () => mw(context, prev);
- }
-
- return fn() as ReturnType;
- }
+// 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/src/init.ts b/src/init.ts
new file mode 100644
index 0000000..bc4ffed
--- /dev/null
+++ b/src/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/src/keyboards.ts b/src/keyboards.ts
new file mode 100644
index 0000000..9c98ce8
--- /dev/null
+++ b/src/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/src/package.json b/src/package.json
new file mode 100644
index 0000000..32dd96a
--- /dev/null
+++ b/src/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/src/publish.ts b/src/publish.ts
new file mode 100644
index 0000000..850f888
--- /dev/null
+++ b/src/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/src/schedule.ts b/src/schedule.ts
new file mode 100644
index 0000000..4cc81f9
--- /dev/null
+++ b/src/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/src/scheduler.ts b/src/scheduler.ts
new file mode 100644
index 0000000..422acd7
--- /dev/null
+++ b/src/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/src/schema.sql b/src/schema.sql
new file mode 100644
index 0000000..1ca64a2
--- /dev/null
+++ b/src/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/src/tsconfig.json b/src/tsconfig.json
new file mode 100644
index 0000000..26010c1
--- /dev/null
+++ b/src/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/src/types.ts b/src/types.ts
index 4ca8aa2..5380a5c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,127 +1,63 @@
-import type {
- APIMethodParams,
- APIMethodReturn,
- APIMethods,
-} from "@gramio/types";
-import type { TelegramError } from "./errors.ts";
-
-/** Type for maybe {@link Promise} or may not */
-export type MaybePromise = Promise | T;
-
-// === Suppress ===
-
-/**
- * Interface for adding `suppress` param to method params.
- *
- * Pass `true` to return {@link TelegramError} instead of throwing.
- *
- * @example
- * ```ts
- * const response = await telegram.api.sendMessage({
- * suppress: true,
- * chat_id: "@not_found",
- * text: "Suppressed method"
- * });
- *
- * if (response instanceof TelegramError) console.error("sendMessage returned an error...");
- * else console.log("Message has been sent successfully");
- * ```
- */
-export interface Suppress<
- IsSuppressed extends boolean | undefined = undefined,
-> {
- suppress?: IsSuppressed;
+export interface Env {
+ BOT_TOKEN: string;
+ OWNER_ID: string;
+ DB_PATH: string;
}
-/** Type that assigns API params with {@link Suppress} */
-export type MaybeSuppressedParams<
- Method extends keyof APIMethods,
- IsSuppressed extends boolean | undefined = undefined,
-> = APIMethodParams & Suppress;
-
-/** Type that returns MaybeSuppressed API method ReturnType */
-export type MaybeSuppressedReturn<
- Method extends keyof APIMethods,
- IsSuppressed extends boolean | undefined = undefined,
-> = true extends IsSuppressed
- ? TelegramError | APIMethodReturn
- : APIMethodReturn;
-
-// === Per-Request Options (second argument) ===
+export interface ButtonInput {
+ label: string;
+ url: string;
+}
-/**
- * Per-request options passed as the second argument to API methods.
- *
- * @example
- * ```ts
- * await telegram.api.sendMessage(
- * { chat_id: 123, text: "hi" },
- * { signal: AbortSignal.timeout(5000) }
- * );
- * ```
- */
-export type RequestOptions = Omit;
+export interface Destination {
+ id: number;
+ chat_id: string;
+ title: string;
+ type: "channel" | "group";
+ added_at: number;
+}
-// === API Methods Map ===
+export interface PostRecord {
+ id: number;
+ label: string;
+ text: string;
+ created_at: number;
+}
-/** Map of APIMethods with {@link Suppress} and per-request {@link RequestOptions} */
-export type SuppressedAPIMethods<
- Methods extends keyof APIMethods = keyof APIMethods,
-> = {
- [APIMethod in Methods]: APIMethodParams extends undefined
- ? (
- params?: Suppress,
- requestOptions?: RequestOptions,
- ) => Promise>
- : undefined extends APIMethodParams
- ? (
- params?: MaybeSuppressedParams,
- requestOptions?: RequestOptions,
- ) => Promise>
- : (
- params: MaybeSuppressedParams,
- requestOptions?: RequestOptions,
- ) => Promise>;
-};
+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;
+}
-// === Middleware ===
+export interface UserRecord {
+ user_id: string;
+ username: string | null;
+ status: "pending" | "approved" | "declined";
+ requested_at: number;
+ decided_at: number | null;
+}
-/** Middleware context ā shared mutable state that flows through the middleware chain */
-export type MiddlewareContext<
- Methods extends keyof APIMethods = keyof APIMethods,
-> = {
- [M in Methods]: {
- method: M;
- params: APIMethodParams;
- /**
- * Set by middleware (e.g. `@gramio/files`) to provide FormData body
- * instead of JSON. When set, remaining `params` are sent as URL query string.
- */
- formData?: FormData;
- };
-}[Methods];
+export interface UserState {
+ user_id: string;
+ step: string;
+ data_json: string;
+ updated_at: number;
+}
-/**
- * Middleware function that wraps the API call lifecycle.
- *
- * - Mutate `context.params` before `next()` (like preRequest)
- * - Set `context.formData` for file uploads
- * - Handle result after `next()` (like onResponse)
- * - Catch errors from `next()` (like onResponseError)
- *
- * @example
- * ```ts
- * const logger: Middleware = async (context, next) => {
- * console.log(`ā ${context.method}`);
- * const result = await next();
- * console.log(`ā ${context.method}`);
- * return result;
- * };
- * ```
- */
-export type Middleware<
- Methods extends keyof APIMethods = keyof APIMethods,
-> = (
- context: MiddlewareContext,
- next: () => Promise,
-) => Promise;
+// 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";
diff --git a/types.ts b/types.ts
new file mode 100644
index 0000000..4c9c5b5
--- /dev/null
+++ b/types.ts
@@ -0,0 +1,56 @@
+// Domain types shared across features.
+
+export interface Destination {
+ id: string; // internal short id, e.g. "main-channel"
+ chatId: number; // Telegram chat id (channel or group)
+ label: string; // human friendly name shown to the owner
+ addedAt: number;
+}
+
+export interface InlineButtonRef {
+ id: string; // internal id used to find/replace this button later
+ text: string;
+ url: string;
+}
+
+export interface PublishedMessageRef {
+ chatId: number;
+ messageId: number;
+}
+
+export interface Post {
+ id: string;
+ text: string;
+ buttons: InlineButtonRef[]; // buttons as originally configured
+ destinations: string[]; // Destination ids
+ publishedMessages: PublishedMessageRef[]; // where it actually landed
+ createdAt: number;
+}
+
+export interface ScheduledPost {
+ id: string;
+ text: string;
+ buttons: InlineButtonRef[];
+ destinations: string[];
+ publishAt: number; // epoch ms
+ published: boolean;
+ createdAt: number;
+}
+
+export type AccessStatus = "pending" | "approved" | "declined";
+
+export interface AccessRequest {
+ userId: number;
+ username?: string;
+ firstName?: string;
+ status: AccessStatus;
+ requestedAt: number;
+ decidedAt?: number;
+}
+
+export interface ConversationState {
+ // Generic scratch space for multi-step owner flows (publish wizard, etc).
+ step: string;
+ data: Record;
+ updatedAt: number;
+}