# Telegram sign-in and rewards (/docs/build/telegram)



If your community lives on Telegram, members can sign in with their Telegram account and earn rewards for what they do in your groups. For example:

* **"Join our Telegram group"**, checked automatically.
* **"Get a ❤ from an admin"** on a message you posted in the group.
* **"Join the conversation"**, a daily reward for posting in the group.
* **"@ana just received +10 XP by getting a ⚡ from @sam"**, posted in the group whenever an admin tips.
* **Sign in without a password** inside your own Telegram Mini App.

Everything runs through Domino's own Telegram bot. You never create a bot for your groups or handle a token.

## What members see [#what-members-see]

* **A "Continue with Telegram" button** in your app on the web, called **Telegram Login**.
* **Instant sign-in inside your Mini App.** A member who opens your Mini App in Telegram is signed in, or signed up, without a button.
* **One account, either way.** Both reach the same Telegram account, so a member who signs in once each way is still one member.
* **A "Connect Telegram" button** for members who signed in another way.
* **Rewards for group activity**, such as joining a group, posting in it, or getting a reaction from an admin.
* **A message in the group when someone earns**, if the quest announces it.

Domino stores the member's numeric Telegram ID, name, @username and profile photo. It never asks for their phone number, and it never needs your bot's token.

|                | Telegram Login                    | Mini App sign-in                                   |
| -------------- | --------------------------------- | -------------------------------------------------- |
| Where          | Your app in a browser             | Your Mini App, opened inside Telegram              |
| Bot            | Domino's own bot                  | Your bot, the one your Mini App runs on            |
| Turned on by   | Domino, for the platform          | You, per project and environment, in Console       |
| What it proves | Telegram's OpenID Connect sign-in | Telegram's signature on the Mini App's launch data |

## What you can do in Console [#what-you-can-do-in-console]

| Without code                                               | Where                                         |
| ---------------------------------------------------------- | --------------------------------------------- |
| Turn on sign-in inside your Mini App                       | **Settings → Sign-in**, **Telegram Mini App** |
| Connect groups and channels, see their status, remove them | **Integrations**, **Telegram**                |
| Pick the group a quest announces in                        | The quest's **Settings**                      |
| See a member's announcements, retry one that failed        | The member's page, **Announcements**          |
| Unlink or relink a member's Telegram account               | The member's page, **Identities**             |

The sign-in buttons, the join quest and reaction rewards come from code. The starters already show the Telegram Login button.

> [!DEV] Telegram is not an `AuthProvider` in SDK 1.x
> `AuthProvider` and the lists typed with it (`relay.auth.accounts()`, `useSignInOptions().options`, `useAccounts()`) stay as they were, so code that switches over providers keeps working. The open lists beside them include Telegram: `useSignInOptions().signInProviders`, `relay.auth.providers().signInProviders`, `relay.auth.signInAccounts()`, `useAccount("telegram")` and, for operators, `management.members.accounts(member)`. `relay.auth.start({ provider: "telegram", mode })` and `relay.auth.disconnect("telegram")` work too. What only Telegram does, Mini App sign-in, is `relay.telegram.miniApp` from `@domino-sdk/relay/telegram`, so apps that don't import it don't carry it.
>
> Telegram's older members still work through SDK 1.x and are removed in 2.0.0: `relay.auth.telegram.start`, `.account`, `.disconnect` and `.miniApp`, `telegramMiniAppInitData()` from `@domino-sdk/relay/browser` and `@domino-sdk/relay/react`, `useTelegramAccount()`, `useSignIn().withTelegram()`, `useSignInOptions().telegram`, `providers().telegram.login`, `management.participantSignIn().telegram.login`, and `management.members.telegramAccount` and `unlinkTelegramAccount`. Each one's documentation names its replacement.

## Offer Telegram Login [#offer-telegram-login]

Telegram Login is available once Domino has turned it on for the platform. The starters' sign-in components already show the button, with the other providers'; in your own app, render one button per sign-in provider:

