/packs / telegram

telegram

Telling a user through Telegram, and hearing the operator's chat back, in the workspace.

  • integration
  • sources
  • Telegram

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member, merges · lands.

01 / apply it

yardr pack apply telegram --source-env telegram=<file> --notify <user>=telegram,<file>

yardr carries this pack in its binary; the name is enough. yardr pack check telegram shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/telegram/ --source-env telegram=<file> --notify <user>=telegram,<file>

From this site, as the files below.

02 / the files

Every file, as it ships.

# Telegram as a pack of kind source: the integration, imported into the
# yard and into no depot. What Telegram is here: one message to one chat
# through the Bot API's sendMessage, which the yard sends to the chat id
# of the person told.
#
#   yardr pack apply telegram --source-env telegram=<file> --notify <user>=telegram,<file>
#
# The source is named telegram: yardr knows Telegram as this pack alone.
name: telegram
kind: source
setup: SETUP.md

sources:
  telegram:
    declaration: telegram.yaml
# Setting up Telegram

The pack wires Telegram into the yard as the declared source `telegram`:
one message to one chat through the Bot API's sendMessage; chats from
users with the operator role are heard back and answered with `yardr
reply`. What lands in a user's inbox then reaches their phone, through
the bot below; what an operator writes to the bot reaches the yardmaster's
inbox.

1. **The bot.** In Telegram, message @BotFather with a name and a
   username, then send:
   ```text
   /newbot
   ```
   It answers with the bot's token, digits, a colon and letters:
   `110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw`. It is the *yard's*:
   anyone with it sends as the bot.
2. **One message to the bot.** From the Telegram account for the user
   named by the `operator` setting, open the bot and press Start, or
   send it anything. Until that user
   has written to it there is no chat, and the yard's messages have
   nowhere to go.
3. **Apply the pack.** As an operator. The steps use `mckean` as the
   user named by the `operator` setting; replace it with that user's
   name in this yard.
   ```sh
   yardr pack apply telegram --notify mckean=telegram
   # source telegram is declared: 4 calls under https://api.telegram.org (yardr source show telegram)
   # user mckean: told outside the yard: through telegram, which is not set up: user mckean has no env_file: yardr user key mckean telegram gives TELEGRAM_CHAT_ID
   ```
   `--notify` is telling, and telling through telegram is optional:
   hearing the chat back (steps 8 on) wants only the chat id in that
   user's env file (step 6), whatever tells them. To keep being
   told through pushover and chat through telegram, apply without it:
   ```sh
   yardr pack apply telegram
   # source telegram is declared: 4 calls under https://api.telegram.org (yardr source show telegram)
   ```
4. **Give the yard the bot's token.** No file is written by hand: the
   command asks for its value and shows nothing as it is typed or
   pasted, and the yard's server writes it to a file only you can read
   (`~/.yardr/sources/telegram.env`). The command below is one thing
   to type, in a block of its own.
   ```sh
   yardr source key telegram           # TELEGRAM_BOT_TOKEN, the bot's token: an operator's to give
   ```
   Or have a crew member ask for it, and answer on its card, in
   footplate or in `yardr chat` (`ctrl+o`), where the field shows dots:
   ```sh
   yardr bead ask <bead> "The Telegram bot's token" --secret telegram --context "@BotFather answered /newbot with it."
   ```
   The secret goes nowhere else: not into mail, chat, a bead or a pack.
5. **The chat id: write to the bot, and confirm it is you.** No
   number is looked up and none is pasted. Once hearing is set up
   (steps 9 to 12 below: the webhook secret, the hook and Telegram
   told where to send), write anything to the bot from the setting-named
   user's Telegram account, as in step 2. The delivery reaches the yard
   from a chat it has no address for, so it is dropped -- and, while
   that user has no chat id in telegram, the yard asks them on
   a card, in footplate or in `yardr chat`:

   > A message reached telegram from chat 123456789 (first words:
   > 'hi'). Is this you?

   The card shows the chat id and at most the first 80 characters of
   the message; the message itself is kept nowhere else. One such
   card per source waits at a time.
6. **Confirm the card, and the yard has the chat id.** Approving the
   card (`yardr bead answer <bead> <ask> --approve`, or the card's
   button in footplate) writes `TELEGRAM_CHAT_ID` into that user's
   env file (`~/.yardr/users/mckean.env`, mode 0600), exactly where
   this command writes it. If the `hearer` setting names a crew member,
   the same approval subscribes that member to messages from this chat;
   the card names who will hear it. `yardr init` sets `hearer` to
   `yardmaster` unless it was already set:
   ```sh
   yardr user key mckean telegram      # TELEGRAM_CHAT_ID, that user's chat id
   # user mckean: TELEGRAM_CHAT_ID is set in …/mckean.env
   ```
   That command, typed unseen, is still the way to give or replace
   the chat id by hand, and a crew member can still ask for it on a
   card, answered where the field shows dots:
   ```sh
   yardr bead ask <bead> "Your Telegram chat id" --secret telegram --for-user mckean --context "The chat id the claim card showed, or the one under result at getUpdates."
   ```
   Declining the claim card drops it and writes nothing. Once the
   chat id is written, step 7's test message can be sent, and the
   next message from that chat is heard by the named crew member, when
   `hearer` names one (step 13).
