The code examples in this series are written in C#/.NET 10 (since that's what our production uses), but this article is about Telegram mechanics, not the language. The exact same architecture can be built on Node.js, Python, or Go without changes. That easily transferable mechanic is exactly why this article was written.
What's this article about? This is the third part of a series about how we vibe-coded GoosleeBot — a bridge from Telegram to Google Meet, Zoom, and other video conferencing services. The first two parts covered the foundation; this one covers the tricks: UX techniques that look like magic but cost a couple of evenings. Here's what's inside:
- how to make a bot "call" — so the user's phone behaves like a real incoming call, even though the Bot API can't make calls at all;
- why the familiar web pattern "click → server generates a link → redirect" is impossible in Telegram — and how to design buttons when there's no redirect;
- how to build an entire onboarding funnel in a single JSON file — with a typing effect, slides, and per-step analytics;
- how one provider interface handles six video meeting services — from OAuth APIs to bare deep links;
- and more.
And here's the full series:
- Part 1 — product history and an honest verdict on Telegram as a platform: rules for agent memory, the "where am I?" function, tunnels.
- Part 2 — the session mechanism: parameters, authentication, feedback, editing without getting banned.
- Part 3 (you are here) — UX hacks: a bot that "calls", links prepared before a click, a scripted chat funnel.
Hack 1. A Bot That Calls
The problem. Our product is about calls, and the Bot API can't make calls at all — a bot can only send a message. But "I created a meeting and the other person didn't notice" is the main scenario killer. A message with a meeting link is a letter. What's needed is a call: persistent, repeating, vibrating in your pocket until you pick up.
The solution. A phone doesn't respond to a "call" — a phone responds to a notification. So the notifications need to keep coming, one after another, like a ringtone. The trick: the bot sends a message "📞 Dmitry is calling you" with "Accept" / "Decline" buttons, waits a few seconds — deletes it and sends it again. Each resend is a new push: the phone vibrates and lights up again. From the user's perspective, this is indistinguishable from a real dial-in; from the chat's perspective, there's always one neat message, not a staircase of ten identical ones.
The entire mechanic is one background scheduler service. Step by step:
create a "ring" job by call session key (idempotent: second request = no-op)
recipients = chat participants, except the initiator, who have calling enabled
loop for up to N attempts with interval T:
delete the previous call message (if any)
send a new one: "X is calling you" [Accept — URL] [Decline — callback]
stop conditions (checked every tick):
participant joined the meeting / pressed "Decline"
call ended or cancelled / session expired
after stopping: delete all lingering call messages
Edge cases that turn the trick into a feature (and that an agent won't handle on its own — dictate them):
- Idempotency. An impatient initiator presses "Call" three times — only one job should be created. The job key = the call session key (the same session from Part 2).
- Stop on join. The service monitors the call state: if the other person joins the meeting, ringing stops immediately — even if they never pressed "Accept" in the chat and just followed the link from the message.
- Cleanup. After stopping — delete all remaining call messages. A "missed call" shouldn't hang in the chat as clutter.
- Cooldown. Re-ringing the same call — no earlier than after a pause. Otherwise the "Call" button becomes a spam weapon and your bot becomes a candidate for being blocked.
- Consent. The recipient must have a "can I be called" flag. Unsolicited repeated pushes are what gets bots banned by people, not by Telegram.
Rule for agent memory: a bot "call" = a "delete and resend" loop with hard stop conditions and self-cleanup.
Hack 2. Links Are Ready Before the Click, Because There Will Be No Redirect
This is the most counterintuitive lesson of the series, so I'll take it slow.
How you're used to it in the web: the user clicks → a request hits the server → the server processes it (creates a meeting, issues a token, selects a provider) → forms the final URL → responds with a redirect → the browser navigates. The click triggers the computation; the link is its result.
In Telegram: this pattern doesn't exist. A URL button in a message is a ready-made link, hardcoded at the moment the message is sent. Between "user pressed" and "client opened URL" there is no your server — Telegram itself instantly opens whatever is in the button. There's nowhere to plug in "process and redirect."
Consequence: all the "redirect work" has to be done upfront — before the user clicks, and even before they see the button. The "Accept" button in the call message already contains a personal link to the join page; it was computed and stored in the session at the moment the bot composed the message. Here's what it looks like for us:
// when building the call message, BEFORE sending:
var prepareUrl = BuildPrepareCallUrl(callSession, recipient); // all the logic is here
callData.PrepareCallUrl = prepareUrl; // and stored in session state
var keyboard = new InlineKeyboard(
UrlButton(t("Accept"), prepareUrl), // a ready-made final URL
CallbackButton(t("Decline"), $"{callSession.Key}@@@decline"));
What this changes in design — three habits to replace:
- Think "what transitions are possible from this screen" instead of "what to do on click." Each button = a pre-computed route. If the route depends on who clicks (and there's one button in the chat for everyone — hello, Part 2), the button holds the URL of a router page that will figure things out by session and token on its own side.
- Separate "expensive" from "instant." Creating a meeting at the provider takes seconds; those seconds can't be hidden behind a URL button click. Either create the meeting upfront, or lead to an intermediate "Connecting…" page that honestly shows the process (we use the second option, with WebSocket status updates from Part 2).
-
The same applies to return links. A "return to Telegram" link from an external browser (a
tg://deep link after OAuth) — is also prepared upfront and depends on the platform (the "where am I?" function from Part 1).
Rule for agent memory: in Telegram, a link is not the result of a click, but its precondition. Everything clickable must contain the final URL at the moment it's rendered.
Hack 3. An Onboarding Funnel in a Single JSON File
The problem. A user signs up — and you need to walk them through it: what this is, why, how to make their first call. Classic options are expensive: a chain of bot messages is a state machine in code with endless "change the text of step three" edits; a full tour in a miniapp is weeks of frontend work.
The solution. Onboarding is a chat. So let it be a chat — just a scripted one. A miniapp page renders "a conversation with the bot," and the entire scenario lives in a single JSON file: message nodes, button transitions, slides. Here's a real excerpt from our funnel (shortened):
{
"chat-intro": {
"chatvelocity": 66, // typing speed, characters per second
"messages": [
{
"name": "intro",
"text": "<b>Hey!</b> I'm GoosleeBot — calls via Meet, Zoom… right from your chat.",
"buttons": [
{ "key": "see_examples", "text": "💡 Example situations", "goto": "examples" },
{ "key": "how_it_works", "text": "❔ How it works", "goto": "how" },
{ "key": "finish", "text": "Return to Telegram", "href": "@return_to_bot" }
]
},
{
"name": "how_to_call",
"text": "Tap the button in the chat — the bot creates a call…",
"slides": [ "step1.png", "step2.png", "step3.png" ]
}
]
}
}
One frontend for all scenarios: types text at a given speed, shows a slide carousel, renders buttons. goto — transition to a graph node, href — external link, the special @return_to_bot closes the miniapp and returns to the chat. Every button press is sent to the server as an analytics event — and there's your per-step funnel for free: how far users get, where they drop off, which button is dead.
Why this is cheap and why it's worth stealing:
- New scenario = new JSON. Help content, settings page, promo for a new feature — without a single line of code. Text edits are file edits — a non-programmer can do them (or an agent — a perfect vibe-coding task: "add a step about group calls to the funnel").
-
Localization = files per locale (
chats_ru.json,chats_en.json) with fallback to default. The catalog requirement from Part 1 is handled automatically. - This is your product presentation. The same engine that onboards a new user shows "what's new" to existing users and sells features — a funnel and a showcase in one mechanism.
Instead of an Epilogue: How One Interface Handles Six Providers
The promised final topic — briefly, because after three articles it's almost obvious. The "bridge" rests on one interface:
public interface ICallProvider
{
CallProviderKind Kind { get; }
bool DirectLinkCall { get; } // can the link be built without an API?
Task<ProviderResult> CreateAsync(CallContext ctx); // → meeting link
}
And the adapters under it come in three flavors, in descending order of complexity:
- OAuth API (Google Meet, Zoom): full integration — user authorization, token storage and refresh, meeting creation via the provider's API. The most expensive and most functional.
- Room generation (Jitsi): no account, no API — a link with a random room name; the room is created on first join. The adapter is twenty lines.
- Deep link (FaceTime, Teams): just a correctly assembled link to someone else's app.
On top — auto-selection: if the author has a preferred provider configured — use that one; if not — the first available by priority. The user pressed one button, and which of the six bridges fired underneath it is an implementation detail. For the vibe-coder, the main takeaway is start with a third-flavor adapter. Our MVP in two days (from Part 1) was possible precisely because the Jitsi adapter is string generation; the OAuth complexity of Meet and Zoom came later, once the idea was already proven.
Series Wrap-Up
Three articles — one thesis: Telegram gives a business application an obscene head start (distribution through chat, authentication without registration, real-time out of the box), but in return it demands that you learn its rules: a webview is not a browser, a button is not a request, a link is not the result of a click, and a chat message is a projection of server state. Learn them yourself — or just dictate them to your agent: all the rules from the series are formulated to be copy-pasted into CLAUDE.md as-is.
Try the bridge yourself: @GoosleeBot · gooslibot.com


Top comments (0)