skip to content
~/docs/bots
Bots

API reference

updated 2026-11-0211 min read edit this page

The telinha package (sdk/python), version 0.3. Everything is asyncio: methods marked await are coroutines. To get started, see the tutorial.

from telinha import Bot, Client, Player, Reaction, Member, Message, Denied
from telinha import Embed, Button, Click, Icon, Style
from telinha import Screen, VideoPlayer

Client

A connection to the room, with its state and events. Bot is a Client with commands; use Client directly when you don't need commands.

Client(invite, name, *, password=None, channel=None, channel_password="", bot_token="", key=None, reconnect=True)
Parameter
invite the invite (telinha://addr1,addr2/channel!fingerprint#password), or just host:port; an Invite works too
name the name in the room (up to 32 bytes; a repeated one gets (2))
password the room password, if the invite doesn't carry it
channel channel to join, if the invite doesn't carry one (otherwise the room's default)
channel_password that channel's password, if it has one (the SDK remembers the one from join_channel/join_call by itself, for reconnecting)
bot_token the bot token, if the room asks for one (--token-bots)
key a number identifying the bot for bans (default: drawn on every run)
reconnect reconnect by itself when the connection drops (default True)

State

Attribute
me the bot's id in the room
room the room's name
welcome the Welcome from the last join
members dict[int, Member]: who the bot can see (its text channel, its call and bots in any channel)
streaming the bot is sharing its screen
channel the current text channel
call the current call ("" = none)
channels dict[str, ChannelInfo]
room_locked the room is locked
fingerprint the server identity's fingerprint (or None if it has none)
web_link the link to watch from a browser ("" = none)

Methods

Method
run() runs until stopped (Ctrl+C too); blocks
await start() the same, inside an asyncio program; raises Denied / WrongPassword
await stop() leaves the room
await wait_ready(timeout=None) waits until the bot is in the room
on(event) handler decorator: @client.on("message")
off(event, fn) removes a handler
await send(text, *, embed=None, image=None, reply_to=0, to=0, wait=True) sends to the chat; reply_to quotes a message, to sends privately, embed attaches a card, image an image (a file, a link or a ChatImage; needs ffmpeg). Returns the recorded Message (or None). Over 4000 bytes becomes several messages
await edit(message_id, text, *, embed=None) changes one of the bot's own messages (text and card; the image stays)
await click(message_id, button) clicks a button on another bot's card
await react(message_id, reaction, on=True) adds (or removes) a reaction
await join_channel(name, password="") switches the text channel (creates it if missing and the server allows)
await join_call(name, password="") / await leave_call() joins a channel's call / leaves it
await set_voice(mic=None, deaf=None) microphone on (shown in the list) / deafened
await send_voice(opus) one Opus packet (48 kHz) in the call; prefer Player
await send_file(path, *, name=None, to=0, reply_to=0, progress=None) sends a file (up to 256 MB); returns its id
await download(file_info, dest) downloads a room file (msg.file); dest can be a folder
await start_stream(width, height, fps=30, *, audio=False) / await stop_stream() starts (or resizes) the bot's screen share / stops it; prefer Screen and VideoPlayer
await send_frame(h264, *, key, width, height, droppable=False) one H.264 frame (Annex B, one access unit) of the stream
await send_stream_audio(opus) one Opus packet (48 kHz stereo, 10 ms) of the stream's sound: goes only to viewers
member(id_or_name) finds someone by id or name (with or without @)
room_time_us() now, on the room clock

Events

Event Arguments When
ready welcome: Welcome joined the room (again too, after reconnecting)
message msg: Message a message in the channel chat or in private, including the bot's own and old ones (msg.history)
join member: Member someone appeared (in the bot's text channel or call; a bot, in any channel)
leave member: Member someone left
members members: list[Member] the list changed (anything: mic, stream...)
reaction message_id, member_id, reaction, on someone added or removed a reaction
action click: Click someone clicked a button on one of the bot's cards (the button's on_click is called too)
edit message_id, text, embed a message changed (a bot updating its card)
channels current: str, channels: dict the channel list changed (or the bot switched)
notice kind, text, extra something an admin did to the bot (Notice)
voice member_id, seq, opus someone's voice in the call (raw Opus packet)
web_link url the browser link changed
keyreq kind the room wants a full frame of the bot's stream (someone started watching); Screen answers it by itself
pointer member_id, x, y, flags the pointer of someone watching the bot: x, y from 0 to 1 on the video; flags & 2 = click
disconnect reason the connection dropped (it will retry)