```tsx
import { useSignInOptions } from "@domino-sdk/relay/react"

export function ProviderButtons() {
  const signIn = useSignInOptions()

  return (signIn.signInProviders ?? []).map((provider) => (
    <button
      key={provider.id}
      type="button"
      disabled={signIn.isPending}
      onClick={() => void provider.start()}
    >
      Continue with {provider.label}
    </button>
  ))
}
```

The button takes the member to Telegram's consent screen, and Telegram sends them back signed in, or signed up.

> [!DEV]
> `useSignInOptions().signInProviders` lists every sign-in provider Domino configured, in Relay's order, with an entry `{ kind: "provider", id: "telegram", label: "Telegram" }` when Telegram Login is configured; it is `null` until the methods are read. A newer Relay can list a provider this SDK does not know, labelled with its name. Without React, `signInOptions(relay)` from `@domino-sdk/relay/browser` returns the same `signInProviders`; `relay.auth.start({ provider: "telegram", mode: "sign-in" })` returns the URL to navigate to.

## Sign in inside your Mini App [#sign-in-inside-your-mini-app]

### 1. Name your bot in Console [#1-name-your-bot-in-console]

Your Mini App must be your app: in @BotFather, point the bot's Mini App URL at the address Domino serves your app on. Domino refuses sign-in from any other address.

Open **Settings → Sign-in**, enter your bot's ID under **Telegram Mini App**, and save. The ID is the number before the colon in the bot's token, such as `123456789` in `123456789:AA…`. Test and Live each have their own setting. Clear the field to turn Mini App sign-in off.

> [!IMPORTANT] The bot you name vouches for its users
> Domino can't check that the bot is yours. A bot's owner receives the launch data of everyone who opens its Mini App, and that data signs them in to this project. Name only a bot your organization controls.

### 2. Sign in when the Mini App opens [#2-sign-in-when-the-mini-app-opens]

Telegram opens your Mini App with signed launch data. Your app reads it and signs the member in, or up:

```ts
import { createBrowserRelayClient } from "@domino-sdk/relay/browser"
import {
  telegramMiniAppInitData,
  withTelegram,
} from "@domino-sdk/relay/telegram"

const relay = withTelegram(createBrowserRelayClient({ baseUrl: "/relay" }))
const initData = telegramMiniAppInitData()

if (initData && !(await relay.auth.session())) {
  await relay.telegram.miniApp.signIn(initData)
}
```

Domino checks Telegram's signature for the bot you named, so launch data from another bot, or edited data, is refused. It accepts launch data at most five minutes old, and each launch signs in once. Check for a session first, as above, so a reload doesn't try again.

> [!DEV]
> `@domino-sdk/relay/telegram` is experimental. `withTelegram(relay)` adds `relay.telegram` to the client and answers the same client, so calling it again, for example on `useRelay()`'s client in React, is safe. `telegramMiniAppInitData()` reads the launch data, and `relay.telegram.miniApp.signIn(initData)` signs in. In React, `useSignIn().withTelegramMiniApp(initData)` does the same, with failures in its `error`.
>
> | Error code                | Why                                                    | What to do                                           |
> | ------------------------- | ------------------------------------------------------ | ---------------------------------------------------- |
> | `sign-in-method-disabled` | The project has no Mini App bot in this environment    | Name the bot in Console                              |
> | `invalid-signature`       | Telegram didn't sign the launch data for that bot      | Check the bot ID in Console                          |
> | `challenge-expired`       | The launch data is too old, or already signed in once  | Ask the member to close and reopen the Mini App      |
> | `forbidden`               | The request didn't come from your app's address        | Point the bot's Mini App URL at your app             |

> [!WARNING] Telegram's rules for crypto Mini Apps
> Telegram's [developer terms](https://telegram.org/tos/bot-developers#7-blockchain-integration) require Mini Apps with crypto features to be based only on TON. Check whether they apply to your project before you launch a Mini App. Telegram Login on the web is not a Mini App.

## Connect Telegram to a signed-in member [#connect-telegram-to-a-signed-in-member]

A member who signed in another way connects Telegram from your app:

