archiver/ writes all four origins into the signal
MariaDB on the isis k3s cluster. The viewer (this crate and frontend/) reads
them, and can reply on IRC, through the irssi that holds the connections, and on
Telegram, through the feed that holds the session.
Browser ──VPN/login──▶ messages.xinutec.org (isis, ns: signal)
│ Rust/axum: Nextcloud OAuth2 (identity) + sessions
│ + API over the archive, + sends (IRC via ssh,
│ Telegram and Signal through outbox tables)
▼
signal MariaDB ─ messages / conversations / reactions (Signal)
├ gchat_messages / gchat_conversations… (Google Chat)
├ irc_messages / irc_conversations (IRC)
└ telegram_messages / telegram_conversations… (Telegram)
IRC shows only what was said: every read restricts to kind IN ('message', 'action'), leaving out joins, parts and notices, and irssi's server-notice
window (is_status) is not listed.
- Nextcloud login and allow-list, the real gate. OAuth2 identity against
dash.xinutec.org; an authenticated user not inALLOWED_USERSgets 403. - VPN-only by DNS.
messages.xinutec.orgresolves to isis's WireGuard IP. This is obscurity: the ingress also answers on the public IP.
An ingress whitelist-source-range would make the second layer real, if client
source IPs survive k3s servicelb; check before relying on it.
archiver/— the ingesters and importers, and the schema's migrations. Its README covers them.irclog/— irssi autolog parsing and theirc_messagesrow each line becomes, used by the importer, the live tail and the viewer's send echo, so all three write the same row.src/— the viewer's backend.nextcloud/identity.rsandsession.rsare the login;routes/auth.rsadds the allow-list;archive/is the origin-normalising query layer, one module per origin;irc_send.rs,telegram_send.rsandsignal_send.rssend, the last two throughoutbox.rs;transcribe.rsis the queue recall's transcriber polls;config.rsbuilds the database connection fromDB_*, so the app reusessignal-secret. The app owns onlysessions,link_imagesandtranscripts(src/db.rs).src/bin/link-fetch.rs— the link-picture fetch service, the only part that reaches the internet. It holds no credentials, database or disk.frontend/— Angular: conversation list with origin filter, thread view, and a composer for IRC and Telegram (one draft per conversation; a paragraph goes to IRC line by line, to Telegram whole). The service worker keeps the app, the list, the threads opened and their pictures for reading offline (frontend/ngsw-config.json, life's policy), and the identity is remembered so it opens offline rather than at sign-in; sending offline refuses and keeps the draft.frontend/src/app/thread-window.tskeeps a window of a long thread in the DOM, andreader-place.tsowns where the reader is in it;frontend/src/app/copy-log.tscopies a selection as an irssi log (the inverse ofirclog/), whether dragged over or picked by hand (a long press, then taps, as in Signal; a further long press or Shift-click picks the range);frontend/src/app/attachment.tsnames attachments for both screen and clipboard.frontend/src/app/generated/is written by ts-rs (scripts/gen-types.sh) and imported throughfrontend/src/app/models.ts.Dockerfile—xinutec/messages:latest, with both viewer binaries;archiver/Dockerfileisxinutec/signal-archiver:latest. Both build from the repository root, since the Cargo workspace spans the crates.
GET /api/me— current user.GET /api/conversations— every conversation, newest activity first.GET /api/conversations/{origin}/{id}/avatar?v=— its picture, where the origin keeps one;vis the list'savatarversion.GET /api/conversations/{origin}/{id}/messages?cursor=&limit=&dir=&on=— one page, oldest first.cursoris opaque,(native_ts, id), so rows sharing a timestamp are never skipped.dirisolder(default),newer, orat(the cursor's own row and after, for landing).onis a day's local midnight in epoch ms; the server converts it to the origin's unit.GET /api/search?q=[&origin=&id=]— substring search, everywhere or in one conversation. Each hit carries a cursor that lands on it.POST /api/conversations/{irc,telegram,signal}/{id}/send— say something, through irssi, the Telegram feed or the Signal ingester, to a DM or group only. The body is the text and, on Telegram and Signal,reply_to, a message in the same conversation; the recipient comes from{id}. Google Chat 404s. A Signal send is archived from Signal's receipt, as the linked device never receives its own sends, and markedsent_here(src/signal_send.rs).GET /api/attachments/{id},/api/gchat-attachments/{id},/api/telegram-media/{id}— held bytes, one route per origin since attachment ids are per origin.POST /api/telegram-media/{id}/requestqueues an unfetched Telegram file;GET …/statereports its progress.GET /api/link-previews/{id}/imageis the picture a Signal link preview carried.GET /api/link-images/{id},POST /api/link-images/{id}/request— a picture for a link in a message, by the URL's hash. Only a held picture is stored; a "not a picture" or "could not reach it" answers that request and is forgotten, so the link is offered again on the next read.POST /api/telemetry— client events into the server log. Always 204.
recall's transcriber uses /work/v1/…, /ingest/v1/blob/… and
/sync/vocabulary/prompt with its own bearer token, not a session; with no token
configured they answer 404 (routes/transcribe.rs).
{origin} is signal, gchat, irc or telegram. {id} is the Signal
thread_id, the Google Chat group_id, the irc_conversations.id, or
Telegram's folded peer id (see v15 in archiver/src/db/migrations.rs).
# backend (needs the database; tunnel signal-db or use a local MariaDB)
DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=signal DB_USER=… DB_PASSWORD=… \
NC_BASE_URL=https://dash.xinutec.org NC_CLIENT_ID=… NC_CLIENT_SECRET=… \
NC_REDIRECT_URI=http://localhost:4200/auth/callback \
SESSION_SECRET=$(openssl rand -hex 32) ALLOWED_USERS=user \
cargo run
# frontend (proxies /api, /login, /auth, /logout to :8080)
cd frontend && pnpm install && pnpm start # http://localhost:4200
Manifests are in the home monorepo (xinutec/pippijn): code/kubes/messages/k8s/
for the viewer, code/kubes/signal/k8s/ for the archiver.
Push to main, wait for CI to build both images, then run
code/kubes/deploy.sh messages (the viewer) or code/kubes/deploy.sh signal
(the archiver) from that checkout. It refuses unless the
manifests are committed and pushed and isis's checkout matches, and restarts a
:latest workload only when the registry has a newer image.
One-time setup, in case it must be redone: register the OAuth2 client in
Nextcloud admin (redirect URI https://messages.xinutec.org/auth/callback); put
a Cloudflare Zone:DNS:Edit token in cert-manager as cloudflare-api-token
and apply 00-letsencrypt-dns-issuer.yaml (isis needs DNS-01); messages → 10.100.0.2 is in code/dns; plan-run secrets (kubes README, "Secrets")
makes the session key and takes the OAuth client from --values.
gate.dhall is the gate and the pre-commit hook:
nix run ../dev-lint#gate -- . gate.jsongate.json is rendered from the Dhall and committed; one check re-renders and
diffs it.
tests/archive.rs— pure units, plus end-to-end tests against a fixture in a throwaway MariaDB, whose schema the archiver's own migrations build. Those needMESSAGES_TEST_DATABASE_URLand skip without it; the gate's test row starts one via dev-lint'swith-test-db.tests/access.rs,tests/session_cookie.rs,tests/error_responses.rs— the allow-list fails closed, sessions cannot be forged, and a 500 says nothing about itself.tests/api_routes.rs— requests through the real router reach the handlers behind the auth extractor. It touches no archive table, sincetests/archive.rsempties the same database when it seeds.- Frontend unit tests (
pnpm test, vitest) cover logic. jsdom has no layout, fonts, or real Selection API, and does not submit forms on Enter, sopnpm run ui-checkruns the Playwright suite against the production build: phone-width layout, copy, scrolling, routing, the Android keyboard and a real IME composition. Treat vitest as no evidence about the composer. pnpm run perf(frontend/perf/) measures rather than gates: what the thread's DOM window saves as a reader pages back, printed per depth. Run it against two builds to compare.archiver/tests/— the archiver's suite, in its own database (SIGNAL_TEST_DATABASE_URL, its own gate row): its rows persist between runs, and the fixture above empties its database.
Each field below is interpreted in more than one place; add a row when a field gains another reader.
| field | Rust | thread.html | copy-log.ts | search |
|---|---|---|---|---|
deleted |
Signal and Telegram; always false for Google Chat and IRC |
hidden behind a click, body and attachments | (deleted) only, attachments included |
listed, with (deleted) for the snippet |
edited |
Signal and Telegram, by different mechanisms; Telegram's also honours edit_hide |
edited tag |
(edited) on the last line |
not shown |
edits |
Signal: revision rows via edit_of_ts, left out of pages and counts; Telegram: telegram_message_edits |
behind the edited tag |
not shown | Signal: a match in any version is one hit on the edited message |
kind |
IRC, and Telegram service events as action |
* before the body |
HH:MM * nick prefix |
not shown |
entities |
Telegram entities; Signal text styles mapped to the same kinds, from an edited message's newest revision | formatted runs, overlaps combined | plain text | not shown |
is_outgoing |
all origins | .out class |
nothing; the sender's name carries it | not shown |
transcript (an attachment's) |
the words, only at mean word confidence ≥ 0.75 | under the player, marked "Transcribed" | [audio, transcribed: "…"] |
not searched |
The server sends retracted text; every reader hides it until asked. A revealed
message still copies as (deleted), since the log is built from the model.
MessagesStore.title names a conversation for the list, the search results and,
before the list loads, the hit's own name; the thread header says
"Conversation" until then.
A message with neither body nor attachments draws an empty bubble but produces no copied line.
?at (a message's cursor) and ?on (a day) mean put me here; ?from carries a
scroll position and means I was here. A landing wins on load, and the reader's first scroll replaces
it with from and clears the landed-message marker. A scroll is the reader's only
during their own scrolling gesture (wheel, finger, scrollbar, page keys); the
thread's own scrolls, a resize and a tap or a key in the box decide nothing.
Delivery state (sent, delivered, read) appears only where the archive can
say: outgoing Signal and Telegram messages sent after capture began. Telegram
reports a read position and names nobody; Signal reports per-person receipts, so
a group says read by 2 rather than read, and my own linked device's read
sync is not counted.
Unread counts theirs after my last read, less any deleted for everyone (as the phone apps count; the list's last line still says "Message deleted"), where the archive can say: Signal from my linked devices' read syncs, Telegram from its inbox mark, Google Chat from the read position its conversation list carries (top-level messages only, as a reply inside a thread does not make a space unread there; none for the Mentions shortcut). Without a read position the count is 0, never a guess.
Sending is optional: without a usable key the app serves the archive and refuses to send on IRC. A Telegram or Signal send waits 20 seconds for the process holding the account and is withdrawn if it never took it.
- Google Chat reactors are named only as far as gchat-archive's second capture
has reached; Telegram truncates its reactor list.
countstays authoritative. - A custom-emoji Telegram reaction has no characters to draw and is left out.
- Telegram secret chats are device-local and absent.
- A Telegram message edited before the archive saw it has no earlier versions: Telegram serves only the current text.
- A Google Chat picture's bytes are held only if a harvest fetched them; the download URL needs the user's session.
- An open thread refreshes only its newest page: a deletion, edit or reaction further back shows when the conversation is next opened.
- A Telegram edit that swaps a message's photo or file keeps the old file.
- Signal attachments that failed to download before raw frames were kept (2026-09-21) cannot be fetched again: their ids were never stored.
- Signal 1:1 calls are absent: signal-cli parses no call-log sync, and a call's own signalling reaches this linked device only rarely. A group call shows once, as "started a group call", with no duration.
- signal-cli writes most sync messages from the phone as
{}: only sent messages, reads, blocks and contact or group syncs come through. Views, view-once opens and message-request answers are parsed inside signal-cli but never sent on; the call log is not parsed at all. A message the archive cannot draw reads "a message this archive can't show". - A drag selection reaches only the rendered window (400 messages); a select-all
of a longer conversation ends with
--- copied 400 of N messages; the rest were not loaded on screen. Messages picked by hand stay picked when they scroll out of the window, but only loaded pages can be picked from.