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 }