diff --git a/.env.coolify.template b/.env.coolify.template index 8b4c549..aeb71fa 100644 --- a/.env.coolify.template +++ b/.env.coolify.template @@ -11,6 +11,12 @@ OPPER_API_KEY=your_opper_api_key_here OPPER_MODEL=gpt-5.6-luna OPPER_VARIANT=medium +# BLOC is qualified with OpenCode v1.19.3; OpenCode v2 is not yet qualified. +# Browse OpenCode image tags and digests at: +# https://github.com/anomalyco/opencode/pkgs/container/opencode +# Pin the digest for a version you have qualified; do not use a floating tag. +OPENCODE_IMAGE=ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb + # Optional GitHub CLI token passed only to the OpenCode service. # Use a dedicated, least-privilege token; leave empty to disable GitHub authentication. GH_TOKEN= diff --git a/.env.template b/.env.template index 4f8a89b..3ae47ae 100644 --- a/.env.template +++ b/.env.template @@ -5,8 +5,7 @@ TELEGRAM_ENABLED=false TELEGRAM_BOT_TOKEN= # Required only when TELEGRAM_ENABLED=true. Comma-separated allowed chat IDs. -# Set this before startup; the bootstrap procedure for the first ID is still to be defined. -# To add another chat later, find its ID in the logs after its message is rejected. +# Discover the first ID safely with `make telegram-chat-id` before enabling Telegram. TELEGRAM_ALLOWED_CHAT_IDS= # Temporary, sanitized OpenCode cost/token diagnostics for an authorized test exchange. @@ -24,3 +23,9 @@ OPPER_MODEL=gpt-5.6-luna # Default OpenCode variant for this model. OPPER_VARIANT=medium + +# BLOC is qualified with OpenCode v1.19.3; OpenCode v2 is not yet qualified. +# Browse OpenCode image tags and digests for the main and repair Compose files at: +# https://github.com/anomalyco/opencode/pkgs/container/opencode +# Pin the digest for a version you have qualified; do not use a floating tag. +OPENCODE_IMAGE=ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 766dcf3..f2c0f14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,7 +7,7 @@ permissions: contents: read jobs: - unit-tests: + checks: runs-on: ubuntu-latest steps: - name: Check out repository @@ -18,3 +18,20 @@ jobs: python-version: "3.13" - name: Run unit tests run: make test + - name: Check Python syntax + run: python3 -m py_compile bot/telegram/*.py scripts/telegram_chat_id.py + - name: Validate Compose in both launch modes + run: | + env_file="$(mktemp)" + trap 'rm -f "$env_file"' EXIT + cat > "$env_file" <<'EOF' + TELEGRAM_ENABLED=false + TELEGRAM_BOT_TOKEN=ci-placeholder + TELEGRAM_ALLOWED_CHAT_IDS=123456789 + OPPER_API_KEY=ci-placeholder + OPPER_MODEL=gpt-5.6-luna + OPPER_VARIANT=medium + OPENCODE_IMAGE=ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb + EOF + make config ENV_FILE="$env_file" + TELEGRAM_ENABLED=true make config ENV_FILE="$env_file" diff --git a/.gitignore b/.gitignore index 128a120..b5e4fd3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ __pycache__/ # secrets .env .env.coolify + +backups/ +# temporary files +tmp/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d7dafa..3cbef9c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,9 @@ # Contributing -Run local operations through the root `Makefile`. Never commit `.env`, credentials, -OpenCode sessions, or Docker volume contents. +Use the root `Makefile` for project operations. Never commit `.env`, credentials, OpenCode +sessions, or Docker volume contents. -## Run tests +## Automated checks Run the dependency-free unit tests with: @@ -11,24 +11,46 @@ Run the dependency-free unit tests with: make test ``` -The tests use Python's standard library and do not require credentials, Docker, or -access to Telegram or an LLM. +The tests use Python's standard library and need no credentials, Docker, Telegram, or LLM +access. Before submitting changes, also run: + +```sh +make config +python3 -m py_compile bot/telegram/bridge.py +git diff --check +``` + +## Manual checks + +For changes to configuration or deployment, validate the Compose configuration in the +intended mode. `make config` uses the local `.env`; check both OpenCode-only mode and +Telegram mode when applicable. Do not share the output of environment files or secrets. + +For Telegram formatting changes, start the bridge and send the static fixture to an +already allowed chat: + +```sh +make format-test CHAT_ID= +``` + +For an end-to-end check, start the services, send a message from an allowed chat, then +check `make status`, `make health`, and `make logs SERVICE=telegram`. The `/health` and +`/cost` commands check status and usage without making an LLM call. ## Draft a release -After the release changes are merged into `main`, checks have passed, and the version -has been approved, create and push an annotated tag, then create a GitHub draft: +After release changes are merged into `main`, checks have passed, and the version is +approved, create a GitHub draft with generated release notes. Replace the example version +with the approved tag: ```sh git switch main git pull --ff-only -VERSION='v0.X.Y' # replace with the approved version, e.g. v0.1.0 +VERSION='v1.2.3' # replace if a different version is approved git tag -a "$VERSION" -m "Release $VERSION" git push origin "$VERSION" gh release create "$VERSION" --draft --verify-tag --title "$VERSION" --generate-notes ``` -`--verify-tag` requires the tag to have been pushed; `--draft` does not publish the -release. To provide hand-written notes, replace `--generate-notes` with -`--notes-file release-notes.md`. Review the draft before publishing. Never attach -`.env`, credentials, sessions, or Docker volumes. +`--verify-tag` requires the tag to have been pushed. Review the generated notes and draft +before publishing. Never attach `.env`, credentials, sessions, or Docker volumes. diff --git a/Makefile b/Makefile index 9aaa848..9c4bf49 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,16 @@ SHELL := /bin/sh ENV_FILE ?= .env SERVICE ?= +BACKUP_DIR ?= backups export ENV_FILE COMPOSE_SCRIPT := ./scripts/compose.sh -.PHONY: help test diagnostics format-test check-env config build start stop down restart logs status health tui repair clean +.PHONY: help test diagnostics format-test telegram-chat-id backup restore check-env config build \ + start stop down restart logs status health tui repair clean help: ## Show available commands - @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf "%-14s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) test: ## Run dependency-free unit tests @python3 -m unittest discover -s tests -v @@ -20,6 +22,15 @@ diagnostics: ## Print sanitized Telegram cost diagnostics from the running bridg format-test: ## Send a static Markdown formatting fixture; usage: make format-test CHAT_ID= @$(COMPOSE_SCRIPT) format-test "$(CHAT_ID)" +telegram-chat-id: ## Safely discover the first Telegram chat ID without starting the bridge + @ENV_FILE="$(ENV_FILE)" python3 scripts/telegram_chat_id.py + +backup: ## Back up persistent volumes under backups/; override with BACKUP_DIR= + @$(COMPOSE_SCRIPT) backup "$(BACKUP_DIR)" + +restore: ## Restore from the latest backup in backups/ or a specific BACKUP_DIR + @$(COMPOSE_SCRIPT) restore "$(BACKUP_DIR)" + check-env: ## Check that the environment file exists @$(COMPOSE_SCRIPT) check-env diff --git a/README.md b/README.md index b8641fa..0f6cd1d 100644 --- a/README.md +++ b/README.md @@ -1,297 +1,26 @@ # BLOC -**BLOC** stands for **BLOC Layer for OpenCode**—a deliberately recursive, GNU-style -name for a minimal Docker Compose setup that runs OpenCode alone or connects it to -Telegram. In Telegram mode, the bridge keeps one OpenCode session per chat in a Docker -volume. +**BLOC** (BLOC Layer for OpenCode) is a minimal Docker Compose setup for running +[OpenCode](https://opencode.ai/) on its own or connecting it to Telegram. -## Requirements +This BLOC release is qualified with **OpenCode v1.19.3**. OpenCode v2 is not yet qualified. -- Docker with Compose v2; -- an Opper AI API key and a model available in your Opper project; -- a Telegram bot created with [@BotFather](https://t.me/BotFather), only if Telegram is enabled. +## Features -## Local startup +- Run OpenCode with a configurable AI provider in Docker. +- Optionally query it from Telegram through a dependency-free Python bridge. +- Keep one persistent OpenCode session per Telegram chat. +- Access the same server through the local OpenCode TUI without publishing its API port. -```sh -cp .env.template .env -# set OPPER_API_KEY; see “Launch modes” for Telegram setup -make start -make logs -``` +## Commands -Operational commands are centralized in the root `Makefile`. Run `make help` for the -full list: +Run `make help` for the available commands. -```text -make start # start OpenCode alone or with Telegram, based on TELEGRAM_ENABLED -make stop # stop without removing containers -make down # remove containers, keep volumes -make status # show service status -make logs # follow logs -make health # check the OpenCode API -make tui # open the OpenCode TUI on the running server -make repair # open the repair container -``` +## Documentation -Contribution and release-draft instructions are in [`CONTRIBUTING.md`](CONTRIBUTING.md). - -## Launch modes - -Set the mode in `.env` with `TELEGRAM_ENABLED`: - -- `TELEGRAM_ENABLED=false` or unset: `make start` starts OpenCode alone. Telegram - variables are not required. -- `TELEGRAM_ENABLED=true`: `make start` starts OpenCode and the Telegram bridge. Set - `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_CHAT_IDS` as well; the Makefile refuses to - start if either value is missing. - -When switching back to `false`, `make start` also stops Telegram before starting -OpenCode alone. `make status` shows both services, and `make stop`, `make down`, and -`make clean` account for both in either mode. The OpenCode API remains on the Docker -network; no host port is published. - -The main Compose file is in `bot/`. The repair Compose file is intentionally separate -in `bot-repair/` so it can be used independently if the main deployment has a problem. - -## OpenCode TUI - -Start the Compose services first, then run `make tui` from an interactive terminal on -the Docker host: - -```sh -make start -make tui -``` - -The command runs the OpenCode CLI inside the existing `opencode` container and connects -it to `http://127.0.0.1:4096` using `opencode attach`. It uses the same server, workspace, and configured -credentials as the Telegram bridge, including the optional `GH_TOKEN` available to the -agent. The TUI is not a separate security boundary. A prompt entered in the TUI is -answered in the TUI; the bridge sends Telegram replies only for incoming Telegram -updates. The server stores sessions centrally: selecting a Telegram session in the TUI -shares its history and concurrent requests may interleave, so use a separate session -for independent work. - -The TUI is an administrative interface. Running it requires Docker access, which must -be restricted to trusted operators. Closing the TUI exits only the client; it does not -stop OpenCode or Telegram. Use `make stop` to stop the services. The TUI does not publish -a port or add a route through Coolify. A Coolify deployment requires an operator shell -with Docker access to that deployment. - -## Opper AI provider - -The configured provider is `opper`, using Opper's official OpenAI-compatible API at -`https://api.opper.ai/v3/compat`. The key is supplied only through `OPPER_API_KEY` and -is never written to `opencode.json`. - -The default model is `gpt-5.6-luna` via Opper AI, with the OpenCode `medium` variant. -The full model name is constructed as `opper/${OPPER_MODEL}#${OPPER_VARIANT}`, which -defaults to `opper/gpt-5.6-luna#medium`. - -To select another model available in Opper, change `OPPER_MODEL` and, if needed, -`OPPER_VARIANT` in `.env`. Use model identifiers provided by Opper. Do not add the -`openai/` prefix to `OPPER_MODEL`: OpenCode already uses the `opper` provider and -expects `gpt-5.6-luna` as the model identifier. - -The bot uses Telegram **long polling**, so no public domain or port is required. A -message from an unauthorized chat is rejected and its `chat_id` is written to the -logs; add it to `TELEGRAM_ALLOWED_CHAT_IDS` and restart the service. The allowlist must -be populated before the first Telegram startup; the bootstrap procedure for an -unknown initial chat ID is still to be defined. - -Telegram replies use a fixed Markdown subset: - -- headings: `# Heading`; -- emphasis: `**bold**`, `*italic*`, and `~~strikethrough~~` (not nested); -- simple lists: `- item` or `1. item`; -- quotes: `> quoted text`; -- code: `` `inline` `` or fenced blocks with an optional language; -- links: `[label](https://example.com)`. - -The bridge converts this syntax to Telegram HTML, escapes text, and splits long answers -into independently valid messages. Unsupported constructs are sent as readable text. If -Telegram rejects formatting, the affected message is retried without formatting. - -To manually check rendering in an authorized chat without making an OpenCode/LLM call, -run: - -```sh -make format-test CHAT_ID= -``` - -Ensure the Telegram service is running with the current image first (`make start`). The -fixture includes special characters and a long code block to exercise message splitting. -It only sends to a chat already present in `TELEGRAM_ALLOWED_CHAT_IDS`. - -Only one process can poll a bot token at a time. Bridge instances sharing the -`telegram-data` volume coordinate with a local lock; a poller running on another host -still needs to be stopped manually. Telegram 409 conflicts are logged without exposing -the bot token and retried with backoff. - -## Telegram cost reporting - -Every authorized OpenCode answer ends with a compact USD cost line; agent subtotals -are included when OpenCode provides them. Send `/cost` to see the current chat's -session total and its total for the current fixed UTC hour, along with available token -counts. These are not Opper account-wide or cross-chat totals. The bridge aggregates -all assistant messages across the session and its child sessions, so tool-using -exchanges are counted without estimating from model prices. `/cost` reads OpenCode -history and does not send an LLM prompt. - -```text -Cost: $0.001971 - -/cost -Session: $0.008800 -UTC hour (18:00–19:00): $0.002100 -``` - -For a temporary cost/usage investigation, set `TELEGRAM_COST_DIAGNOSTICS=true` in -`.env` and restart with `make start`. Retrieve the sanitized metadata with -`make diagnostics`. The file is stored in the Telegram data volume; it includes -message and step cost/token fields, but excludes message text and chat/session IDs. -Turn diagnostics off again after the test and do not publish the generated file -without reviewing it. - -## First startup - -1. Create an API key in your Opper AI project and set `OPPER_API_KEY`. -2. Initialize the configuration and choose OpenCode-only mode by default: - - ```sh - cp .env.template .env - ${EDITOR:-vi} .env - ``` - -3. Start and check OpenCode alone (`TELEGRAM_ENABLED=false`): - - ```sh - make start - make status - make health - ``` - -4. To enable Telegram, create a bot with [@BotFather](https://t.me/BotFather), set - `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ALLOWED_CHAT_IDS`, then set - `TELEGRAM_ENABLED=true`. The allowlist must contain at least one ID before startup; - do not leave it empty. -5. Start both services and check their status: - - ```sh - make start - make status - make health - ``` - -6. Send a message from an authorized chat. If there is an error, - Telegram returns a short reference. Search for `request_failed ref=` in - `make logs SERVICE=telegram` to find the client, phase, elapsed time, timeout, and safe error - details. Prompts, credentials, session IDs, and raw OpenCode response bodies are not logged. - -The bridge acknowledges a request immediately, then edits that message with the result. It may -also show generic thinking or tool-progress updates when OpenCode emits matching events; raw -reasoning content is never forwarded. At startup, logs show the effective OpenCode and Telegram -timeouts. OpenCode requests have a 300-second deadline; Telegram HTTP requests use 70 seconds, -with long polling configured for 50 seconds. - -If Telegram replies `Invalid Opper API key` or the logs mention `401`, regenerate the -key in your Opper AI project and replace only the -`OPPER_API_KEY` value in `.env`. Then recreate the service: - -```sh -make down -make start -``` - -## Coolify deployment - -The Docker Compose deployment has been tested with Coolify, including redeploys. Use -these settings: - -- **Base Directory:** `/bot` -- **Docker Compose Location:** `/docker-compose.yml` (relative to the base directory) -- Add the variables from [`.env.coolify.template`](.env.coolify.template) to the - Coolify resource environment, replacing the example credentials and chat ID. The - `COMPOSE_PROFILES=telegram` setting enables the Telegram service. `GH_TOKEN` is - optional; set it only if the agent needs GitHub CLI access. -- Store credentials in Coolify's environment configuration and restrict access to the - resource. This Compose file does not publish the OpenCode port or configure a public - route. Check the Coolify resource's domains, port mappings, and proxy settings as well: - platform-level routing is outside this repository's control and must not expose the API. - -## GitHub CLI access (optional) - -The OpenCode image includes GitHub CLI (`gh`), so the executable remains available -after a container rebuild or redeploy. To authenticate it, set `GH_TOKEN` in `.env` or -as a Coolify resource environment variable. GitHub CLI reads this variable directly; -there is no need to run an interactive `gh auth login`. The token is passed only to the -OpenCode service, not to the Telegram bridge. Leave it unset to run without GitHub -authentication. - -Treat this as a powerful credential: the agent can use the permissions granted to the -token and may expose it through commands or content it processes. Use a dedicated -GitHub account or fine-grained token with access limited to the repositories and -permissions it needs, keep the deployment restricted to trusted users, and never put a -real token in the image, repository, or logs. For Coolify, store it as a secret -environment variable. See GitHub CLI's [environment variable reference](https://cli.github.com/manual/gh_help_environment). - -## Repair - -If the main Compose deployment does not start, open an independent shell: - -```sh -make repair -``` - -The main deployment's volumes must already exist for their contents to be accessible. -This container does not start OpenCode automatically; it is only for diagnosing or -repairing the workspace. The repository's `bot/opencode.json` is mounted read-only at -`/workspace/opencode.json`, as in the main service. To change it, edit it on the host -and recreate the service if needed. The shell is minimal: run `make` commands on the -host, not inside this container. - -## Security model and product scope - -- Telegram was chosen because its official API supports long polling without a webhook - or a publicly reachable endpoint. The bridge processes only chats listed in - `TELEGRAM_ALLOWED_CHAT_IDS`. -- The bridge uses no external Python dependencies. -- The OpenCode API has no HTTP authentication configured. Compose does not publish its - port on the host, and this project does not provide a public OpenCode UI or route. The - API is intended to be reachable only by the Telegram bridge and trusted Docker - operators on the deployment's private network. -- The Telegram chat allowlist is the application-level access boundary. Treat every - allowed chat as a trusted operator: its users can submit prompts to OpenCode and use - the agent's available tools and credentials. -- The deployment platform is part of the trust boundary. Docker/Coolify administrators - can access containers and their environment; they can also expose ports or attach - workloads to networks. Restrict platform administration to trusted operators, do not - attach untrusted workloads to the OpenCode network, and verify that no platform-level - route or port mapping exposes the API. This repository cannot verify a live Coolify - resource's routing or firewall configuration. -- Secrets in `.env` or Coolify's environment are available to the host/platform - administrators. Keep them out of Git and logs. Grant `GH_TOKEN` only the minimum - permissions needed, and leave it unset when GitHub access is unnecessary. -- To rotate a credential, follow its issuing provider's replacement/revocation process - (Telegram via BotFather, Opper in the project settings, or GitHub for `GH_TOKEN`), - update the environment value, redeploy the affected service, and verify it - works. Revoke the previous credential as soon as the replacement is confirmed; if a - credential is suspected to be compromised, revoke it immediately. -- Authentication, TLS, and reverse-proxy hardening are not included because the API is - not intended to be exposed. If public API access or a network-facing web client is - introduced, treat it as a new security project: define authentication and authorization, - TLS, and a threat model before exposing the service. -- The TUI is available only through an interactive Docker-host command. It adds no - published port or public web interface, but Docker access is privileged and must be - restricted to trusted operators. -- The OpenCode web client and a publicly reachable OpenCode API are not included in - this release. -- Services explicitly clear proxy variables (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY` - and their lowercase variants) so they do not inherit a proxy configured on the host. -- The anti-hallucination instruction is injected into every session. It does not - replace human review for sensitive actions. - -Official references: [OpenCode Server](https://opencode.ai/docs/server), -[OpenCode Docker](https://opencode.ai/docs/), and -[Telegram Bot API](https://core.telegram.org/bots/api). +- [Quickstart](docs/quickstart.md): configure, start, and stop BLOC. +- [Usage](docs/usage.md): Telegram formatting, sessions, and cost reporting. +- [Provider](docs/provider.md): configure Opper AI and select a model. +- [Security](docs/security.md): trust boundaries and safe deployment. +- [Coolify deployment](docs/coolify_deploy.md): deploy with Coolify Compose. +- [Contributing](CONTRIBUTING.md): tests and release process. diff --git a/bot-repair/docker-compose.yml b/bot-repair/docker-compose.yml index 55ea9ed..2945afa 100644 --- a/bot-repair/docker-compose.yml +++ b/bot-repair/docker-compose.yml @@ -4,7 +4,7 @@ name: bloc-repair # even if the main Compose stack fails to start. services: repair: - image: ghcr.io/anomalyco/opencode:latest + image: "${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb}" entrypoint: ["/bin/sh"] stdin_open: true tty: true diff --git a/bot/docker-compose.yml b/bot/docker-compose.yml index 4e013e6..857e8a5 100644 --- a/bot/docker-compose.yml +++ b/bot/docker-compose.yml @@ -2,7 +2,10 @@ name: bloc services: opencode: - build: ./opencode + build: + context: ./opencode + args: + OPENCODE_IMAGE: "${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb}" command: serve --hostname 0.0.0.0 --port 4096 environment: # Do not inherit a proxy configured in the image or on the host. diff --git a/bot/opencode/Dockerfile b/bot/opencode/Dockerfile index 38d18ad..becf635 100644 --- a/bot/opencode/Dockerfile +++ b/bot/opencode/Dockerfile @@ -1,4 +1,6 @@ -FROM ghcr.io/anomalyco/opencode:latest +# BLOC is qualified with OpenCode v1.19.3; qualify a new version before updating the digest. +ARG OPENCODE_IMAGE=ghcr.io/anomalyco/opencode@sha256:6ebc13097f2381301220abe9d1b2a54f50cd1603a910dc1c66e93d8f2c4e8ebb +FROM ${OPENCODE_IMAGE} # Keep GitHub CLI in the image so it is available after container recreation. # Docker may inject proxy build arguments even though the Compose service clears them. diff --git a/bot/telegram/bridge.py b/bot/telegram/bridge.py index a90d656..2d9ebd1 100644 --- a/bot/telegram/bridge.py +++ b/bot/telegram/bridge.py @@ -325,6 +325,8 @@ def authorized_response(chat_id, prompt, sessions): command = prompt.split(maxsplit=1)[0].split("@", 1)[0].lower() if command == "/start": return "Hello. Send your request to OpenCode." + if command == "/health": + return health_response(chat_id, sessions) if command == "/cost": session_id = sessions.get(str(chat_id)) if not session_id: @@ -333,6 +335,33 @@ def authorized_response(chat_id, prompt, sessions): return answer(chat_id, prompt, sessions) +def health_response(chat_id, sessions): + """Report sanitized bridge dependency and persisted-session status without model calls.""" + lines = ["Bridge: healthy"] + try: + health = http_api.http(OC + "/global/health", timeout=OPENCODE_STATUS_TIMEOUT_SECONDS) + opencode_status = ( + "healthy" + if isinstance(health, dict) and health.get("healthy") is True + else "unavailable" + ) + lines.append(f"OpenCode API: {opencode_status}") + except Exception: + lines.append("OpenCode API: unavailable") + + session_id = sessions.get(str(chat_id)) + if not session_id: + lines.append("This chat's session: not initialized") + else: + try: + session = opencode_api.session_details(session_id, timeout=OPENCODE_STATUS_TIMEOUT_SECONDS) + available = session.get("id") == session_id + lines.append("This chat's session: available" if available else "This chat's session: unavailable") + except Exception: + lines.append("This chat's session: unavailable") + return "\n".join(lines) + + def send(chat_id, text): result = None for message in markdown_renderer.render_markdown(text): @@ -484,7 +513,7 @@ def progress(text): if command == "/start": set_phase("command") response = authorized_response(chat_id, prompt, sessions) - elif command == "/cost": + elif command in ("/cost", "/health"): set_phase("opencode_cost") response = authorized_response(chat_id, prompt, sessions) else: diff --git a/bot/telegram/opencode_api.py b/bot/telegram/opencode_api.py index c08345d..6560cf2 100644 --- a/bot/telegram/opencode_api.py +++ b/bot/telegram/opencode_api.py @@ -43,6 +43,15 @@ def session_messages(session_id, timeout=None): return messages +def session_details(session_id, timeout=None): + """Return one session's metadata without requesting a model response.""" + encoded_id = urllib.parse.quote(session_id) + session = http(f"{OC}/session/{encoded_id}", timeout=timeout) + if not isinstance(session, dict): + raise RuntimeError("OpenCode returned an invalid session") + return session + + def session_tree_messages(session_id): """Return messages from a session and all descendants, preserving session IDs internally.""" messages = [] diff --git a/docs/coolify_deploy.md b/docs/coolify_deploy.md new file mode 100644 index 0000000..7c414ac --- /dev/null +++ b/docs/coolify_deploy.md @@ -0,0 +1,20 @@ +# Coolify deployment + +The Compose deployment has been tested with Coolify. Configure the resource as follows: + +- **Base Directory:** `/bot` +- **Docker Compose Location:** `/docker-compose.yml` +- Add the values from [`.env.coolify.template`](../.env.coolify.template) as resource + environment variables. Keep `COMPOSE_PROFILES=telegram` to enable the bot. +- Set real credentials and allowed chat IDs; discover the first ID locally with + [`make telegram-chat-id`](quickstart.md#configure-and-start) before enabling Telegram. +- Keep the pinned `OPENCODE_IMAGE` digest unless a replacement has been qualified. + +Store credentials in Coolify's environment configuration, not in the repository. The +Compose file does not publish the OpenCode port or configure a public route. Check the +resource's domains, port mappings, and proxy settings as well: platform-level routing is +outside this repository's control and must not expose the API. + +For provider settings, see [Provider](provider.md); for trust boundaries, see +[Security](security.md). The local TUI requires an operator shell with Docker access to +the deployment. diff --git a/docs/provider.md b/docs/provider.md new file mode 100644 index 0000000..4f52824 --- /dev/null +++ b/docs/provider.md @@ -0,0 +1,34 @@ +# Provider + +Opper AI is the example provider configured by default in BLOC. Its OpenAI-compatible +endpoint and provider settings are defined in [`bot/opencode.json`](../bot/opencode.json). +The API key comes from `OPPER_API_KEY`; it is passed to the OpenCode container and is not +stored in that configuration file. + +OpenCode supports many providers and local models, and also lets you configure a custom +provider. You can replace the example provider or add your own in `bot/opencode.json`. +See the official [OpenCode provider documentation](https://opencode.ai/docs/providers/) +for supported providers and configuration options. + +Set the key in the ignored local `.env` file. The defaults are: + +```dotenv +OPPER_MODEL=gpt-5.6-luna +OPPER_VARIANT=medium +``` + +The resulting OpenCode model identifier is +`opper/${OPPER_MODEL}#${OPPER_VARIANT}`, by default +`opper/gpt-5.6-luna#medium`. Use a model identifier available in your Opper project and +do not add the `openai/` prefix to `OPPER_MODEL`. + +After changing provider settings, validate and restart: + +```sh +make config +make start +make health +``` + +For Coolify, configure these values as resource environment variables; see the +[Coolify deployment guide](coolify_deploy.md). Never commit or share a real API key. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..cf49b9e --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,59 @@ +# Quickstart + +## Requirements + +- Docker with Compose v2; +- an [Opper AI](https://opper.ai/) API key (see [Provider](provider.md)); +- a Telegram bot from [@BotFather](https://t.me/BotFather) if you want Telegram access. + +Run all operations from the repository root through the `Makefile`. + +## Configure and start + +Create the local environment file and set `OPPER_API_KEY`: + +```sh +cp .env.template .env +${EDITOR:-vi} .env +``` + +Start OpenCode alone, then check it: + +```sh +make config +make start +make status +make health +``` + +To enable Telegram, first put the bot token in `.env` and discover your chat ID: + +```sh +make telegram-chat-id +``` + +In a private chat, send the exact one-time challenge shown in the terminal. Put the +resulting ID in `TELEGRAM_ALLOWED_CHAT_IDS` and set `TELEGRAM_ENABLED=true` in `.env`. +Then start and verify the services: + +```sh +make config +make start +make status +make health +``` + +See [Usage](usage.md) for interacting with the bot and [Security](security.md) before +allowing other people to use it. + +## Stop and inspect + +```sh +make logs # follow all service logs +make logs SERVICE=telegram +make stop # stop containers, keep them and their data +make down # remove containers, keep persistent volumes +``` + +Use `make help` for the complete command list. `make clean` also deletes persistent +volumes; it is not a routine stop or update command. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..e368a2a --- /dev/null +++ b/docs/security.md @@ -0,0 +1,39 @@ +# Security + +## Intended use + +BLOC is intended to be deployed with runtime configuration and secrets supplied through +environment variables, not committed to the repository or baked into images. For Telegram, +only chats explicitly listed in `TELEGRAM_ALLOWED_CHAT_IDS` may exchange prompts and +responses with OpenCode. The local TUI is a separate administrative interface, available +only to trusted operators with Docker access. + +## Trust boundaries + +- Every ID in `TELEGRAM_ALLOWED_CHAT_IDS` is trusted to use OpenCode and the tools and + credentials available to the agent. Only allow chats whose users you trust. +- The OpenCode API has no HTTP authentication. The Compose setup does not publish its + port; keep it on the private Docker network and do not expose it to the Internet. +- Docker and Coolify administrators can access containers and environment variables. + Restrict administrative access and verify that platform-level routing does not expose + the API. +- The local TUI requires Docker access and is an administrative interface, not a separate + security boundary. + +## Secrets and data + +Keep `.env`, provider keys, Telegram tokens, and optional `GH_TOKEN` out of Git and logs. +Grant GitHub tokens only the permissions needed, and leave `GH_TOKEN` unset when GitHub +access is unnecessary. Store deployment credentials in the platform's secret environment +configuration. + +Persistent volumes and backups may contain session history, workspace files, credentials, +or other private state. Restrict access, encrypt backups before storing them off-host, and +never commit them. + +The anti-hallucination instruction is not a security control. Review sensitive actions +and outputs. If public API access is ever required, first define authentication, +authorization, TLS, and a threat model; these are not provided by this project. + +For operational guidance, see the [Quickstart](quickstart.md) and +[Coolify deployment guide](coolify_deploy.md). diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..ba9999b --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,59 @@ +# Usage + +## Telegram + +Send a message in a chat listed in `TELEGRAM_ALLOWED_CHAT_IDS`. The bridge keeps one +OpenCode session per chat, so later messages continue that chat's conversation. OpenCode +stores sessions centrally; selecting a Telegram session in the TUI shares its history. +Use a separate session for independent work. + +The bot supports `/health` for a sanitized service and session check, and `/cost` for the +current chat's session total and current fixed UTC-hour total. Neither command sends a +prompt to the model. Costs are based on usage reported by OpenCode, not an estimate from +provider prices, and are not account-wide totals. Replies include a cost line when +OpenCode provides usage data. + +Telegram replies support this Markdown subset: + +- headings: `# Heading`; +- emphasis: `**bold**`, `*italic*`, and `~~strikethrough~~` (not nested); +- simple lists: `- item` or `1. item`; +- quotes: `> quoted text`; +- inline and fenced code, with an optional language; +- links: `[label](https://example.com)`. + +The bridge converts supported formatting to Telegram HTML and escapes text. Unsupported +constructs are sent as readable text; if Telegram rejects formatted text, it is retried +without formatting. To check rendering without an OpenCode request, use +`make format-test CHAT_ID=` while the Telegram service is running. + +## Local OpenCode TUI + +On the Docker host, run `make tui` after starting the deployment. It connects to the +existing server and uses the same workspace, credentials, and sessions. Docker access is +required; the command does not publish an API port. Closing the TUI does not stop the +services. See [Security](security.md). + +## Persistent data + +`make stop` and `make down` preserve OpenCode data, workspace files, and Telegram session +mappings. Use `make backup` and `make restore` for volume backups and recovery. Backups go +under the ignored `backups/` directory by default, in a timestamped folder containing one +`.tar.gz` archive per Docker volume. + +```sh +make stop +make backup +tar -tzf backups/bloc-/opencode-data.tar.gz +make restore # selects the latest complete backup under backups/ +make restore BACKUP_DIR=backups/bloc- # select a specific backup +``` + +Restore displays the selected folder and requires typing `RESTORE` before replacing any +volume contents. Backups may contain private state and conversation history: protect them +as secrets and never commit them. Run backup as your normal user, not with `sudo`, so the +resulting files remain accessible to you. On WSL filesystems that do not support POSIX +permissions, access is governed by the Windows ACL; keep backups in a suitably private +location. `make clean` deletes the volumes. + +See the [Quickstart](quickstart.md) for startup and operational commands. diff --git a/scripts/compose.sh b/scripts/compose.sh index 869f0e9..8511bd8 100755 --- a/scripts/compose.sh +++ b/scripts/compose.sh @@ -94,6 +94,9 @@ check_mode_and_telegram() { } case "$COMMAND" in + backup|restore) + "$ROOT_DIR/scripts/volumes.sh" "$COMMAND" "${1:-backups}" + ;; check-env) check_env_file ;; diff --git a/scripts/telegram_chat_id.py b/scripts/telegram_chat_id.py new file mode 100644 index 0000000..6fa29c6 --- /dev/null +++ b/scripts/telegram_chat_id.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Discover an operator's Telegram chat ID without starting the agent bridge.""" + +import json +import os +from pathlib import Path +import secrets +import sys +import time +import urllib.error +import urllib.request + + +def load_bot_token(env_file): + """Read only the Telegram token from the local environment file.""" + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if token: + return token + + try: + lines = Path(env_file).read_text(encoding="utf-8").splitlines() + except OSError: + return "" + + for line in lines: + key, separator, value = line.partition("=") + if separator and key.strip() == "TELEGRAM_BOT_TOKEN": + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + return value.strip() + return "" + + +def matching_chat_id(update, challenge): + """Return the ID only for a private /start message with the one-time challenge.""" + if not isinstance(update, dict): + return None + message = update.get("message", {}) + if not isinstance(message, dict): + return None + chat = message.get("chat", {}) + sender = message.get("from", {}) + if not isinstance(chat, dict) or not isinstance(sender, dict): + return None + text = message.get("text", "") + if not isinstance(text, str): + return None + words = text.split() + if ( + chat.get("type") != "private" + or sender.get("is_bot") is True + or len(words) != 2 + or not words[0].lower().startswith("/start") + or words[1] != challenge + ): + return None + command = words[0].split("@", 1)[0].lower() + chat_id = chat.get("id") + if command != "/start" or not isinstance(chat_id, int) or isinstance(chat_id, bool): + return None + return chat_id + + +def generate_challenge(): + """Generate a short-lived, URL-safe challenge for the private /start message.""" + return secrets.token_urlsafe(4) + + +def telegram_updates(token, offset, timeout): + """Fetch updates while keeping the bot token out of command output and errors.""" + payload = { + "timeout": timeout, + "allowed_updates": ["message"], + } + if offset is not None: + payload["offset"] = offset + request = urllib.request.Request( + f"https://api.telegram.org/bot{token}/getUpdates", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=timeout + 10) as response: + result = json.loads(response.read()) + if not isinstance(result, dict) or result.get("ok") is not True: + raise RuntimeError("Telegram returned an unsuccessful response") + updates = result.get("result") + if not isinstance(updates, list): + raise RuntimeError("Telegram returned an invalid updates list") + return updates + + +def discover_chat_id(token, challenge, timeout_seconds=600, on_connected=None): + """Confirm Telegram polling works, then wait for the private challenge message.""" + offset = None + updates = telegram_updates(token, offset, 0) + if on_connected is not None: + on_connected() + + deadline = time.monotonic() + timeout_seconds + while True: + for update in updates: + update_id = update.get("update_id") if isinstance(update, dict) else None + if isinstance(update_id, int): + offset = update_id + 1 + chat_id = matching_chat_id(update, challenge) if isinstance(update, dict) else None + if chat_id is not None: + return chat_id + + remaining = deadline - time.monotonic() + if remaining <= 0: + break + wait_seconds = min(50, max(1, int(remaining))) + updates = telegram_updates(token, offset, wait_seconds) + raise TimeoutError("Timed out waiting for the private challenge message") + + +def safe_error_message(error): + """Describe common failures without exposing the token embedded in request URLs.""" + if isinstance(error, urllib.error.HTTPError): + if error.code == 409: + return ( + "Error: Telegram rejected long polling (HTTP 409 Conflict). Another process may " + "already be polling this bot (for example, a Coolify deployment), or a webhook " + "may be configured. Stop the other poller or remove the webhook, then retry." + ) + if error.code == 401: + return "Error: Telegram rejected the bot token (HTTP 401 Unauthorized); check TELEGRAM_BOT_TOKEN." + return f"Error: Telegram API returned HTTP {error.code}; check bot configuration." + if isinstance(error, urllib.error.URLError): + return "Error: could not connect to the Telegram API; check network connectivity." + if isinstance(error, TimeoutError): + return "Error: timed out waiting for the private challenge message." + return "Error: could not discover the chat ID; check connectivity and bot configuration." + + +def main(): + env_file = os.environ.get("ENV_FILE", ".env") + token = load_bot_token(env_file) + if not token: + print(f"Error: TELEGRAM_BOT_TOKEN is missing from {env_file}.", file=sys.stderr) + return 1 + + challenge = generate_challenge() + print("Send this exact message in a private chat with your bot:") + print(f"/start {challenge}") + print("Connecting to Telegram...", flush=True) + try: + chat_id = discover_chat_id( + token, + challenge, + on_connected=lambda: print( + "Connected. Listening for the private challenge for up to 10 minutes; " + "the bridge is not started and other chats will not be answered.", + flush=True, + ), + ) + except KeyboardInterrupt: + print("Cancelled.", file=sys.stderr) + return 130 + except Exception as error: + print(safe_error_message(error), file=sys.stderr) + return 1 + + print(f"TELEGRAM_ALLOWED_CHAT_IDS={chat_id}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/volumes.sh b/scripts/volumes.sh new file mode 100755 index 0000000..90ebf83 --- /dev/null +++ b/scripts/volumes.sh @@ -0,0 +1,127 @@ +#!/bin/sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +COMMAND=${1:-} +BACKUP_DIR=${2:-backups} + +case "$BACKUP_DIR" in + /*) ;; + *) BACKUP_DIR="$ROOT_DIR/$BACKUP_DIR" ;; +esac + +VOLUMES="opencode-data workspace telegram-data" +VOLUME_PREFIX=bloc_ +ARCHIVE_IMAGE=busybox:1.37.0 + +check_stopped() { + for suffix in $VOLUMES; do + volume="$VOLUME_PREFIX$suffix" + if [ -n "$(docker ps -q --filter "volume=$volume" --filter status=running)" ]; then + printf 'Error: stop all containers using %s before continuing.\n' "$volume" >&2 + return 1 + fi + done +} + +backup() { + check_stopped + umask 077 + stamp=$(date -u +%Y%m%dT%H%M%SZ) + destination="$BACKUP_DIR/bloc-$stamp" + mkdir -p "$BACKUP_DIR" + mkdir "$destination" + for suffix in $VOLUMES; do + volume="$VOLUME_PREFIX$suffix" + if ! docker volume inspect "$volume" >/dev/null 2>&1; then + if [ "$suffix" = telegram-data ]; then + docker volume create "$volume" >/dev/null + else + printf 'Error: volume %s does not exist; start the deployment before backing it up.\n' "$volume" >&2 + return 1 + fi + fi + tmp_archive="$destination/.$suffix.tar.gz.tmp" + if ! docker run --rm --network none \ + --mount "type=volume,src=$volume,dst=/data,readonly" \ + "$ARCHIVE_IMAGE" tar -czf - -C /data . > "$tmp_archive"; then + rm -f "$tmp_archive" + return 1 + fi + mv "$tmp_archive" "$destination/$suffix.tar.gz" + done + printf 'Backup created in %s\n' "$destination" +} + +has_archives() { + for suffix in $VOLUMES; do + [ -f "$1/$suffix.tar.gz" ] || return 1 + done +} + +resolve_restore_dir() { + if has_archives "$BACKUP_DIR"; then + return + fi + + latest= + for candidate in "$BACKUP_DIR"/bloc-*; do + [ -d "$candidate" ] || continue + if has_archives "$candidate"; then + latest=$candidate + fi + done + if [ -n "$latest" ]; then + BACKUP_DIR=$latest + printf 'Using latest complete backup: %s\n' "$BACKUP_DIR" + return + fi + + printf 'Error: no complete backup found in %s. Pass a timestamped directory such as %s/bloc-.\n' \ + "$BACKUP_DIR" "$BACKUP_DIR" >&2 + return 1 +} + +restore() { + if [ ! -d "$BACKUP_DIR" ]; then + printf 'Error: backup directory does not exist: %s\n' "$BACKUP_DIR" >&2 + return 1 + fi + resolve_restore_dir + for suffix in $VOLUMES; do + if [ ! -f "$BACKUP_DIR/$suffix.tar.gz" ]; then + printf 'Error: required archive is missing: %s/%s.tar.gz\n' "$BACKUP_DIR" "$suffix" >&2 + return 1 + fi + docker run --rm --network none \ + --mount "type=bind,src=$BACKUP_DIR,dst=/backup,readonly" \ + "$ARCHIVE_IMAGE" tar -tzf "/backup/$suffix.tar.gz" >/dev/null + done + check_stopped + printf 'This will replace the contents of the BLOC volumes from %s.\n' "$BACKUP_DIR" + printf 'Type RESTORE to continue: ' + IFS= read -r confirmation + if [ "$confirmation" != RESTORE ]; then + printf 'Restore cancelled.\n' + return 1 + fi + for suffix in $VOLUMES; do + volume="$VOLUME_PREFIX$suffix" + docker volume create "$volume" >/dev/null + docker run --rm --network none \ + --mount "type=volume,src=$volume,dst=/data" \ + --mount "type=bind,src=$BACKUP_DIR,dst=/backup,readonly" \ + "$ARCHIVE_IMAGE" sh -c \ + "find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar -xzf /backup/$suffix.tar.gz -C /data" + done + printf 'Volumes restored from %s. Review the deployment settings before running `make start`.\n' "$BACKUP_DIR" +} + +case "$COMMAND" in + backup) backup ;; + restore) restore ;; + *) + printf 'Usage: %s {backup|restore} [directory]\n' "$0" >&2 + exit 2 + ;; +esac diff --git a/tests/test_telegram_bridge.py b/tests/test_telegram_bridge.py index eeedc75..cd6c878 100644 --- a/tests/test_telegram_bridge.py +++ b/tests/test_telegram_bridge.py @@ -625,8 +625,45 @@ def test_cost_command_does_not_call_the_llm(self): self.assertIn("Session: $0.125000", report) request.assert_not_called() - def test_unauthorized_cost_command_is_rejected(self): - update = {"message": {"chat": {"id": 456}, "text": "/cost"}} + def test_health_command_reports_only_sanitized_status_without_a_model_call(self): + with patch.object(http_api, "http", return_value={"healthy": True}) as request: + with patch.object( + opencode_api, + "session_details", + return_value={"id": "private-session-id", "title": "private title"}, + ) as get_session: + with patch.object(bridge, "answer") as answer: + report = bridge.authorized_response(123, "/health", {"123": "private-session-id"}) + + self.assertEqual( + report, + "Bridge: healthy\nOpenCode API: healthy\nThis chat's session: available", + ) + self.assertNotIn("private-session-id", report) + self.assertNotIn("private title", report) + request.assert_called_once_with( + bridge_config.OC + "/global/health", + timeout=bridge_config.OPENCODE_STATUS_TIMEOUT_SECONDS, + ) + get_session.assert_called_once_with( + "private-session-id", + timeout=bridge_config.OPENCODE_STATUS_TIMEOUT_SECONDS, + ) + answer.assert_not_called() + + def test_health_command_does_not_require_a_session(self): + with patch.object(http_api, "http", return_value={"healthy": False}): + with patch.object(opencode_api, "session_details") as get_session: + report = bridge.authorized_response(123, "/health", {}) + + self.assertEqual( + report, + "Bridge: healthy\nOpenCode API: unavailable\nThis chat's session: not initialized", + ) + get_session.assert_not_called() + + def test_unauthorized_health_command_is_rejected(self): + update = {"message": {"chat": {"id": 456}, "text": "/health"}} with patch.object(bridge, "authorized_response") as respond: with patch.object(bridge, "send") as send: bridge.handle_update(update, {}) @@ -667,6 +704,13 @@ def test_session_tree_includes_recursive_child_messages(self): ] ) + def test_session_details_uses_the_read_only_session_endpoint(self): + with patch.object(opencode_api, "http", return_value={"id": "session one"}) as request: + result = opencode_api.session_details("session one", timeout=10) + + self.assertEqual(result, {"id": "session one"}) + request.assert_called_once_with(bridge_config.OC + "/session/session%20one", timeout=10) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_telegram_chat_id.py b/tests/test_telegram_chat_id.py new file mode 100644 index 0000000..292f373 --- /dev/null +++ b/tests/test_telegram_chat_id.py @@ -0,0 +1,114 @@ +import json +import os +import sys +import tempfile +import unittest +import urllib.error +from pathlib import Path +from unittest.mock import patch + + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) +import telegram_chat_id + + +class TelegramChatIdTests(unittest.TestCase): + def test_loads_only_the_bot_token_from_env_file(self): + with tempfile.TemporaryDirectory() as directory: + env_file = Path(directory) / ".env" + env_file.write_text( + "OPPER_API_KEY=do-not-read\nTELEGRAM_BOT_TOKEN='bot-token'\n", + encoding="utf-8", + ) + + with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": ""}): + self.assertEqual(telegram_chat_id.load_bot_token(env_file), "bot-token") + + def test_environment_token_takes_precedence(self): + with patch.dict(os.environ, {"TELEGRAM_BOT_TOKEN": "environment-token"}): + self.assertEqual(telegram_chat_id.load_bot_token("missing.env"), "environment-token") + + def test_matching_chat_id_requires_private_challenge_command(self): + matching_update = { + "message": { + "chat": {"id": 123, "type": "private"}, + "from": {"is_bot": False}, + "text": "/start@bloc_bot private-challenge", + } + } + self.assertEqual(telegram_chat_id.matching_chat_id(matching_update, "private-challenge"), 123) + + group_update = { + "message": { + "chat": {"id": -123, "type": "group"}, + "from": {"is_bot": False}, + "text": "/start private-challenge", + } + } + wrong_challenge = { + **matching_update, + "message": {**matching_update["message"], "text": "/start other-value"}, + } + for update in (group_update, wrong_challenge): + with self.subTest(update=update): + self.assertIsNone(telegram_chat_id.matching_chat_id(update, "private-challenge")) + + def test_challenge_is_six_url_safe_characters(self): + with patch.object(telegram_chat_id.secrets, "token_urlsafe", return_value="Ab12_-") as generate: + challenge = telegram_chat_id.generate_challenge() + + generate.assert_called_once_with(4) + self.assertEqual(len(challenge), 6) + + def test_get_updates_omits_an_unset_offset(self): + response_body = json.dumps({"ok": True, "result": []}).encode("utf-8") + with patch.object(telegram_chat_id.urllib.request, "urlopen") as urlopen: + urlopen.return_value.__enter__.return_value.read.return_value = response_body + updates = telegram_chat_id.telegram_updates("test-token", None, 50) + + request = urlopen.call_args.args[0] + self.assertEqual(updates, []) + self.assertNotIn("offset", json.loads(request.data)) + self.assertEqual(urlopen.call_args.kwargs["timeout"], 60) + + def test_discovery_announces_listening_after_successful_connection(self): + update = { + "update_id": 1, + "message": { + "chat": {"id": 123, "type": "private"}, + "from": {"is_bot": False}, + "text": "/start private-challenge", + }, + } + announcements = [] + with patch.object(telegram_chat_id, "telegram_updates", return_value=[update]) as poll: + chat_id = telegram_chat_id.discover_chat_id( + "bot-token", + "private-challenge", + on_connected=lambda: announcements.append("connected"), + ) + + self.assertEqual(chat_id, 123) + self.assertEqual(announcements, ["connected"]) + poll.assert_called_once_with("bot-token", None, 0) + + def test_conflict_error_mentions_another_poller_without_exposing_token(self): + error = urllib.error.HTTPError( + "https://api.telegram.org/botsecret-token/getUpdates", + 409, + "Conflict", + None, + None, + ) + + message = telegram_chat_id.safe_error_message(error) + + self.assertIn("409 Conflict", message) + self.assertIn("Coolify", message) + self.assertIn("webhook", message) + self.assertNotIn("secret-token", message) + error.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_volumes.py b/tests/test_volumes.py new file mode 100644 index 0000000..d8b3d87 --- /dev/null +++ b/tests/test_volumes.py @@ -0,0 +1,81 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +VOLUME_NAMES = ("opencode-data", "workspace", "telegram-data") + + +class VolumeBackupTests(unittest.TestCase): + def make_fake_docker(self, directory, body): + docker = Path(directory) / "docker" + docker.write_text( + "#!/bin/sh\n" + "case \"$1\" in\n" + " run)\n" + f" {body}\n" + " ;;\n" + "esac\n" + "exit 0\n", + encoding="utf-8", + ) + docker.chmod(0o755) + return docker.parent + + def run_volumes_script(self, docker_path, *args, input_text=None): + environment = os.environ.copy() + environment["PATH"] = f"{docker_path}{os.pathsep}{environment['PATH']}" + return subprocess.run( + ["/bin/sh", str(ROOT / "scripts/volumes.sh"), *args], + cwd=ROOT, + env=environment, + input=input_text, + capture_output=True, + text=True, + check=False, + ) + + def test_backup_writes_archives_as_the_invoking_user_without_chmod(self): + with tempfile.TemporaryDirectory() as temporary_directory: + docker_path = self.make_fake_docker(temporary_directory, "printf archive-data") + backup_root = Path(temporary_directory) / "backups" + + result = self.run_volumes_script(docker_path, "backup", str(backup_root)) + + self.assertEqual(result.returncode, 0, result.stderr) + backup_dirs = list(backup_root.glob("bloc-*")) + self.assertEqual(len(backup_dirs), 1) + for volume_name in VOLUME_NAMES: + archive = backup_dirs[0] / f"{volume_name}.tar.gz" + self.assertEqual(archive.read_text(encoding="utf-8"), "archive-data") + self.assertEqual(archive.stat().st_uid, os.getuid()) + + def test_restore_from_parent_selects_latest_complete_backup(self): + with tempfile.TemporaryDirectory() as temporary_directory: + docker_path = self.make_fake_docker(temporary_directory, ":") + backup_root = Path(temporary_directory) / "backups" + backup_root.mkdir() + older = backup_root / "bloc-20261018T120000Z" + latest = backup_root / "bloc-20261019T120000Z" + for backup_dir in (older, latest): + backup_dir.mkdir() + for volume_name in VOLUME_NAMES: + (backup_dir / f"{volume_name}.tar.gz").touch() + + result = self.run_volumes_script( + docker_path, + "restore", + str(backup_root), + input_text="RESTORE\n", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Using latest complete backup: {latest}", result.stdout) + self.assertIn(f"Volumes restored from {latest}", result.stdout) + + +if __name__ == "__main__": + unittest.main()