This package contains a node which act as a Telegram Client. It is based on teleproto which implements the mtproto mobile protocol. (see https://core.telegram.org/mtproto). Unlike node-red-contrib-telegrambot it does not support the telegram bot api. The package can be used to create so-called userbots or selfbots which to automate things under your own user-name. However you should be aware of the fact, that if you cause flooding and other havoc telegram will quickly ban your account either for 24h or even forever. It is recommended to use a test account while developing.
If you want to support this free project. Any help is welcome. You can donate by clicking one of the following links:
You can install the nodes using node-red's "Manage palette" in the side bar.
Or run the following command in the root directory of your Node-RED installation
npm install node-red-node-telegrambot --save
Note that the minimum node-red version 1.3.7 and minimum nodejs version is 20.x.
The nodes are tested with Node.js v18.12.1 and Node-RED v3.0.2.
Up to version 0.2.1 this package used GramJS, which was archived in 2026. teleproto is a maintained fork of it. Your stored session keeps working — see ADR 0013.
Coming from an earlier version? MIGRATION.md has the upgrade path, including the two manual steps anyone on 0.1.x needs.
Changes can be followed here.
The Telegram client receiver node receives messages from like a telegram client. You need to login with a phone-number and an API ID and API Hash in order to be able to receive message under your own user name. In addition to that you can also login using a bot token retrieved from @botfather.
You can create an API ID and Hash when you login to your telegram account here https://my.telegram.org/auth Then go to 'API Development Tools' and create your API ID and API Hash. Both are required when configuring your nodes.
The nodes log in only once, to produce a so-called session string, which is then used instead of an interactive login. Create it in the config node — either with a phone code or with a QR code, see below.
Do not use one of the websites that offer to generate a session string for you — this page used to link to one, and that was bad advice. A session string authenticates your whole Telegram account, so producing one on someone else's site means handing that site your phone number, your login code and, with them, your account. Both routes in the config node keep it on your own machine.
Press Login. The panel asks for one thing at a time:
- Telegram sends a code. Type it into Phone-Code and click outside the field — leaving the field is what submits it.
- The Password field appears next. It is only needed if your account has two-step verification; if it has none, the login finishes on its own and the panel closes.
If a password is already stored for this config node, it travels with the login and step 2 is skipped — the tip under the button says so. You do not need to clear the field first.
Instead of waiting for a phone code, click Login with QR and scan the code with a Telegram app that is already signed in — in the app: Settings, Devices, Link Desktop Device.
The code is replaced every half minute until you scan it. If your account has two-step verification, enter the password in the same field the phone-code login uses. On a machine where Telegram is installed you can click the link under the code instead of scanning.
Both routes produce the same session string, so it makes no difference afterwards which one you used.
The Telegram client receiver node receives message which are sent to your account or bot. Just add a debug node to the
output and investigate the objects in msg.payload.
Every event arrives in the same shape, so a flow can switch on one field:
msg.payload.type; // 'NewMessage' | 'EditedMessage' | 'DeletedMessage' | 'Album' | 'CallbackQuery' | 'Raw'
msg.payload.event; // the event itself, whatever the typeWhat each type adds next to those two:
type |
Also in msg.payload |
|---|---|
NewMessage |
message, sender, chat, originalUpdate |
EditedMessage |
message, sender, chat |
DeletedMessage |
deletedIds |
Album |
messages, originalUpdates |
CallbackQuery |
query |
Raw |
nothing — the update is msg.payload.event |
sender and chat are resolved for you, which costs a lookup per message; that is why the filters below are
worth setting on a busy account. Raw events also set msg.type = 'Raw' at the top level.
⚠ Changed in 2.0.0. Raw events used to put the update directly in msg.payload, with no
msg.payload.type. See MIGRATION.md — it is one path segment.
Tick the event types you want, and optionally narrow them down. Filtering is done by Telegram's event builders, so traffic you are not interested in never reaches the flow at all — which matters on a busy account, because every message that does arrive costs a sender and chat lookup.
| Field | Effect |
|---|---|
| Chats | Comma separated usernames or ids. Empty means every chat. |
| Exclude | Turns the chat list into an exclude list. |
| Direction | incoming only or outgoing only. Mutually exclusive in Telegram, hence one choice. |
| From users | Comma separated senders, independent of the chat. |
| Pattern | A regular expression the message text must match. |
Not every filter applies to every event type, because Telegram's builders differ:
- New messages and Edited messages support all of them.
- Callback query supports Chats, Exclude and Pattern.
- Deleted messages and Albums support Chats and Exclude only.
- Raw events support none — raw updates arrive before Telegram resolves entities, so there is nothing to match a chat against.
Leaving every field empty reproduces the behaviour of earlier versions: no filtering.
Every node that talks to Telegram shows what the connection is doing:
| Status | Meaning |
|---|---|
green ring connected |
the client is up |
red ring disconnected |
the connection dropped; it recovers on its own |
red dot broken: login again or redeploy |
the session is unusable or a reconnect gave up |
| red dot with a reason | the connect never succeeded — see below |
yellow ring flood wait Ns |
Telegram is throttling; it clears itself |
| blue dot | working: uploading, downloading, reading |
When a connect fails, the red dot names the cause rather than just saying disconnected:
no session: login first if the config node was never logged in, session invalid: login again if the
session died, api id or hash is wrong, and otherwise Telegram's own error code — which is worth searching
for or quoting in an issue. It turns green as soon as a connection works, and the reason is dropped then.
The Telegram client download node fetches the media on a received message — a photo, video, voice note or document. Wire it straight to a receiver output; it accepts what the receiver emits, so nothing has to be unwrapped.
[client receiver] --> [client download] --> [file out]
Output:
| Property | Contents |
|---|---|
msg.payload |
the file as a Buffer |
msg.filename |
the document's own name, or a generated one |
msg.mimetype |
the mime type (photos are always image/jpeg) |
msg.telegram |
the original message, so the sender and chat stay reachable |
That is the shape file out and http response already expect, so they can follow directly.
Two settings matter:
- Thumbnail — leave empty for the media itself, or give an index to fetch a thumbnail instead
(
0is the smallest). Useful when you want a preview rather than a 40 MB original. - Max size — in megabytes. A larger download is refused with an error instead of being read into
memory.
0disables the check. The size is not known in advance for every kind of media, and the check is skipped when it cannot be determined — a safeguard, not a guarantee.
The node shows downloading 42% while it works, and a redeploy stops a download in flight rather than
letting it stream into a node that no longer exists.
The Telegram client upload node sends a file to a chat — the mirror of the download node.
[file in] --> [client upload]
Inputs:
| Property | Contents |
|---|---|
msg.payload |
the file: a Buffer or a path as a string |
msg.filename |
required for a Buffer — a Buffer carries no name of its own |
msg.peer |
overrides the configured Send to |
msg.caption |
overrides the configured Caption |
msg.payload becomes the message Telegram created, so a following node can reply to it, edit it or pin
it. The Buffer is not carried through.
Why msg.filename is required: teleproto names an unnamed Buffer literally unnamed, so the file would
arrive in the chat called that. The node reports an error instead of sending it wrongly. A path needs no
filename — Telegram uses the file's basename.
For large files pass a path rather than a Buffer: the whole file otherwise has to fit in memory before it can be sent.
An array in msg.payload is sent as one album, which is how Telegram groups several photos or videos
into a single post. Every Buffer in it still needs its own name, so msg.filename is an array aligned by
index; a path in the array needs no entry.
msg.payload = [bufferOne, '/tmp/second.jpg', bufferThree];
msg.filename = ['first.jpg', undefined, 'third.jpg'];If any item is wrong the whole album is refused, naming the position — msg.filename[2] is required when msg.payload[2] is a Buffer. An album is a unit, and half of one arriving is worse than none.
The output is an array of the messages Telegram created, one per item.
| Property | Effect |
|---|---|
msg.silent |
overrides Silent — deliver without a notification sound. |
msg.replyTo |
a message id to reply to. Left out of the request entirely when unset. |
msg.caption |
overrides Caption. An array is accepted for an album. |
The node shows uploading 42% while the file goes up, so a large one does not look like a hang. A
redeploy cancels an upload in flight rather than letting it finish into a node that no longer exists.
There is no thumbnail option. Telegram ignores thumb unless the file is a JPEG under roughly 20 kB and
320×320 and the underlying media's dimensions are supplied through the raw TL attributes, which this
node does not expose — a field that mostly does nothing is worse than no field.
upload file flow upload album flow
The Telegram client list node reads existing data: a chat's message history, your dialogs (the chat list), or the participants of a group or channel.
None of these work through the sender node, because Telegram returns them as async iterators — putting
one into msg.payload gives a flow an object it cannot use. This node iterates and decides how the items
become messages.
one message per item (the default) emits each item separately with msg.parts set, so a standard
join node in automatic mode reassembles the array:
[inject] --> [client list] --> [join] --> [debug]
Use this for history: a large one never has to fit in memory at once. msg.parts.count comes from
Telegram's own total for the query, capped at the limit.
one message with an array emits a single message whose msg.payload is the whole array, with
msg.total alongside. Simpler for small reads — a chat list, the members of a group — but everything is
held in memory first.
| Limit | Meaning |
|---|---|
| blank | 100 items |
| a number | that many items |
0 |
no limit |
Telegram's own default here is unbounded. Iterating a busy channel back to its first message takes a
long time and can earn a FLOOD_WAIT; on a user account, repeatedly, that risks the account. 0 is
available, but it has to be asked for — the same convention as Max size on the download node.
Every setting the dialog offers can also arrive with the message, so one configured node can serve a flow
that reads different things. Send msg.payload as an object:
msg.payload = {
what: 'messages', // Read: messages | dialogs | participants
peer: 'me', // Read from: a username, a chat id, or "me" for Saved Messages
limit: 50, // Limit: 0 means everything
mode: 'stream', // Output: stream | array
search: 'invoice', // only messages and participants can be searched
};Leave a field out to keep what the node has configured. The ReadHistory example ships a Function node
that does exactly this.
The flat properties came first and still work:
| Property | Effect |
|---|---|
msg.peer |
overrides Read from. Ignored for dialogs. |
msg.limit |
overrides Limit. |
msg.search |
overrides Search. Ignored for dialogs, which Telegram cannot search here. |
msg.payload wins where both are given. A payload that is not an object — a timestamp from an inject,
a string — contributes nothing and simply triggers the read, so existing flows are unaffected. An unknown
what or mode is reported as an error rather than guessed at.
Any message triggers a read, and its other properties are carried through to every emitted message.
The node shows read n while it works, so a long read does not look like a hang. A redeploy stops it at
the next item rather than continuing to pull from Telegram.
The Telegram client sender node is able to call nearly all functions provided by teleproto. For a full list of client methods see the TelegramClient reference; for the raw MTProto requests see core.telegram.org/methods.
Leaving api out calls a method on the teleproto client directly, with args spread as its arguments —
so args must be an array:
msg.payload = { func: 'sendMessage', args: ['someone', { message: 'Hello' }] };Setting api builds a raw MTProto request, which takes a single options object:
msg.payload = { api: 'messages', func: 'SendMessage', args: { peer: 'someone' } };By default text is sent exactly as given. To get bold, links or code blocks, Telegram has to be told to interpret the text — either per message, which is usually what you want:
msg.payload = {
func: 'sendMessage',
args: ['someone', { message: '*bold* and _italic_', parseMode: 'md' }],
};or for every message this client sends, via Parse mode on the config node (Markdown,
MarkdownV2 or HTML).
Escaping: with a parse mode active, any text your flow did not write itself — a user's name, a value from an API, an error message — must be escaped first. A stray
*,_or<will render wrongly or make Telegram reject the message outright. If only some of your messages are formatted, leave the config-node setting off and passparseModeper message.
Messages that arrive while Node-RED is stopped or redeploying are normally lost — the receiver subscribes to the live stream and has no idea what it missed.
Catch up on the config node changes that. The position in the update stream is remembered in
<user directory>/telegram-updates/<node id>.json, and on the next start everything since is fetched and
emitted through the receiver. Replayed messages travel the same path as live ones, so a flow needs no
changes and cannot tell the difference.
Off by default, on purpose: after a long outage on a busy account this arrives as a flood the moment you deploy.
Two limits worth knowing:
- Telegram decides how far back it will replay. Past that it refuses, those messages are gone, and the log
says
getDifference: too long. - A message may be delivered twice around the boundary. Most duplicates are filtered out, but a flow that must not act twice should be idempotent.
Telegram will not let a client address a user by numeric id alone — it needs an access hash, which the
client only holds for peers it has already seen. That list normally lives in memory, so it is empty after
every restart: a flow that addresses peers by id works while you build it and then fails with
Could not find the input entity after a redeploy.
Remember peers on the config node keeps the list on disk, in
<user directory>/telegram-sessions/<node id>, so numeric ids keep working.
It is off by default, deliberately. That directory also holds this account's session key — the same secret as the session string, which otherwise lives only in Node-RED's encrypted credentials file. Anyone who can read the directory can act as your account. Turn it on only if you address peers by numeric id, and treat the directory like the credentials file.
Usernames and invite links never needed it: they are resolved on demand.
You do not need to resolve peers yourself — both calling conventions accept a username and let teleproto look it up. How you address a peer decides whether it keeps working, though:
| You pass | Works |
|---|---|
'username' |
Always. Costs one lookup, which Telegram then caches for the session. |
| An invite link | Always. |
| A numeric id | Only while that peer is in the session's cache. |
| A phone number | Only if that person is in your account's contacts. |
The catch is the numeric id. Telegram will not let you address an arbitrary user by id alone — it needs
an access_hash, which the client only holds for peers it has already seen in this session. That cache
lives in memory and is lost on every restart, so a flow that works while you are building it can
fail after a redeploy with:
Could not find the input entity for ...
Two more messages mean a peer problem without saying so. The node adds an explanation to the log for both:
| Error | What it means |
|---|---|
Cannot cast undefined to any kind of undefined |
No peer was passed at all — peer is undefined. |
Cannot cast User to any kind of peer |
The peer is an object that lost its class, e.g. one that went through JSON. |
The first is the one a flow built on a received message hits: msg.payload.chat comes from getChat(),
which returns nothing when the session has no access hash for that chat. msg.payload.message.peerId is
on every message and is the fallback to use — the shipped EchoMessage example does exactly that.
Address peers by username and none of this happens. If you only have an id, resolve it once in the
same flow with getEntity and pass the result on:
msg.payload = { func: 'getEntity', args: ['username'] };
// then send to msg.payload from the next nodePass a plain-JSON buttons description in the options object and the node turns it into the objects
Telegram needs — an array of rows, each row an array of buttons:
msg.payload = {
func: 'sendMessage',
args: [
'someone',
{
message: 'Pick one',
buttons: [
[{ type: 'url', text: 'Open the docs', url: 'https://docs.teleproto.dev' }],
[
{ type: 'callback', text: 'Yes', data: 'yes' },
{ type: 'callback', text: 'No', data: 'no' },
],
],
},
],
};type |
Needs | Effect |
|---|---|---|
url |
url |
opens a link |
callback |
data |
sends data back — bot mode only, see below |
switchInline |
— | starts an inline query, optional query, samePeer |
text |
— | a plain keyboard button; the text is sent as a message |
requestLocation |
— | asks the user to share their location |
requestPhone |
— | asks the user to share their phone number |
An invalid button is reported with its position, for example
buttons[1][0]: a 'url' button needs a 'url'.
Callback buttons only work in bot mode. Telegram sends the button press to the bot that created the button — the API documentation is explicit that the update is "sent to the bot". A user account has no bot, so it will never receive the press, and the receiver's Callback query event will stay silent. Use
urlbuttons for userbot flows, or set the config node's login mode to bot.
Anything on the client is reachable by name. The ones worth knowing:
| Area | Methods |
|---|---|
| Sending | sendMessage, sendFile, forwardMessages, editMessage, deleteMessages |
| Chats | pinMessage, unpinMessage, markAsRead, kickParticipant |
| Reading | getMessages, getDialogs, getParticipants |
| Media | downloadMedia, downloadFile, downloadProfilePhoto, uploadFile |
| Entities | getEntity, getInputEntity, getPeerId |
| Account | getMe, isBot, isUserAuthorized, checkAuthorization |
The iter* variants (iterMessages, iterDialogs, iterParticipants) return async iterators, which
do not survive being placed in msg.payload. Use the non-iterating methods until dedicated support
exists.
Careful: the client is shared by every node using the same config node. Connection and authentication methods —
connect,disconnect,destroy,start,signIn…,addEventHandler— are reachable too, and calling them from a flow will disrupt the other nodes.
To call the SendMessage function, you must do the following: Create a function node and enter 'messages' for the api property and 'SendMessage' for the func property. The arguments described in the api must be added to args. SendMessage contains a field randomId which must be set by the user to a random number to prevent message looping in the telegram server. Peer must be set to the name of the user you want to send the message to.
let randomId = BigInt(Math.floor(Math.random() * 1e15));
let username = msg.payload;
msg.payload = {
api: 'messages',
func: 'SendMessage',
args: {
peer: 'to username',
message: 'Test1',
randomId: randomId,
noWebpage: true,
noforwards: true,
scheduleDate: 0,
// sendAs: "from username",
},
};
return msg;To call the CheckUsername function, you must do the following: Create a function node and enter 'account' for the api property and 'CheckUsername' for the func property. The arguments described in the api must be added to args. In this case it is only the username property.
let username = msg.payload;
msg.payload = {
api: 'account',
func: 'CheckUsername',
args: {
username: 'usernameToCheckHere',
},
};
return msg;