GoyGram keeps its core zero-abstraction: dynamic dispatch, lazy raw fields, no generated models. The sugar layer is a set of thin wrappers on top of the transports, added in 0.7.74. Nothing below changes; everything here is optional convenience. If you never import it, you pay nothing.
Formatting helpers
goygram.sugar exposes a small html builder for Telegram HTML markup:
from goygram.sugar import html
text = html.join(
html.b("Title"),
html.code("x = 1"),
html.link("docs", "https://example.com"),
sep="\n",
)
await app.send_msg(chat, text, parse_mode="HTML")parse_mode="md" (MarkdownV2) is also supported on MTProto sends and edits: md_to_entities() converts *bold*, _italic_, __underline__, ~~strike~~, ||spoiler||, `code`, ```lang ...```, and [text](url) / [text](tg://user?id=...) into native entities, stripping escapes and markers from the plain text. Both parsers emit offsets in UTF-16 code units (what Telegram expects), so emoji before an entity never shifts it. md_escape(text) escapes MarkdownV2 specials for raw Bot API sends.
from goygram.sugar import md_to_entities, md_escape
plain, entities = md_to_entities("*hi* _there_ `[x](y)_")
await app.send_msg(chat, "*bold*", parse_mode="md")Full list: b/bold, i/italic, u, s/strike, code, pre(lang), link, mention(user_id, name), spoiler, quote(text, expandable=), emoji(custom_id), join(*parts, sep=).
Utility formatters live in the same module:
| Helper | What it does |
|---|---|
human_size(2048) | 2.0 KB |
human_duration(90) | 1m 30s |
chunk_text(long, 4096) | splits long text into Telegram-sized parts |
parse_entities_html(text, entities) | converts Bot API entities to HTML |
progress_bar(done, total) | [##### ] |
code_block(code, "py") | fenced block as a ready-to-send string |
plural_ru(3, "ключ", "ключа", "ключей") | Russian plural forms |
json_dumps(obj) | pretty JSON, ensure_ascii=False |
b64e/b64d | base64 bytes helpers |
rand_id() | random int for random_id fields |
Import them from the sugar module: from goygram.sugar import html, human_size, chunk_text.
Event object sugar
Every event object (e) gained read-only properties that pull from raw fields with sane fallbacks:
@app.on_msg()
async def h(e):
if e.is_private and e.has_text:
words = e.words # text.split()
n = e.word_count
urls = e.urls # url entities as strings
if e.is_reply:
orig = e.reply_msg # lazy Obj for reply_to_message
await e.reply(html.b("ok"))- Chat:
chat_type(also readsraw["chat"]["type"]),chat_title,username,is_private,is_group,is_super_group,is_channel - Sender:
full_name,mention(ready HTML link),from_id,is_out - Media:
media_type,file_name,file_size,mime,is_media,file_id - Text:
has_text,words,word_count,args_list,command_name,html_text,urls,entities - Time:
date_ts,edit_date_ts,ago("5m","2h","3d")
Methods mirror the client: reply(), respond(), edit(), delete(), ask(), copy_to(), forward_to(), typing(), mark_read(), get_chat(), get_sender(), download(), answer() (callbacks/inline), react().
Sending media
Media sends carry automatic metadata: send_voice/send_audio probe the real duration via media_duration() (stdlib wave for WAV, mutagen if installed, ffprobe fallback) and attach documentAttributeAudio with the true length, so voice bubbles never show 0:00. Non-OGG files sent as voice get their mime forced to audio/ogg. MTProto uploads now serialize nested constructors correctly (inputFile with md5_checksum) — photo/voice/audio sends work end-to-end.
One entry point, two transports. Pass bytes, a path, or an http(s) URL:
await app.send_photo(chat, "photo.jpg", "caption")
await app.send_doc(chat, b"report.pdf", via="bot")
await app.send_voice(chat, "note.ogg")
await app.send_sticker(chat, "CAACAgIAAx0...")send_photo, send_doc/send_document, send_audio, send_video, send_voice, send_sticker, send_animation all route through send_media(), which picks the active transport (via= overrides per call), guesses MIME by extension, and uploads via mt.upload_file when MTProto is active.
Other client helpers added in 0.7.74: edit_msg, delete_msg (single or list), get_chat/get_user (cached, refresh= to bypass), copy_msg (true copyMessage on Bot API), forward_msg, send_action, mark_read, send_reaction, pin_msg, ask, iter_dialogs.
Added in 0.7.75: send_rich/edit_rich (see Rich API), download_media (Bot API file_id or MTProto document/photo location, both transports), get_self/get_self(full=True) (self user dict, optionally with users.getFullUser), search_messages (MTProto messages.search), iter_search (lazy search iterator with pagination), vote_poll (messages.sendVote), get_forum_topics (Bot API getForumTopics or MTProto messages.getForumTopics), send_media_group (true album via messages.sendMultiMedia), send_contact/send_venue/send_location/send_poll/send_dice, promote_member/ban_member/unban_member/create_invite_link, set_chat_title/set_chat_about, join_chat/leave_chat, get_chat_info, send_draft, pin_msg/unpin_msg/unpin_all, iter_participants/iter_members, and a full typing map in send_action (record_video, upload_document, choose_sticker, find_location, record_video_note, upload_video_note, ... with progress=).
Dialog iteration
async for d in app.iter_dialogs(limit=50, batch=100):
print(d.get("peer"), d.get("top_message"))MTProto-only, paginated via messages.getDialogs, same lazy pattern as iter_history.
Keyboard builder shortcuts
KbdBuilder got row-level shortcuts (see Keyboards page for the full API): url(), cb(), copy(), switch(), web(), plus row(), line(btn_dict), join(other_builder), len(), and truthiness. Everything chains.
New filters
42 new filters join the existing set (251 total exported). Highlights:
from goygram.filters import has_document, file_ext, arg_int, in_chat, reply_to_me, outgoing, mime_prefix- Media presence:
has_photo,has_video,has_audio,has_voice,has_document,has_sticker,has_animation,has_video_note,has_contact,has_location,has_poll,has_dice - Arguments:
args_count(n),args_n(n),arg_is(i, v),arg_int(i),arg_float(i),caption(sub?) - Chat scope:
in_chat(ids...),chat_id_range(lo, hi),from_chat_type(types...),outgoing,incoming,silent_msg - Reply/forward:
reply_to_me,forwarded_from(uid),edited_recently(within) - Content:
text_lower,has_digits,only_digits,is_command,url_contains,hashtag(tags...),has_caption_entities(type?),mime_is,mime_prefix,file_ext(exts...),cmd_group(names...) - Time/chaos:
time_window(start, end, tz),weekday(days...),random_chance(p)
Error handling
Register handlers for exceptions raised inside your own handlers:
@app.on_error
async def report(evt, exc):
await app.send_msg(admin_chat, f"handler died: {exc!r}")Sync functions are supported too. Handlers fire after the internal log entry; a broken error handler is logged, never propagated. StopPropagation is still respected — it is not an error.
Conversations
conv_wait matching is now precise: an incoming message resolves the waiting future with key (chat_id, from_id) first, then (chat_id, None), and only falls back to any-waiter when the event carries no sender. Obj.ask(prompt) waits for the same user who triggered it by default (from_me=True waits for yourself instead).
@app.on_cmd("form")
async def form(e):
await e.reply("Name?")
name = await e.ask()
if name is None:
await e.reply("timed out")
return
await e.reply(f"Hi, {html.b(name.text)}")Admin and moderation
Rights are plain dicts, mapping is identical on both transports:
await app.ban_member(chat, user, until=ts)
await app.unban_member(chat, user)
await app.kick_member(chat, user)
await app.promote_member(chat, user, rights={"can_delete_messages": True}, title="mod")
await app.demote_member(chat, user)
await app.restrict_member(chat, user, {"send_messages": True}, until=ts)
await app.set_slow_mode(chat, 30)Invite links (MTProto path uses messages.getExportedChatInvites / messages.editExportedChatInvite):
links = await app.get_invite_links(chat, admin_id=me)
await app.edit_invite_link(chat, link, name="main", member_limit=50)
await app.revoke_invite_link(chat, link)
await app.approve_join_request(chat, user)
await app.decline_join_request(chat, user)on_update receives typed updatePendingJoinRequests / updateBotChatInviteRequester events with chat_id / from_id resolved.
Stories
MTProto-only domain (Bot API has no story methods for bots):
mine = await app.get_stories("me")
res = await app.send_story("me", "photo.jpg", "caption", pinned=True, period=86400)
await app.edit_story("me", story_id, caption="new")
await app.delete_story("me", [1, 2])
await app.read_stories(peer, max_id=5)
views = await app.get_story_views(peer, [1])
link = await app.export_story_link(peer, story_id)updateStory / updateReadStories / updateSentStoryReaction arrive as typed update events with owner_id / story_id.
Stars and gifts
balance = await app.get_stars_balance()
gifts = await app.get_star_gifts()
await app.send_star_gift(peer, gift_id, message="happy birthday")send_star_gift follows the real flow: inputInvoiceStarGift → payments.getPaymentForm → payments.sendStarsForm. updateBotChatBoost arrives typed with the raw boost payload.
Drafts and scheduled messages
await app.save_draft(chat, "half-written text")
drafts = await app.get_all_drafts()
sched = await app.get_scheduled_messages(chat)
await app.send_scheduled(chat, "hi", schedule_date=ts)
await app.delete_scheduled(chat, [msg_id])updateDraftMessage arrives typed with chat_id and the raw draft payload.
Takeout (data export)
Any MTProto call accepts takeout_id= and is automatically wrapped into invokeWithTakeout:
t = await app.start_takeout(files=True)
tid = t["result"]["id"]
res = await app.mt_req("account.getPrivacy", takeout_id=tid, key={"_": "inputPrivacyKeyStatusTimestamp"})
await app.finish_takeout(tid, success=False)Init and finish do not wrap themselves; every other call with takeout_id rides inside the takeout context.
Update coverage
on_update now maps every significant update family to typed fields before reaching raw fallback: typing (user/chat/channel), stories, folders/dialog filters, boosts, reactions (message + bot variants), join requests, drafts, pinned messages, read history, user status/name/phone/emoji, service notifications, deleted messages, and channel participant changes. Unknown updates still arrive as update_type + raw without data loss.