This project was forked from the open source GramJS project in 2025 and is now developed independently.
This README is just a fast quick start. Ongoing discussion happens in the Telegram chat.
teleproto is a TypeScript client for Telegram's MTProto API — the same protocol Telegram's own apps speak. Through it, your code gets the full account surface: userbots, multi-account automation, file transfer, raw TL invocation when you need it. If you only need to push notifications from a bot, the official Bot API is simpler; teleproto exists for everything beyond that.
% npm install teleproto
Pure JavaScript, no native build step — installs cleanly on Alpine, ARM, and serverless runtimes.
You need an api_id and api_hash from https://my.telegram.org. Then:
import { TelegramClient } from "teleproto";
import { StringSession } from "teleproto/sessions";
import { createInterface } from "node:readline/promises";
const rl = createInterface({ input: process.stdin, output: process.stdout });
const apiId = 0; // from https://my.telegram.org
const apiHash = ""; // from https://my.telegram.org
const session = new StringSession("");
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
});
await client.start({
phoneNumber: () => rl.question("Phone: "),
password: () => rl.question("2FA password: "),
phoneCode: (_, info) => rl.question(`Code (${info?.type}): `),
emailAddress: () => rl.question("Login email: "),
emailVerification: async () => ({ type: "code", code: await rl.question("Email code: ") }),
onError: console.error,
});
console.log(await client.getMe());
console.log("Session string:", client.session.save());
rl.close();
A code of type app arrives in the Telegram chat on your other devices, not by SMS. Telegram may also ask for a login email instead, which is what the email callbacks handle.
The session string is your saved login. Drop it back into new StringSession(saved) next time and skip the auth flow entirely.
Send a message, listen for incoming ones:
await client.sendMessage("me", { message: "hello from teleproto" });
client.updates.on("newMessage", (update) => console.log(update.message.message));
Handlers form a chain: next() passes the update to the handlers behind it, returning without it consumes the update. Names are the schema's own — updateNewChannelMessage is "newChannelMessage" — so every update type is subscribable, autocompleted and typed as its exact class:
client.updates.use(async (update, next) => {
update.state.startedAt = Date.now(); // for the handlers behind this one
await next();
});
client.updates.on(["newMessage", "newChannelMessage"], handler, { chats: ["durov"] });
client.updates.on("botCallbackQuery", [checkAuth, handleClick]);
client.updates.catch((error, update) => console.error(update?.className, error));
Telegram streams channel updates only to a session that keeps the channel open, so listening to a channel you are not a member of takes a subscription. watch keeps it alive and stops on the returned function:
const stop = client.updates.watch("durov", (update) => console.log(update.message.id));
Event builders still work, and give the event objects with their shortcuts:
import { NewMessage } from "teleproto/events";
client.updates.on(new NewMessage({ pattern: /^\/start/ }), (event) =>
event.message.reply({ message: "hi" }));
The older client.addEventHandler(handler, new NewMessage({})) is untouched and runs before the chain.
Build them from a grid, by chaining, or both — the class is the builder:
import { InlineKeyboard, ReplyKeyboard, Keyboard } from "teleproto";
const keyboard = new InlineKeyboard()
.callback("Yes", "yes", { style: { color: "success" } })
.callback("No", "no", { style: { color: "danger" } })
.row()
.url("Open", "https://t.me/durov");
await client.sendMessage(chat, { message: "Well?", buttons: keyboard });
Reply keyboards carry their own options, and Keyboard holds the markups without buttons:
const menu = new ReplyKeyboard([], { resize: true, placeholder: "Pick one" })
.text("Hi")
.requestPhone("Share phone")
.requestPeer("Pick a chat", { buttonId: 1, peerType, max: 3 });
Keyboard.hide({ selective: true });
Keyboard.forceReply({ placeholder: "Answer here" });
Only bots can attach keyboards; the server drops them from messages sent by a user account.
Layer 229 replaced the separate keyboardButtonUrl, keyboardButtonCallback and their fifteen siblings with keyboardButton and keyboardInlineButton, each carrying a type. Code that matched the old classes needs the new shape:
if (button instanceof Api.KeyboardInlineButton) {
if (button.type instanceof Api.InlineButtonTypeCallback) handle(button.type.data);
if (button.type instanceof Api.InlineButtonTypeUrl) open(button.type.url);
}
Every method in Telegram's TL schema is callable through client.api, typed and autocompleted:
const dialogs = await client.api.messages.getDialogs({ limit: 10 });
const full = await client.api.users.getFullUser({ id: "me" });
Or construct requests manually:
import { Api } from "teleproto";
const config = await client.invoke(new Api.help.GetConfig());
teleproto uses a three-part version MAJOR.LAYER.PATCH:
1.225.x ships layer 225).This stays compatible with npm's range syntax:
^1.225.0 accepts new layers and patches — recommended default.~1.225.0 sticks to layer 225 only; useful if you depend on
schema specifics that newer layers might change.1.225.1 is an exact pin.Runnable scripts live in teleproto_examples/:
print_updates.ts — log every update the client receivesprint_messages.ts — listen for new messages onlyreplier.ts — auto-reply pattern for bots and userbotsinteractive_terminal.ts — REPL against a live clientEach is self-contained. Set your credentials at the top and run:
% npx ts-node --transpile-only teleproto_examples/print_updates.ts
Please see CONTRIBUTING.md.
teleproto is distributed under the MIT License.