Compare commits

...

10 Commits

Author SHA1 Message Date
izackp b6c8e63f4e feat(runners): add runner_bridge context window display, enhance claude/codex/opencode runners and telegram session handling
CI / build (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / ruff (push) Has been cancelled
CI / format (push) Has been cancelled
CI / ty (push) Has been cancelled
CI / notify-commit (push) Has been cancelled
2026-06-04 22:11:02 -04:00
izackp 7e3bc363f9 chore: ignore .bkit/, add CLAUDE.md and update-service.sh 2026-06-04 21:58:36 -04:00
banteg b3f7e26675 chore(release): v0.22.3 2026-03-02 13:07:44 +04:00
ayvee 10775bf9eb Allow coercible chat_id values (#186)
Co-authored-by: banteg <4562643+banteg@users.noreply.github.com>
2026-03-02 12:27:48 +04:00
ayvee 058092c1a1 fix: make telegram config optional for external transports (#177)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: banteg <4562643+banteg@users.noreply.github.com>
2026-03-02 12:09:29 +04:00
fjolne 6cf469c8ac fix: deny root-level files with default deny_globs (#216) 2026-03-02 11:51:53 +04:00
banteg eedfa0bba5 chore(release): v0.22.2 2026-02-24 17:17:32 +04:00
banteg ebc823f616 fix: prevent Telegram 400 from local markdown links (#214) 2026-02-24 17:15:44 +04:00
banteg 3e85848292 chore(release): v0.22.1 2026-02-11 01:49:13 +04:00
banteg 56bc1681c6 fix(telegram): preserve numbering with malformed nested lists (#202) 2026-02-11 01:47:43 +04:00
34 changed files with 669 additions and 50 deletions
+2
View File
@@ -11,3 +11,5 @@ mutants/
research/
_site/
docs/reference/changelog.md
.bkit/
+88
View File
@@ -0,0 +1,88 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Run all checks (format, lint, type, tests)
just check
# Individual checks
uv run ruff format --check src tests
uv run ruff check src tests
uv run ty check src tests
uv run pytest
# Single test
uv run pytest tests/test_runner_contract.py
# Run directly without install
uv run takopi --help
# Mutation testing
uv run mutmut run
# Docs
just docs-serve
```
## Architecture
Takopi is a Telegram bridge for agent CLIs (Claude Code, Codex, OpenCode, Pi). It polls Telegram, routes messages to agent runners, streams progress back, and embeds resume tokens so sessions can continue.
### Layer stack (top → bottom)
| Layer | Key modules |
|-------|-------------|
| **CLI** | `cli.py` — entry point, config, lock |
| **Plugins** | `plugins.py`, `engines.py`, `transports.py`, `commands.py`, `api.py` |
| **Orchestration** | `router.py` (auto-route by resume token), `scheduler.py` (per-thread FIFO queue), `transport_runtime.py` |
| **Bridge** | `telegram/bridge.py` (poll loop, parse directives), `runner_bridge.py` (progress + final render) |
| **Runner** | `runner.py` (protocol + `JsonlSubprocessRunner`), `runners/*.py`, `schemas/*.py` |
| **Transport** | `transport.py`, `presenter.py`, `telegram/client.py`, `telegram/render.py` |
| **Domain** | `model.py`, `events.py`, `progress.py` |
### Plugin system
Engines, transports, and commands are discovered via Python entrypoints:
- `takopi.engine_backends` — runner backends (id must match entrypoint name)
- `takopi.transport_backends` — transport backends
- `takopi.command_backends` — in-chat command handlers
Public API surface for plugin authors: `takopi.api`. Internal modules should not be imported directly from plugins.
### Runner contract
Every runner must yield events satisfying (enforced by `tests/test_runner_contract.py`):
1. Exactly one `StartedEvent` (first)
2. Exactly one `CompletedEvent` (last)
3. `CompletedEvent.resume == StartedEvent.resume`
Runners extend `JsonlSubprocessRunner` + `ResumeTokenMixin` and implement:
- `build_args(...)` / `stdin_payload(...)` — build subprocess command
- `decode_jsonl(...)` — parse one JSONL line via msgspec
- `translate(...)` — pure function converting engine events to `TakopiEvent`s
- `format_resume()` / `extract_resume()` / `is_resume_line()` — resume codec
### Resume flow
Resume tokens are embedded as inline code in the final Telegram message (e.g., `` `claude --resume abc123` ``). When a user replies to that message, the bridge extracts the token and passes it to the matching runner. Each runner owns its own resume regex and format.
### Thread scheduling
`ThreadScheduler` maintains per-thread FIFO queues. Same-thread jobs run sequentially; different threads run in parallel. The "thread" key is the Telegram message thread / reply chain.
### Adding a runner
See `docs/how-to/add-a-runner.md`. Short version: add `src/takopi/runners/<engine>.py` + `src/takopi/schemas/<engine>.py`, expose `BACKEND = EngineBackend(...)`, add entrypoint in `pyproject.toml`. No changes to bridge or CLI needed.
## Key invariants
- `StartedEvent` must be emitted as soon as the session ID is known (enables early lock acquisition).
- Runners must not invent new event types — translate everything into `StartedEvent`, `ActionEvent`, `CompletedEvent`.
- Schema decoding uses **msgspec** (not pydantic); decoders live in `schemas/`.
- Config lives in `~/.takopi/takopi.toml`; loaded via pydantic-settings.
- Coverage threshold: 81% (`pyproject.toml` → `--cov-fail-under=81`).
+23
View File
@@ -1,5 +1,28 @@
# changelog
## v0.22.3 (2026-03-02)
### changes
- allow coercible `chat_id` values in config [#186](https://github.com/banteg/takopi/pull/186)
### fixes
- make `[transports.telegram]` optional for external transports and validate it only when telegram is used [#177](https://github.com/banteg/takopi/pull/177)
- deny root-level files with default `deny_globs` [#216](https://github.com/banteg/takopi/pull/216)
## v0.22.2 (2026-02-24)
### fixes
- prevent Telegram `400 Bad Request` failures on local/relative markdown links by dropping invalid `text_link` entities [#214](https://github.com/banteg/takopi/pull/214)
## v0.22.1 (2026-02-10)
### fixes
- preserve ordered list numbering when nested list indentation is malformed in telegram render output [#202](https://github.com/banteg/takopi/pull/202)
## v0.22.0 (2026-02-10)
### changes
+15 -3
View File
@@ -1,10 +1,10 @@
# Takopi Specification v0.22.0 [2026-02-10]
# Takopi Specification v0.22.3 [2026-03-02]
This document is **normative**. The words **MUST**, **SHOULD**, and **MAY** express requirements.
## 1. Scope
Takopi v0.22.0 specifies:
Takopi v0.22.3 specifies:
- A **Telegram** bot bridge that runs an agent **Runner** and posts:
- a throttled, edited **progress message**
@@ -15,7 +15,7 @@ Takopi v0.22.0 specifies:
- **Automatic runner selection** among multiple engines based on ResumeLine (with a configurable default for new threads)
- A Takopi-owned **normalized event model** produced by runners and consumed by renderers/bridge
Out of scope for v0.22.0:
Out of scope for v0.22.3:
- Non-Telegram clients (Slack/Discord/etc.)
- Token-by-token streaming of the assistants final answer
@@ -444,6 +444,18 @@ The lock file SHOULD be removed on clean shutdown. Stale locks from crashed proc
## 11. Changelog
### v0.22.3 (2026-03-02)
- No normative changes; align spec version with the v0.22.3 release.
### v0.22.2 (2026-02-24)
- No normative changes; align spec version with the v0.22.2 release.
### v0.22.1 (2026-02-10)
- No normative changes; align spec version with the v0.22.1 release.
### v0.22.0 (2026-02-10)
- No normative changes; align spec version with the v0.22.0 release.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "takopi"
authors = [{name = "banteg"}]
version = "0.22.0"
version = "0.22.3"
description = "Telegram bridge for Codex, Claude Code, and other agent CLIs."
readme = "readme.md"
license = { file = "LICENSE" }
+17 -11
View File
@@ -14,7 +14,7 @@ from ..config import ConfigError
from ..engines import list_backend_ids
from ..ids import RESERVED_CHAT_COMMANDS
from ..runtime_loader import resolve_plugins_allowlist
from ..settings import TakopiSettings, TelegramTopicsSettings
from ..settings import TakopiSettings, TelegramTopicsSettings, TelegramTransportSettings
from ..telegram.client import TelegramClient
from ..telegram.topics import _validate_topics_setup_for
@@ -33,8 +33,8 @@ class DoctorCheck:
return f"- {self.label}: {self.status}"
def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]:
files = settings.transports.telegram.files
def _doctor_file_checks(settings: TelegramTransportSettings) -> list[DoctorCheck]:
files = settings.files
if not files.enabled:
return [DoctorCheck("file transfer", "ok", "disabled")]
if files.allowed_user_ids:
@@ -44,10 +44,10 @@ def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]:
return [DoctorCheck("file transfer", "warning", "enabled for all users")]
def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]:
if not settings.transports.telegram.voice_transcription:
def _doctor_voice_checks(settings: TelegramTransportSettings) -> list[DoctorCheck]:
if not settings.voice_transcription:
return [DoctorCheck("voice transcription", "ok", "disabled")]
api_key = settings.transports.telegram.voice_transcription_api_key
api_key = settings.voice_transcription_api_key
if api_key:
return [
DoctorCheck("voice transcription", "ok", "voice_transcription_api_key set")
@@ -115,8 +115,8 @@ def run_doctor(
[str, int, TelegramTopicsSettings, tuple[int, ...]],
Awaitable[list[DoctorCheck]],
],
file_checks: Callable[[TakopiSettings], list[DoctorCheck]],
voice_checks: Callable[[TakopiSettings], list[DoctorCheck]],
file_checks: Callable[[TelegramTransportSettings], list[DoctorCheck]],
voice_checks: Callable[[TelegramTransportSettings], list[DoctorCheck]],
) -> None:
try:
settings, config_path = load_settings_fn()
@@ -130,6 +130,13 @@ def run_doctor(
err=True,
)
raise typer.Exit(code=1)
tg = settings.transports.telegram
if tg is None:
typer.echo(
f"error: Missing [transports.telegram] in {config_path}.",
err=True,
)
raise typer.Exit(code=1)
allowlist = resolve_plugins_allowlist(settings)
engine_ids = list_backend_ids(allowlist=allowlist)
@@ -143,7 +150,6 @@ def run_doctor(
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc
tg = settings.transports.telegram
project_chat_ids = projects_cfg.project_chat_ids()
telegram_checks_result = anyio.run(
telegram_checks,
@@ -156,8 +162,8 @@ def run_doctor(
telegram_checks_result = []
checks = [
*telegram_checks_result,
*file_checks(settings),
*voice_checks(settings),
*file_checks(tg),
*voice_checks(tg),
]
typer.echo("takopi doctor")
for check in checks:
+2 -1
View File
@@ -65,7 +65,8 @@ def chat_id(
settings, _ = load_settings_optional_fn()
if settings is not None:
tg = settings.transports.telegram
token = tg.bot_token or None
if tg is not None:
token = tg.bot_token or None
chat = anyio.run(partial(onboarding_mod.capture_chat_id, token=token))
if chat is None:
raise typer.Exit(code=1)
+2
View File
@@ -289,6 +289,8 @@ def _run_auto_router(
)
if settings.transport == "telegram":
transport_config = settings.transports.telegram
if transport_config is None:
raise ConfigError(f"Missing [transports.telegram] in {config_path}.")
else:
transport_config = settings.transport_config(
settings.transport, config_path=config_path
+2
View File
@@ -240,6 +240,8 @@ class MarkdownFormatter:
def _format_footer(self, state: ProgressState) -> str | None:
lines: list[str] = []
if state.usage_line:
lines.append(state.usage_line)
if state.context_line:
lines.append(state.context_line)
if state.resume_line:
+3
View File
@@ -25,6 +25,7 @@ class ProgressState:
resume: ResumeToken | None
resume_line: str | None
context_line: str | None
usage_line: str | None = None
class ProgressTracker:
@@ -82,6 +83,7 @@ class ProgressTracker:
*,
resume_formatter: Callable[[ResumeToken], str] | None = None,
context_line: str | None = None,
usage_line: str | None = None,
) -> ProgressState:
resume_line: str | None = None
if self.resume is not None and resume_formatter is not None:
@@ -96,4 +98,5 @@ class ProgressTracker:
resume=self.resume,
resume_line=resume_line,
context_line=context_line,
usage_line=usage_line,
)
+58
View File
@@ -25,6 +25,61 @@ from .transport import (
logger = get_logger(__name__)
_CONTEXT_WINDOWS: dict[str, int] = {
"claude": 200_000,
"pi": 200_000,
}
def _fuzzy_tokens(n: int) -> str:
if n < 1000:
return str(n)
k = n / 1000
formatted = f"{k:.2f}".rstrip("0").rstrip(".")
return f"{formatted}k"
def _format_usage_line(usage: dict | None, *, engine: str = "") -> str:
if not usage:
return "ctx: n/a"
inner: dict = usage.get("usage") or {}
is_claude_nested = bool(inner)
if is_claude_nested:
input_tokens: int | None = inner.get("input_tokens")
_out: int | None = inner.get("output_tokens")
ctx_tokens: int | None = (
(input_tokens + _out)
if input_tokens is not None and _out is not None
else (input_tokens or _out)
)
output_tokens: int | None = None # folded into ctx_tokens
else:
# codex: use last_usage (per-request) for accurate context window display.
# cumulative usage.input_tokens grows across API calls and exceeds window size.
last: dict = usage.get("last_usage") or {}
last_input: int | None = last.get("input_tokens")
last_cached: int | None = last.get("cached_input_tokens")
if last_input is not None:
ctx_tokens = last_input + (last_cached or 0)
else:
ctx_tokens = None
output_tokens = None
if ctx_tokens is None:
return "ctx: n/a"
ctx_str = _fuzzy_tokens(ctx_tokens)
context_window = _CONTEXT_WINDOWS.get(engine)
if context_window:
max_str = _fuzzy_tokens(context_window)
parts = [f"ctx: {ctx_str}/{max_str}"]
else:
parts = [f"ctx: {ctx_str}"]
if output_tokens is not None:
parts.append(f"out: {_fuzzy_tokens(output_tokens)}")
cost = usage.get("total_cost_usd")
if cost is not None:
parts.append(f"${cost:.4f}")
return " · ".join(parts)
def _log_runner_event(evt: TakopiEvent) -> None:
for line in render_event_cli(evt):
@@ -499,6 +554,7 @@ async def handle_message(
state = progress_tracker.snapshot(
resume_formatter=runner.format_resume,
context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
)
final_rendered = cfg.presenter.render_final(
state,
@@ -535,6 +591,7 @@ async def handle_message(
state = progress_tracker.snapshot(
resume_formatter=runner.format_resume,
context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
)
final_rendered = cfg.presenter.render_progress(
state,
@@ -589,6 +646,7 @@ async def handle_message(
state = progress_tracker.snapshot(
resume_formatter=runner.format_resume,
context_line=context_line,
usage_line=_format_usage_line(completed.usage, engine=completed.engine),
)
final_rendered = cfg.presenter.render_final(
state,
+21 -1
View File
@@ -10,6 +10,7 @@ from typing import Any
import msgspec
from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..events import EventFactory
from ..logging import get_logger
from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
@@ -288,6 +289,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
allowed_tools: list[str] | None = None
dangerously_skip_permissions: bool = False
use_api_billing: bool = False
extra_args: list[str] | None = None
session_title: str = "claude"
logger = logger
@@ -311,6 +313,8 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
args.extend(["--allowedTools", allowed_tools])
if self.dangerously_skip_permissions is True:
args.append("--dangerously-skip-permissions")
if self.extra_args:
args.extend(self.extra_args)
args.append("--")
args.append(prompt)
return args
@@ -455,7 +459,11 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
claude_cmd = shutil.which("claude") or "claude"
cmd_override = config.get("claude_cmd")
if isinstance(cmd_override, str) and cmd_override:
claude_cmd = shutil.which(cmd_override) or cmd_override
else:
claude_cmd = shutil.which("claude") or "claude"
model = config.get("model")
if "allowed_tools" in config:
@@ -464,6 +472,17 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools = DEFAULT_ALLOWED_TOOLS
dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True
use_api_billing = config.get("use_api_billing") is True
extra_args_raw = config.get("extra_args")
if extra_args_raw is None:
extra_args = None
elif isinstance(extra_args_raw, list) and all(
isinstance(x, str) for x in extra_args_raw
):
extra_args = list(extra_args_raw)
else:
raise ConfigError(
f"Invalid `claude.extra_args` in {_config_path}; expected a list of strings."
)
title = str(model) if model is not None else "claude"
return ClaudeRunner(
@@ -472,6 +491,7 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools=allowed_tools,
dangerously_skip_permissions=dangerously_skip_permissions,
use_api_billing=use_api_billing,
extra_args=extra_args,
session_title=title,
)
+11 -3
View File
@@ -585,13 +585,16 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
title="turn started",
)
]
case codex_schema.TurnCompleted(usage=usage):
case codex_schema.TurnCompleted(usage=usage, last_usage=last_usage):
resume_for_completed = found_session or resume
usage_dict = msgspec.to_builtins(usage)
if last_usage is not None:
usage_dict["last_usage"] = msgspec.to_builtins(last_usage)
return [
factory.completed_ok(
answer=state.final_answer or "",
resume=resume_for_completed,
usage=msgspec.to_builtins(usage),
usage=usage_dict,
)
]
case codex_schema.ItemCompleted(
@@ -664,7 +667,12 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, config_path: Path) -> Runner:
codex_cmd = "codex"
cmd_value = config.get("cmd")
if cmd_value is not None and not isinstance(cmd_value, str):
raise ConfigError(
f"Invalid `codex.cmd` in {config_path}; expected a string."
)
codex_cmd = cmd_value if isinstance(cmd_value, str) else "codex"
extra_args_value = config.get("extra_args")
if extra_args_value is None:
+16
View File
@@ -308,6 +308,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
opencode_cmd: str = "opencode"
model: str | None = None
extra_args: list[str] | None = None
session_title: str = "opencode"
logger = logger
@@ -335,6 +336,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
model = run_options.model
if model is not None:
args.extend(["--model", str(model)])
if self.extra_args:
args.extend(self.extra_args)
args.extend(["--", prompt])
return args
@@ -486,11 +489,24 @@ def build_runner(config: EngineConfig, config_path: Path) -> Runner:
f"Invalid `opencode.model` in {config_path}; expected a string."
)
extra_args_raw = config.get("extra_args")
if extra_args_raw is None:
extra_args = None
elif isinstance(extra_args_raw, list) and all(
isinstance(x, str) for x in extra_args_raw
):
extra_args = list(extra_args_raw)
else:
raise ConfigError(
f"Invalid `opencode.extra_args` in {config_path}; expected a list of strings."
)
title = str(model) if model is not None else "opencode"
return OpenCodeRunner(
opencode_cmd=opencode_cmd,
model=model,
extra_args=extra_args,
session_title=title,
)
+1
View File
@@ -54,6 +54,7 @@ class TurnStarted(msgspec.Struct, tag="turn.started", kw_only=True):
class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
usage: Usage
last_usage: Usage | None = None
class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True):
+22 -7
View File
@@ -5,6 +5,7 @@ from typing import Annotated, Any, ClassVar, Literal
from collections.abc import Iterable
from pydantic import (
BeforeValidator,
BaseModel,
ConfigDict,
Field,
@@ -53,6 +54,15 @@ def _normalize_project_path(value: str, *, config_path: Path) -> Path:
return path
def _coerce_chat_id(value: Any) -> Any:
if isinstance(value, str):
return int(value.strip())
return value
ChatId = Annotated[StrictInt, BeforeValidator(_coerce_chat_id)]
class TelegramTopicsSettings(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
@@ -76,8 +86,8 @@ class TelegramFilesSettings(BaseModel):
".git/**",
".env",
".envrc",
"**/*.pem",
"**/.ssh/**",
"*.pem",
".ssh/**",
]
)
@@ -93,7 +103,7 @@ class TelegramTransportSettings(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
bot_token: NonEmptyStr
chat_id: StrictInt
chat_id: ChatId
allowed_user_ids: list[StrictInt] = Field(default_factory=list)
message_overflow: Literal["trim", "split"] = "trim"
voice_transcription: bool = False
@@ -110,7 +120,7 @@ class TelegramTransportSettings(BaseModel):
class TransportsSettings(BaseModel):
telegram: TelegramTransportSettings
telegram: TelegramTransportSettings | None = None
model_config = ConfigDict(extra="allow")
@@ -128,7 +138,7 @@ class ProjectSettings(BaseModel):
worktrees_dir: NonEmptyStr = ".worktrees"
default_engine: NonEmptyStr | None = None
worktree_base: NonEmptyStr | None = None
chat_id: StrictInt | None = None
chat_id: ChatId | None = None
class TakopiSettings(BaseSettings):
@@ -191,6 +201,8 @@ class TakopiSettings(BaseSettings):
self, transport_id: str, *, config_path: Path
) -> dict[str, Any]:
if transport_id == "telegram":
if self.transports.telegram is None:
raise ConfigError(f"Missing [transports.telegram] in {config_path}.")
return self.transports.telegram.model_dump()
extra = self.transports.model_extra or {}
raw = extra.get(transport_id)
@@ -211,7 +223,8 @@ class TakopiSettings(BaseSettings):
reserved: Iterable[str] = ("cancel",),
) -> ProjectsConfig:
default_project = self.default_project
default_chat_id = self.transports.telegram.chat_id
tg = self.transports.telegram
default_chat_id = tg.chat_id if tg is not None else None
reserved_lower = {value.lower() for value in reserved}
engine_map = {engine.lower(): engine for engine in engine_ids}
@@ -248,7 +261,7 @@ class TakopiSettings(BaseSettings):
chat_id = entry.chat_id
if chat_id is not None:
if chat_id == default_chat_id:
if default_chat_id is not None and chat_id == default_chat_id:
raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"must not match transports.telegram.chat_id."
@@ -323,6 +336,8 @@ def require_telegram(settings: TakopiSettings, config_path: Path) -> tuple[str,
"(telegram only for now)."
)
tg = settings.transports.telegram
if tg is None:
raise ConfigError(f"Missing [transports.telegram] in {config_path}.")
return tg.bot_token, tg.chat_id
+13
View File
@@ -90,6 +90,19 @@ class ChatSessionStore(JsonStateStore[_ChatSessionsState]):
chat.sessions[token.engine] = _SessionState(resume=token.value)
self._save_locked()
async def get_any_session_resume(
self, chat_id: int, owner_id: int | None
) -> ResumeToken | None:
async with self._lock:
self._reload_locked_if_needed()
chat = self._get_chat_locked(chat_id, owner_id)
if chat is None:
return None
for engine, entry in chat.sessions.items():
if entry.resume:
return ResumeToken(engine=engine, value=entry.resume)
return None
async def clear_sessions(self, chat_id: int, owner_id: int | None) -> None:
async with self._lock:
self._reload_locked_if_needed()
+10
View File
@@ -717,6 +717,11 @@ class ResumeResolver:
topic_key[1],
engine_for_session,
)
if stored is None:
stored = await self._topic_store.get_any_session_resume(
topic_key[0],
topic_key[1],
)
if stored is not None:
resume_token = stored
if (
@@ -729,6 +734,11 @@ class ResumeResolver:
chat_session_key[1],
engine_for_session,
)
if stored is None:
stored = await self._chat_session_store.get_any_session_resume(
chat_session_key[0],
chat_session_key[1],
)
if stored is not None:
resume_token = stored
return ResumeDecision(resume_token=resume_token, handled_by_running_task=False)
+15 -9
View File
@@ -207,17 +207,23 @@ def check_setup(
settings, config_path = load_settings()
if transport_override:
settings = settings.model_copy(update={"transport": transport_override})
try:
require_telegram(settings, config_path)
except ConfigError:
issues.append(config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE))
if settings.transport == "telegram":
try:
require_telegram(settings, config_path)
except ConfigError:
issues.append(
config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE)
)
except ConfigError:
issues.extend(backend_issues)
title = (
_CONFIGURE_TELEGRAM_TITLE
if config_path.exists() and config_path.is_file()
else _CREATE_CONFIG_TITLE
)
if transport_override and transport_override != "telegram":
title = _CREATE_CONFIG_TITLE
else:
title = (
_CONFIGURE_TELEGRAM_TITLE
if config_path.exists() and config_path.is_file()
else _CREATE_CONFIG_TITLE
)
issues.append(config_issue(config_path, title=title))
return SetupResult(issues=issues, config_path=config_path)
+71 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlparse
from markdown_it import MarkdownIt
from sulguk import transform_html
@@ -14,6 +15,8 @@ MAX_BODY_CHARS = 3500
_MD_RENDERER = MarkdownIt("commonmark", {"html": False})
_BULLET_RE = re.compile(r"(?m)^(\s*)•")
_FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>[`~]{3,})(?P<info>.*)$")
_ORDERED_ITEM_RE = re.compile(r"^(?P<indent>[ \t]{0,3})(?P<marker>\d+[.)])\s+")
_UNORDERED_ITEM_RE = re.compile(r"^(?P<indent>[ \t]{0,3})[-+*]\s+")
@dataclass(frozen=True, slots=True)
@@ -23,16 +26,82 @@ class _FenceState:
header: str
def _normalize_nested_list_markers(md: str) -> str:
if not md:
return md
lines: list[str] = []
ordered_indent: str | None = None
fence_state: _FenceState | None = None
for raw_line in md.splitlines(keepends=True):
line, ending = _split_line_ending(raw_line)
fence_state = _update_fence_state(line, fence_state)
if fence_state is not None:
ordered_indent = None
lines.append(raw_line)
continue
if not line.strip():
ordered_indent = None
lines.append(raw_line)
continue
ordered_match = _ORDERED_ITEM_RE.match(line)
if ordered_match is not None:
ordered_indent = ordered_match.group("indent")
lines.append(raw_line)
continue
if ordered_indent is not None:
unordered_match = _UNORDERED_ITEM_RE.match(line)
if (
unordered_match is not None
and unordered_match.group("indent") == ordered_indent
):
lines.append(f"{ordered_indent} {line}{ending}")
continue
if line.startswith(ordered_indent) and len(line) > len(ordered_indent):
lines.append(raw_line)
continue
ordered_indent = None
lines.append(raw_line)
return "".join(lines)
def render_markdown(md: str) -> tuple[str, list[dict[str, Any]]]:
html = _MD_RENDERER.render(md or "")
html = _MD_RENDERER.render(_normalize_nested_list_markers(md or ""))
rendered = transform_html(html)
text = _BULLET_RE.sub(r"\1-", rendered.text)
entities = [dict(e) for e in rendered.entities]
entities = _sanitize_entities(rendered.entities)
return text, entities
def _sanitize_entities(entities: list[Any]) -> list[dict[str, Any]]:
sanitized: list[dict[str, Any]] = []
for raw in entities:
entity = dict(raw)
if entity.get("type") == "text_link":
url = entity.get("url")
if not isinstance(url, str) or not _is_supported_text_link_url(url):
continue
sanitized.append(entity)
return sanitized
def _is_supported_text_link_url(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme in {"http", "https"} and bool(parsed.netloc):
return True
return parsed.scheme == "tg" and (bool(parsed.netloc) or bool(parsed.path))
def _split_line_ending(line: str) -> tuple[str, str]:
if line.endswith("\r\n"):
return line[:-2], "\r\n"
+13
View File
@@ -174,6 +174,19 @@ class TopicStateStore(JsonStateStore[_TopicState]):
return None
return ResumeToken(engine=engine, value=entry.resume)
async def get_any_session_resume(
self, chat_id: int, thread_id: int
) -> ResumeToken | None:
async with self._lock:
self._reload_locked_if_needed()
thread = self._get_thread_locked(chat_id, thread_id)
if thread is None:
return None
for engine, entry in thread.sessions.items():
if entry.resume:
return ResumeToken(engine=engine, value=entry.resume)
return None
async def get_default_engine(self, chat_id: int, thread_id: int) -> str | None:
async with self._lock:
self._reload_locked_if_needed()
+35
View File
@@ -176,3 +176,38 @@ def test_run_auto_router_missing_config_noninteractive(
assert exc.value.exit_code == 1
assert not transport.build_calls
def test_run_auto_router_rejects_missing_telegram_config(
monkeypatch, tmp_path: Path
) -> None:
setup = SetupResult(issues=[], config_path=tmp_path / "takopi.toml")
transport = _FakeTransport(setup)
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate(
{"transport": "telegram", "transports": {}}
)
monkeypatch.setattr(
cli,
"_resolve_setup_engine",
lambda _override: (None, None, None, "codex", _engine_backend()),
)
monkeypatch.setattr(cli, "_resolve_transport_id", lambda _override: "telegram")
monkeypatch.setattr(cli, "get_transport", lambda _id, allowlist=None: transport)
monkeypatch.setattr(cli, "load_settings", lambda: (settings, config_path))
monkeypatch.setattr(cli, "setup_logging", lambda **_kwargs: None)
monkeypatch.setattr(cli, "build_runtime_spec", lambda **_kwargs: object())
with pytest.raises(cli.typer.Exit) as exc:
cli._run_auto_router(
default_engine_override=None,
transport_override=None,
final_notify=True,
debug=False,
onboard=False,
)
assert exc.value.exit_code == 1
assert not transport.lock_calls
assert not transport.build_calls
+26
View File
@@ -68,3 +68,29 @@ def test_chat_id_command_uses_config_token(monkeypatch) -> None:
assert result.exit_code == 0
assert "chat_id = 321" in result.output
def test_chat_id_command_without_telegram_config(monkeypatch) -> None:
settings = TakopiSettings.model_validate(
{"transport": "my-transport", "transports": {}}
)
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
async def _capture(*, token: str | None = None):
assert token is None
return onboarding.ChatInfo(
chat_id=321,
username=None,
title="takopi",
first_name=None,
last_name=None,
chat_type="supergroup",
)
monkeypatch.setattr(cli.onboarding, "capture_chat_id", _capture)
runner = CliRunner()
result = runner.invoke(cli.create_app(), ["chat-id"])
assert result.exit_code == 0
assert "chat_id = 321" in result.output
+13
View File
@@ -56,6 +56,19 @@ def test_doctor_errors_exit_nonzero(monkeypatch) -> None:
assert "telegram token: error" in result.output
def test_doctor_missing_telegram_config_exits(monkeypatch) -> None:
settings = TakopiSettings.model_validate(
{"transport": "telegram", "transports": {}}
)
monkeypatch.setattr(cli, "load_settings", lambda: (settings, Path("x")))
runner = CliRunner()
result = runner.invoke(cli.create_app(), ["doctor"])
assert result.exit_code == 1
assert "Missing [transports.telegram]" in result.output
class _FakeBot:
def __init__(self, me: User | None, chat: Chat | None) -> None:
self._me = me
+13 -7
View File
@@ -5,7 +5,7 @@ import pytest
from takopi import cli
from takopi.config import ConfigError
from takopi.lockfile import LockError
from takopi.settings import TakopiSettings
from takopi.settings import TakopiSettings, TelegramTransportSettings
def _settings(overrides: dict | None = None) -> TakopiSettings:
@@ -18,6 +18,12 @@ def _settings(overrides: dict | None = None) -> TakopiSettings:
return TakopiSettings.model_validate(payload)
def _telegram_settings(settings: TakopiSettings) -> TelegramTransportSettings:
tg = settings.transports.telegram
assert tg is not None
return tg
def test_parse_key_path_valid() -> None:
assert cli._parse_key_path("transports.telegram.chat_id") == [
"transports",
@@ -98,7 +104,7 @@ def test_resolve_transport_id_override(monkeypatch) -> None:
def test_doctor_file_checks() -> None:
settings = _settings()
checks = cli._doctor_file_checks(settings)
checks = cli._doctor_file_checks(_telegram_settings(settings))
assert checks[0].detail == "disabled"
settings = _settings(
@@ -112,13 +118,13 @@ def test_doctor_file_checks() -> None:
}
}
)
checks = cli._doctor_file_checks(settings)
checks = cli._doctor_file_checks(_telegram_settings(settings))
assert checks[0].status == "warning"
def test_doctor_voice_checks(monkeypatch) -> None:
settings = _settings()
checks = cli._doctor_voice_checks(settings)
checks = cli._doctor_voice_checks(_telegram_settings(settings))
assert checks[0].detail == "disabled"
settings = _settings(
@@ -133,7 +139,7 @@ def test_doctor_voice_checks(monkeypatch) -> None:
}
)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
checks = cli._doctor_voice_checks(settings)
checks = cli._doctor_voice_checks(_telegram_settings(settings))
assert checks[0].status == "error"
assert checks[0].detail == "API key not set"
@@ -149,12 +155,12 @@ def test_doctor_voice_checks(monkeypatch) -> None:
}
}
)
checks = cli._doctor_voice_checks(settings_with_key)
checks = cli._doctor_voice_checks(_telegram_settings(settings_with_key))
assert checks[0].status == "ok"
assert checks[0].detail == "voice_transcription_api_key set"
monkeypatch.setenv("OPENAI_API_KEY", "key")
checks = cli._doctor_voice_checks(settings)
checks = cli._doctor_voice_checks(_telegram_settings(settings))
assert checks[0].status == "ok"
+5 -4
View File
@@ -92,8 +92,8 @@ def test_require_telegram_rejects_empty_token(tmp_path) -> None:
require_telegram(settings, config_path)
def test_load_settings_rejects_string_chat_id(tmp_path) -> None:
from takopi.config import ConfigError
def test_load_settings_accepts_string_chat_id(tmp_path) -> None:
from takopi.settings import require_telegram
config_path = tmp_path / "takopi.toml"
config_path.write_text(
@@ -102,8 +102,9 @@ def test_load_settings_rejects_string_chat_id(tmp_path) -> None:
encoding="utf-8",
)
with pytest.raises(ConfigError, match="chat_id"):
load_settings(config_path)
settings, _ = load_settings(config_path)
_, chat_id = require_telegram(settings, config_path)
assert chat_id == 123
def test_codex_extract_resume_finds_command() -> None:
+41
View File
@@ -75,3 +75,44 @@ def test_check_setup_marks_invalid_bot_token(monkeypatch, tmp_path: Path) -> Non
titles = {issue.title for issue in result.issues}
assert "configure telegram" in titles
def test_check_setup_skips_telegram_validation_for_external_transport(
monkeypatch, tmp_path: Path
) -> None:
backend = engines.get_backend("codex")
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
monkeypatch.setattr(
onboarding,
"load_settings",
lambda: (
TakopiSettings.model_validate(
{"transport": "my-transport", "transports": {}}
),
tmp_path / "takopi.toml",
),
)
result = onboarding.check_setup(backend, transport_override="my-transport")
assert result.ok is True
assert len(result.issues) == 0
def test_check_setup_external_transport_missing_config(
monkeypatch, tmp_path: Path
) -> None:
backend = engines.get_backend("codex")
monkeypatch.setattr(onboarding.shutil, "which", lambda _name: "/usr/bin/codex")
monkeypatch.setattr(onboarding, "HOME_CONFIG_PATH", tmp_path / "takopi.toml")
def _raise() -> None:
raise onboarding.ConfigError("Missing config file")
monkeypatch.setattr(onboarding, "load_settings", _raise)
result = onboarding.check_setup(backend, transport_override="my-transport")
titles = {issue.title for issue in result.issues}
assert "create a config" in titles
assert "configure telegram" not in titles
+16
View File
@@ -130,6 +130,22 @@ def test_projects_chat_id_must_be_unique() -> None:
)
def test_projects_string_chat_id_is_coerced() -> None:
config = {
"transports": {"telegram": {"bot_token": "token", "chat_id": 123}},
"projects": {"z80": {"path": "/tmp/repo", "chat_id": "-10"}},
}
settings = TakopiSettings.model_validate(config)
projects = settings.to_projects_config(
config_path=Path("takopi.toml"),
engine_ids=["codex"],
reserved=RESERVED_CHAT_COMMANDS,
)
assert projects.projects["z80"].chat_id == -10
assert projects.chat_map[-10] == "z80"
def test_projects_relative_path_resolves(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate(
+46
View File
@@ -1,3 +1,5 @@
import re
from takopi.telegram.render import render_markdown, split_markdown_body
@@ -20,6 +22,50 @@ def test_render_markdown_code_fence_language_is_string() -> None:
assert any(e.get("type") == "code" for e in entities)
def test_render_markdown_drops_local_text_links() -> None:
text, entities = render_markdown("[/tmp/file.py#L12](/tmp/file.py#L12)")
assert "/tmp/file.py#L12" in text
assert not any(e.get("type") == "text_link" for e in entities)
def test_render_markdown_keeps_https_text_links() -> None:
_, entities = render_markdown("[docs](https://example.com/path)")
assert any(
e.get("type") == "text_link" and e.get("url") == "https://example.com/path"
for e in entities
)
def test_render_markdown_keeps_ordered_numbering_with_unindented_sub_bullets() -> None:
md = (
"1. Tune maker\n"
"- Sweep\n"
"- Keep data\n"
"1. Increase\n"
"- Raise target\n"
"- Keep\n"
"1. Train\n"
"- Start\n"
"1. Add\n"
"- Keep exposure\n"
"1. Run\n"
"- Target pnl\n"
)
text, _ = render_markdown(md)
numbered = [line for line in text.splitlines() if re.match(r"^\d+\.\s", line)]
assert numbered == [
"1. Tune maker",
"2. Increase",
"3. Train",
"4. Add",
"5. Run",
]
def test_split_markdown_body_closes_and_reopens_fence() -> None:
body = "```py\n" + ("line\n" * 10) + "```\n\npost"
+35
View File
@@ -176,6 +176,15 @@ def test_transport_config_telegram_and_extra(tmp_path: Path) -> None:
settings.transport_config("discord", config_path=config_path)
def test_transport_config_telegram_missing(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate(
{"transport": "discord", "transports": {"discord": {"token": "abc"}}}
)
with pytest.raises(ConfigError, match=r"Missing \[transports\.telegram\]"):
settings.transport_config("telegram", config_path=config_path)
def test_bot_token_none_rejected(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
data = {
@@ -198,6 +207,15 @@ def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None
require_telegram(settings, config_path)
def test_require_telegram_rejects_missing_telegram_config(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate(
{"transport": "telegram", "transports": {}}
)
with pytest.raises(ConfigError, match=r"Missing \[transports\.telegram\]"):
require_telegram(settings, config_path)
def test_load_settings_if_exists_missing(tmp_path: Path) -> None:
config_path = tmp_path / "missing.toml"
assert load_settings_if_exists(config_path) is None
@@ -235,3 +253,20 @@ def test_load_settings_rejects_non_file(tmp_path: Path) -> None:
config_path.mkdir()
with pytest.raises(ConfigError, match="exists but is not a file"):
load_settings(config_path)
def test_load_settings_without_telegram(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'transport = "my-transport"\n\n[transports.my-transport]\nsome_key = "value"\n',
encoding="utf-8",
)
settings, loaded_path = load_settings(config_path)
assert loaded_path == config_path
assert settings.transport == "my-transport"
assert settings.transports.telegram is None
assert settings.transport_config("my-transport", config_path=config_path) == {
"some_key": "value"
}
+10
View File
@@ -28,3 +28,13 @@ def test_settings_rejects_bool_chat_id(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="chat_id"):
validate_settings_data(data, config_path=tmp_path / "takopi.toml")
def test_settings_rejects_float_chat_id(tmp_path: Path) -> None:
data = {
"transport": "telegram",
"transports": {"telegram": {"bot_token": "token", "chat_id": 123.0}},
}
with pytest.raises(ConfigError, match="chat_id"):
validate_settings_data(data, config_path=tmp_path / "takopi.toml")
+9
View File
@@ -6,6 +6,7 @@ from pathlib import Path
import pytest
from takopi.settings import TelegramFilesSettings
from takopi.telegram import files as tg_files
from takopi.telegram.files import ZipTooLargeError, zip_directory
@@ -100,6 +101,14 @@ def test_deny_reason_matches_patterns() -> None:
assert tg_files.deny_reason(Path("secrets/key.pem"), ["**/*.pem"]) == "**/*.pem"
def test_default_deny_globs_cover_sensitive_paths() -> None:
patterns = TelegramFilesSettings().deny_globs
assert tg_files.deny_reason(Path("key.pem"), patterns) == "*.pem"
assert tg_files.deny_reason(Path(".ssh/id_rsa"), patterns) == ".ssh/**"
assert tg_files.deny_reason(Path("secrets/key.pem"), patterns) == "*.pem"
assert tg_files.deny_reason(Path("configs/.ssh/id_rsa"), patterns) == ".ssh/**"
def test_format_bytes_various_units() -> None:
assert tg_files.format_bytes(0) == "0 b"
assert tg_files.format_bytes(1536) == "1.5 kb"
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "Installing takopi from source..."
uv tool install --editable "$REPO_DIR" --force
echo "Restarting takopi service..."
systemctl --user restart takopi.service
echo "Status:"
systemctl --user status takopi.service --no-pager
Generated
+1 -1
View File
@@ -925,7 +925,7 @@ wheels = [
[[package]]
name = "takopi"
version = "0.22.0"
version = "0.22.3"
source = { editable = "." }
dependencies = [
{ name = "anyio" },