Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.coolify.template
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
# Comma-separated Telegram chat IDs allowed to use the bot.
# Replace this example ID with your own numeric chat ID(s).
TELEGRAM_ALLOWED_CHAT_IDS=123456789

# Optional /health Opper API reachability check, without credentials or inference.
TELEGRAM_HEALTH_OPPER_ENABLED=false
4 changes: 4 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ TELEGRAM_ALLOWED_CHAT_IDS=
# Keep disabled normally; output is written to /data/cost-diagnostics.jsonl in the bridge volume.
TELEGRAM_COST_DIAGNOSTICS=false

# Optional /health reachability check from the bridge to Opper (no key or inference).
# Does not validate OpenCode's provider credentials, credits, or model availability.
TELEGRAM_HEALTH_OPPER_ENABLED=false

# Opper AI provider (https://opper.ai).
OPPER_API_KEY=

Expand Down
1 change: 1 addition & 0 deletions bot/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_ALLOWED_CHAT_IDS: ${TELEGRAM_ALLOWED_CHAT_IDS:-}
TELEGRAM_COST_DIAGNOSTICS: ${TELEGRAM_COST_DIAGNOSTICS:-false}
TELEGRAM_HEALTH_OPPER_ENABLED: ${TELEGRAM_HEALTH_OPPER_ENABLED:-false}
OPENCODE_URL: http://opencode:4096
OPENCODE_MODEL: opper/${OPPER_MODEL:?OPPER_MODEL is required}#${OPPER_VARIANT:-medium}
depends_on:
Expand Down
3 changes: 3 additions & 0 deletions bot/telegram/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Docker runs `python -m bloc_bridge`. Its Dockerfile copies the entire package.
- `opencode/cost_reporting.py`: usage aggregation and sanitized diagnostics.
- `bridge_runtime.py`: daemon workers, shutdown context, and cooperative cancellation.
- `http_api.py`: shared HTTP transport, Telegram error classification, and polling lock.
- `opper_health.py`: isolated, optional, unauthenticated Opper reachability check for `/health` only.
- `bridge_config.py` and `session_state.py`: environment settings and persisted chat sessions.