```tsx
import { useAccount } from "@domino-sdk/relay/react"

export function TelegramAccount() {
  const telegram = useAccount("telegram")

  if (telegram.isConnected) {
    return (
      <button type="button" onClick={() => void telegram.disconnect()}>
        Disconnect Telegram
      </button>
    )
  }

  return (
    <button type="button" onClick={() => void telegram.connect()}>
      Connect Telegram
    </button>
  )
}
```

A Telegram account another member already uses can't be connected.

> [!DEV]
> `connect()` leaves for Telegram Login. Inside your Mini App, `relay.telegram.miniApp.connect(initData)` connects the user who opened it instead; then call `invalidate([relayKeys.accounts, relayKeys.experience])` from `useInvalidate()`, so the account and the quests that need it read again. `data` is the account: `status`, and `subject`, the numeric Telegram user ID. Without React, the account is the `"telegram"` entry of `relay.auth.signInAccounts()`; connect with `relay.auth.start({ provider: "telegram", mode: "connect" })` and disconnect with `relay.auth.disconnect("telegram")`.

## Connect a group or channel [#connect-a-group-or-channel]

Domino's bot reads your chats as an administrator.

### 1. Start in Console [#1-start-in-console]

Open **Integrations**, open the **Telegram** card, and choose **Add chat**. Console shows a one-time link that works for ten minutes.

### 2. Choose the chat in Telegram [#2-choose-the-chat-in-telegram]

Open the link and start the bot. It first says which organization and project the chat will be connected to: your organization's name, the project's name, its slug and the environment. Check it, then choose **Choose a group** or **Choose a channel**. Telegram lists only chats you administer, and adds Domino's bot to the one you pick as an administrator. The bot confirms the chat it connected and the project again.

Whoever opens the link connects the chat, so if you send it to another chat administrator, tell them which project to expect.

### 3. Return to Console [#3-return-to-console]

The chat appears in the list once Domino has checked that you and the bot are both administrators. Connect as many chats as the project needs; Test and Live each have their own.

Every Domino organization shares the same bot. If the bot already has an administrator right in the chat that you don't, such as posting messages in a channel, the bot replies with the rights you're missing and connects nothing. The chat's owner, or an administrator with those rights, connects it instead.

| Status              | Meaning                                                                         |
| ------------------- | ------------------------------------------------------------------------------- |
| **Ready**           | The bot is an administrator and reads the chat                                  |
| **Needs attention** | The bot was removed or is no longer an administrator; the issue says what to do |

**Remove chat** disconnects a chat from the project and environment. The bot leaves the chat once no project uses it.

