From b74dfffd0b55b86cf2647898180a164c196e3fbd Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 9 Aug 2026 20:19:07 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Matrix/Synapse=20sender=20+=20listener?= =?UTF-8?q?=20=E2=80=94=20second=20notify=20platform=20(telegram,matrix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pos communication matrix sender: send (plain/--markdown/--room), login (password->access token via m.login.password), test. Implements the lib/notify.sh sender contract, so NOTIFY_PLATFORM=telegram,matrix now fans out for real; matrix.env config scope registered for pos config. pos communication matrix listener: systemd user daemon long-polling /sync (since token, compact m.room.message filter); reacts to own user's messages (/ and ! both resolve), threaded m.in_reply_to replies, @quiet marker, ai bridge with per-room session, interactive editor. Added to INTERACTIVE_CMDS. Docs: POS.md, howto/communication.md, HOWTO.md, usage EXAMPLES. Verified against a mock homeserver (send shape, login, owner filter, replies, exit codes, editor). make gen && make check green. --- AGENT_TODO.md | 2 +- DOC/AGENT_Context_Project.md | 38 +- DOC/HOWTO.md | 11 +- DOC/POS.md | 28 +- DOC/howto/communication.md | 107 ++++- bin/pos | 8 +- bin/pos-communication-matrix-listener | 565 ++++++++++++++++++++++++++ bin/pos-communication-matrix-sender | 214 ++++++++++ completions/pos.bash | 4 +- 9 files changed, 945 insertions(+), 32 deletions(-) create mode 100755 bin/pos-communication-matrix-listener create mode 100755 bin/pos-communication-matrix-sender diff --git a/AGENT_TODO.md b/AGENT_TODO.md index a7ccb4f..67eb7cd 100644 --- a/AGENT_TODO.md +++ b/AGENT_TODO.md @@ -16,7 +16,7 @@ summary (newest last). ## Done -- **2026-08-09** — New `pos system event-trigger` — state-based threshold rule monitors (eventer). Each line of `~/.config/linux_post_install/event.env` is an independent rule: `["" if ] ` (op `> < >= <= == !=`, unit suffix ok `60c`/`80%`). The check command is run on every pass and its **first numeric output** compared float-safe; operator detected as the rightmost `op threshold` pair so checks containing their own `>`/`<` (awk, redirection) parse fine. Alerts once on false→true plus one recovery message on true→false (no repeats while a condition holds); per-rule state in `~/.local/share/linux_post_install/eventer/state/` keyed by rule-line hash (editing a rule resets its state). Subcommands: `run` (timer entrypoint), `config` (interactive add/remove/edit with validation by test-running the check), `list` (rules + live values), `enable [interval]` (systemd **user** timer `pos-event-trigger.timer` + oneshot service; `5m…weekly` or `OnCalendar=…`; graceful warnings when no user systemd manager, `loginctl enable-linger` attempt), `disable`, `status`. `--dry-run` honors the DEV.md dry-run convention. Alerts via `lib/notify.sh` (Telegram default; other platforms via `NOTIFY_PLATFORM`). New: `bin/pos-system-event-trigger`, `lib/eventer-lib.sh`, `config/event.env` template (installed no-clobber by postinstall), `lib/eventer-lib.sh` installed by install.sh, `system-event-trigger` added to `INTERACTIVE_CMDS`, usage EXAMPLES row. Docs: POS.md system row, HOWTO.md index row, howto/event-trigger.md; `make gen && make check` green; functional tests covered trigger/recovery/no-repeat, float + unit parsing, editor add/remove/edit + validation + dry-run, timer enable/disable/status (graceful), dispatcher routing. +- **2026-08-09** — Matrix/Synapse `communication` tools — `pos communication matrix sender` + `listener`, completing the second notify platform `lib/notify.sh` was designed for (`NOTIFY_PLATFORM=telegram,matrix` fan-out; the sender implements the `send [--markdown]` contract via `notify_sender_name()`'s default key→tool mapping, no lib changes). **Sender** (`bin/pos-communication-matrix-sender`): `send [--markdown] [--room ]` PUTs `m.room.message` (`m.text`) to the client-server API v3 — room ids/aliases URL-encoded (`#pos:example.org` → `%23pos%3A…`), unique per-message txn id, `--markdown` sends `org.matrix.custom.html` via a best-effort markdown→HTML converter (bold/italic/code/fences/strike/links/headers/lists, escapes HTML, never fails the send); `login --user <@id>` (masked password prompt → `m.login.password` → saves `access_token`+`user_id`); `test`. Config scope `matrix` (`~/.config/linux_post_install/matrix.env`, `MATRIX_HOMESERVER`/`MATRIX_ACCESS_TOKEN`/`MATRIX_USER_ID`/`MATRIX_ROOM_ID`, secret masked) registered via `# POS_CONFIG:` → `pos config matrix` + tab-completion scope. **Listener** (`bin/pos-communication-matrix-listener`): systemd **user** daemon (`pos-matrix-listener.service`) long-polling `/sync` (30s timeout, per-sync `since` token, compact filter dropping presence/account_data/device noise, `m.room.message` only); reacts to `MATRIX_USER_ID`'s own messages (resolved via `/account/whoami` if unset), `MATRIX_ROOM_ID` restricts to one room; `/` and `!` both resolve; replies threaded `m.in_reply_to`; `@quiet` no-reply marker; `/cmd::desc=…` map descriptions; `ai …` bridge (`pos ai gemini ask`, per-room session `matrix-`, `ai /reset` clears, markdown stripped); interactive editor (`--status`/`--enable`/`--disable`/`--run`), 60s command timeout, exit-code prefix, ~3800-char truncation. `communication-matrix-listener` added to `INTERACTIVE_CMDS` (stdin editor + forever-loop daemon). Docs: POS.md rows + "in detail" sections + ai bridge note, howto/communication.md rewritten Matrix sections, HOWTO.md index + config table + platform note, `bin/pos` usage EXAMPLES; `make gen && make check` green. Verified against a mock homeserver: send plain/markdown/`--room`/test request shape (URL-encoding, Bearer auth, JSON body), login token save, listener owner-filter + `/status` reply + `/help` + `@quiet` silence + non-zero exit reply + interactive editor add. — state-based threshold rule monitors (eventer). Each line of `~/.config/linux_post_install/event.env` is an independent rule: `["" if ] ` (op `> < >= <= == !=`, unit suffix ok `60c`/`80%`). The check command is run on every pass and its **first numeric output** compared float-safe; operator detected as the rightmost `op threshold` pair so checks containing their own `>`/`<` (awk, redirection) parse fine. Alerts once on false→true plus one recovery message on true→false (no repeats while a condition holds); per-rule state in `~/.local/share/linux_post_install/eventer/state/` keyed by rule-line hash (editing a rule resets its state). Subcommands: `run` (timer entrypoint), `config` (interactive add/remove/edit with validation by test-running the check), `list` (rules + live values), `enable [interval]` (systemd **user** timer `pos-event-trigger.timer` + oneshot service; `5m…weekly` or `OnCalendar=…`; graceful warnings when no user systemd manager, `loginctl enable-linger` attempt), `disable`, `status`. `--dry-run` honors the DEV.md dry-run convention. Alerts via `lib/notify.sh` (Telegram default; other platforms via `NOTIFY_PLATFORM`). New: `bin/pos-system-event-trigger`, `lib/eventer-lib.sh`, `config/event.env` template (installed no-clobber by postinstall), `lib/eventer-lib.sh` installed by install.sh, `system-event-trigger` added to `INTERACTIVE_CMDS`, usage EXAMPLES row. Docs: POS.md system row, HOWTO.md index row, howto/event-trigger.md; `make gen && make check` green; functional tests covered trigger/recovery/no-repeat, float + unit parsing, editor add/remove/edit + validation + dry-run, timer enable/disable/status (graceful), dispatcher routing. - **2026-08-09** — `pos media mp3`/`mp4` hardened + smart format selection. Both tools: yt-dlp calls go through `spawn` (honor `$DRY_RUN`; `--dry-run` prints the exact command and skips dep checks), `-o/--output`, `--no-playlist`, `--cookies` (file existence check), clean ffmpeg/yt-dlp guards, `# POS_FLAGS:` for completion, full metadata (`--embed-metadata --embed-chapters --embed-thumbnail --no-overwrites`, mp3 also `--convert-thumbnails jpg` + `--parse-metadata "%(artist,uploader)s:%(artist)s"` so the uploader fills the artist tag). mp3 gains `--by-artist` (`~/Music//.mp3`). mp4 gains `-f <id>` / `--best` / `--worst` (no prompt), conflict validation, and an interactive picker that shows a **curated** `-F` table (`[audio]`/`[video]`/`[combo]` grouping, raw clutter dropped) on stderr — stdout carries only the chosen id (ui_pick lesson) — with id validation against the real table and empty/best default. Docs: howto/media.md rewritten (flags, metadata, by-artist, troubleshooting); `make gen && make check` green. diff --git a/DOC/AGENT_Context_Project.md b/DOC/AGENT_Context_Project.md index ddb096b..5dc07c9 100644 --- a/DOC/AGENT_Context_Project.md +++ b/DOC/AGENT_Context_Project.md @@ -10,19 +10,19 @@ <!-- GEN:START docmap --> | ## 1. Project Overview | 28–43 | -| ## 2. Directory Structure | 44–184 | -| ## 3. Installation Flow | 185–237 | -| ## 4. The `pos` CLI System | 238–302 | -| ## 5. Shared Library — `lib/common.sh` | 303–334 | -| ## 6. Docker Compose / ScaleTail | 335–377 | -| ## 7. Optional Apps (`apps/`) | 378–407 | -| ## 8. Entertainment Module | 408–421 | -| ## 9. Systemd Services | 422–434 | -| ## 10. Configuration Files | 435–461 | -| ## 11. Coding Conventions | 462–494 | -| ## 12. Development Workflow | 495–547 | -| ## 13. Key File Quick Reference | 548–599 | -| ## 14. Common Tasks for Agents | 600–625 | +| ## 2. Directory Structure | 44–186 | +| ## 3. Installation Flow | 187–239 | +| ## 4. The `pos` CLI System | 240–306 | +| ## 5. Shared Library — `lib/common.sh` | 307–338 | +| ## 6. Docker Compose / ScaleTail | 339–381 | +| ## 7. Optional Apps (`apps/`) | 382–411 | +| ## 8. Entertainment Module | 412–425 | +| ## 9. Systemd Services | 426–438 | +| ## 10. Configuration Files | 439–465 | +| ## 11. Coding Conventions | 466–498 | +| ## 12. Development Workflow | 499–551 | +| ## 13. Key File Quick Reference | 552–605 | +| ## 14. Common Tasks for Agents | 606–631 | <!-- GEN:END docmap --> ## 1. Project Overview @@ -59,6 +59,8 @@ Linux_post_install/ │ ├── pos # Main dispatcher — smart arg matching to pos-* scripts <!-- GEN:START tree --> │ ├── pos-ai-gemini # Chat with Google Gemini (ask, chat, models, sessions) +│ ├── pos-communication-matrix-listener # Matrix listener: map /command → bash, run them on room messages +│ ├── pos-communication-matrix-sender # Send messages to a Matrix room via the client-server API (send, test, login) │ ├── pos-communication-telegram-listener # Telegram bot listener: map /command → bash, run them on chat messages │ ├── pos-communication-telegram-sender # Send Telegram messages/files/links/stickers via Bot API (send, test) │ ├── pos-config # Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) @@ -255,6 +257,8 @@ All non-interactive `pos` commands log output to `~/.local/share/linux_post_inst |----------|---------|--------|-------------| <!-- GEN:START dispatch --> | ai | gemini | `pos-ai-gemini` | Chat with Google Gemini (ask, chat, models, sessions) | +| communication | matrix-listener | `pos-communication-matrix-listener` | Matrix listener: map /command → bash, run them on room messages | +| communication | matrix-sender | `pos-communication-matrix-sender` | Send messages to a Matrix room via the client-server API (send, test, login) | | communication | telegram-listener | `pos-communication-telegram-listener` | Telegram bot listener: map /command → bash, run them on chat messages | | communication | telegram-sender | `pos-communication-telegram-sender` | Send Telegram messages/files/links/stickers via Bot API (send, test) | | | config | `pos-config` | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | @@ -327,7 +331,7 @@ source "$(dirname "$0")/../lib/common.sh" **Scripts that do NOT source common.sh** (self-contained): <!-- GEN:START selfcontained --> -`pos`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`. +`pos`, `pos-communication-matrix-listener`, `pos-communication-matrix-sender`, `pos-communication-telegram-listener`, `pos-communication-telegram-sender`, `pos-network-checkport`, `pos-network-hotspot`, `pos-network-ip`, `pos-network-scan`, `pos-ssh-load-keys`, `pos-system-firewall`. <!-- GEN:END selfcontained --> --- @@ -562,8 +566,10 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/flag-clear` | 21 | Unset a flag | | `features/autostart.sh` | 14 | Boot-time feature (moved from `bin/`, flag-gated service) | <!-- GEN:START filetable --> -| `bin/pos` | 280 | CLI dispatcher with smart arg matching + logging + category help | +| `bin/pos` | 286 | CLI dispatcher with smart arg matching + logging + category help | | `bin/pos-ai-gemini` | 311 | Chat with Google Gemini (ask, chat, models, sessions) | +| `bin/pos-communication-matrix-listener` | 565 | Matrix listener: map /command → bash, run them on room messages | +| `bin/pos-communication-matrix-sender` | 214 | Send messages to a Matrix room via the client-server API (send, test, login) | | `bin/pos-communication-telegram-listener` | 563 | Telegram bot listener: map /command → bash, run them on chat messages | | `bin/pos-communication-telegram-sender` | 221 | Send Telegram messages/files/links/stickers via Bot API (send, test) | | `bin/pos-config` | 80 | Interactive editor for the tools' runtime config (reads # POS_CONFIG: registry) | @@ -591,7 +597,7 @@ Use conventional prefixes: `feat:`, `fix:`, `docs:`, `refactor:`, `chore:` | `bin/pos-system-nfs-server` | 134 | Manage the NFS kernel server (status, share/unshare exports, enable/disable) | | `bin/pos-tree` | 112 | Show the pos CLI command tree: categories, commands, and subcommands | | `bin/pos-usb-server` | 218 | USB Redirector server control (--ls, --share; prompts when args omitted) | -| `completions/pos.bash` | 286 | Dynamic bash completion | +| `completions/pos.bash` | 288 | Dynamic bash completion | <!-- GEN:END filetable --> | `apps/install.sh` | 171 | App install/uninstall picker/orchestrator | diff --git a/DOC/HOWTO.md b/DOC/HOWTO.md index 7204d8f..bea02c3 100644 --- a/DOC/HOWTO.md +++ b/DOC/HOWTO.md @@ -17,7 +17,7 @@ authoritative one-line reference (every command + flag), see | `pos system event-trigger` | Threshold-rule monitors that alert on crossing | [event-trigger](howto/event-trigger.md) | | `pos ssh` | Load keys into the agent | [ssh](howto/ssh.md) | | `pos usb` | Share USB devices over the network | [usb](howto/usb.md) | -| `pos communication` | Send Telegram messages/files/alerts | [communication](howto/communication.md) | +| `pos communication` | Send Telegram/Matrix messages & alerts, /command listeners | [communication](howto/communication.md) | | `pos entertainment` | Scheduled auto-messages from public APIs | [entertainment](howto/entertainment.md) | Every tool is `bin/pos-<category>-<command>`; run `pos <category> --help` to @@ -36,6 +36,7 @@ templates (without overwriting an existing file): | File | Used by | Keys | |------|---------|------| | `telegram.env` | `pos communication telegram sender` / `listener`, everything that alerts | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID` | +| `matrix.env` | `pos communication matrix sender` / `listener` | `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_ROOM_ID` | | `notify.env` | `lib/notify.sh` (all alerting) | `NOTIFY_PLATFORM` (e.g. `telegram,matrix`) | | `system.env` | `pos system health`, `pos system backup` | `BACKUP_SERVICE_ROOTS`, `HEALTH_BACKUP_MAX_AGE_DAYS` | | `compose.env` | `pos docker compose` | `TS_AUTHKEY`, `TZ`, `DNS_SERVER`, `SERVICES_BASE` | @@ -44,6 +45,8 @@ templates (without overwriting an existing file): ```bash pos config telegram # set TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID +pos config matrix # set MATRIX_HOMESERVER / MATRIX_ROOM_ID, then: +pos communication matrix sender login --user @you:example.org # fetch an access token pos entertainment config set WEATHER_LAT=36.51 WEATHER_LON=40.75 ``` @@ -58,8 +61,10 @@ platform is configured it warns and never breaks the calling tool. NOTIFY_PLATFORM=telegram # comma-separated to send to all ``` -Adding a platform later (e.g. Matrix/Synapse) = create `bin/pos-communication-<p>` -implementing `send <value> [--markdown]` and list it. See +Ship with `telegram` and `matrix` — add both to `NOTIFY_PLATFORM` to fan out +alerts (Matrix needs `pos config matrix` + a `login`-fetched token first). +Adding another platform = create `bin/pos-communication-<p>` implementing +`send <value> [--markdown]` and list it. See [DOC/DEV.md → Alerting](DEV.md) for the contract. ### Scheduling diff --git a/DOC/POS.md b/DOC/POS.md index 6a5d47a..6c15791 100644 --- a/DOC/POS.md +++ b/DOC/POS.md @@ -76,7 +76,7 @@ Category-less tools (`config`, `tree`) live outside any category and are documen Precedence: `--model` flag > `AI_GEMINI_MODEL` env > config file > `gemini-2.5-flash`. `postinstall.sh` copies the repo's `config/ai.env` template to `~/.config/linux_post_install/ai.env` on install (no clobber). Dependencies: `curl` + `jq` (both in `preinstall.sh` PACKAGES). -**Telegram bridge:** the Telegram listener forwards non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and replies with the model's answer — see [communication → listener](#communication). +**Messaging bridges:** the Telegram and Matrix listeners forward non-command messages starting with `ai ` (case-insensitive) to `pos ai gemini ask` and reply with the model's answer — see [communication → listener](#communication). The Telegram bridge uses one session per chat (`telegram-<chat id>`), the Matrix bridge one per room (`matrix-<room>`). ### network @@ -226,6 +226,8 @@ Subcommands that need input prompt interactively when args are omitted. |---------|------|---------|---------------| | `pos communication telegram sender send "text"` | `bin/pos-communication-telegram-sender` | Send a message, link, or media file (auto-detects the type) to a Telegram chat via the Bot API | Token + chat ID from `~/.config/linux_post_install/telegram.env` (`TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`, chmod 600). Precedence: `--token`/`--chat-id` flags > env > config file | | `pos communication telegram listener` | `bin/pos-communication-telegram-listener` | Telegram bot listener: map `/command` → bash commands and run them from chat; interactive editor for the map | Same `telegram.env` (the bot is the owner, `TELEGRAM_CHAT_ID`). Map lives in `~/.config/linux_post_install/telegram_commands.env` (`/cmd=bash command` lines, chmod 600) | +| `pos communication matrix sender send "text"` | `bin/pos-communication-matrix-sender` | Send a text message (plain or `--markdown`) to a Matrix room via the client-server API; also `login` (password → access token) and `test` | Homeserver + room from `~/.config/linux_post_install/matrix.env` (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, `MATRIX_USER_ID`, `MATRIX_ROOM_ID`, chmod 600, secrets masked by `pos config matrix`). Precedence: `--room` flag > env > config file | +| `pos communication matrix listener` | `bin/pos-communication-matrix-listener` | Matrix listener: map `/command` → bash commands and run them from room messages; interactive editor for the map | Same `matrix.env` (reacts to `MATRIX_USER_ID`'s own messages; watches `MATRIX_ROOM_ID` or all joined rooms). Map lives in `~/.config/linux_post_install/matrix_commands.env` (`/cmd=bash command` lines, chmod 600) | `pos communication telegram sender` in detail: @@ -259,6 +261,30 @@ The map file is re-read for every message — edits apply without a restart. The Map entries may carry an optional **description** shown in the bot's `/` menu: `/cmd::short description=bash command` (the description falls back to the bash command, truncated to ~40 chars, when omitted). After every add/edit/remove the command list is pushed to the bot via `setMyCommands`, so the menu stays in sync; an empty map clears the menu. Telegram only registers lowercase `[a-z0-9_]` names (1–32 chars) — commands like `/Status` or `/my-cmd` are skipped from the menu with a warning but still resolve when typed. +`pos communication matrix sender` in detail: + +| Command | Behavior | +|---------|----------| +| `pos communication matrix sender send "text"` | PUTs an `m.room.message` (`m.text`) to the homeserver's client-server API v3 (60s timeout); prints `[+] m.text sent to room <room>` or fails with a nonzero exit. Room id/alias is URL-encoded automatically; a unique transaction id (`<timestamp>ns`) is generated per message | +| `pos communication matrix sender send "text" --markdown` | Sends with `format: org.matrix.custom.html` — a best-effort markdown → HTML conversion (`**bold**`, `__bold__`, `*em*`, `_em_`, `` `code` ``, ``` ```fences``` ``, `~~strike~~`, `[link](url)`, headers, list items). Deliberately simple; it never fails the send | +| `pos communication matrix sender send "text" --room <id\|alias>` | One-shot override of the room for this send only (e.g. `--room '#ops:example.org'`) | +| `pos communication matrix sender login --user <@id>` | Prompts (masked) for the account password, POSTs `m.login.password` to `/login`, and saves the returned `access_token` + `user_id` to `matrix.env` | +| `pos communication matrix sender test` | Sends a canned test message (`Test message from pos <timestamp>`) using the current config | + +The access token is a secret — it is stored only in `~/.config/linux_post_install/matrix.env` and never in the repo. `pos config matrix` edits `MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN` (masked), `MATRIX_USER_ID`, `MATRIX_ROOM_ID`. Requires network access to your homeserver. The sender implements the `lib/notify.sh` sender contract, so `matrix` can be added to `NOTIFY_PLATFORM` for multi-platform alerting. + +`pos communication matrix listener` in detail: + +| Command | Behavior | +|---------|----------| +| `pos communication matrix listener` | Interactive editor for the `/command` → bash map (`a`dd / `e`dit / `r`emove / `t`est / `q`uit); test-runs run `bash -n` first and may execute the command live | +| `pos communication matrix listener --status` | Shows service state (running/autostart), config + map file paths, and the mapped commands | +| `pos communication matrix listener --enable` | Installs + starts a systemd **user** service (`pos-matrix-listener.service`); the daemon long-polls `/sync` and runs mapped commands | +| `pos communication matrix listener --disable` | Stops, disables, and removes the service | +| `pos communication matrix listener --run` | Run the polling loop in the foreground (what the service executes) | + +The daemon long-polls `/sync` (30s timeout, per-sync `since` token, compact filter that drops presence/account_data/device noise and only requests `m.room.message` timeline events). It reacts only to messages **from `MATRIX_USER_ID`** (your own account — resolved via `/account/whoami` if unset); a `MATRIX_ROOM_ID` restricts it to one room, otherwise every joined room is watched. `/` and `!` prefixes both resolve (`!status` = `/status`). `/help` lists mapped commands; an unmapped command replies "Unknown command". Non-command text starting with `ai ` (case-insensitive, e.g. `ai what is Nvidia`) is forwarded to Gemini via `pos ai gemini ask` with a per-room session (`matrix-<room>`; `ai /reset` clears it) and the answer is replied verbatim with markdown stripped. Replies are sent as `m.text` threaded with `m.in_reply_to` on your message. Commands run as your user via `timeout 60 bash -c "…"` (stdout + stderr are replied, truncated to ~3800 chars; empty output → `OK`; non-zero exit is prefixed with `exit <rc>`), so `sudo` inside them needs a NOPASSWD rule. A map value prefixed with `@quiet ` runs the command but does NOT reply — for commands that already send their own notification (e.g. `/status=@quiet pos system health --send`). Map lines may carry a `/cmd::description=…` description. `--enable` warns if linger is off — the service stops when you log out unless you run `sudo loginctl enable-linger $(whoami)`. + ### entertainment **File:** `bin/pos-entertainment-send` diff --git a/DOC/howto/communication.md b/DOC/howto/communication.md index de394eb..46864ee 100644 --- a/DOC/howto/communication.md +++ b/DOC/howto/communication.md @@ -1,16 +1,18 @@ # How-To: `pos communication` -Telegram messaging and alerts. Tools: `telegram-sender`, `telegram-listener`, -`matrix`. +Messaging and alerts over Telegram and Matrix. Tools: `telegram-sender`, +`telegram-listener`, `matrix-sender`, `matrix-listener`. | Tool | What it does | |------|--------------| | `pos communication telegram sender` | Send messages/files/links/stickers, test, config (token + chat id) | | `pos communication telegram listener` | Bot listener: map `/command` → bash and run it from chat (systemd user daemon) | -| `pos communication matrix` | Matrix/Synapse sender (extensible; not yet implemented) | +| `pos communication matrix sender` | Send messages to a Matrix room via the client-server API (send, test, login) | +| `pos communication matrix listener` | Matrix listener: map `/command` → bash and run it from room messages (systemd user daemon) | `telegram-sender` is the workhorse: it backs the whole **notify system** — health digests, backup alerts, firewall changes — and can be used directly. +`matrix-sender` plugs into the same system as a second platform. --- @@ -121,20 +123,107 @@ NOTIFY_PLATFORM=telegram # default; comma-separated to fan out to all ``` - `notify_send "msg"` → delivers to every platform in `NOTIFY_PLATFORM` - (current senders: `telegram`). + (current senders: `telegram`, `matrix`). - **Silent-fail:** no platform configured → one WARN line, exit 0, never breaks the calling tool. -- New platform (e.g. Matrix): implement `bin/pos-communication-<p> send +- New platform: implement `bin/pos-communication-<p> send <value> [--markdown]`, then list it in `NOTIFY_PLATFORM`. Details: [DOC/DEV.md → Alerting](../DEV.md). --- -## `pos communication matrix` +## `pos communication matrix sender` -Sender contract exists (`send <value> [--markdown]`) and the dispatcher routes -to it, but no implementation ships yet. When present, add `matrix` to -`NOTIFY_PLATFORM` and configure it via `pos communication matrix config set …`. +Send plain-text (or markdown-formatted) messages to a Matrix room via the +homeserver's client-server API (v3). No bot is needed — it uses a regular +account's access token. + +### One-time setup + +```bash +pos config matrix +# set MATRIX_HOMESERVER (https://matrix.example.org) and MATRIX_ROOM_ID +# (room id or alias like #ops:example.org), then grab a token: +pos communication matrix sender login --user @you:example.org +# password is prompted (masked) — access token + user id are saved +pos communication matrix sender test +# config lives in ~/.config/linux_post_install/matrix.env (chmod 600) +``` + +`login` calls the homeserver's password endpoint and stores the resulting +access token in `matrix.env` (masked by `pos config matrix`). Tokens from any +Matrix client (Element, `synctl`…) work too — set `MATRIX_ACCESS_TOKEN` +directly. Requires network access to your homeserver. + +### Send + +```bash +pos communication matrix sender send "Backup finished" # plain text +pos communication matrix sender send --markdown "**bold** ok" # org.matrix.custom.html +pos communication matrix sender send "hi" --room '#ops:example.org' # one-shot override +pos communication matrix sender test # canned test message +``` + +`--markdown` sends with `org.matrix.custom.html` (best-effort markdown → +HTML: `**bold**`, `` `code` ``, links, headers, lists). Room ids and aliases +are URL-encoded automatically. + +**Troubleshooting:** +- "No access token" → run `pos communication matrix sender login` first. +- Login fails → is the homeserver URL right (`pos config matrix`) and is the + account password correct? Homeservers may require the full + `@user:example.org` id. +- Nothing arrives → the room id/alias must exist and the account must be a + member. Public aliases like `#pos:example.org` work when the account has + joined. + +--- + +## `pos communication matrix listener` + +Turns any room you're in into a remote control for your server: message your +own account a `/command` and the mapped bash runs. + +```bash +pos communication matrix listener # edit the /command → bash map +pos communication matrix listener --status # service state + mapped commands +pos communication matrix listener --enable # install the systemd user daemon +pos communication matrix listener --disable # remove it +``` + +- **Map file:** `~/.config/linux_post_install/matrix_commands.env` (chmod 600), + one `/cmd=bash command` per line — re-read on every message, so edits apply + instantly. Example: + ``` + /status=@quiet pos system health --send + /temp=sensors | grep -i 'Tctl\|package id 0' + /update=cd /path/to/repo && git pull + ``` +- **Self-messaging:** the listener reacts to messages **from your own user id** + (`MATRIX_USER_ID`) — in practice that means a second device (or another + account) sending the commands. If `MATRIX_ROOM_ID` is set it only watches + that room, otherwise every room you've joined. `/` and `!` both work + (`!status` = `/status`). `/help` lists mapped commands; unknown → "Unknown + command". +- **Runs as you:** mapped commands execute as your user with a 60s timeout, + stdout + stderr are replied to the room as a thread reply to your message + (truncated ~3800 chars; empty → `OK`). `sudo` inside a command needs a + NOPASSWD rule. +- **`@quiet` prefix:** a map value starting with `@quiet ` runs the command but + does NOT reply — for commands that already send their own notification. +- **`ai …` bridge:** non-command messages starting with `ai ` are answered by + `pos ai gemini` (per-room memory session; `ai /reset` clears it) — replying + with the model's answer, markdown stripped. +- **Daemon lifecycle:** the service is a systemd **user** unit; it stops at + logout unless you enable linger: `sudo loginctl enable-linger $(whoami)`. + `--enable` prints this warning if linger is off. + +**Troubleshooting:** +- Doesn't answer → check the service with + `systemctl --user status pos-matrix-listener.service`; the daemon logs to + `~/.local/share/linux_post_install/logs/pos.log`. +- "Unknown command" → send `/help` for the mapped list. +- Needs `jq` (in preinstall PACKAGES). --- diff --git a/bin/pos b/bin/pos index 00003d6..c9dd49d 100755 --- a/bin/pos +++ b/bin/pos @@ -168,6 +168,12 @@ EXAMPLES pos communication telegram listener --enable Install the Telegram bot listener + pos communication matrix sender send "Backup done" + Send a Matrix room message + pos communication matrix sender login --user @me:example.org + Fetch a Matrix access token + pos communication matrix listener Edit the /command → bash map + pos entertainment send weather Fetch Open-Meteo weather → Telegram pos entertainment send joke --print Preview a joke locally (no send) pos entertainment enable weather 5m Auto-send weather to Telegram every 5 min @@ -244,7 +250,7 @@ MAIN_LOG="$LOG_DIR/pos.log" log_cmd() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $* → exit $2" >> "$MAIN_LOG"; } # Commands that read from stdin interactively — only log invocation -INTERACTIVE_CMDS="system-firewall media-mp4 system-backup usb-server communication-telegram-listener ai-gemini system-event-trigger config" +INTERACTIVE_CMDS="system-firewall media-mp4 system-backup usb-server communication-telegram-listener communication-matrix-listener ai-gemini system-event-trigger config" for ((i=n-1; i>=0; i--)); do cmd="pos" diff --git a/bin/pos-communication-matrix-listener b/bin/pos-communication-matrix-listener new file mode 100755 index 0000000..24afa50 --- /dev/null +++ b/bin/pos-communication-matrix-listener @@ -0,0 +1,565 @@ +#!/usr/bin/env bash +set -euo pipefail +# POS: communication matrix-listener — Matrix listener: map /command → bash, run them on room messages +# POS_FLAGS: --enable --disable --status --run + +CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_FILE="$CONFIG_DIR/matrix.env" +MAP_FILE="$CONFIG_DIR/matrix_commands.env" +SERVICE="pos-matrix-listener.service" +USER_SYSTEMD_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + +# System prompt for the "ai " bridge: replies are posted straight into the +# room, so ask for concise, emoji-friendly Matrix-style answers. +AI_SYSTEM="You are a friendly assistant chatting in a Matrix room. Keep replies concise, use emojis and light formatting to make them lively, and never claim to send messages yourself." + +err() { echo "ERROR: $*" >&2; exit 1; } +log() { echo "[+] $*"; } +warn() { echo "[!] $*" >&2; } + +usage() { + cat <<EOF +Usage: pos communication matrix listener [command] + +Matrix listener: map /command → bash commands and run them from room messages. + +Commands: + (none) Interactive editor for the /command → bash map + --enable Install + start the systemd user service (autostarts on login) + --disable Stop + disable + remove the service + --status Show service state and the command map + --run Run the polling loop in the foreground (used by the service) + +Config: $CONFIG_FILE (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, + MATRIX_USER_ID — edit with 'pos config matrix') +Map: $MAP_FILE — '/cmd=bash command' per line (optional + '/cmd::short description=bash command') + +Prefix a map value with '@quiet ' to run the command without replying — +for commands that already send their own notification, so you don't get it +twice (e.g. '/backup=@quiet pos system backup --send'). + +To reply with a command's stdout (e.g. the health dashboard), just map it +without '@quiet' and the listener forwards the output: + '/status=pos system health' + +The listener only reacts to messages from your own Matrix user +(MATRIX_USER_ID). If MATRIX_ROOM_ID is set it only watches that room, +otherwise it watches every room you've joined. Commands run as your user, +so 'sudo' inside them needs a NOPASSWD rule. The interactive editor runs +'bash -n' to syntax-check commands before saving. + +Commands are matched with a leading '/' or '!' — '/status' and '!status' +both resolve. There is no equivalent of Telegram's bot "/" menu on Matrix, +so command discovery is via /help. + +Examples: + pos communication matrix listener + pos communication matrix listener --enable + pos communication matrix listener --status +EOF + exit 0 +} + +# ── matrix.env (same pattern as pos-communication-matrix-sender) ── +load_config() { + [ -f "$CONFIG_FILE" ] || return 0 + local k v + while IFS='=' read -r k v; do + [ -n "$k" ] || continue + case "$k" in + \#*) continue ;; + esac + v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}" + if [ -z "${!k:-}" ]; then + export "$k"="$v" + fi + done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true) +} + +api_base() { + local base="${MATRIX_HOMESERVER:-}" + [ -n "$base" ] || err "No homeserver — run 'pos config matrix' (MATRIX_HOMESERVER)" + printf '%s' "${base%/}" +} + +urlencode() { + local s="$1" + s="${s//#/%23}" + s="${s//\"/%22}" + s="${s// /%20}" + printf '%s' "$s" +} + +# ── command map (MAP_FILE) ────────────────────────────────────── +# Lines: /cmd=bash command, or /cmd::description=bash command. Keys keep the +# leading slash; read via awk so values may contain '='. Entries are emitted +# with a \x1f (unit separator) delimiter so bash commands may contain pipes. +# The map is re-read per message — edits apply without restarting the listener. + +map_entries() { + [ -f "$MAP_FILE" ] || return 0 + grep -E '^/[A-Za-z0-9_.-]+(::.*)?=' "$MAP_FILE" | while IFS= read -r line; do + local left="${line%%=*}" cmd desc + if [[ "$left" == *::* ]]; then + cmd="${left%%::*}" + desc="${left#*::}" + else + cmd="$left" + desc="" + fi + printf '%s\x1f%s\x1f%s\n' "$cmd" "$desc" "${line#*=}" + done +} + +map_has() { + [ -f "$MAP_FILE" ] || return 1 + awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {found=1} END{exit !found}' "$MAP_FILE" +} + +map_get() { + [ -f "$MAP_FILE" ] || return 0 + awk -F= -v k="$1" '$1==k || index($1, k "::")==1 {sub(/^[^=]*=/,""); print}' "$MAP_FILE" +} + +map_set() { + local cmd="$1" value="$2" desc="${3:-}" + local key="$cmd" + mkdir -p "$CONFIG_DIR" + touch "$MAP_FILE" + chmod 600 "$MAP_FILE" + local tmp + tmp="$(mktemp)" + [ -n "$desc" ] && key="${cmd}::${desc}" + awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp" + printf '%s=%s\n' "$key" "$value" >> "$tmp" + mv "$tmp" "$MAP_FILE" + chmod 600 "$MAP_FILE" +} + +map_del() { + [ -f "$MAP_FILE" ] || return 0 + local cmd="$1" tmp + tmp="$(mktemp)" + + awk -v k="$cmd" 'index($0, k "=") != 1 && index($0, k "::") != 1 { print }' "$MAP_FILE" > "$tmp" + mv "$tmp" "$MAP_FILE" + chmod 600 "$MAP_FILE" +} + +MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0 +load_map() { + MAP_CMDS=(); MAP_VALS=(); MAP_DESCS=(); MAP_N=0 + [ -f "$MAP_FILE" ] || return 0 + local i=0 cmd desc value + while IFS=$'\x1f' read -r cmd desc value; do + [ -n "$cmd" ] || continue + i=$((i + 1)) + MAP_CMDS[$i]="$cmd"; MAP_DESCS[$i]="$desc"; MAP_VALS[$i]="$value" + done <<< "$(map_entries)" + MAP_N="$i" +} + +map_cmds_list() { + load_map + local out="" i + for ((i=1; i<=MAP_N; i++)); do + [ -n "$out" ] && out+=", " + out+="${MAP_CMDS[$i]}" + done + printf '%s' "${out:-none}" +} + +check_syntax() { + bash -n -c "$(strip_quiet "$1")" 2>&1 +} + +# ── interactive editor ────────────────────────────────────────── +ui_pick() { + load_map + if [ "$MAP_N" -eq 0 ]; then + warn "no commands mapped yet — add one first" + return 1 + fi + echo >&2 + local i + for ((i=1; i<=MAP_N; i++)); do + printf ' %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" >&2 + done + local idx + read -rp "Entry number: " idx + if ! [[ "$idx" =~ ^[0-9]+$ ]] || (( idx < 1 || idx > MAP_N )); then + warn "invalid number '$idx'" + return 1 + fi + echo "$idx" +} + +QUIET_PREFIX="@quiet" + +strip_quiet() { + local value="$1" + if [ "${value#"$QUIET_PREFIX "}" != "$value" ]; then + printf '%s' "${value#"$QUIET_PREFIX "}" + else + printf '%s' "$value" + fi +} + +ui_run_command() { + local value="$1" output rc + value="$(strip_quiet "$value")" + echo + echo "--- running: $value" + if output="$(timeout 60 bash -c "$value" 2>&1)"; then + rc=0 + else + rc=$? + fi + printf '%s\n' "$output" | head -c 3800 + [ -z "$output" ] || echo + echo "--- exit $rc" +} + +ui_add() { + local cmd value desc out + read -rp "/command name (e.g. /status): " cmd + [ -n "$cmd" ] || { warn "empty command name"; return; } + case "$cmd" in + /[A-Za-z0-9_.-]*) ;; + *) warn "command must start with '/' and use [A-Za-z0-9_.-]: $cmd"; return ;; + esac + read -rp "bash command: " value + [ -n "$value" ] || { warn "empty bash command"; return; } + read -rp "description (optional, shown by /help): " desc + if out="$(check_syntax "$value")"; then + map_set "$cmd" "$value" "$desc" + log "saved $cmd -> $value" + else + warn "syntax error — not saved:" + printf '%s\n' "$out" | sed 's/^/ /' + return + fi + local yn + read -rp "Test-run it now? [y/N] " yn + case "$yn" in + y|Y) ui_run_command "$value" ;; + esac +} + +ui_edit() { + local idx cmd value desc out + idx="$(ui_pick)" || return + cmd="${MAP_CMDS[$idx]}" + echo "Editing: $cmd -> ${MAP_VALS[$idx]}" + read -rp "bash command: " value + [ -n "$value" ] || { warn "empty bash command"; return; } + read -rp "description (current: ${MAP_DESCS[$idx]:-none}): " desc + if out="$(check_syntax "$value")"; then + map_set "$cmd" "$value" "$desc" + log "updated $cmd -> $value" + else + warn "syntax error — not saved:" + printf '%s\n' "$out" | sed 's/^/ /' + fi +} + +ui_remove() { + local idx cmd yn + idx="$(ui_pick)" || return + cmd="${MAP_CMDS[$idx]}" + read -rp "Remove '$cmd'? [y/N] " yn + case "$yn" in + y|Y) map_del "$cmd"; log "removed $cmd" ;; + *) warn "canceled" ;; + esac +} + +ui_test() { + local idx + idx="$(ui_pick)" || return + ui_run_command "${MAP_VALS[$idx]}" +} + +ui() { + local choice + while true; do + echo + echo "Matrix listener — /command -> bash map" + echo "---------------------------------------" + load_map + if [ "$MAP_N" -eq 0 ]; then + echo " (no commands mapped yet)" + else + local i + for ((i=1; i<=MAP_N; i++)); do + printf ' %2d) %-16s -> %s%s\n' "$i" "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" + done + fi + echo + read -rp "Menu: [a]dd [e]dit [r]emove [t]est [q]uit > " choice + case "$choice" in + a|A|add) ui_add ;; + e|E|edit) ui_edit ;; + r|R|remove) ui_remove ;; + t|T|test) ui_test ;; + q|Q|quit|exit) echo; log "bye"; return 0 ;; + *) warn "unknown choice '$choice'" ;; + esac + done +} + +# ── systemd user service ──────────────────────────────────────── +enable_service() { + command -v systemctl &>/dev/null || err "systemctl not found — cannot create the listener service" + mkdir -p "$USER_SYSTEMD_DIR" + + local runner + if [ -x /usr/local/bin/pos-communication-matrix-listener ]; then + runner=/usr/local/bin/pos-communication-matrix-listener + else + runner="$(cd "$(dirname "$0")/.." && pwd)/bin/pos-communication-matrix-listener" + warn "using repo path $runner — re-run 'install.sh' so the service survives a deleted repo" + fi + + cat >"$USER_SYSTEMD_DIR/$SERVICE" <<EOF +[Unit] +Description=pos Matrix listener (/command to bash) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=$runner --run +Restart=always +RestartSec=5 +# Do NOT pin Environment=HOME here — the systemd user manager already sets the +# correct HOME for the user; pinning a stale enable-time value made the Telegram +# daemon read ~/.config/... from the wrong home. + +[Install] +WantedBy=default.target +EOF + chmod 644 "$USER_SYSTEMD_DIR/$SERVICE" + + systemctl --user daemon-reload + systemctl --user enable --now "$SERVICE" + log "listener service enabled: $SERVICE" + warn "commands run as $(id -un) — 'sudo' inside them needs a NOPASSWD rule" + if command -v loginctl >/dev/null 2>&1; then + if ! loginctl show-user "$(id -un)" 2>/dev/null | grep -q '^Linger=yes'; then + warn "enable linger so the listener survives logout: sudo loginctl enable-linger $(id -un)" + fi + fi +} + +disable_service() { + if [ ! -f "$USER_SYSTEMD_DIR/$SERVICE" ]; then + warn "no listener service installed ($SERVICE)" + exit 0 + fi + systemctl --user disable --now "$SERVICE" 2>/dev/null || true + rm -f "$USER_SYSTEMD_DIR/$SERVICE" + systemctl --user daemon-reload + log "listener service disabled" +} + +status() { + if systemctl --user is-active --quiet "$SERVICE" 2>/dev/null; then + echo "listener: running" + else + echo "listener: not running" + fi + if systemctl --user is-enabled "$SERVICE" >/dev/null 2>&1; then + echo "autostart: enabled (starts on login)" + else + echo "autostart: disabled" + fi + echo "config: $CONFIG_FILE" + echo "map file: $MAP_FILE" + load_map + echo "commands: $MAP_N mapped" + local i + for ((i=1; i<=MAP_N; i++)); do + printf ' %-16s -> %s%s\n' "${MAP_CMDS[$i]}" "${MAP_VALS[$i]}" "${MAP_DESCS[$i]:+ (${MAP_DESCS[$i]})}" + done +} + +# ── polling daemon ────────────────────────────────────────────── +# Build a compact sync filter: only timeline m.room.message events, drop +# presence/account_data/device noise. Reduces bandwidth on busy rooms. +SYNC_FILTER='{"presence":{"not_types":["*"]},"account_data":{"not_types":["*"]},"device":{"not_types":["*"]},"room":{"timeline":{"limit":20,"types":["m.room.message"]}}}' + +reply_room() { + local room="$1" text="$2" event_id="${3:-}" rc="${4:-}" + text="${text:0:3800}" + [ -n "${MATRIX_ACCESS_TOKEN:-}" ] || { warn "reply failed — no access token"; return; } + local base txn body + base="$(api_base)" + txn="$(date +%s%N)" + room="$(urlencode "$room")" + if [ -n "$event_id" ]; then + body="$(jq -n --arg body "$text" --arg eid "$event_id" \ + '{msgtype:"m.text", body:$body, "m.relates_to":{"m.in_reply_to":{"event_id":$eid}}}')" + else + body="$(jq -n --arg body "$text" '{msgtype:"m.text", body:$body}')" + fi + curl -fsS -m 60 -X PUT \ + -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$body" \ + "${base}/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}" >/dev/null 2>&1 \ + || warn "reply failed (rc ${rc:-?})" +} + +# Strip common markdown so AI output reads cleanly in a plain-text +# Matrix message (no format is sent). +strip_markdown() { + local t="$1" + t="$(printf '%s' "$t" | sed -E \ + -e 's/!\[[^]]*\]\([^)]*\)//g' \ + -e 's/\[([^]]*)\]\([^)]*\)/\1/g' \ + -e 's/\*\*([^*]*)\*\*/\1/g' \ + -e 's/\*([^*]*)\*/\1/g' \ + -e 's/__([^_]*)__/\1/g' \ + -e 's/`([^`]*)`/\1/g' \ + -e 's/^[[:space:]]*#{1,6}[[:space:]]+//' \ + -e 's/^[[:space:]]*>[[:space:]]?//' \ + -e 's/^[[:space:]]*([-*+]|[0-9]+\.)[[:space:]]+/• /')" + t="$(printf '%s' "$t" | sed -E '/^[[:space:]]*([-*_][[:space:]]*){3,}[[:space:]]*$/d')" + printf '%s' "$t" +} + +# Match map commands whether typed with '/' or '!' — keys in the map keep +# the leading '/'. Returns the map value for /cmd or !cmd. +map_lookup() { + local text="$1" key + if [[ "$text" == !* ]]; then + key="/${text#!}" + else + key="$text" + fi + map_get "$key" +} + +handle_message() { + local room="$1" text="$2" event_id="$3" sender="$4" + local value output rc quiet=0 key + case "$text" in + /help|!help|/start|!start) + reply_room "$room" "Mapped commands: $(map_cmds_list)" "$event_id" + return ;; + esac + # AI bridge: non-command text starting with "ai " (case-insensitive) is + # forwarded to Gemini; the model's answer is replied verbatim. Each room + # gets its own persistent memory session ("matrix-<room>"); the exact + # prompt "ai /reset" clears it. + if [[ "$text" != /* && "$text" != !* && "$text" =~ ^[Aa][Ii][[:space:]](.*)$ ]]; then + local prompt="${BASH_REMATCH[1]}" answer session + [ -n "$prompt" ] || { reply_room "$room" "Usage: ai <prompt> — e.g. 'ai what is Nvidia'" "$event_id"; return; } + session="matrix-${room}" + if [[ "$prompt" =~ ^/?reset[[:space:]]*$ ]]; then + if pos ai gemini sessions reset "$session" >/dev/null 2>&1; then + reply_room "$room" "Memory cleared." "$event_id" + else + reply_room "$room" "AI error: could not clear memory" "$event_id" + fi + return + fi + log "ai: $prompt" + if answer="$(timeout 120 pos ai gemini ask --session "$session" --system "$AI_SYSTEM" "$prompt" 2>&1)"; then + reply_room "$room" "$(strip_markdown "$answer")" "$event_id" + else + [ -n "$answer" ] || answer="timed out after 120s" + reply_room "$room" "AI error: $answer" "$event_id" + fi + return + fi + if [[ "$text" != /* && "$text" != !* ]]; then + return + fi + value="$(map_lookup "$text")" + if [ -z "$value" ]; then + reply_room "$room" "Unknown command: $text (send /help)" "$event_id" + return + fi + if [ "${value#"$QUIET_PREFIX "}" != "$value" ]; then + quiet=1 + value="${value#"$QUIET_PREFIX "}" + fi + log "exec: $text" + if output="$(timeout 60 bash -c "$value" 2>&1)"; then + rc=0 + else + rc=$? + fi + [ "$quiet" -eq 1 ] && return + if [ -z "$output" ]; then + output="OK" + fi + if [ "$rc" -ne 0 ]; then + reply_room "$room" "$(printf 'exit %s\n%s' "$rc" "$output")" "$event_id" "$rc" + else + reply_room "$room" "$output" "$event_id" + fi +} + +run_daemon() { + command -v jq &>/dev/null || err "jq not found (install jq — in preinstall PACKAGES)" + load_config + [ -n "${MATRIX_HOMESERVER:-}" ] || err "No homeserver — run 'pos config matrix'" + [ -n "${MATRIX_ACCESS_TOKEN:-}" ] || err "No access token — run 'pos communication matrix sender login'" + local owner="${MATRIX_USER_ID:-}" + local room_only="${MATRIX_ROOM_ID:-}" + if [ -z "$owner" ]; then + local who + who="$(curl -fsS -m 30 -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ + "$(api_base)/_matrix/client/v3/account/whoami" 2>/dev/null)" || { warn "cannot resolve own user id — set MATRIX_USER_ID"; owner=""; } + owner="$(printf '%s' "$who" | jq -r '.user_id // empty')" + [ -n "$owner" ] || err "cannot resolve own user id — run 'pos config matrix' and set MATRIX_USER_ID" + log "owner resolved: $owner" + fi + + local since="" filter_enc resp nb + filter_enc="$(printf '%s' "$SYNC_FILTER" | jq -sRr @uri)" + log "listener running (owner ${owner}${room_only:+ — room ${room_only}}) — Ctrl+C to stop" + while true; do + local args=("$(api_base)/_matrix/client/v3/sync?timeout=30000&filter=${filter_enc}") + [ -n "$since" ] && args[0]+="&since=${since}" + resp="$(curl -fsS -m 45 -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" "${args[0]}" 2>/dev/null)" || { sleep 5; continue; } + nb="$(printf '%s' "$resp" | jq -r '.next_batch // empty')" + [ -n "$nb" ] || { sleep 5; continue; } + since="$nb" + + local room ecount i roomid ev sender text eid + for room in $(printf '%s' "$resp" | jq -r '.rooms.join // {} | to_entries[] | .key' 2>/dev/null); do + if [ -n "$room_only" ] && [ "$room" != "$room_only" ]; then + continue + fi + ecount="$(printf '%s' "$resp" | jq -r --arg r "$room" ".rooms.join[\"$room\"].timeline.events // [] | length")" + for ((i=0; i<ecount; i++)); do + ev="$(printf '%s' "$resp" | jq -c --arg r "$room" --argjson i "$i" ".rooms.join[\"$room\"].timeline.events[\$i]")" + [ -n "$ev" ] || continue + [ "$(printf '%s' "$ev" | jq -r '.type // empty')" = "m.room.message" ] || continue + [ "$(printf '%s' "$ev" | jq -r '.content.msgtype // empty')" = "m.text" ] || continue + sender="$(printf '%s' "$ev" | jq -r '.sender // empty')" + [ -n "$sender" ] || continue + [ "$sender" = "$owner" ] || continue + text="$(printf '%s' "$ev" | jq -r '.content.body // empty')" + [ -n "$text" ] || continue + eid="$(printf '%s' "$ev" | jq -r '.event_id // empty')" + handle_message "$room" "$text" "$eid" "$sender" + done + done + done +} + +case "${1:-}" in + -h|--help) usage ;; + --enable) enable_service ;; + --disable) disable_service ;; + --status) status ;; + --run) run_daemon ;; + "") ui ;; + *) err "Unknown option '$1' (see --help)" ;; +esac diff --git a/bin/pos-communication-matrix-sender b/bin/pos-communication-matrix-sender new file mode 100755 index 0000000..453dfbd --- /dev/null +++ b/bin/pos-communication-matrix-sender @@ -0,0 +1,214 @@ +#!/usr/bin/env bash +set -euo pipefail +# POS: communication matrix-sender — Send messages to a Matrix room via the client-server API (send, test, login) +# POS_SUBCMDS: send test login +# POS_CONFIG: matrix | matrix.env | MATRIX_HOMESERVER=:Homeserver URL (https://matrix.example.org)::https://matrix.example.org | MATRIX_ACCESS_TOKEN=secret:Access token (from 'pos communication matrix sender login' or a Matrix client) | MATRIX_USER_ID=:Your Matrix user id (set by login)::@you:example.org | MATRIX_ROOM_ID=:Room id or alias::#pos:example.org + +CONFIG_DIR="$HOME/.config/linux_post_install" +CONFIG_FILE="$CONFIG_DIR/matrix.env" + +usage() { + cat <<EOF +Usage: pos communication matrix sender [command] [args] + +Send messages to a Matrix room via the client-server API (v3). + +Commands: + send <value> [options] Send a text message to the configured room + test Send a test message using the current config + login --user <@id> Get an access token (password prompt) and save it + +Options (send): + --markdown Send with org.matrix.custom.html formatting + (best-effort markdown → HTML conversion) + --room <id|alias> Override the room for one send + +Config: $CONFIG_FILE (MATRIX_HOMESERVER, MATRIX_ACCESS_TOKEN, + MATRIX_USER_ID, MATRIX_ROOM_ID) — edit it with 'pos config matrix' + +Precedence: CLI flags > environment > config file. + +Examples: + pos communication matrix sender send "Backup finished" + pos communication matrix sender send "**disk full**" --markdown + pos communication matrix sender send "hello" --room '#ops:example.org' + pos communication matrix sender login --user @you:example.org + pos communication matrix sender test +EOF + exit 0 +} + +err() { echo "ERROR: $*" >&2; exit 1; } +log() { echo "[+] $*"; } + +load_config() { + [ -f "$CONFIG_FILE" ] || return 0 + local k v + while IFS='=' read -r k v; do + [ -n "$k" ] || continue + case "$k" in + \#*) continue ;; + esac + v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}" + v="${v//$'\r'/}" + if [ -z "${!k:-}" ]; then + export "$k"="$v" + fi + done < <(grep -E '^[A-Z_]+=' "$CONFIG_FILE" || true) +} + +save_config() { + mkdir -p "$CONFIG_DIR" + local key="$1" val="$2" tmp + tmp="$(mktemp)" + grep -v "^${key}=" "$CONFIG_FILE" 2>/dev/null >"$tmp" || true + printf '%s="%s"\n' "$key" "$val" >>"$tmp" + mv "$tmp" "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" +} + +urlencode() { + local s="$1" + s="${s//#/%23}" + s="${s//\"/%22}" + s="${s// /%20}" + printf '%s' "$s" +} + +# Best-effort markdown → HTML for org.matrix.custom.html. Handles code +# fences, inline code, bold/italic/strike, links, headers, and lists. The +# conversion is deliberately simple — it must never fail the send. +md_to_html() { + local t="$1" + t="$(printf '%s' "$t" | sed -E \ + -e 's/&/\&/g' \ + -e 's/</\</g' \ + -e 's/>/\>/g' \ + -e 's/```([^`]*)```/\n<pre><code>\1<\/code><\/pre>\n/g' \ + -e 's/`([^`]*)`/<code>\1<\/code>/g' \ + -e 's/\*\*([^*]*)\*\*/<strong>\1<\/strong>/g' \ + -e 's/__([^_]*)__/<strong>\1<\/strong>/g' \ + -e 's/\*([^*]*)\*/<em>\1<\/em>/g' \ + -e 's/_([^_]*)_/<em>\1<\/em>/g' \ + -e 's/~~([^~]*)~~/<del>\1<\/del>/g' \ + -e 's/\[([^]]*)\]\(([^)]*)\)/<a href="\2">\1<\/a>/g' \ + -e 's/^[[:space:]]*#{1,6}[[:space:]]+/<h3>/' \ + -e 's/^[[:space:]]*([-*+]|[0-9]+\.)[[:space:]]+/<li>/' \ + -e 's/$/<br\/>/')" + printf '%s' "$t" +} + +api_base() { + local base="${MATRIX_HOMESERVER:-}" + [ -n "$base" ] || err "No homeserver — run 'pos config matrix' (MATRIX_HOMESERVER)" + printf '%s' "${base%/}" +} + +send_room_message() { + local room="$1" text="$2" markdown="$3" + [ -n "${MATRIX_ACCESS_TOKEN:-}" ] || err "No access token — run 'pos communication matrix sender login'" + [ -n "$room" ] || err "No room — run 'pos config matrix' (MATRIX_ROOM_ID) or pass --room" + local base txn body fmt + base="$(api_base)" + txn="$(date +%s%N)" + room="$(urlencode "$room")" + if [ "$markdown" -eq 1 ]; then + fmt="$(md_to_html "$text")" + body="$(jq -n --arg body "$text" --arg html "$fmt" \ + '{msgtype:"m.text", body:$body, format:"org.matrix.custom.html", formatted_body:$html}')" + else + body="$(jq -n --arg body "$text" '{msgtype:"m.text", body:$body}')" + fi + curl -fsS -m 60 -X PUT \ + -H "Authorization: Bearer ${MATRIX_ACCESS_TOKEN}" \ + -H "Content-Type: application/json" \ + --data "$body" \ + "${base}/_matrix/client/v3/rooms/${room}/send/m.room.message/${txn}" >/dev/null +} + +cmd_send() { + local value="" markdown=0 room="${MATRIX_ROOM_ID:-}" + while [ $# -gt 0 ]; do + case "$1" in + --markdown) markdown=1; shift ;; + --room) + [ $# -ge 2 ] || err "--room needs a value" + room="$2"; shift 2 ;; + -h|--help) usage ;; + --) shift; [ $# -ge 1 ] || err "No value given for send"; value="$1"; shift ;; + -*) err "Unknown option '$1'" ;; + *) [ -z "$value" ] || err "Unexpected extra argument '$1'"; value="$1"; shift ;; + esac + done + [ -n "$value" ] || err "No value given for send" + load_config + [ -n "${room:-}" ] || room="${MATRIX_ROOM_ID:-}" + send_room_message "$room" "$value" "$markdown" + log "m.text sent to room ${room}" +} + +cmd_login() { + local user="" password + while [ $# -gt 0 ]; do + case "$1" in + --user) + [ $# -ge 2 ] || err "--user needs a value" + user="$2"; shift 2 ;; + -h|--help) usage ;; + -*) err "Unknown option '$1'" ;; + *) err "Unexpected argument '$1' (use --user @you:server)" ;; + esac + done + [ -n "$user" ] || err "--user is required (e.g. --user @you:example.org)" + load_config + local base + base="$(api_base)" + read -rsp "Password for ${user}: " password </dev/tty || true + echo >&2 + [ -n "$password" ] || err "empty password" + local resp token uid + resp="$(curl -fsS -m 60 -X POST \ + -H "Content-Type: application/json" \ + --data "$(jq -n --arg user "$user" --arg pw "$password" \ + '{type:"m.login.password", identifier:{type:"m.id.user", user:$user}, password:$pw, initial_device_display_name:"pos"}')" \ + "${base}/_matrix/client/v3/login")" || err "login failed (wrong credentials? homeserver reachable?)" + token="$(printf '%s' "$resp" | jq -r '.access_token // empty')" + uid="$(printf '%s' "$resp" | jq -r '.user_id // empty')" + [ -n "$token" ] || err "login succeeded but no access_token in response" + [ -n "$uid" ] || uid="$user" + save_config MATRIX_ACCESS_TOKEN "$token" + save_config MATRIX_USER_ID "$uid" + log "logged in as $uid — token saved to $CONFIG_FILE" +} + +cmd_test() { + load_config + send_room_message "${MATRIX_ROOM_ID:-}" "Test message from pos $(date '+%Y-%m-%d %H:%M:%S')" 0 + log "test message sent to room ${MATRIX_ROOM_ID:-}" +} + +cmd="${1:-}" + +case "$cmd" in + -h|--help) usage ;; + send) + shift + cmd_send "$@" + ;; + test) + shift + cmd_test "$@" + ;; + login) + shift + cmd_login "$@" + ;; + "") + usage + ;; + *) + echo "ERROR: Unknown matrix command '$cmd'" + echo "Run 'pos communication matrix-sender --help' for usage." + exit 1 + ;; +esac diff --git a/completions/pos.bash b/completions/pos.bash index 308d7fe..85eb629 100644 --- a/completions/pos.bash +++ b/completions/pos.bash @@ -4,6 +4,7 @@ # GEN:START posflags declare -A _pos_flags _pos_flags[ai-gemini]="--model --session --system" +_pos_flags[communication-matrix-listener]="--enable --disable --status --run" _pos_flags[communication-telegram-listener]="--enable --disable --status --sync-commands --run" _pos_flags[communication-telegram-sender]="--type --caption --parse-mode --no-preview --token --chat-id --markdown" _pos_flags[entertainment-send]="--print --markdown" @@ -18,13 +19,14 @@ _pos_flags[usb-server]="--ls --ls-shared --share --unshare --auto-share --callba # GEN:START possubcmds declare -A _pos_subcmds _pos_subcmds[ai-gemini]="ask chat models sessions" +_pos_subcmds[communication-matrix-sender]="send test login" _pos_subcmds[communication-telegram-sender]="send test" _pos_subcmds[docker-compose]="ls installed up down restart logs update config" _pos_subcmds[docker-vbox]="create enter stop start rm ls" _pos_subcmds[system-event-trigger]="run config list enable disable status" # GEN:END possubcmds # GEN:START posconfigscopes -declare -a _pos_config_scopes=(ai compose entertainment notify system telegram) +declare -a _pos_config_scopes=(ai compose entertainment matrix notify system telegram) # GEN:END posconfigscopes _pos() {