7. **One test message, on a card.** A crew member asks for it with one
   ask on one bead:
   ```sh
   yardr bead ask <bead> "May the yard send the test message?" --run "source check telegram --user mckean"
   ```
   Its card says it will send one real test message to that user's
   chat, with the message whole, and on approval the yard sends it. The
   phone shows "yardr: a test", and the approval records `telegram:
   told mckean: 200 in …ms`. This is one real message through Telegram.
   Anything but 200 fails the approval with the status, and the ask
   stays open: 401 is a token it does not take, 400 a chat id it does
   not take or a chat that never wrote to the bot, so look at steps 1,
   2, 4 and 5 again; approving again retries it. By hand it is yours
   instead:
   ```sh
   yardr source check telegram --user mckean
   ```

Where a user is told through telegram, mail to them and
their own timers are told from now on. To see who is told through what:
```sh
yardr user list
```

## Hearing operator chats

The pack hears chats belonging to users whose role is `operator`. A
message from a member's chat is dropped — answered 200, recorded nowhere,
never answered — even where that member's env file sets the address.
Telling a member through telegram tells them, but hears nothing back.
While the user named by the `operator` setting has no chat id set, a
dropped message offers that user the claim card of steps 5 and 6. The
claim only sets that user's address; another operator sets their own
address with `yardr user key <user> telegram`. Write to the bot in a
private chat, one to one; if an operator's chat id is a group's, every
member of that group is heard.

The yardmaster treats a line from your chat as your word, as your mail:
it discusses, shapes, builds and lands what you ask there. A number in
the chat answers no ask: asks keep their card, told on the phone as
`yardmaster asks`, answered at the yard, in footplate or through a
device. Approving a run and giving a secret stay the operator's at the
terminal too: the yard refuses both from a chat. Pictures and files
arrive as a bracketed note (`[photo]`, `[document: <name>]`, `[voice
message]`), and the yard fetches them into its blob store with the
source's token (docs/integrations.md, "Telegram, where it differs").
There is no `thread open telegram`, no status and no bead link to a chat.

8. **Two-step verification on the Telegram account.** Turn it on, in
   Telegram under Settings → Privacy and Security. Whoever holds the
   account writes as that user: a taken-over account can have the
   yardmaster shape, build and land. Two-step verification adds another
   barrier to taking over the account.
9. **The webhook and its secret, on one card.** Set the yard's public
   hook address once, as the operator, to the address Telegram can
   reach this yard's hooks at (its host, on a port Telegram takes:
   443, 80, 88 or 8443):
   ```sh
   yardr settings set hook_public_url https://<his host>/hooks
   ```
   The URL of the telegram hook is then
   `https://<his host>/hooks/telegram`. A crew member then sets the
   hook up with one ask on one bead (`--run "hook add telegram
   --source telegram"`; no secret ask, and no secret typed): its card
   says it will call `POST /bot{credential}/setWebhook` at
   `https://api.telegram.org` to register that URL, and on approval
   the yard makes the signing secret (no one types it), keeps it in
   `<home>/hooks/telegram.secret` (mode 0600) which the hook names,
   and makes that call with the URL and the secret. The secret is in
   no note, event, answer or card. If the call fails the hook stays
   in place and approving again retries it. One hook serves the yard.
   `yardr hook list` shows it with STATE `ok`; step 12's `getWebhookInfo`
   shows what Telegram holds.

   By hand, the secret and the call are yours to make instead. The
   secret goes in a file, mode 0600, which the hook names, as a Linear
   signing secret does. A new file takes its mode from the shell's
   umask, so narrow it first: with the usual 022 the secret is
   readable by others until the chmod. Each command below is one thing
   to type, in a block of its own.
   ```sh
   umask 077
   ```
   ```sh
   openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' > ~/.yardr/telegram.secret
   ```
   ```sh
   chmod 600 ~/.yardr/telegram.secret
   ```
   ```sh
   yardr hook add telegram --source telegram --secret-file ~/.yardr/telegram.secret
   ```
   The manual steps above remain available by hand; the card above
   is the way a crew member sets it up.