> [!DEV]
> The management client has the same actions: `management.telegram.chats()`, `connect()` and `remove(chat)`. Telegram is also a [plugin connection](/docs/plugins/telegram): `management.connections.get("telegram")` and `domino connections get telegram` say whether the environment has a chat (`ready`), has one that needs attention (`attention`, with the chat's issue), or has none (`disconnected`). Chats connect only through the bot, so `domino connections connect telegram` answers 409 `plugin-managed`.

## Check membership in a quest [#check-membership-in-a-quest]

A join quest checks that the member is in one of your connected groups or channels. You pick the chat in Console from the ones you connected. The `telegram-membership` snippet is a complete join quest; install it as described in [Your first quest](/docs/build/first-quest).

The member needs their Telegram account in your app, by signing in with it or connecting it.

```ts
import {
  decision,
  defineQuest,
  manual,
  once,
  settings,
} from "@domino-sdk/relay/authoring"

export default defineQuest({
  key: "join-telegram",
  title: "Join our Telegram group",
  settings: settings.object({
    chat: settings.telegramChat({ label: "Telegram group" }),
  }),
  trigger: manual(),
  completion: once(),
  rewards: [],
  async evaluate(ctx) {
    // No chat chosen yet: nothing to check. Relay supplies ctx.telegram to every quest.
    const member = ctx.settings.chat
      ? await ctx.telegram!.member({ chat: ctx.settings.chat })
      : null

    return member?.isMember
      ? decision.accept()
      : decision.reject({
          reason: "Link your Telegram account and join the group, then try again.",
        })
  },
})
```

> [!DEV]
> - **The reader.** `ctx.telegram.member({ chat })` reads whether the member is in a connected chat, with their linked Telegram account. `settings.telegramChat()` names the chat; Console fills it from the connected chats.
> - **No `integrations` entry needed.** Relay provides `ctx.telegram` to every quest, declared or not; declaring `{ provider: "telegram" }` also publishes. It is typed optional only so that a test context written by hand before it keeps compiling, so call it as `ctx.telegram!.member(...)`; in a context a test builds, `fakeTelegramReader()` from `@domino-sdk/relay/testing` supplies it.
> - **The result.** `{ userId, username, status, isMember, isAdmin, untilDate }`, or `null` when the member isn't in the chat or hasn't linked Telegram. `status` is Telegram's: `creator`, `administrator`, `member` or `restricted`.
> - **Pinned once.** The member's Telegram account is pinned at the attempt's first Telegram read, so a later link or unlink doesn't change the attempt.
> - **Errors.** A chat the project hasn't connected, or one whose bot lost access, throws a `CapabilityError` with code `integration-required`; a Telegram outage throws `provider-unavailable`.

### Require a linked Telegram account [#require-a-linked-telegram-account]

A quest can also ask only that the member linked Telegram, such as an onboarding quest that wants a wallet, X and Telegram connected. It needs no connected chat. An account your app vouched for at sign-in, or one a member import brought in, counts like one the member connected.

> [!DEV]
> - **The reader.** `ctx.telegram.connection()` returns `{ userId }`, the numeric Telegram user ID as a string, or `null` when the member hasn't linked Telegram. It reads the same pinned account as `member()`, and asks neither Telegram nor Domino's bot.
> - **In a quest.** `const telegram = await ctx.telegram!.connection!()` and accept when it isn't `null`: Relay supplies both to every quest, and the types are optional only for test contexts written by hand. In the testing kit, answer it with `facts.telegram.connection`.

## Reward reactions [#reward-reactions]

Reward members for reactions in your connected groups, such as paying the author of a message when an admin reacts to it with a heart.

* **Linked accounts only.** Domino records a reaction only when the person reacting, or the author of the message, has linked Telegram in the project. Reacting to your own message doesn't count.
* **What the bot saw.** Domino knows who wrote a message only if it was posted while the bot was in the chat. Telegram sends no reactions from before the bot became an administrator.
* **Groups, not channels.** Telegram doesn't say who reacted to a channel post, so channel reactions are never recorded.
* **Removing a reaction** marks it removed. Adding the same reaction again later doesn't pay twice.

Your developer sets this up as a tracker, Domino's running record of activity, and a quest that pays from it:

```ts
import {
  automatic,
  decision,
  defineQuest,
  defineTracker,
  settings,
  telegram,
} from "@domino-sdk/relay/authoring"

export const reactions = defineTracker({
  key: "telegram-reactions",
  title: "Telegram reactions",
  source: telegram.reactions(),
  window: { start: Date.parse("2026-10-01T00:00:00Z") },
})

export default defineQuest({
  key: "admin-heart",
  title: "Get a ❤ from an admin",
  settings: settings.object({}),
  trigger: automatic({
    tracker: reactions,
    beneficiary: "messageAuthor",
    where: [
      { path: "reactorIsAdmin", equals: true },
      { path: "emoji", equals: "❤" },
    ],
  }),
  completion: ({ keyed }) => keyed(({ tracker }) => String(tracker.record.data.messageId)),
  rewards: [],
  async evaluate() {
    return decision.accept()
  },
})
```

This example pays a message's author once per message when an administrator reacts to it with a heart.

> [!DEV]
> `telegram.reactions()` is a tracker source of the reactions members add to messages in the project's connected groups and supergroups. Each record is one added reaction: its `author` is the reactor, and its `messageAuthor` account is the author of the message. Define the tracker in its own file and list it under `trackers` in `relay.json`, like any tracker. Each record's `data` has scalar fields, so a trigger's `where` can select on them:
>
> | Field                             | Value                                                                                                                   |
> | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
> | `chatId`, `chatType`, `messageId` | The message reacted to                                                                                                  |
> | `reactionType`                    | `emoji`, `custom_emoji` or `paid`                                                                                       |
> | `emoji`, `customEmojiId`          | The emoji, such as `"❤"`, or the custom emoji's ID; null for other types                                                |
> | `reactorStatus`, `reactorIsAdmin` | `creator` or `administrator` for the chat's administrators, read at most ten minutes earlier; `member` for anyone else |
>
> - **One chat or all.** A tracker records every connected group; select one with `{ path: "chatId", equals: "-100…" }` in `where`.
> - **Once per reaction.** A quest on a Telegram tracker runs on each record's first observation (`on: "new-record"`) unless it sets `on`, so a reaction removed and added again is the same record and doesn't run it again. Quests built with SDK 1.62 or earlier ran on every observation (`"new-observation"`) unless they set `on`, and keep that mode until republished with a newer SDK.
> - **Self-reactions.** Relay skips a reaction to one's own message with `beneficiary-is-author`.
> - **Relay writes these records.** In SDK 1.x the source is a custom source on the reserved namespace `telegram-reactions`; sending records to it is refused.

### Tip with reactions, capped per giver and per receiver [#tip-with-reactions-capped-per-giver-and-per-receiver]

A tip pays a message's author each time an admin reacts with ⚡ or ❤. Two daily limits keep it fair:

* **Per receiver.** A member earns at most 20 tips a day, from everyone together.
* **Per giver.** Each admin gives at most 10 tips a day, to everyone together. This holds whether or not the admin has linked Telegram: Domino counts the Telegram account that reacted.

After either limit is reached, the next tip that day pays nothing, and that reaction doesn't pay later either. A new day starts at midnight in your [project calendar](/docs/concepts/calendar).

Each tip that pays is also [announced in the group](#announce-tips-in-the-group), replying to the tipped message: "@ana just received +10 XP by getting a tip from @sam". Pick the group in the quest's **Settings**.

```ts
import {
  automatic,
  budget,
  decision,
  defineQuest,
  defineTracker,
  points,
  settings,
  telegram,
} from "@domino-sdk/relay/authoring"

export const reactions = defineTracker({
  key: "telegram-tip-reactions",
  title: "Telegram reactions",
  source: telegram.reactions(),
  window: { start: Date.parse("2026-10-01T00:00:00Z") },
})

export default defineQuest({
  key: "admin-tip",
  title: "Get tipped by an admin",
  settings: settings.object({
    balance: settings.pointLedger({ label: "Point ledger" }),
    chat: settings.telegramChat({ label: "Telegram group" }),
  }),
  trigger: automatic({
    tracker: reactions,
    beneficiary: "messageAuthor",
    where: { path: "reactorIsAdmin", equals: true },
  }),
  // Every tip pays: one completion per reaction.
  completion: ({ keyed }) => keyed(({ tracker }) => tracker.record.id),
  budgets: [
    budget({ key: "tips-received", scope: "member", period: "day", limit: 20, cost: 1 }),
    budget({ key: "tips-given", scope: "author", period: "day", limit: 10, cost: 1 }),
  ],
  review: () => ({ enabled: false }),
  rewards: ({ settings }) => (settings.balance ? [points(settings.balance, 10)] : []),
  // Posted after the points are saved; none until a group is picked.
  announcements: ({ settings }) =>
    settings.chat
      ? [
          telegram.postMessage({
            chat: settings.chat,
            text: "{beneficiary} just received +{points} XP by getting a tip from {author}",
            replyToTriggeringMessage: true,
          }),
        ]
      : [],
  async evaluate({ tracker }) {
    return ["⚡", "❤"].includes(String(tracker.record.data.emoji))
      ? decision.accept()
      : decision.reject({ reason: "Tip with ⚡ or ❤." })
  },
})
```

> [!DEV]
> `scope: "member"` counts per member paid, here the message's author. `scope: "author"` counts per record author, the account that caused the record: for a reaction, the reactor's Telegram account, whether or not a member linked it. The same works on `discord.reactions()`, keyed by the reactor's Discord account. An author budget needs a tracker trigger; `domino check`, `domino build` and `domino deploy` refuse it on other quests with `AUTHOR_BUDGET_TRACKER_REQUIRED`. A reaction over either limit is rejected with code `budget-exhausted`. Author budgets don't appear in a member's quest `budgets`, since they cap someone else's giving.

## Reward messages [#reward-messages]

Reward members for posting in your connected groups, such as a daily reward for joining the conversation or a streak of active days.

* **Linked accounts only.** Domino records a message only when its poster has linked Telegram in the project.
* **People, not bots.** Bots' messages, service messages such as joins and pins, and messages sent on behalf of a chat are never recorded.
* **Groups, not channels.** A channel post is authored by the channel, so channel posts are never recorded.
* **No text.** Domino records that a message was posted, never what it says.
* **Edits and deletes.** An edited message is the same message. Telegram doesn't tell bots when a message is deleted, so a deleted message stays recorded.

The `telegram-contribution` snippet pays a member at most once per day for posting; install it as described in [Your first quest](/docs/build/first-quest). Days follow the [project calendar](/docs/concepts/calendar). In the quest's settings, pick the point ledger and, optionally, one group; with no group picked, posts in any connected group count.

> [!DEV]
> `telegram.messages()` is a tracker source of the messages members post in the project's connected groups and supergroups, one record per message, keyed `chatId:messageId`. Its `author` is the poster. Define the tracker in its own file and list it under `trackers` in `relay.json`, like any tracker. Each record's `data` has scalar fields, so a trigger's `where` can select on them:
>
> | Field                             | Value                                                                                                     |
> | --------------------------------- | --------------------------------------------------------------------------------------------------------- |
> | `chatId`, `chatType`, `messageId` | The message: the chat's numeric ID as a string, `group` or `supergroup`, and the message's ID             |
> | `replyToMessageId`                | The message it replies to; null when it isn't a reply                                                     |
> | `threadId`                        | The forum topic, or the reply thread outside forums; null when Telegram names none                        |
>
> ```ts
> import { defineTracker, telegram } from "@domino-sdk/relay/authoring"
>
> export default defineTracker({
>   key: "telegram-messages",
>   title: "Telegram messages",
>   source: telegram.messages(),
>   window: { start: Date.parse("2026-10-01T00:00:00Z") },
> })
> ```
>
> - **One chat or all.** A tracker records every connected group; select one with `{ path: "chatId", equals: "-100…" }` in `where`, or compare `ctx.tracker.record.data.chatId` with a `settings.telegramChat()` setting, as the snippet does.
> - **Messages from before the chat was connected** are not recorded.
> - **A streak instead of a daily reward.** Replace the snippet's keyed completion with `streak({ days: 7 })` and remove its daily budget. See [Calendar-day streaks](/docs/reference/quest-rules#calendar-day-streaks).
> - **Relay writes these records.** In SDK 1.x the source is a custom source on the reserved namespace `telegram-messages`; sending records to it is refused.

## Announce tips in the group [#announce-tips-in-the-group]

A quest can post a message in one of your connected groups each time it pays, such as "@ana just received +10 XP by getting a ⚡ from @sam" when an admin tips a message. The message is what makes tipping visible to everyone in the group.

1. **Install or write the quest.** Your developer adds the announcement to the quest; see below.
2. **Pick the group.** In the quest's **Settings**, choose the group from your connected chats. The picker says when Domino's bot can't post in a chat.
3. **Publish.** Publishing checks that the chat is connected in this environment and that the bot can post there.

How it behaves:

* **Points first.** The message is posted after the points are saved. If Telegram is down or refuses the message, the member keeps the points.
* **It keeps trying.** A failed post is retried with backoff for about a day. If Telegram refuses it for good, for example because the bot was removed, the member's **Announcements** in Console show why, and **Retry delivery** posts it once you've fixed the cause.
* **Paced, never dropped.** Telegram lets a bot send about 20 messages a minute in one group, so Domino posts at most one message every 3 seconds per chat. A burst of tips waits in line, oldest first. If Telegram asks Domino to slow down, the whole chat waits.
* **People are mentioned.** The member who earned is mentioned by their @username, or by a link to their Telegram account that shows their name. The same goes for whoever triggered it, such as the admin who tipped. A member with no Telegram account in the project is named by their Domino display name, or as "someone" while their profile is hidden.
* **Replies, if you want.** The message can reply to the tipped message. A message quest that doesn't reply still posts in the forum topic the message was in.
* **Test stays in Test.** A Test environment posts only in chats connected to Test, and never in a chat any project connected to Live. Publication refuses such a chat too.
* **Revoking.** Revoking the completion cancels a message not yet posted. A message already posted stays in the chat, and restoring the completion doesn't post a cancelled one.

> [!DEV]
> Add `announcements` beside `rewards`. `telegram.postMessage({ chat, text, replyToTriggeringMessage })` builds one; a quest posts at most three. Like `rewards`, `announcements` can be a function of the settings, so it can return none until a chat is picked. [Tip with reactions](#tip-with-reactions-capped-per-giver-and-per-receiver) is a complete quest that announces each tip:
>
> ```ts
> import { telegram } from "@domino-sdk/relay/authoring"
>
> // In defineQuest({ … }): `chat` is a settings.telegramChat() setting, "" until a group is picked.
> const announcements = ({ settings }: { settings: { chat: string } }) =>
>   settings.chat
>     ? [
>         telegram.postMessage({
>           chat: settings.chat,
>           text: "{beneficiary} just received +{points} XP by getting a tip from {author}",
>           replyToTriggeringMessage: true,
>         }),
>       ]
>     : []
> ```
>
> The text is a template, not code. These placeholders are the only ones; `{{` and `}}` write a literal brace:
>
> | Placeholder     | Becomes                                                                                       |
> | --------------- | --------------------------------------------------------------------------------------------- |
> | `{beneficiary}` | The member the quest paid                                                                     |
> | `{author}`      | The triggering record's `author`, such as the reactor. Only with an automatic tracker trigger |
> | `{points}`      | The amount of the first points reward the completion paid, such as `10`                       |
> | `{rewards}`     | Every points reward paid, with its ledger's name, such as `10 XP and 2 Gems`                  |
> | `{quest}`       | The quest's title                                                                             |
>
> - **Escaping.** Relay posts with Telegram's HTML formatting and escapes everything, the template included, so names can't add formatting or links.
> - **Replies.** `replyToTriggeringMessage` replies to the record's message when it is in the same chat, and posts a plain message otherwise. It, and `{author}`, need an automatic tracker trigger; `describe()` refuses them on other quests.
> - **Unknown placeholders** fail when the quest is built, never when it posts.
> - **Delivery records.** Each post is a reward delivery with `provider: "telegram"`, `action: "post-message"` (`RewardDeliveryAction.PostMessage`) and an empty `roleId`. `management.rewardDeliveries(member)` lists it and `management.retryRewardDelivery(id)` retries a failed one; the `reward.delivery.updated` and `reward.delivery.failed` activity events report it. An announcement that can't post at all, such as one for a chat not connected in this environment, creates no delivery: the attempt records why, and the points stand.

## Operators [#operators]

Console lists the member's Telegram account under **Identities** on the member's page, beside X and Discord. Reviewers unlink it, or relink one the member proved, with a reason, as they do the others. The member keeps their sign-in and can connect again. An imported or vouched Telegram account the member never used is released instead, so its owner can connect it. See [operator identity changes](/docs/concepts/participants#operator-identity-changes).

> [!DEV]
> The management client reads it with `management.members.identityStates.list(member)` and changes it with `identityStates.unlink` or `relink(member, { identity: { kind: "telegram", subject }, expectedRevision, reason })`. `management.members.accounts(member)` reads the current selection beside X's and Discord's, and `unlinkAccount(member, { provider: "telegram", subject, expectedRevision, reason })` unlinks it. The older `management.members.telegramAccount(member)` and `unlinkTelegramAccount(member, { subject, revision, reason })` are deprecated.
