Skip to content

Commit 85fca68

Browse files
committed
feat(s071): the Telegram gateway, and two fixes to the gateway core
Slice 071. Every automatic criterion runs against a local fake Bot API. The live run needs the owner's bot token and is queued with exact steps. Two security defects in slice 070's core, each reproduced red first: - F1. Router.prompt_for/1 sent an unknown sender the very pairing code it was meant to read on /gateways, so anyone who found the bot could pair by echoing the prompt back. 070's own test asserted the leak. The prompt now says where the code is, never what it is, restoring docs/07's rule ("shown on /gateways and nowhere else"). - F2. Approval ids are UUIDv7, so the 8-character short id is shared by every request raised within about 65 seconds, and /approve <short> decided whichever came first. Commands.find_pending/1 now refuses an ambiguous prefix, and buttons carry the full id. The gateway: - Bot API calls over Req, already in the lock: telegex has only an old release candidate and ex_gram keeps its offset in memory, so neither meets AC5. The token is read on every call and never stored; errors never carry the URL, which holds it; Req's retry step is off. - MarkdownV2 conversion that only emits a closed entity, with chunking checked after conversion and properties against an independent parse checker. - An outbox that throttles edits to at least 1 s and keeps the final text; long polling with the offset persisted before routing (at most once); photos fetched only after admission, size-capped and magic-checked, stored by digest; approval buttons only under the channel cap. - Trinity.Gateways.Supervisor starts the configured gateways, chosen by TRINITY_GATEWAYS; nothing in the app started a gateway before. - Images reach a session row and a :vision model, and are replaced by a note otherwise. The log filter and secret scanner learn the token's shape. Red evidence R1 to R10 is in the slice NOTES. Signed-off-by: Ayla Croft <aylacroft@proton.me>
1 parent 3d118a0 commit 85fca68

48 files changed

Lines changed: 3189 additions & 64 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ TRINITY_LIVE_MODEL=
3939
OLLAMA_BASE_URL=http://localhost:11434/v1
4040
OLLAMA_MODEL=
4141

42+
# ---- Telegram gateway (slice 071), optional ---------------------------------------------
43+
# The bot token from @BotFather. Read through Trinity.Config.secret/1 at each Bot API call and
44+
# never stored. The gateway runs only when it is named: TRINITY_GATEWAYS=telegram. Without the
45+
# token it stays idle and /gateways says so. Leave both empty to keep Telegram off.
46+
TELEGRAM_BOT_TOKEN=
47+
# TRINITY_GATEWAYS=telegram
48+
4249
# ---- App runtime (config/runtime.exs reads these; all have defaults) ------------------
4350
# PORT=4000
4451
# DATABASE_PATH=

‎CHANGELOG.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,30 @@ evidence for each increment is retained by the maintainers and summarised here.
2222
tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as
2323
the public record of delivered work.
2424

25+
### `slice/071`: Trinity answers on Telegram
26+
27+
A Telegram bot is now a channel Trinity can be reached from, alongside the console. It answers in
28+
private chats and in groups where it is mentioned or replied to, streams its answer by editing one
29+
message no more than once a second, converts the model's markdown to Telegram's strict MarkdownV2
30+
so a half-written answer never fails to send, offers approvals as Approve and Deny buttons within the
31+
channel's existing cap, and passes a photo to a model that declares it can see images. A restarted
32+
bot does not process the same message twice: where it is up to is written to disk before a message
33+
is handled. It is off until named (`TRINITY_GATEWAYS=telegram`) and given a token
34+
(`TELEGRAM_BOT_TOKEN`); the application now starts the gateways it is configured with.
35+
36+
No Telegram library is used. Both candidates were measured: one cannot be pinned under this
37+
project's version rule, and the other keeps its polling position in memory and cannot be pointed at a
38+
test server. The Bot API is a handful of JSON calls over the HTTP client the project already uses.
39+
40+
Two defects in the gateway core were found and fixed on the way. The pairing prompt told an unknown
41+
sender the very code it asked them to prove they could read on the desktop, which on a public
42+
platform would let anyone pair; it now says only where the code is. And the short approval id a
43+
channel shows was shared by every request raised in the same minute, so typing it decided whichever
44+
came first; an ambiguous id is now refused, and buttons carry the full id.
45+
46+
The live checks (a real bot, a real phone, a vision model, a scheduled delivery) are listed for the
47+
maintainer to run; everything else runs in the suite against a fake Bot API with no network.
48+
2549
## 2026-09-26
2650