10. **The crew member's credential.** The yardmaster answers with the
   bot's token, as every reply is answered with the crew member's own
   credential: there is one bot for the whole yard and it speaks as the
   bot. The token is the same value the source's env file holds, in the
   crew member's env file. Have a crew member ask for it, and answer on
   its card, in footplate or in `yardr chat` (`ctrl+o`), where the field
   shows dots:
   ```sh
   yardr bead ask <bead> "The token it answers with" --secret telegram --for-crew yardmaster --context "@BotFather answered /newbot with it."
   ```
   The yard's server writes it into the crew member's env file, or with
   none set into `~/.yardr/crew/yardmaster.env`, which is then set as
   `yardr crew set` sets it. The running session does not see the new
   value: it takes effect when it next starts, so restart it:
   ```sh
   yardr crew restart yardmaster
   ```
   By hand it is yours instead. First note which file that is:
   ```sh
   yardr crew show yardmaster
   ```
   If the crew member has none, choose one. Appending creates the file
   where there is none, with the shell's umask for its mode, so narrow
   it first. Append the token to that file, `<that file>` below:
   ```sh
   umask 077
   ```
   ```sh
   printf 'TELEGRAM_BOT_TOKEN=%s\n' "$(grep TELEGRAM_BOT_TOKEN ~/.yardr/sources/telegram.env | cut -d= -f2-)" >> <that file>
   ```
   ```sh
   chmod 600 <that file>
   ```
   The crew member does not start unless its env file is 0600 or
   stricter. Only when it had none, name the file and restart:
   ```sh
   yardr crew set yardmaster env_file=<that file>
   ```
   ```sh
   yardr crew restart yardmaster
   ```
   Rotation rewrites both files, the source's and the crew member's.
11. **The subscription.** When `hearer` names a crew member, approving
    the claim in step 6 creates this subscription (a fresh setup defaults
    to `yardmaster`). For a chat id entered by hand, check the member with
    `yardr settings get hearer` and run:
    ```sh
    yardr subscribe <hearer> telegram --scope all --where type=message --where chat=<his chat id>
    ```
    `all` is needed, not `involved`: the first message mentions nobody
    and is assigned to nobody. The brakeman subscribes to nothing: he
    reaches the chat through the hearer, as today. Set no telegram
    identity on any crew member: the API sends the bot only others'
    messages, so there is no echo of its answers to exclude. And do not
    subscribe the user himself: his own messages would arrive as
    notifications of his own acts and be told back to the phone he is
    holding.
12. **Tell Telegram where to send.** The hook card of step 9 does
    this: on approval the yard calls `setWebhook` itself with the
    public URL from `hook_public_url` and the secret it made. By hand
    it is yours instead: `<his host>` is the host his GitHub and Linear
    webhooks have, on a port Telegram takes: 443, 80, 88 or 8443. The
    bot's token is part of the address, and an argument can be read by
    every process on the machine while curl runs, and stays in the
    shell's history: the token and the webhook secret are read from
    their files and go to curl as config lines on its stdin, written by
    the shell itself. Type neither into a command.
    ```sh
    printf 'url = "https://api.telegram.org/bot%s/setWebhook"\ndata = "secret_token=%s"\n' "$(grep TELEGRAM_BOT_TOKEN ~/.yardr/sources/telegram.env | cut -d= -f2-)" "$(cat ~/.yardr/telegram.secret)" | curl -K - -d url=https://<his host>/hooks/telegram -d allowed_updates='["message"]'
    ```
    That Telegram took it, whichever way it was told:
    ```sh
    printf 'url = "https://api.telegram.org/bot%s/getWebhookInfo"\n' "$(grep TELEGRAM_BOT_TOKEN ~/.yardr/sources/telegram.env | cut -d= -f2-)" | curl -K -
    ```
