GhostPaw

@ghostpaw/telegram 0.6.0

Your bot remembers the chat, including what was edited.

telegram writes every incoming update into a SQLite file before your handler runs: messages, edits, replies, reactions, members and files. Your bot and your agent read from that record, and the agent's tools return plans until you switch execution on.

As a library

As a local MCP server

Replay the run Read the source on GitHub

Free and MIT licensed for Node.js 24 or newer, with zero runtime dependencies. The mirror is a SQLite file on your disk; the bot token stays in your environment.

A wireframe paper plane drawn in cyan light, a violet dotted orbit looping from it into a stacked database cylinder with a glowing paw on its lid.

0runtime dependencies

One group, three updates, six calls

Your handler never has to save anything. The row is already there.

This ran with createBot and long polling against telegram 0.6.0 on 2026-10-24. The Telegram servers were replaced by a local stand-in passed through the documented api.fetch option, so three updates arrive in a group called Renewal desk and nothing reaches Telegram. Everything else is the library. Run the steps in order.

Mirror

  • Messages stored0
  • Edits kept0
  • Outbound calls0
  • Last read—
  • Tool result—

Eight empty tables after initTelegramTables(db).

  1. 01 Lena posts in the group

    The first update arrives through long polling.

    const bot = createBot({ token, db, api: { fetch: standIn },
      onMessage: async ({ message }) => {
        read.getMessage(db, message.chatId, message.messageId); // already there?
      },
    });
    await bot.start();
    // update 1, chat -1001234 "Renewal desk", from Lena (@lena_ops)
    // "Invoice 4471 is paid. The café receipt is in the shared drive."
    // inside onMessage: read.getMessage(...) -> row found
    //   { messageId: 10, direction: 'in', fromDisplayName: 'Lena', type: 'text', … }

    The row was written before the handler ran, so the handler can rely on it.

  2. 02 The handler replies

    One plain-text reply from inside onMessage.

    await write.sendMessage(bot.client, -1001234, 'Noted. Filed under 4471.');
    // the stand-in received:
    // POST sendMessage { chat_id: -1001234, text: 'Noted. Filed under 4471.' }

    One outbound call. With a real token this goes to api.telegram.org.

  3. 03 Lena edits her message

    An edited_message update, one minute later.

    // update 2: edited_message, msg 10
    read.getMessage(db, -1001234, 10).text;
    read.editHistory(db, -1001234, 10);
    // "Invoice 4471 is paid. The café receipt is in the finance folder."
    // [{ id: 1, messageId: 10,
    //    text: 'Invoice 4471 is paid. The café receipt is in the shared drive.' }]

    The message shows the new text, and the old one is kept. You can see what changed and when.

  4. 04 Jun replies to it

    A reply to message 10.

    // update 3: "Which café?", reply_to_message: 10
    read.replyChain(db, -1001234, 11);
    // [{ messageId: 10, text: 'Invoice 4471 is paid. The café receipt is in the finance folder.' },
    //  { messageId: 11, text: 'Which café?' }]

    The chain runs from the original down to the reply.

  5. 05 Search without the accent

    FTS5 over the mirror, no network.

    read.searchMessages(db, -1001234, 'cafe');
    // [{ messageId: 11, snippet: 'Which <b>café</b>?' },
    //  { messageId: 10, snippet: 'Invoice 4471 is paid. The <b>café</b> receipt is in the shared drive.' }]

    Both messages, accent or not. The snippet for message 10 still shows the text from before the edit; see the note below.

  6. 06 The agent wants to answer

    The tg_send tool, called the way an agent harness calls it.

    tools.getTelegramToolByName('tg_send').handler(db, {
      subcommand: 'send_message', chatId: -1001234, text: 'Receipt found, thanks.',
    });
    // { ok: true, outcome: 'success', summary: 'Message ready to send',
    //   data: { action: 'send_message', chatId: -1001234, text: 'Receipt found, thanks.' } }

    A plan, and the outbound count is still one. Sending takes createTelegramToolFacade({ client, execute: true }), which you have to write yourself.

In this run the search index kept the text from before the edit: a search for “finance” found nothing and “shared” still found message 10. editHistory and getMessage were right. We do not claim that search sees edits.

The README says files are downloaded eagerly. In 0.6.0 DEFAULTS.autoDownload is false, so check that setting if you want file bytes stored.

What the mirror keeps, and four tools to read it

Cyan speech bubbles stacking down into a band of stored lines, with a violet magnifying glass over them.

Eight tables, created in one call. The agent reads them through four tools.

Messages
Text, media, edits, reply edges, albums and threads, with an FTS5 index that folds diacritics.
Users, chats, members
Names, chat titles and types, member status, permissions and admin titles.
Reactions, callbacks
Emoji per user per message, and inline button presses with their data.
Files
Metadata, and the bytes as a BLOB deduplicated by SHA-256 when downloading is on.
tg_read
Messages, chats, users, reactions, edit history and reply chains.
tg_send
Send, edit, delete, forward and pin, set reactions, broadcast.
tg_manage
Ban, unban, restrict, promote and kick, and member status.
tg_connect
Bot stats, bot state and active chats.

Each tool carries its JSON Schema, a side-effect level and a readOnly flag, so a harness can tell reading from acting without reading docs.

If a bot that posts by itself worries you

Some people log every agent action to a Telegram bot as an audit trail. That trail is only useful if you know what the bot did on its own.

  • The tools return plans by default. Execution needs an explicit facade with execute: true.
  • The executing facade limits calls per chat and globally with token buckets, waits on Telegram's retry_after and retries twice by default. It masks bot tokens in errors.
  • The MCP server executes sends only when TELEGRAM_BOT_TOKEN is set, keeps the mirror in ~/.ghostpaw/telegram.db and does not poll for updates itself.
  • The library does not filter senders for you. In 0.6.0 DEFAULTS.allowedChatIds exists but nothing reads it, so the allowlist belongs in your handler.
  • Node.js 24 or newer, because it uses the built-in node:sqlite and global fetch.

What it costs to add

Runtime dependencies
0
Node.js
24 or newer (node:sqlite)
Size of dist/index.js, gzip
33.7 KB
Import time
about 11.8 ms
Tests
883 passing
Line coverage
97.9%
Agent tools
4

Measured 2026-10-24 on the maintainer's machine with Node.js 24.14.0 against telegram 0.6.0: size of the published dist/index.js at gzip -9, median of 11 cold imports, tests and coverage from the v0.6.0 tag.

Start from the README

import { DatabaseSync } from 'node:sqlite';
import { createBot, initTelegramTables } from '@ghostpaw/telegram';

const db = new DatabaseSync('bot.db');
initTelegramTables(db);

const bot = createBot({
  token: process.env.TELEGRAM_TOKEN,
  db,
  onMessage: async ({ message, user }) => {
    console.log(user?.firstName, message.text);
  },
});

await bot.start(); // long-polling

To give an agent the mirror as an MCP server, add npx -y @ghostpaw/telegram mcp to your client.