For an overview, see [message flow](../../docs/diagrams/bloc_bridge-overview.puml).
Expand All @@ -36,6 +37,8 @@ For details, see [exchanges and shutdown](../../docs/diagrams/bloc_bridge-exchan
persist mappings with atomic file replacement.
- Slash commands never submit a model prompt. `/new`, `/health`, and `/cost` can call
OpenCode APIs; `/start`, `/help`, and unknown commands are handled locally.
- `/health` contacts Opper only when `TELEGRAM_HEALTH_OPPER_ENABLED=true`; this is a
bridge-side reachability check, not proof of authentication or working inference.
- Correlate assistant responses to the generated prompt message ID. SSE is optional
progress information, not proof of completion; status and history determine completion.
- Costs come from OpenCode usage, including descendant sessions, not price estimates.
Expand Down
73 changes: 60 additions & 13 deletions bot/telegram/bloc_bridge/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@
import urllib.parse
import uuid

from . import bridge_runtime, http_api, session_state
from . import bridge_runtime, http_api, opper_health, session_state
from .opencode import cost_reporting, api as opencode_api
from .telegram import markdown_renderer, polling as telegram_polling
from .bridge_config import (
ALLOWED,
HEALTH_OPPER_ENABLED,
MODEL,
OC,
OPENCODE_HTTP_TIMEOUT_SECONDS,
OPENCODE_STATUS_TIMEOUT_SECONDS,
OPENCODE_PROGRESS_INTERVAL_SECONDS,
SYSTEM,
TELEGRAM_HTTP_TIMEOUT_SECONDS,
TELEGRAM_LONG_POLL_TIMEOUT_SECONDS,
Expand Down Expand Up @@ -244,6 +246,7 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress):
set_phase("response_wait")
idle_samples = 0
last_progress = None
last_progress_at = time.monotonic()
response_messages = []
while True:
bridge_runtime.check_shutdown()
Expand All @@ -264,25 +267,27 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress):
if time.monotonic() >= deadline:
raise OpenCodeRequestTimeout("OpenCode response deadline exceeded")
correlated = opencode_api.assistant_messages_for_prompt(response_messages, prompt_message_id)
for message in correlated:
opencode_api.raise_response_error(message)
assistant_ids = {
message.get("info", {}).get("id")
for message in correlated
if message.get("info", {}).get("id")
}
stage = None
while True:
try:
event_message_id, part_type = event_queue.get_nowait()
except queue.Empty:
break
if event_message_id not in assistant_ids:
continue
stage = {
event_stage = {
"reasoning": "OpenCode is thinking…",
"tool": "OpenCode is working with a tool…",
}.get(part_type)
if stage and stage != last_progress:
progress(stage)
last_progress = stage
if event_stage:
stage = event_stage

if (
correlated
Expand All @@ -291,6 +296,21 @@ def answer_async(chat_id, prompt, sessions, reference, set_phase, progress):
and idle_samples >= 2
):
break
now = time.monotonic()
if (
(stage and last_progress is None)
or now - last_progress_at >= OPENCODE_PROGRESS_INTERVAL_SECONDS
):
elapsed = int(now - (deadline - OPENCODE_HTTP_TIMEOUT_SECONDS))
text = stage or "Still waiting for OpenCode"
if last_progress is not None or stage is None:
text = f"{text} ({elapsed}s elapsed)."
progress(text)
last_progress = stage or "waiting"
last_progress_at = time.monotonic()
remaining = deadline - time.monotonic()
if remaining <= 0:
raise OpenCodeRequestTimeout("OpenCode response deadline exceeded")
bridge_runtime.wait(min(1, max(0, remaining)))

set_phase("history_after")
Expand Down Expand Up @@ -358,7 +378,7 @@ def help_response():
"/start — welcome message",
"/help — list available commands",
"/new — start a new session; the previous session is kept",
"/health — check bridge, OpenCode API, and session status",
"/health — check bridge, OpenCode API, session, and optional provider reachability",
"/cost — show cost and usage for this chat's current session",
)
)
Expand Down Expand Up @@ -408,6 +428,11 @@ def health_response(chat_id, sessions):
lines.append("This chat's session: available" if available else "This chat's session: unavailable")
except Exception:
lines.append("This chat's session: unavailable")
lines.append(
opper_health.health_status()
if HEALTH_OPPER_ENABLED
else "Opper API: not checked (disabled)"
)
return "\n".join(lines)


Expand Down Expand Up @@ -473,6 +498,8 @@ def _safe_error_detail(error):
else None
)
suffix = f" error_code={error_code}" if error_code else ""
if error.status_code is not None:
suffix += f" http_status={error.status_code}"
return f"response_error={error_name}{suffix}"
if isinstance(error, urllib.error.URLError):
reason = error.reason
Expand Down Expand Up @@ -508,6 +535,31 @@ def _is_timeout_error(error):
return isinstance(error, http_api.OpenCodeHTTPError) and error.status in (408, 504)


def _opencode_failure_text(error, reference):
if _is_timeout_error(error):
summary = "OpenCode timed out."
elif isinstance(error, opencode_api.OpenCodeResponseError):
status = error.status_code
if error.error_name == "ProviderAuthError" or status in (401, 403):
summary = "OpenCode reported a provider authentication/access failure. Ask the operator to check credentials."
elif status == 429:
summary = "OpenCode reported a provider rate/quota limit. Ask the operator to check provider limits."
elif status is not None and status >= 500:
summary = "OpenCode reported a provider service error. Ask the operator to check provider availability."
else:
summary = "OpenCode reported an assistant/provider error."
elif isinstance(error, (urllib.error.URLError, ConnectionError)):
summary = "OpenCode API is unreachable. Ask the operator to check the service and network."
elif isinstance(error, http_api.OpenCodeHTTPError):
summary = f"OpenCode API rejected the request (HTTP {error.status})."
else:
summary = "OpenCode request failed."
return (
f"{summary} The request may still be running; please check before retrying. "
f"Please share this reference with the operator: {reference}"
)


def _log_request_error(reference, update_id, phase, started, error):
if phase.startswith("telegram_"):
client = "telegram"
Expand Down Expand Up @@ -556,6 +608,7 @@ def progress(text):
edit_reply(chat_id, acknowledgement_id, f"{text}\n\nRef: {reference}")
except Exception as error:
_log_request_error(reference, update_id, state["phase"], started, error)
finally:
set_phase("response_wait")