2751
### `slice/128`: the checks that guard attribution now say what they check

‎README.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,10 @@ enforces it.
132132
streaming back as the turn runs, and any approval the turn raises rendered into the same
133133
conversation. What a channel may approve is capped below what the desktop may: a `write` at
134134
most by default, so an `exec` or a `destructive` request is decided at the machine, and the
135-
refusal is receipted. The console adapter ships (`mix trinity.console`); the platforms are next.
135+
refusal is receipted. The console adapter ships (`mix trinity.console`), and so does Telegram:
136+
a bot that answers in private chats and in groups where it is mentioned, streams its reply by
137+
editing one message, offers approvals as buttons (within the same cap), and passes a photo to a
138+
model that can see. Set `TELEGRAM_BOT_TOKEN` and `TRINITY_GATEWAYS=telegram` to turn it on.
136139
- **Delegates.** The assistant can hand a bounded piece of work to a child session with a
137140
`delegate` tool: its own context, its own history, and a result handed back as text. The
138141
parent's conversation never contains the child's messages, which is the point:
@@ -171,10 +174,9 @@ enforces it.
171174
curator that marks old memories stale and archives the untouched ones (never deleting)
172175
run on the same queues; `/oban` shows the jobs.
173176

174-
Not there yet: the messaging platforms themselves (071 Telegram and 072 Discord, which need a bot
175-
token the project does not yet hold), the native desktop shell and signed releases (the rest of M6),
176-
store-and-forward receipts for disconnected operation (026, waiting on an answer from the external
177-
authority plane). The [Milestones](#milestones) section below sets out the order the remaining work
177+
Not there yet: the second messaging platform (072), the native desktop shell and signed releases
178+
(the rest of M6), store-and-forward receipts for disconnected operation (026, waiting on an answer
179+
from the external authority plane). The [Milestones](#milestones) section below sets out the order the remaining work
178180
is being built in.
179181

180182
The interface is a local web page; the desktop shell exists as a packaging spike, not a product.

‎VERSIONS.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ never pin a version hex marks as retired or vulnerable.
9494
| `stream_data` | ~> 1.4 | ✅ in `mix.lock` | Property-based testing: generated input for `test/property`. Apache-2.0; 1.4.0 measured on hex.pm 2026-09-24, released 2026-07-14. Test only, so it ships in no artifact. Elixir has no focused fuzzing library and property-based testing is what the OpenSSF gold criterion `dynamic_analysis` accepts in its place, which is why this is a dependency rather than a habit. Added at Slice 004. |
9595
| `jose` | ~> 1.11 | ✅ in `mix.lock` | JOSE (JWK, JWS, JWKS) over OTP's crypto, for the MCP resource server's token validation and the personal profile's embedded authorization server. MIT; 1.11.12 measured on hex.pm 2026-09-22. Not joken, a layer over it this tree does not need. Added at Slice 062. |
9696
| `oban_web` | ~> 2.13 | ✅ in `mix.lock` | The Oban dashboard, a LiveView mounted at /oban (dev, and behind `config :trinity, :oban_web, true` elsewhere). Apache-2.0 on hex.pm since the 2.12 line (2.13.0 measured 2026-09-22); the plan of 2026-09-05 wrote it in when it was commercial. Added at Slice 050. |
97-
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. |
97+
| `req` | ~> 0.5 | ✅ in `mix.lock` | HTTP client. Since Slice 071 also the Telegram Bot API client, in place of a Telegram library. |
9898
| `req_llm` | ~> 1.22 | ✅ in `mix.lock` | Provider layer (streaming, tools, structured output, usage). ⚠️ The pin was `~> 1.10` against a recorded latest of 1.10.0; the real latest was twelve minors ahead. Check event shapes against the current version at Slice 011, not against this file's prose. Added at Slice 011. |
9999
| `beam_mcp` | ~> 0.9 | ✅ in `mix.lock` | MCP server core, Apache-2.0, ADR-0007 decision 5 (owner decision 2026-09-08, recorded 2026-09-20). 0.9.0 on hex.pm (2026-09-22): the :server seam on both transports and the core server as a named behaviour, which slice 061's wrapper implements; standing before 1.0.0. Server side only: the client, MRTR and OAuth are Trinity's, above it. Added at Slice 059, bumped at 061. The earlier candidate list (anubis_mcp, fastest_mcp, gen_mcp) is history. |
100100
| `jido` | not used (ADR-0009, decided 2026-09-20) | 🔍 not a single package | Measured at the Slice 012 checkpoint and not adopted: the agent runtime duplicates PubSub, Oban and the gateways and adds a second tool executor; the action shape is written in-tree at Slice 020 with `jsv` for its schemas. The row stays so the decision is visible where a reader would look for the package. |
@@ -141,7 +141,8 @@ never pin a version hex marks as retired or vulnerable.
141141
| `burrito` | ~> 1.6 | ✅ in `mix.lock` | ⚠️ ERTS availability drives the OTP pin, and Slice 000 measured it: only the OTP 28 line is fetchable for macOS and Linux. Corrected 2026-09-05: this row previously read `~> 1.5 / 1.5.0 ✅`; that mark was not measured. Added at Slice 001. |
142142
| `ex_tauri` | ~> 0.2 | ✅ in `mix.lock` | ⚠️ Declares `otp_release: "~> 27.0"`, and Slice 000's probe refutes the reason it gives: OTP 28 macOS universal returns 200 and OTP 27 returns 404. Whether it runs on the pinned OTP is Slice 001's first measurement. ⚠️ 439 downloads all-time, so the ADR-0004 fallback matrix carries real weight. |
143143
| `nostrum` | ~> 0.10 | 🔍 not yet a dependency | Discord. ⚠️ No release in roughly 18 months. R11's trigger already fires. Check intents and components against the current gateway before Slice 072. |
144-
| `telegex` | **not pinned** | 🔍 not a single package | Telegram. ⚠️ The latest release on hex is a release candidate, roughly two years old, and this file's own rule forbids pinning an `-rc`. Alternative: ex_gram. Slice 071 decides with the measurement. |
144+
| `telegex` | not used (measured at Slice 071, 2026-10-08) | 🔍 not a single package | Telegram. Measured at Slice 071: the latest release on hex is `1.9.0-rc.0` (2024-09-18) and the last stable `1.8.0` (2024-05-28); this file's own rule forbids pinning an `-rc`. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` (slice 071 NOTES, "The library, measured"). The row stays so the decision is visible where a reader would look for the package. |
145+
| `ex_gram` | not used (measured at Slice 071, 2026-10-08) | 🔍 not a single package | Telegram. Measured at Slice 071 on `0.71.0` (2026-10-01): its poller keeps the `getUpdates` offset in process state and starts at `-1`, so a restarted poller reprocesses or skips unconfirmed updates (Slice 071's AC5 is that property); its file download host is a module attribute that cannot be pointed at a test server; Beerware licence; 18,054 lines for what is here a handful of JSON calls. Not used: `Trinity.Gateways.Telegram` speaks the Bot API with `req` and owns its poller. |
145146
| `phoenix_streamdown` | not used (measured at Slice 013, 2026-09-20) | 🔍 not a single package | Streaming markdown renderer for LiveView. Measured at Slice 013: 1.0.0-beta.4 (2026-05-03) was still the latest release with no stable behind it, and this file's own rule forbids a pre-release. It is 801 lines over `mdex`, whose own `streaming: true` option completes fragments; the rest is a rendering rule Slice 013 keeps anyway. The row stays so the decision is visible where a reader would look for the package. |
146147
| `mdex` | ~> 0.13 | ✅ in `mix.lock` | Markdown renderer for the chat (`TrinityWeb.Markdown`), streaming fragments completed by its `streaming: true` option, raw HTML omitted (`unsafe: false`) and the default sanitizer on top. Added at Slice 013 after the measurement in its NOTES.md: `earmark` 1.4.49 is retired on hex with an open XSS advisory (EEF-CVE-2026-48591), which the gate refuses. ⚠️ A Rust NIF (`mdex_native`): the first in the bundle. Measured at Slice 013 (NOTES finding 13): neither precompiled artifact loads in Burrito's musl ERTS on Linux, so the linux package builds it from source for musl with Zig as the linker (`rustler` below, `scripts/zig-cc-musl`, the three settings in config/config.exs); macOS and Windows load the precompiled artifact. The `--smoke` path prints whether it rendered, and the `package` workflow reads that line on every target. |
147148
| `jsv` | ~> 0.23 | ✅ in `mix.lock` | JSON Schema (2020-12) validation of tool arguments in `Trinity.Tools.Schema`, with `cast: false` so a malformed call is refused and never repaired (docs/07). Was transitive through req_llm; direct since Slice 020 because a module of ours calls it (ADR-0009: Trinity's own tool behaviour, jsv for its schemas). |

‎config/config.exs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,3 +241,9 @@ import_config "#{config_env()}.exs"
241241
# Slice 070: a scheduled run can be delivered to a gateway conversation; `deliver_to`'s "kind"
242242
# names the implementation (slice 050's `Trinity.Scheduler.Delivery.for/1`).
243243
config :trinity, :deliveries, %{"gateway" => Trinity.Scheduler.Delivery.Gateway}
244+
245+
# Slice 071: the gateways this build carries, by module. None runs unless it is named:
246+
# `config :trinity, :gateways, adapters: [...]` names modules, and `TRINITY_GATEWAYS`
247+
# (config/runtime.exs) names adapters from this list by their name ("telegram", "console"), so an
248+
# environment variable never mints an atom. A new platform is a module and a line here.
249+
config :trinity, :gateways, available: [Trinity.Gateways.Console, Trinity.Gateways.Telegram]

‎config/llm.exs‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import Config
77
# are US dollars per million tokens and are the source of every recorded cost. Free-tier
88
# models carry 0.0. The default model is the OpenRouter one the owner picked; the NVIDIA
99
# endpoint is reached as an OpenAI-compatible base_url. Test config replaces all of this.
10+
# Slice 071: the default OpenRouter model takes images. llm_db's snapshot (the dependency in
11+
# mix.lock) lists `openrouter` / `inclusionai/ling-3.0-flash-vl:free` with input modalities
12+
# ["text", "image", "video"]. `:vision` is declared for that id only: a TRINITY_LIVE_MODEL naming
13+
# another model gets no vision until someone checks that one and says so here.
14+
ling_default = "inclusionai/ling-3.0-flash-vl:free"
15+
ling_model = System.get_env("TRINITY_LIVE_MODEL", ling_default)
16+
ling_vision = if ling_model == ling_default, do: [:vision], else: []
17+
1018
config :trinity, :llm,
1119
default_model: "openrouter:ling",
1220
providers: %{req_llm: Trinity.LLM.Providers.ReqLLM},
@@ -15,11 +23,9 @@ config :trinity, :llm,
1523
%{
1624
id: "openrouter:ling",
1725
provider: :req_llm,
18-
model:
19-
"openrouter:" <>
20-
System.get_env("TRINITY_LIVE_MODEL", "inclusionai/ling-3.0-flash-vl:free"),
26+
model: "openrouter:" <> ling_model,
2127
api_key_env: "OPENROUTER_API_KEY",
22-
caps: [:stream, :tools, :json],
28+
caps: [:stream, :tools, :json] ++ ling_vision,
2329
price: %{input: 0.0, output: 0.0}
2430
},
2531
%{

‎config/runtime.exs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,21 @@ if config_env() != :test and auth_profile not in [nil, ""] do
109109
client_metadata_url: present.("TRINITY_MCP_AUTH_CLIENT_METADATA_URL")
110110
end
111111

112+
# Slice 071: which gateways run. `TRINITY_GATEWAYS=telegram` (comma separated) names adapters from
113+
# `config :trinity, :gateways, available:` by name; `Trinity.Gateways.adapters/0` matches them, so
114+
# a name that is not available matches nothing and mints no atom. Unset, no gateway runs. The
115+
# suite configures its own and never reads this.
116+
if config_env() != :test do
117+
case System.get_env("TRINITY_GATEWAYS") do
118+
names when names in [nil, ""] ->
119+
:ok
120+
121+
names ->
122+
config :trinity, :gateways,
123+
enabled: names |> String.split(",", trim: true) |> Enum.map(&String.trim/1)
124+
end
125+
end
126+
112127
# Slice 013. `TRINITY_FAKE_PROVIDER=1 mix phx.server` runs the chat on the scripted provider:
113128
# the registry becomes the fake's two entries and a fresh stream answers with its markdown
114129
# demo, so the UI can be exercised and screenshotted with no key and no egress. Development

0 commit comments

Comments
 (0)