13. **Try it: one line from the phone.** Write this to the bot:
    ```text
    Can the shed land on Monday?
    ```
    The yardmaster's inbox has it as a thread delivery:
    ```text
    4211 10-13 17:40  hook (all) telegram message create by mckean on "mckean": Can the shed land on Monday?
    ```
    Answer it where it came from, with the words on stdin (here: Noted; I
    will post here when it lands.):
    ```sh
    yardr reply 4211 -
    ```
    The answer is a message from the bot in the same chat, and arrives
    once: the API sends the bot only others' messages, so no echo of
    the answer arrives. Three limits, where you meet them: a reply run
    again after its answer was lost posts twice, since the service
    takes no idempotency key; `yardr thread show` on a telegram delivery
    shows nothing, since a bot cannot read a chat's history — the
    delivery's own text is in the inbox line and the raw body in `yardr
    hook show <seq>`; and `yardr reply --to` is refused for telegram,
    since it finds the conversation by listing the thread.

    A picture or a file goes with the answer, `--file` once for each, the
    words still on stdin (here: The shed, as built.):
    ```sh
    yardr reply 4211 - --file shed.png
    ```
    A picture arrives as a picture and anything else as a document. The
    words are its caption where they fit in 1024 characters, else they
    go first as a message of their own and the files follow, one after
    another. A user told through telegram is told with a picture too:
    the first image of a mail, or of the newest note on a done bead
    that has one, goes as a photo with the words for its caption. The
    service takes a photo of 10 MB at most and 10000 pixels in width
    and height together; a reply with one it refuses fails with the
    service's words, and a tell with one is told as words alone.
14. **Ending hearing.** `deleteWebhook` ends hearing while leaving
    telling as it was:
    ```sh
    printf 'url = "https://api.telegram.org/bot%s/deleteWebhook"\n' "$(grep TELEGRAM_BOT_TOKEN ~/.yardr/sources/telegram.env | cut -d= -f2-)" | curl -K -
    ```
    So does taking the address out of that user's env file: delete
    the `TELEGRAM_CHAT_ID` line from `~/.yardr/users/mckean.env` (the
    `mckean` from step 3), and that user's chat is heard no more.
    Setting that user's `notify` to empty ends telling alone now: hearing
    follows the address, not `notify`, so the chat is heard while the
    address is set, whatever the user is told through.
#!/bin/sh
# A verified Telegram Update to the yard's envelope: stdin
# {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
# internal/hooks feeds it the Update bodies its tests recorded and compares
# the envelopes with the design's (sections 3.3 and 3.7 of
# docs/design/2026-10-13-yardr-c55e-telegram-hears.md).
#
# Every Update gets an envelope, a stranger's and an unknown kind too: the
# hear key drops chats not belonging to operator-role users after this
# returns, and a
# non-zero exit would be answered 400, which the service retries. Nothing
# of the body is ever printed to stderr.
exec jq -c '
def obj: if type == "object" then . else {} end;
def str($v): if ($v | type) == "string" then $v elif $v == null then "" else "\($v)" end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if index($x) then . else . + [$x] end);
# mentions is the envelope'"'"'s standard shape: the lowercase names a text
# mentions with @, once each, in order. An @name in code is text, not a
# mention. A Telegram username is at most 32 letters, digits and _.
def mentions($md):
  ((($md // "") | gsub("(?s)```.*?(```|$)|`[^`\n]*`"; " "))
   | [scan("(?:^|[^A-Za-z0-9_@./-])@([A-Za-z0-9_]+)")
      | .[0] | ascii_downcase | select(length <= 32)]
   | ordered);
# display is how the service names a user or a private chat: first and
# last name, else the username.
def display($u):
  ($u | obj) as $o
  | ([($o.first_name // ""), ($o.last_name // "")] | map(select(type == "string" and . != "")) | join(" ")) as $n
  | if $n != "" then $n else (($o.username // "") | if type == "string" then . else "" end) end;
# mmss is a duration in seconds as the phone shows it.
def mmss: (. / 60 | floor) as $m | (. % 60) as $s | "\($m):\(if $s < 10 then "0" else "" end)\($s)";
def nonblank: if type == "string" and . != "" then . else null end;
# fileref is a file the message carries as the envelope names it: the
# service'"'"'s file_id, the name the sender gave, the kind, the size where
# the service said. Fetched by thread get once the delivery is kept; this
# is handed no credential and fetches nothing.
def fileref($kind):
  obj
  | select((.file_id | type) == "string" and .file_id != "")
  | {id: .file_id, type: $kind}
    + (if (.file_name | type) == "string" and .file_name != "" then {name: .file_name} else {} end)
    + (if (.file_size | type) == "number" then {size: .file_size} else {} end);
# files are the message'"'"'s photo (its largest size, by file_size, else the
# last the service lists), document, voice, video and audio. A sticker,
# an animation and a video note stay their bracketed note alone.
def files($m):
  [ ($m.photo | if type == "array" and length > 0
                then (if any(.[]; (obj | .file_size | type) == "number")
                      then max_by(obj | .file_size // 0) else last end)
                     | fileref("photo")
                else empty end),
    ($m.document // empty | fileref("document")),
    ($m.voice // empty | fileref("voice")),
    ($m.video // empty | fileref("video")),
    ($m.audio // empty | fileref("audio")) ];

(.body | if type == "object" then . else error("telegram Update is not an object") end) as $u
| (($u.message | obj) | if ((.chat | obj).id) == null then null else . end) as $m
| if $m != null then
    ($m.chat | obj) as $chat
    | (($chat.id) | "\(.)") as $id
    | (($m.text | nonblank) // ($m.caption | nonblank)
       // (if ($m.photo // null) != null then "[photo]"
           elif ($m.document // null) != null then
             ((($m.document | obj).file_name // "") | if type == "string" and . != "" then "[document: \(.)]" else "[document]" end)
           elif ($m.voice // null) != null then
             ((($m.voice | obj).duration // null) | if type == "number" then "[voice message \(.|mmss)]" else "[voice message]" end)
           elif ($m.video // null) != null then "[video]"
           elif ($m.video_note // null) != null then "[video note]"
           elif ($m.audio // null) != null then "[audio]"
           elif ($m.animation // null) != null then "[animation]"
           elif ($m.sticker // null) != null then "[sticker]"
           else "[message]" end)) as $text
    | {source: "telegram", type: "message", action: "create",
       actor: {id: str(($m.from | obj).id), name: display($m.from)},
       thread: {id: $id, title: (if ($chat.type // "") == "private"
                                 then display($chat) else str($chat.title) end)},
       text: ($text | trunc), mentions: mentions($text),
       message: str($m.message_id), parent: str(($m.reply_to_message | obj).message_id),
       fields: {chat: [$id]}}
    + (files($m) | if length > 0 then {files: .} else {} end)
  else
    ([$u.edited_message, $u.channel_post, $u.edited_channel_post,
      $u.my_chat_member, $u.chat_member, $u.chat_join_request,
      $u.message_reaction, $u.message_reaction_count]
     | map(((. | obj | .chat | obj).id) | select(. != null)) | .[0]) as $cid
    | ([$u.callback_query]
       | map((((. | obj | .message | obj).chat | obj).id) | select(. != null)) | .[0]) as $qcid
    | ($cid // $qcid) as $found
    | {source: "telegram", type: "update", actor: {}, thread: {}, mentions: []}
    | if $found == null then . else . + {fields: {chat: ["\($found)"]}} end
  end
'
#!/bin/bash
# thread: what the yard does in a Telegram chat: reply, posted, read, get,
# and tell for notify.image. One JSON object on stdin, one on stdout, as
# the thread key of the declaration says, and the notify key for tell.
# TELEGRAM_BOT_TOKEN and YARDR_SOURCE_BASE, the declaration's base,
# are in the environment. Needs curl, jq, file and the standard shell
# utilities it calls (grep, head, wc, tr).
#
# The service gives a bot no history to read and no idempotency key to
# set, so posted and read answer what there is to find, nothing, and a
# reply retried after a lost answer posts twice.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }

send_message() { # one text to the chat, by sendMessage; prints the message's id
  text=$1
  # The token is part of the address, and an argument can be read by every
  # process on the machine while curl runs: the address goes to curl as a
  # config line on its stdin, written by the shell itself.
  out=$(printf 'url = "%s"\n' "$YARDR_SOURCE_BASE/bot$TELEGRAM_BOT_TOKEN/sendMessage" \
    | curl -sS --fail-with-body -K - \
    -H 'Content-Type: application/json' \
    -d "$(jq -n -c --arg c "$thread" --arg t "$text" '{chat_id: $c, text: $t}')") || {
    code=$?
    desc=$(describe "$out")
    echo "sendMessage to chat $thread failed${desc:+: $desc} (curl exit $code)" >&2
    return 1
  }
  jq -e .ok >/dev/null <<<"$out" 2>/dev/null || {
    desc=$(describe "$out")
    echo "${desc:-sendMessage failed}" >&2
    return 1
  }
  mid=$(jq -r '(.result.message_id // "") | tostring' <<<"$out" 2>/dev/null) && [ -n "$mid" ] || {
    echo "sendMessage answered no message" >&2
    return 1
  }
  printf '%s' "$mid"
}

describe() { # what the service said of a refusal, for an error the daemon logs: one line of printable characters, 200 at most, the token and chat id taken out should the service echo them. The delete names its range in octal because busybox tr reads [:print:] with -c as literal characters.
  local d
  d=$(jq -r '.description // empty' <<<"$1" 2>/dev/null | tr -d '\000-\037\177') || d=""
  d=${d//"$TELEGRAM_BOT_TOKEN"/}
  d=${d//"$thread"/}
  printf '%s' "${d:0:200}"
}

qform() { # a value for curl's -F, quoted, so that a ; or , or " in a file's name is no part of the option's syntax
  local s=${1//\\/\\\\}
  printf '"%s"' "${s//\"/\\\"}"
}

send_media() { # one picture or document to the chat, by sendPhoto or sendDocument; prints the message's id
  method=$1; field=$2; file=$3; caption=$4
  [ -f "$file" ] || { echo "$method found no file at $file" >&2; return 1; }
  # The part goes with a type and a name, as a client's upload does. A
  # file the yard keeps is called by its hash, which names nothing a
  # person could open: it goes as photo.png or document.
  mime=$(file -b --mime-type "$file" 2>/dev/null) || mime=""
  [[ $mime =~ ^[a-z0-9.+_-]+/[a-z0-9.+_-]+$ ]] || mime=application/octet-stream
  name=${file##*/}
  if [[ $name =~ ^[0-9a-f]{64}$ ]]; then
    case "$mime" in
    image/png) name=$field.png ;;
    image/jpeg) name=$field.jpg ;;
    image/gif) name=$field.gif ;;
    image/webp) name=$field.webp ;;
    image/bmp) name=$field.bmp ;;
    *) name=$field ;;
    esac
  fi
  # The address goes to curl on its stdin, as send_message's does; the
  # chat and the caption go as data, never parsed for @ or <. An error
  # names the method and what the service said, not the chat: a tell's
  # error is logged and recorded, and the chat is the person's address.
  args=(--form-string "chat_id=$thread" -F "$field=@$(qform "$file");type=$mime;filename=$(qform "$name")")
  if [ -n "$caption" ]; then args+=(--form-string "caption=$caption"); fi
  out=$(printf 'url = "%s"\n' "$YARDR_SOURCE_BASE/bot$TELEGRAM_BOT_TOKEN/$method" \
    | curl -sS --fail-with-body -K - "${args[@]}") || {
    code=$?
    desc=$(describe "$out")
    echo "$method failed${desc:+: $desc} (curl exit $code)" >&2
    return 1
  }
  jq -e .ok >/dev/null <<<"$out" 2>/dev/null || {
    desc=$(describe "$out")
    echo "$method failed${desc:+: $desc}" >&2
    return 1
  }
  mid=$(jq -r '(.result.message_id // "") | tostring' <<<"$out" 2>/dev/null) && [ -n "$mid" ] || {
    echo "$method answered no message" >&2
    return 1
  }
  printf '%s' "$mid"
}

case "$1" in
reply) # sendMessage to the chat the delivery came from; with files, the
  # text is the caption of the first picture or document instead. The
  # parent is left out: a chat is one conversation, and the key is left
  # out with it: the service takes no idempotency key.
  thread=$(arg thread)
  body=$(arg body)
  [ -n "$thread" ] || { echo "thread.reply was handed no thread" >&2; exit 1; }
  [ -n "$body" ] || { echo "thread.reply was handed no body" >&2; exit 1; }
  : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN is not set}"
  : "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
  nfiles=$(jq '.files | length' <<<"$in")
  if ! [ "$nfiles" -gt 0 ] 2>/dev/null; then
    id=$(send_message "$body")
    jq -n -c --arg id "$id" '{id: $id}'
    exit 0
  fi
  # A caption holds 1024 characters: a longer text goes first as its own
  # message, and the files follow without one, so no words are lost and
  # the order is kept. Several files go one after another, no album.
  caption="$body"
  id=""
  if [ "$(jq -n --arg b "$body" '$b | length')" -gt 1024 ]; then
    id=$(send_message "$body")
    caption=""
  fi
  i=0
  first=1
  while [ "$i" -lt "$nfiles" ]; do
    file=$(jq -r ".files[$i]" <<<"$in")
    [ -n "$file" ] || { echo "thread.reply was handed no file" >&2; exit 1; }
    [ -f "$file" ] || { echo "thread.reply found no file at $file" >&2; exit 1; }
    mime=$(file -b --mime-type "$file" 2>/dev/null) || { echo "thread.reply cannot read $file" >&2; exit 1; }
    # Only the kinds the service shows as a photo go by sendPhoto; a vector
    # drawing or a camera's raw file, which it cannot, goes as a document,
    # which takes any file.
    case "$mime" in
    image/png | image/jpeg | image/gif | image/webp | image/bmp) method=sendPhoto; field=photo ;;
    *) method=sendDocument; field=document ;;
    esac
    cap=""
    if [ "$first" -eq 1 ]; then cap="$caption"; first=0; fi
    mid=$(send_media "$method" "$field" "$file" "$cap")
    [ -n "$id" ] || id="$mid"
    i=$((i + 1))
  done
  [ -n "$id" ] || { echo "thread.reply posted nothing" >&2; exit 1; }
  jq -n -c --arg id "$id" '{id: $id}' ;;
tell) # one picture with its caption to the chat, for notify.image: in
  # {address, title, text, image}, out {status}. The caption is the title
  # then the text, cut to the service's 1024 characters.
  thread=$(arg address)
  title=$(arg title)
  text=$(arg text)
  image=$(arg image)
  [ -n "$thread" ] || { echo "notify.image was handed no address" >&2; exit 1; }
  [ -n "$image" ] || { echo "notify.image was handed no image" >&2; exit 1; }
  : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN is not set}"
  : "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
  caption=$(jq -nr --arg t "$title" --arg x "$text" '[$t, $x] | map(select(. != "")) | join("\n") | .[0:1024]')
  send_media sendPhoto photo "$image" "$caption" >/dev/null
  printf '%s' '{"status":200}' ;;
get) # The file a kept delivery names, by getFile and then the file's own
  # address, into the directory the core made: in {ref, dir, max_bytes},
  # out {path, bytes}. TELEGRAM_MAX_BYTES bounds a file as the yard's own
  # cap (max_bytes) does, and the lower holds; one over it is refused
  # before or while it downloads, with nothing left behind.
  TELEGRAM_MAX_BYTES=8388608
  id=$(jq -r '.ref.id // ""' <<<"$in")
  dir=$(arg dir)
  max=$(jq -r '.max_bytes // 0 | floor' <<<"$in")
  [ -n "$id" ] || { echo "thread.get was handed no ref.id" >&2; exit 1; }
  [ -d "$dir" ] || { echo "thread.get was handed no directory" >&2; exit 1; }
  : "${TELEGRAM_BOT_TOKEN:?TELEGRAM_BOT_TOKEN is not set}"
  : "${YARDR_SOURCE_BASE:?YARDR_SOURCE_BASE is not set}"
  limit=$TELEGRAM_MAX_BYTES
  if [ "$max" -gt 0 ] && [ "$max" -lt "$limit" ]; then limit=$max; fi
  # The token is in both addresses: each goes to curl on its stdin, as
  # reply's does.
  out=$(printf 'url = "%s"\n' "$YARDR_SOURCE_BASE/bot$TELEGRAM_BOT_TOKEN/getFile" \
    | curl -sS --fail-with-body -K - \
    -H 'Content-Type: application/json' \
    -d "$(jq -n -c --arg f "$id" '{file_id: $f}')" 2>/dev/null) || {
    code=$?
    echo "getFile failed (curl exit $code)" >&2
    exit 1
  }
  jq -e .ok >/dev/null <<<"$out" 2>/dev/null || {
    # The response is outside the yard and can echo the token in the URL;
    # it is kept as a delivery's failure reason, so only say what failed.
    echo "getFile failed" >&2
    exit 1
  }
  jq -e '(.result.file_path | type) == "string"' >/dev/null <<<"$out" 2>/dev/null || {
    echo "getFile answered no file path" >&2
    exit 1
  }
  jq -e '(.result.file_size == null) or ((.result.file_size | type) == "number" and .result.file_size >= 0)' \
    >/dev/null <<<"$out" 2>/dev/null || {
    echo "getFile answered no file size" >&2
    exit 1
  }
  path=$(jq -r '.result.file_path' <<<"$out" 2>/dev/null) || {
    echo "getFile answered no file path" >&2
    exit 1
  }
  size=$(jq -r '.result.file_size // 0 | floor' <<<"$out" 2>/dev/null) || {
    echo "getFile answered no file size" >&2
    exit 1
  }
  # The path goes into an address on a config line: a path of the
  # service's own shape, never one that climbs or closes the quote.
  case "$path" in
  "" | *..* | /*) echo "getFile answered no file path" >&2; exit 1 ;;
  esac
  if ! grep -Eq '^[A-Za-z0-9_./-]+$' <<<"$path"; then
    echo "getFile answered no file path" >&2
    exit 1
  fi
  if [ "$size" -gt "$limit" ]; then
    echo "$size bytes, over the limit of $limit" >&2
    exit 1
  fi
  # The response is arbitrary file content, so keep the temporary copy
  # owner-only even if the server was started with a permissive umask.
  umask 077
  file="$dir/file"
  printf 'url = "%s"\n' "$YARDR_SOURCE_BASE/file/bot$TELEGRAM_BOT_TOKEN/$path" \
    | curl -sS --fail -K - --max-filesize "$limit" 2>/dev/null \
    | head -c "$((limit + 1))" >"$file" || {
    code=$?
    n=$(wc -c <"$file" | tr -d ' ')
    rm -f "$file"
    if [ "$code" -eq 63 ] || [ "$n" -gt "$limit" ]; then
      echo "over the limit of $limit bytes" >&2
    else
      echo "the file did not download (curl exit $code)" >&2
    fi
    exit 1
  }
  n=$(wc -c <"$file" | tr -d ' ')
  if [ "$n" -gt "$limit" ]; then
    rm -f "$file"
    echo "$n bytes, over the limit of $limit" >&2
    exit 1
  fi
  jq -n -c --arg p "$file" --argjson n "$n" '{path: $p, bytes: $n}' ;;
posted) # {} always: without an idempotency key there is nothing to find
  printf '%s' '{}' ;;
read) # no history: a bot cannot read a chat
  printf '%s' '{"messages":[]}' ;;
*) echo "unknown: $1" >&2; exit 2 ;;
esac
# Telegram as a declared source: one message or one file to one chat
# through the Bot API's sendMessage and sendDocument, each a POST with
# the bot's token in the path and the chat's id in the body: a message
# goes as JSON, a file as an upload. The notify key makes it a source a
# user can be told through (yardr user set <name> notify=telegram); the
# hook key hears operator-role users' chats back, answered with yardr reply.
# The check call reads what Telegram holds of the webhook
# (getWebhookInfo): its url, its pending_update_count, and the last
# failed delivery's date and message, which say a broken webhook from a
# quiet person, whose chat has no steady rhythm to time silence by.
#
# Checked against core.telegram.org/bots/api on 24 October 2026: queries
# are https://api.telegram.org/bot<token>/METHOD_NAME; sendMessage takes
# chat_id (Integer or String, required) and text (1-4096 characters,
# required); sendDocument takes chat_id and the file as a multipart
# upload (50 MB at most), with an optional caption (1024 characters at
# most); each answers 200 with the message on success. Receiving is a
# webhook carrying one Update, authenticated with the secret token in the
# X-Telegram-Bot-Api-Secret-Token header, compared as it arrives; each
# Update carries its own update_id, by which a retry is told.
#
# The service has one text where the yard has a title and a text, so the
# body carries both: the title first (who and what: "mail from mckean"),
# then the text. Their bounds leave the body under the service's 4096.
# The chat id goes as a string of digits: the notify key fills text, and
# the service takes either form. A person's chat is its numeric id, which
# the file holds every address to; the @username form names a channel.
name: telegram
about: Messages with a person's phone through Telegram.
base: https://api.telegram.org
credential:
  env: TELEGRAM_BOT_TOKEN            # the bot's token: the name, never the value
  in_path: true                      # the service takes it in the path (/bot<token>/...)
calls:
  message:
    kind: update
    about: One message to one chat
    params:
      chat:  { type: string, pattern: "^-?[0-9]+$" }
      title: { type: string, max: 250 }
      text:  { type: string, max: 3800 }
    request:
      method: POST
      path: /bot{credential}/sendMessage
      body: { chat_id: "{chat}", text: "{title}\n{text}" }
  document:
    kind: update
    about: One file to one chat
    params:
      chat:    { type: string, pattern: "^-?[0-9]+$" }
      path:    { type: file, max: 52428800 }
      caption: { type: string, max: 1024, optional: true }
    request:
      method: POST
      path: /bot{credential}/sendDocument
      body: { chat_id: "{chat}", document: "{path}", caption: "{caption}" }
  setwebhook:
    kind: update
    about: Tell Telegram where to send updates (setWebhook)
    params:
      url:          { type: string, max: 2048 }
      secret_token: { type: string, max: 256 }
    request:
      method: POST
      path: /bot{credential}/setWebhook
      body: { url: "{url}", secret_token: "{secret_token}", allowed_updates: ["message"] }
  webhookinfo:
    kind: read
    about: What Telegram holds of the webhook (getWebhookInfo)
    request:
      method: GET
      path: /bot{credential}/getWebhookInfo
check: webhookinfo
notify:
  call: message
  address: { param: chat, env: TELEGRAM_CHAT_ID }   # the person's own chat id, read from the user's env_file
  title: title                                      # yardr's value -> the call's parameter
  text: text
  image: [telegram/thread, tell]                    # with a picture, the command sends it with the caption instead

# ---- what it sends us ----------------------------------------------------

hook:
  # Adapter.Verify stays data, checked by the core: the webhook secret is
  # handed to no command, and an unverified body reaches none. Telegram
  # sends no signature over the body, only the secret itself in the header.
  verify:
    scheme: secret
    signature: { header: X-Telegram-Bot-Api-Secret-Token }
    delivery: { body: update_id }   # the Update's own id: a retry repeats it, re-serialized or not
  bodies: unique

  # The yard registers the webhook itself on the hook card: it makes
  # the secret, and calls setWebhook with the public URL and it.
  register:
    call: setwebhook
    url: url
    secret: secret_token

  # Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
  # envelope. The core sets source and delivery itself.
  normalize: [telegram/normalize]

  # The keys its events' fields may carry: a subscription's --where may
  # name them beside type, action, team, project, repo and label.
  keys: [chat]

# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under TELEGRAM_BOT_TOKEN, with YARDR_SOURCE_BASE from base
# above. The service gives a bot no history to read and no idempotency
# key to set, so posted and read answer what there is to find: nothing.
# file fetches what a kept delivery's photo, document, voice, video or
# audio names (getFile, then the file), with the source's own token, into
# the blob store: at most the yard's blob_max_bytes and the script's
# TELEGRAM_MAX_BYTES (8 MiB), whichever is lower; the Bot API serves a bot
# files up to 20 MB. files says replies take files: with them, the reply
# sends pictures by sendPhoto and anything else by sendDocument, the text
# as the caption where it fits in 1024 characters. The shapes are in
# docs/integrations.md.

thread:
  reply:  [telegram/thread, reply]      # in  {thread, parent, body, key}      out {id}
  posted: [telegram/thread, posted]     # in  {thread, key}                    out {} always
  read:   [telegram/thread, read]       # in  {thread}                         out {messages: []} always
  file:   [telegram/thread, get]        # in  {ref, dir, max_bytes}            out {path, bytes}
  files: true                           # replies take files: reply's in gains files: [paths]

# ---- which chats are heard ------------------------------------------------
# Not the mirror of notify: a delivery is kept only when its chat is the
# address of a user whose env file sets the address, read from the file
# as telling reads it. Anyone can find the bot's name and write to it,
# so any other chat is dropped: answered 200, recorded nowhere.

hear:
  field: chat
  address: { env: TELEGRAM_CHAT_ID }
  from: operator                        # operator-role users' chats; a member's is dropped like a stranger's
  # A claim approved subscribes the yard's hearer to this chat with
  # type=message beside it: the chat's other Updates (edits, member
  # changes) are noise nobody asked to hear.
  subscribe: { type: message }