try:
Expand Down Expand Up @@ -603,13 +656,7 @@ def progress(text):
"response_wait",
"history_after",
):
if _is_timeout_error(error):
error_text = (
"OpenCode timed out. The request may still be running; "
f"please check before retrying. Ref: {reference}"
)
else:
error_text = f"OpenCode request failed. Please share this reference with the operator: {reference}"
error_text = _opencode_failure_text(error, reference)
else:
error_text = f"Request failed. Please share this reference with the operator: {reference}"
edit_reply(
Expand Down
2 changes: 2 additions & 0 deletions bot/telegram/bloc_bridge/bridge_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@
OPENCODE_HTTP_TIMEOUT_SECONDS = 300
OPENCODE_STATUS_TIMEOUT_SECONDS = 10
OPENCODE_EVENT_TIMEOUT_SECONDS = 15
OPENCODE_PROGRESS_INTERVAL_SECONDS = 30
TELEGRAM_HTTP_TIMEOUT_SECONDS = 70
TELEGRAM_LONG_POLL_TIMEOUT_SECONDS = 50

SESSIONS_PATH = "/data/sessions.json"
POLLING_LOCK_PATH = "/data/telegram-polling.lock"
COST_DIAGNOSTICS_ENABLED = os.getenv("TELEGRAM_COST_DIAGNOSTICS", "false").strip().lower() == "true"
COST_DIAGNOSTICS_PATH = "/data/cost-diagnostics.jsonl"
HEALTH_OPPER_ENABLED = os.getenv("TELEGRAM_HEALTH_OPPER_ENABLED", "false").strip().lower() == "true"

SYSTEM = (
"Never guess: rely on a test with an official binary, "
Expand Down
24 changes: 16 additions & 8 deletions bot/telegram/bloc_bridge/opencode/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,28 @@
class OpenCodeResponseError(RuntimeError):
"""OpenCode returned an assistant error; keep user/provider text out of logs."""

def __init__(self, error_name, error_code=None):
def __init__(self, error_name, error_code=None, status_code=None):
self.error_name = error_name if isinstance(error_name, str) else "unknown"
self.error_code = error_code if isinstance(error_code, (str, int)) else None
self.status_code = status_code if type(status_code) is int and 100 <= status_code <= 599 else None
super().__init__("OpenCode returned an error response")


def raise_response_error(response):
"""Raise on an assistant error, retaining only structured diagnostic fields."""
error = response.get("info", {}).get("error")
if error is None:
return
if not isinstance(error, dict):
raise OpenCodeResponseError("unknown")
details = error.get("data", {})
if not isinstance(details, dict):
details = {}
raise OpenCodeResponseError(error.get("name"), details.get("code"), details.get("statusCode"))


def text_from_response(response):
raise_response_error(response)
text = "\n".join(
part.get("text", "")
for part in response.get("parts", [])
Expand All @@ -25,13 +40,6 @@ def text_from_response(response):
if text:
return text

error = response.get("info", {}).get("error", {})
details = error.get("data", {}) if isinstance(error, dict) else {}
detail_message = details.get("message") if isinstance(details, dict) else None
message = detail_message or error.get("name") if isinstance(error, dict) else None
if message:
error_code = details.get("code") if isinstance(details, dict) else None
raise OpenCodeResponseError(error.get("name"), error_code)
raise RuntimeError("OpenCode returned no text")


Expand Down
41 changes: 41 additions & 0 deletions bot/telegram/bloc_bridge/opper_health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Optional, unauthenticated Opper API reachability check; never requests inference."""

import urllib.error
import urllib.request

from . import bridge_runtime


URL = "https://api.opper.ai/v3/compat/models"
TIMEOUT_SECONDS = 5


class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None


def health_status():
"""Check the API response headers only, without credentials, redirects, or retries."""
bridge_runtime.check_shutdown()
request = urllib.request.Request(URL, method="GET")
opener = urllib.request.build_opener(_NoRedirect())
try:
with opener.open(request, timeout=TIMEOUT_SECONDS) as response:
status = response.status
except urllib.error.HTTPError as error:
status = error.code
error.close()
except (TimeoutError, urllib.error.URLError, OSError) as error:
reason = error.reason if isinstance(error, urllib.error.URLError) else error
if isinstance(reason, TimeoutError):
return "Opper API: reachability check timed out"
return "Opper API: unreachable from the bridge (check network, DNS, and TLS)"
finally:
bridge_runtime.check_shutdown()

if status in (200, 401):
return "Opper API: reachable (authentication and inference not checked)"
if status == 429:
return "Opper API: rate limited (HTTP 429; inference not checked)"
return f"Opper API: reachability check failed (HTTP {status}; inference not checked)"
13 changes: 12 additions & 1 deletion docs/diagrams/bloc_bridge-exchanges.puml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ participant "Request worker\nbridge._request_worker" as worker
database "Session mapping\nsession_state + volume" as state
participant "OpenCode API\nvia opencode.api" as oc
participant "Event watcher\nDaemon thread" as events
participant "Opper API\noptional, no credentials" as opper

note over poll,oc
Network calls use http_api; Telegram replies use telegram.markdown_renderer.
Expand Down Expand Up @@ -43,6 +44,10 @@ else Allowed text message
opt /new
worker -> state : Persist selected session
end
opt /health and TELEGRAM_HEALTH_OPPER_ENABLED=true
worker -> opper : GET /v3/compat/models (5 s socket timeout)
opper --> worker : Reachability only; no inference
end
end
else Regular prompt
opt No session for this chat
Expand All @@ -63,7 +68,13 @@ else Allowed text message
opt Correlated reasoning/tool event available
oc --> events : SSE event
events -> worker : Bounded event queue (128)
worker -> tg : Edit acknowledgement with safe progress
worker -> worker : Select safe progress stage
end
opt Assistant error in correlated history
worker -> worker : Fail promptly with sanitized status + reference
end
opt First progress event or 30 s since last progress
worker -> tg : Edit acknowledgement with safe progress or elapsed wait\nKeep original deadline
end
end
worker -> oc : Read session tree history
Expand Down
19 changes: 19 additions & 0 deletions docs/provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,22 @@ 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.

## Optional reachability check

Set `TELEGRAM_HEALTH_OPPER_ENABLED=true` in the deployment environment to include an
Opper check in Telegram's `/health`. It is disabled by default and can be removed from
the command by setting the variable to `false` and recreating the bridge.

The isolated `bloc_bridge/opper_health.py` module sends an unauthenticated
`GET https://api.opper.ai/v3/compat/models` from the bridge, with a five-second socket
timeout, no redirects, no retries, and no response-body processing. DNS resolution can
depend on the system resolver's timeout. No key is passed to the bridge and no model is
invoked, so there is no inference charge. A `401` is an expected reachability result;
`200` is also accepted. Other HTTP statuses and transport failures are reported explicitly.
The endpoint is documented in Opper's [List Models API](https://docs.opper.ai/v3-api-reference/compatibility/list-models).

This checks only the bridge's network path to the Opper API, not OpenCode's network
path, its credentials, available credits, or the ability of a model to generate a response.
It is not an end-to-end LLM health check. It is never run automatically before a prompt,
and `make health` continues to check only the OpenCode server.
10 changes: 10 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The Telegram commands are:
are kept and remain available in OpenCode/TUI. `/cost` then reports usage for the new
session, starting at zero.
- `/health` reports a sanitized bridge, OpenCode API, and current-session status.
Set `TELEGRAM_HEALTH_OPPER_ENABLED=true` to also check Opper API reachability without
credentials or inference; it is disabled by default. See [Provider](provider.md#optional-reachability-check).
- `/cost` reports the current session's total and its total for the current fixed UTC hour.
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
Expand All @@ -24,6 +26,14 @@ An unknown slash command returns an error directing you to `/help`; it is not se
model. Normal messages are still sent to OpenCode. `/health` and `/cost` do not send a
prompt to the model.

While a normal request is running, the acknowledgement is updated with sanitized
reasoning/tool progress or a waiting message approximately every 30 seconds when no
progress is available. Network calls can delay updates. These updates do not reset the
300-second OpenCode deadline. Failures include a reference for the operator and, when
OpenCode supplies a structured error, distinguish provider authentication/access,
rate/quota limits, and provider service errors. Do not automatically retry: an interrupted
request may still be running in OpenCode.

Telegram replies support this Markdown subset:

- headings: `# Heading`;
Expand Down
Loading
Loading