Each handler runs in its own task; an error in one goes to the log and doesn't bring the bot down.

Bot

Bot(invite, name, *, prefix="!", help_command="ajuda", cooldown=1.0, accept_bots=False, lang="pt", **client_options)
Parameter
prefix what comes before commands
help_command the automatic help command's name (None = none; help also works)
cooldown seconds between commands from the same person
accept_bots obey commands from other bots
lang language of what the SDK answers by itself (wrong usage, errors, help): "pt" or "en"

@bot.command(name=None, *, help="", aliases=(), admin_only=False) registers a command. The function receives ctx and the arguments, converted by their types (int, float, str, bool, Member, *rest); the last str takes the rest of the line. bot.commands holds them all.

Raising CommandError("text") inside a command replies with that text to whoever called it. Any other error goes to the log and the bot answers with a generic error message.

Context

ctx.bot the bot
ctx.message the command's Message
ctx.command the Command
ctx.args the raw text after the command
ctx.author the calling Member (or None if they already left)
await ctx.reply(text) answers quoting; returns the Message
await ctx.send(text) sends to the same place (channel or private); returns the Message
await ctx.reply_card(embed, text="", *, image=None) answers with a card, quoting; text is what apps before 3.4 show instead

Message

id the id in the room
author_id, author who sent it (0 = system notice)
text the text (Markdown as typed)
time_us the time on the room clock
history it's old (arrived because the bot just joined)
system it's a system notice ("Ana joined the room")
quote if it's a reply: Quote(id, author_id, author, text)
private, to_id, to_name private, and to whom
file if it has a file: FileInfo(id, name, size)
has_image has an image (the SDK doesn't decode images)
reactions [(reaction, member_id)] (only on old ones; new ones come through the event)
channel the bot's text channel when it arrived
embed the card (Embed), if it has one
author_channel, author_call where the author is (text channel and call; "" = not in a call). The server only tells bots, and it works in private too: that's how /msg Bot !join knows where to go
await reply(text) answers quoting (privately if it was private)
await respond(text) answers in the same place, without quoting
await react(reaction, on=True)
await edit(text=None, *, embed=None) changes the message (only the bot's own)

Member

id, name, mention mention = "@name"
bot is a bot
owner, admin created the room / is an admin
streaming, camera streaming the screen / the camera
mic, deaf, muted microphone on / deafened / muted by an admin
in_channel, in_call is in the bot's text channel / the bot's call
op, op_call is an OP of the bot's text channel / of the bot's call's channel (the creator is too)
route how they got in (1 this machine, 2 LAN, 3–4 internet, 5 public link, 6 browser)
version their app version
watching the streams they're watching

ChannelInfo

name, people (in the chat), in_call, topic, plus live, fixed, has_password, locked, hidden (invisible), op (the bot is an OP of it), created (the bot created it).

Player

Player(client, *, bitrate=128_000, stereo=True, ffmpeg="ffmpeg")
await play(source, *, volume=None, start=0.0, headers=None) plays a file or link to the end; returns True if it finished, False if stopped. Turns the bot's mic on while playing. headers: HTTP headers for the link (what yt-dlp says to use)
pause() / resume()
await stop() stops (play returns False)
set_volume(0.0–2.0) takes effect in about half a second, without restarting the song (the gain is applied to the samples)
playing, paused, position, source, volume

Needs ffmpeg (FFmpegMissing if absent).

Streaming the screen

A bot can go live like anyone else: whoever is in the same call clicks it and watches. Two ready-made pieces, in telinha.video:

screen = Screen(bot)
await screen.show("scoreboard.png")  # a file, link, bytes, a Pillow image or a ChatImage
await screen.clear()                 # stops streaming

player = VideoPlayer(bot, max_height=720)
await player.play("movie.mp4")       # or player.play(video_url, audio_url, headers=...)

Screen(client, *, max_side=1280): a still image (a panel, a scoreboard, a quiz question). Each show becomes one full frame, and whoever starts watching later gets the last one again: no new image, nothing is sent (almost zero CPU and bandwidth). Together with the pointer event, you can make a screen people click on.

VideoPlayer(client, *, max_height=720, bitrate=2_500_000, audio_bitrate=128_000):

await probe(source, headers=None) VideoInfo (codec, width, height, fps, duration, has_audio) from ffprobe
await play(video, audio=None, *, headers=None, start=0.0, info=None) plays to the end; True if it finished, False if stopped. audio: the sound as a separate link (YouTube sends it that way). The stream stays on at the end, so the next one follows on
pause() / resume() / await stop() pausing holds the picture and the sound together
playing, paused, position, info, passthrough passthrough: the video goes without re-encoding

H.264 up to max_height is passed straight through: the packets go as they came, without decoding or encoding again (YouTube video up to 1080p is H.264). It uses almost no CPU; B-frames nobody references go as droppable (the room skips them for whoever can't keep up). Other codecs (VP9, AV1) get re-encoded to H.264 by ffmpeg, and that's heavy (~1 core for 720p). The sound goes as the stream's sound (10 ms Opus, on the same clock as the picture), only to viewers. Viewers on an app before 3.6 without a hardware decoder see video with B-frames stutter.

Embed

A card in the chat: a colored bar, a small line on top (kicker), a title (a link, with url), Markdown text, fields side by side, a progress bar that moves by itself in each app, and up to 5 buttons. Apps before 3.4 show only the message text.

card = Embed(title="Popular Monster", kicker="NOW PLAYING", color=0xd946ef,
             description="by **Falling In Reverse**", url="https://...",
             progress=(31_000, 232_000), thumbnail=True, footer="SoundCloud")
card.add_field("Requested by", "Ana").add_field("Up next", "Voices in My Head")
card.add_button("Pause", icon=Icon.PAUSE, style="primary", on_click=pause)
card.add_button("Skip", icon="next", on_click=skip)
msg = await bot.send("Now playing: Popular Monster", embed=card, image="cover.jpg")
...
card.kicker, card.paused = "PAUSED", True
await msg.edit(embed=card)
title, url, kicker, description, footer text (the description takes **bold**, *italic*, `code` and [links](https://...))
color 0xRRGGBB of the bar (0 = the viewer's theme accent)
thumbnail / big_image the message's image goes as the cover (in the corner) / large, below
progress, paused (position_ms, total_ms); total 0 = live. Each app moves it by itself until paused
panel the panel of the bot's stream: whoever watches its screen (app 3.6+) sees the card docked under the video, with the buttons; in the chat it stays as usual
add_field(name, value, inline=True) up to 12
add_button(label="", *, icon=Icon.NONE, style="neutral", disabled=False, on_click=None) up to 5. style: neutral, primary, danger, success. on_click: async def f(click: Click)

Icon: PLAY, PAUSE, NEXT, PREVIOUS, STOP, SHUFFLE, REPEAT, VOLUME_UP, VOLUME_DOWN, LIST, HEART, MUSIC, RADIO, SEARCH, PLUS, CHECK, CLOSE, LINK, JOIN, TRASH, STAR, DICE (or the name as text: icon="next").

A click reaches only the card's author, at most every 120 ms per person, and edits at most every 200 ms (the SDK waits for its turn, so two edits in a row both get through). The SDK remembers the buttons of the last 300 cards.

Bot panel

await bot.set_panel(embed) puts a fixed card of the bot outside the chat: everyone in the room sees it from any channel (in the app, clicking the bot in the list opens its controls, and the title shows under its name). Its buttons work like a chat card's; the click arrives with click.panel == True. Call it again on every state change (the SDK coalesces and sends the latest; the server takes one every 200 ms on average) and set_panel(None) to remove it. After a reconnect the SDK sends the panel again. Servers before 3.9 ignore it. The music bot uses it to play, pause, skip and come to the caller's call.

Click

message_id, button which card and which button (from 0)
member_id, member_name who clicked
channel, call where they are
panel the click came from the bot panel (set_panel), not a message
await reply(text, **kw) answers quoting the card (from the panel: only to whoever clicked)
await whisper(text) answers only to whoever clicked (private)

Invite

Invite.parse(text) → addresses (a list of Address(host, port)), password, channel, fingerprint. str(invite) builds the invite back.

Errors

Denied the room refused or removed the bot; reason and code (Deny: KICKED, BANNED, CLOSED, LOCKED, FULL, BOTS...). Doesn't reconnect
WrongPassword wrong password
IdentityMismatch the server didn't prove it's the invite's (different fingerprint)
ConnectError couldn't connect (the reason is in the message)
BadInvite an invite with no usable address
CommandError inside a command: replies the text to whoever called it

Constants

  • Reaction: LIKE, LOVE, HAHA, FIRE, WOW, SAD (the six reactions in the app, in that order).
  • Notice: MUTED, UNMUTED, ADMIN_ON, ADMIN_OFF, CHANNEL, MOVED, DM, FILE_GONE...
  • Deny: the refusal reasons (above).

The protocol underneath is in Protocol.