|
| 1 | +# Client external hooks (experimental) |
| 2 | + |
| 3 | +**Status:** experimental. The hook protocol, environment variables, and payload fields may change or be removed in a future release without a major version bump. If you build integrations on this, pin a marchat version and watch release notes. |
| 4 | + |
| 5 | +Client hooks run **local executables** you choose, fed one JSON object per event on **stdin** (newline-terminated). They are **not** server plugins: they only see what **your** client process sees, after decrypt on receive and before encrypt on send. |
| 6 | + |
| 7 | +## Why this exists |
| 8 | + |
| 9 | +- **Server plugins** automate hub-side behavior and see server-wide trust boundaries. |
| 10 | +- **Built-in notifications** cover in-TUI alerts with a fixed feature set. |
| 11 | +- **Client hooks** fill the gap: pipe chat events to **your** scripts, loggers, bridges, or external notification systems **without** new client dependencies or server changes. |
| 12 | + |
| 13 | +Hooks are **side-effect only**: they cannot modify, block, or transform messages (runs are asynchronous). |
| 14 | + |
| 15 | +## Security and trust |
| 16 | + |
| 17 | +1. **Plaintext:** A receive hook runs after the client decrypts; a send hook runs with the plaintext you typed. Anyone who can read hook logs or the hook binary's behavior gets the same material as the TUI. This does **not** weaken wire encryption (the server still sees ciphertext for E2E traffic); it widens **local** exposure to whatever you execute. |
| 18 | +2. **Absolute paths only:** `MARCHAT_CLIENT_HOOK_RECEIVE` and `MARCHAT_CLIENT_HOOK_SEND` must be absolute paths to a regular file. Relative paths are rejected to avoid surprising `PATH` / working-directory behavior. |
| 19 | +3. **Trust the binary:** Hooks run as your user. Only point at programs you trust, same as running them manually. |
| 20 | +4. **Timeouts:** Each invocation is killed if it exceeds the configured timeout (default 5s, max 120s) so a stuck script does not pile up forever. |
| 21 | +5. **File messages:** Payloads include file **metadata** (`filename`, `size`) only; raw file bytes are never sent to hooks. |
| 22 | + |
| 23 | +## Environment variables |
| 24 | + |
| 25 | +| Variable | Meaning | |
| 26 | +|----------|---------| |
| 27 | +| `MARCHAT_CLIENT_HOOK_RECEIVE` | Absolute path to executable for inbound events (`message_received`). | |
| 28 | +| `MARCHAT_CLIENT_HOOK_SEND` | Absolute path to executable for outbound composer sends (`message_send`). | |
| 29 | +| `MARCHAT_CLIENT_HOOK_TIMEOUT_SEC` | Optional. Per-hook timeout in seconds (default `5`, max `120`). | |
| 30 | +| `MARCHAT_CLIENT_HOOK_RECEIVE_TYPING` | Set to `1`, `true`, or `yes` to deliver **typing** indicators to the receive hook. Default: **off** (reduces log noise). | |
| 31 | +| `MARCHAT_CLIENT_HOOK_DEBUG` | Set to `1`, `true`, or `yes` to log successful hook completion (duration) and label stdout as a debug preview. | |
| 32 | +| `MARCHAT_HOOK_LOG` | Optional log file path for the **bundled** `example_hook` binary only. The marchat client does not read this variable. | |
| 33 | + |
| 34 | +Unset hook paths mean that hook is disabled. |
| 35 | + |
| 36 | +## Diagnostics (`-doctor`) |
| 37 | + |
| 38 | +Run **`marchat-client -doctor`** (or **`-doctor-json`**) to inspect your client environment: |
| 39 | + |
| 40 | +- The **Environment** section lists hook-related variables, including `MARCHAT_HOOK_LOG` for visibility, even though only `example_hook` uses it (with the same masking and truncation rules as other doctor output). |
| 41 | +- If **`MARCHAT_CLIENT_HOOK_RECEIVE`** or **`MARCHAT_CLIENT_HOOK_SEND`** is set, doctor runs a **path check**: the value must be an absolute path to an existing regular file, matching what the client enforces at runtime. |
| 42 | + |
| 43 | +Server doctor does **not** list client-only hook variables, even if they are set in the environment (the server never reads them; hiding them avoids noise when client and server are run from the same shell). |
| 44 | + |
| 45 | +## Protocol |
| 46 | + |
| 47 | +### Transport |
| 48 | + |
| 49 | +- One UTF-8 JSON object per invocation, written to the process **stdin**, terminated with a single newline (`\n`). |
| 50 | +- The client waits for the process to exit, up to the timeout. **Stdout** and **stderr** are captured; non-empty stdout is logged at INFO (see debug flag above). A non-zero exit or timeout is logged as a failure. |
| 51 | + |
| 52 | +### Envelope |
| 53 | + |
| 54 | +```json |
| 55 | +{ |
| 56 | + "event": "message_received | message_send", |
| 57 | + "version": 1, |
| 58 | + "message": { } |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +- **`version`:** Integer. Incremented when incompatible payload changes are introduced; until then, treat as `1`. |
| 63 | +- **`event`:** |
| 64 | + - **`message_received`:** Fired for each inbound `shared.Message` on the chat WebSocket path after the client has applied decrypt when E2E is on. Typing is **excluded** unless `MARCHAT_CLIENT_HOOK_RECEIVE_TYPING` is enabled. |
| 65 | + - **`message_send`:** Fired for outbound text from the main composer path: global plaintext, global E2E (plaintext before encrypt), DMs, and `:` server/admin lines sent as `AdminCommandType`. Other UI actions (e.g. code snippet, file picker) may not invoke the send hook. |
| 66 | + |
| 67 | +### `message` object |
| 68 | + |
| 69 | +Mirrors [`shared.Message`](shared/types.go) fields where applicable, as a JSON object: |
| 70 | + |
| 71 | +| Field | Notes | |
| 72 | +|-------|--------| |
| 73 | +| `type` | e.g. `text`, `dm`, `typing`, `reaction`, `admin_command`, … May be empty if the server omitted it on older history. | |
| 74 | +| `sender`, `content`, `encrypted`, `message_id`, `recipient`, `edited`, `channel` | Same meaning as wire types. | |
| 75 | +| `created_at` | RFC3339 nano UTC. **Omitted** when the client has no real timestamp (zero time), so you will not see `0001-01-01` sentinels. | |
| 76 | +| `reaction` | Present for reaction events (`emoji`, `target_id`, `is_removal`). | |
| 77 | +| `file` | For file messages: `{ "filename", "size" }` only; no `data`. | |
| 78 | + |
| 79 | +Filter scripts on `message.type` / `event` as needed. |
| 80 | + |
| 81 | +## Example: append-only logger |
| 82 | + |
| 83 | +Build the bundled sample (from repo root): |
| 84 | + |
| 85 | +```bash |
| 86 | +go build -o /tmp/marchat-hook-log ./client/exthook/example_hook |
| 87 | +``` |
| 88 | + |
| 89 | +Run the client with hooks (paths must be absolute on your OS): |
| 90 | + |
| 91 | +```bash |
| 92 | +export MARCHAT_CLIENT_HOOK_RECEIVE=/tmp/marchat-hook-log |
| 93 | +export MARCHAT_CLIENT_HOOK_SEND=/tmp/marchat-hook-log |
| 94 | +# Optional: override log path (otherwise uses $TMPDIR/marchat-client-hook.log) |
| 95 | +export MARCHAT_HOOK_LOG=$HOME/marchat-hook.log |
| 96 | +go run ./client |
| 97 | +``` |
| 98 | + |
| 99 | +On Windows (PowerShell), use `Resolve-Path` for absolute paths and run `go run ./client` from the repository root (not `go run .`). |
| 100 | + |
| 101 | +## Use cases (illustrative) |
| 102 | + |
| 103 | +- Custom logging or archival to your own storage |
| 104 | +- Webhooks or bridges to other chat systems |
| 105 | +- Keyword-triggered local alerts beyond built-in notifications |
| 106 | +- Development and integration testing |
| 107 | + |
| 108 | +## Relationship to other systems |
| 109 | + |
| 110 | +| Mechanism | Where it runs | Typical use | |
| 111 | +|-----------|----------------|-------------| |
| 112 | +| Server plugins | Server | Commands, hub automation, shared bots | |
| 113 | +| Client hooks | Your machine | Personal automation, local pipelines | |
| 114 | +| Second WebSocket client | Your machine / elsewhere | Full bots, independent sessions | |
| 115 | + |
| 116 | +## Stability |
| 117 | + |
| 118 | +Treat this document and the `version` field as the reference for experiments. For production-like integrations, prefer discussing on GitHub issues so breaking changes can be coordinated. |
0 commit comments