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/ research/
_site/ _site/
docs/reference/changelog.md 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 # 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) ## v0.22.0 (2026-02-10)
### changes ### 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. This document is **normative**. The words **MUST**, **SHOULD**, and **MAY** express requirements.
## 1. Scope ## 1. Scope
Takopi v0.22.0 specifies: Takopi v0.22.3 specifies:
- A **Telegram** bot bridge that runs an agent **Runner** and posts: - A **Telegram** bot bridge that runs an agent **Runner** and posts:
- a throttled, edited **progress message** - 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) - **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 - 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.) - Non-Telegram clients (Slack/Discord/etc.)
- Token-by-token streaming of the assistants final answer - 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 ## 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) ### v0.22.0 (2026-02-10)
- No normative changes; align spec version with the v0.22.0 release. - No normative changes; align spec version with the v0.22.0 release.
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "takopi" name = "takopi"
authors = [{name = "banteg"}] authors = [{name = "banteg"}]
version = "0.22.0" version = "0.22.3"
description = "Telegram bridge for Codex, Claude Code, and other agent CLIs." description = "Telegram bridge for Codex, Claude Code, and other agent CLIs."
readme = "readme.md" readme = "readme.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+17 -11
View File
@@ -14,7 +14,7 @@ from ..config import ConfigError
from ..engines import list_backend_ids from ..engines import list_backend_ids
from ..ids import RESERVED_CHAT_COMMANDS from ..ids import RESERVED_CHAT_COMMANDS
from ..runtime_loader import resolve_plugins_allowlist 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.client import TelegramClient
from ..telegram.topics import _validate_topics_setup_for from ..telegram.topics import _validate_topics_setup_for
@@ -33,8 +33,8 @@ class DoctorCheck:
return f"- {self.label}: {self.status}" return f"- {self.label}: {self.status}"
def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]: def _doctor_file_checks(settings: TelegramTransportSettings) -> list[DoctorCheck]:
files = settings.transports.telegram.files files = settings.files
if not files.enabled: if not files.enabled:
return [DoctorCheck("file transfer", "ok", "disabled")] return [DoctorCheck("file transfer", "ok", "disabled")]
if files.allowed_user_ids: 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")] return [DoctorCheck("file transfer", "warning", "enabled for all users")]
def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]: def _doctor_voice_checks(settings: TelegramTransportSettings) -> list[DoctorCheck]:
if not settings.transports.telegram.voice_transcription: if not settings.voice_transcription:
return [DoctorCheck("voice transcription", "ok", "disabled")] 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: if api_key:
return [ return [
DoctorCheck("voice transcription", "ok", "voice_transcription_api_key set") DoctorCheck("voice transcription", "ok", "voice_transcription_api_key set")
@@ -115,8 +115,8 @@ def run_doctor(
[str, int, TelegramTopicsSettings, tuple[int, ...]], [str, int, TelegramTopicsSettings, tuple[int, ...]],
Awaitable[list[DoctorCheck]], Awaitable[list[DoctorCheck]],
], ],
file_checks: Callable[[TakopiSettings], list[DoctorCheck]], file_checks: Callable[[TelegramTransportSettings], list[DoctorCheck]],
voice_checks: Callable[[TakopiSettings], list[DoctorCheck]], voice_checks: Callable[[TelegramTransportSettings], list[DoctorCheck]],
) -> None: ) -> None:
try: try:
settings, config_path = load_settings_fn() settings, config_path = load_settings_fn()
@@ -130,6 +130,13 @@ def run_doctor(
err=True, err=True,
) )
raise typer.Exit(code=1) 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) allowlist = resolve_plugins_allowlist(settings)
engine_ids = list_backend_ids(allowlist=allowlist) engine_ids = list_backend_ids(allowlist=allowlist)
@@ -143,7 +150,6 @@ def run_doctor(
typer.echo(f"error: {exc}", err=True) typer.echo(f"error: {exc}", err=True)
raise typer.Exit(code=1) from exc raise typer.Exit(code=1) from exc
tg = settings.transports.telegram
project_chat_ids = projects_cfg.project_chat_ids() project_chat_ids = projects_cfg.project_chat_ids()
telegram_checks_result = anyio.run( telegram_checks_result = anyio.run(
telegram_checks, telegram_checks,
@@ -156,8 +162,8 @@ def run_doctor(
telegram_checks_result = [] telegram_checks_result = []
checks = [ checks = [
*telegram_checks_result, *telegram_checks_result,
*file_checks(settings), *file_checks(tg),
*voice_checks(settings), *voice_checks(tg),
] ]
typer.echo("takopi doctor") typer.echo("takopi doctor")
for check in checks: for check in checks:
+2 -1
View File
@@ -65,7 +65,8 @@ def chat_id(
settings, _ = load_settings_optional_fn() settings, _ = load_settings_optional_fn()
if settings is not None: if settings is not None:
tg = settings.transports.telegram 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)) chat = anyio.run(partial(onboarding_mod.capture_chat_id, token=token))
if chat is None: if chat is None:
raise typer.Exit(code=1) raise typer.Exit(code=1)
+2
View File
@@ -289,6 +289,8 @@ def _run_auto_router(
) )
if settings.transport == "telegram": if settings.transport == "telegram":
transport_config = settings.transports.telegram transport_config = settings.transports.telegram
if transport_config is None:
raise ConfigError(f"Missing [transports.telegram] in {config_path}.")
else: else:
transport_config = settings.transport_config( transport_config = settings.transport_config(
settings.transport, config_path=config_path settings.transport, config_path=config_path
+2
View File
@@ -240,6 +240,8 @@ class MarkdownFormatter:
def _format_footer(self, state: ProgressState) -> str | None: def _format_footer(self, state: ProgressState) -> str | None:
lines: list[str] = [] lines: list[str] = []
if state.usage_line:
lines.append(state.usage_line)
if state.context_line: if state.context_line:
lines.append(state.context_line) lines.append(state.context_line)
if state.resume_line: if state.resume_line:
+3
View File
@@ -25,6 +25,7 @@ class ProgressState:
resume: ResumeToken | None resume: ResumeToken | None
resume_line: str | None resume_line: str | None
context_line: str | None context_line: str | None
usage_line: str | None = None
class ProgressTracker: class ProgressTracker:
@@ -82,6 +83,7 @@ class ProgressTracker:
*, *,
resume_formatter: Callable[[ResumeToken], str] | None = None, resume_formatter: Callable[[ResumeToken], str] | None = None,
context_line: str | None = None, context_line: str | None = None,
usage_line: str | None = None,
) -> ProgressState: ) -> ProgressState:
resume_line: str | None = None resume_line: str | None = None
if self.resume is not None and resume_formatter is not None: if self.resume is not None and resume_formatter is not None:
@@ -96,4 +98,5 @@ class ProgressTracker:
resume=self.resume, resume=self.resume,
resume_line=resume_line, resume_line=resume_line,
context_line=context_line, context_line=context_line,
usage_line=usage_line,
) )
+58
View File
@@ -25,6 +25,61 @@ from .transport import (
logger = get_logger(__name__) 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: def _log_runner_event(evt: TakopiEvent) -> None:
for line in render_event_cli(evt): for line in render_event_cli(evt):
@@ -499,6 +554,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
) )
final_rendered = cfg.presenter.render_final( final_rendered = cfg.presenter.render_final(
state, state,
@@ -535,6 +591,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(None, engine=runner.engine),
) )
final_rendered = cfg.presenter.render_progress( final_rendered = cfg.presenter.render_progress(
state, state,
@@ -589,6 +646,7 @@ async def handle_message(
state = progress_tracker.snapshot( state = progress_tracker.snapshot(
resume_formatter=runner.format_resume, resume_formatter=runner.format_resume,
context_line=context_line, context_line=context_line,
usage_line=_format_usage_line(completed.usage, engine=completed.engine),
) )
final_rendered = cfg.presenter.render_final( final_rendered = cfg.presenter.render_final(
state, state,
+21 -1
View File
@@ -10,6 +10,7 @@ from typing import Any
import msgspec import msgspec
from ..backends import EngineBackend, EngineConfig from ..backends import EngineBackend, EngineConfig
from ..config import ConfigError
from ..events import EventFactory from ..events import EventFactory
from ..logging import get_logger from ..logging import get_logger
from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent from ..model import Action, ActionKind, EngineId, ResumeToken, TakopiEvent
@@ -288,6 +289,7 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
allowed_tools: list[str] | None = None allowed_tools: list[str] | None = None
dangerously_skip_permissions: bool = False dangerously_skip_permissions: bool = False
use_api_billing: bool = False use_api_billing: bool = False
extra_args: list[str] | None = None
session_title: str = "claude" session_title: str = "claude"
logger = logger logger = logger
@@ -311,6 +313,8 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
args.extend(["--allowedTools", allowed_tools]) args.extend(["--allowedTools", allowed_tools])
if self.dangerously_skip_permissions is True: if self.dangerously_skip_permissions is True:
args.append("--dangerously-skip-permissions") args.append("--dangerously-skip-permissions")
if self.extra_args:
args.extend(self.extra_args)
args.append("--") args.append("--")
args.append(prompt) args.append(prompt)
return args return args
@@ -455,7 +459,11 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, _config_path: Path) -> Runner: 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") model = config.get("model")
if "allowed_tools" in config: if "allowed_tools" in config:
@@ -464,6 +472,17 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools = DEFAULT_ALLOWED_TOOLS allowed_tools = DEFAULT_ALLOWED_TOOLS
dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True dangerously_skip_permissions = config.get("dangerously_skip_permissions") is True
use_api_billing = config.get("use_api_billing") 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" title = str(model) if model is not None else "claude"
return ClaudeRunner( return ClaudeRunner(
@@ -472,6 +491,7 @@ def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
allowed_tools=allowed_tools, allowed_tools=allowed_tools,
dangerously_skip_permissions=dangerously_skip_permissions, dangerously_skip_permissions=dangerously_skip_permissions,
use_api_billing=use_api_billing, use_api_billing=use_api_billing,
extra_args=extra_args,
session_title=title, session_title=title,
) )
+11 -3
View File
@@ -585,13 +585,16 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
title="turn started", 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 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 [ return [
factory.completed_ok( factory.completed_ok(
answer=state.final_answer or "", answer=state.final_answer or "",
resume=resume_for_completed, resume=resume_for_completed,
usage=msgspec.to_builtins(usage), usage=usage_dict,
) )
] ]
case codex_schema.ItemCompleted( case codex_schema.ItemCompleted(
@@ -664,7 +667,12 @@ class CodexRunner(ResumeTokenMixin, JsonlSubprocessRunner):
def build_runner(config: EngineConfig, config_path: Path) -> Runner: 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") extra_args_value = config.get("extra_args")
if extra_args_value is None: if extra_args_value is None:
+16
View File
@@ -308,6 +308,7 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
opencode_cmd: str = "opencode" opencode_cmd: str = "opencode"
model: str | None = None model: str | None = None
extra_args: list[str] | None = None
session_title: str = "opencode" session_title: str = "opencode"
logger = logger logger = logger
@@ -335,6 +336,8 @@ class OpenCodeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
model = run_options.model model = run_options.model
if model is not None: if model is not None:
args.extend(["--model", str(model)]) args.extend(["--model", str(model)])
if self.extra_args:
args.extend(self.extra_args)
args.extend(["--", prompt]) args.extend(["--", prompt])
return args 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." 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" title = str(model) if model is not None else "opencode"
return OpenCodeRunner( return OpenCodeRunner(
opencode_cmd=opencode_cmd, opencode_cmd=opencode_cmd,
model=model, model=model,
extra_args=extra_args,
session_title=title, 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): class TurnCompleted(msgspec.Struct, tag="turn.completed", kw_only=True):
usage: Usage usage: Usage
last_usage: Usage | None = None
class TurnFailed(msgspec.Struct, tag="turn.failed", kw_only=True): 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 collections.abc import Iterable
from pydantic import ( from pydantic import (
BeforeValidator,
BaseModel, BaseModel,
ConfigDict, ConfigDict,
Field, Field,
@@ -53,6 +54,15 @@ def _normalize_project_path(value: str, *, config_path: Path) -> Path:
return 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): class TelegramTopicsSettings(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
@@ -76,8 +86,8 @@ class TelegramFilesSettings(BaseModel):
".git/**", ".git/**",
".env", ".env",
".envrc", ".envrc",
"**/*.pem", "*.pem",
"**/.ssh/**", ".ssh/**",
] ]
) )
@@ -93,7 +103,7 @@ class TelegramTransportSettings(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
bot_token: NonEmptyStr bot_token: NonEmptyStr
chat_id: StrictInt chat_id: ChatId
allowed_user_ids: list[StrictInt] = Field(default_factory=list) allowed_user_ids: list[StrictInt] = Field(default_factory=list)
message_overflow: Literal["trim", "split"] = "trim" message_overflow: Literal["trim", "split"] = "trim"
voice_transcription: bool = False voice_transcription: bool = False
@@ -110,7 +120,7 @@ class TelegramTransportSettings(BaseModel):
class TransportsSettings(BaseModel): class TransportsSettings(BaseModel):
telegram: TelegramTransportSettings telegram: TelegramTransportSettings | None = None
model_config = ConfigDict(extra="allow") model_config = ConfigDict(extra="allow")
@@ -128,7 +138,7 @@ class ProjectSettings(BaseModel):
worktrees_dir: NonEmptyStr = ".worktrees" worktrees_dir: NonEmptyStr = ".worktrees"
default_engine: NonEmptyStr | None = None default_engine: NonEmptyStr | None = None
worktree_base: NonEmptyStr | None = None worktree_base: NonEmptyStr | None = None
chat_id: StrictInt | None = None chat_id: ChatId | None = None
class TakopiSettings(BaseSettings): class TakopiSettings(BaseSettings):
@@ -191,6 +201,8 @@ class TakopiSettings(BaseSettings):
self, transport_id: str, *, config_path: Path self, transport_id: str, *, config_path: Path
) -> dict[str, Any]: ) -> dict[str, Any]:
if transport_id == "telegram": 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() return self.transports.telegram.model_dump()
extra = self.transports.model_extra or {} extra = self.transports.model_extra or {}
raw = extra.get(transport_id) raw = extra.get(transport_id)
@@ -211,7 +223,8 @@ class TakopiSettings(BaseSettings):
reserved: Iterable[str] = ("cancel",), reserved: Iterable[str] = ("cancel",),
) -> ProjectsConfig: ) -> ProjectsConfig:
default_project = self.default_project 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} reserved_lower = {value.lower() for value in reserved}
engine_map = {engine.lower(): engine for engine in engine_ids} engine_map = {engine.lower(): engine for engine in engine_ids}
@@ -248,7 +261,7 @@ class TakopiSettings(BaseSettings):
chat_id = entry.chat_id chat_id = entry.chat_id
if chat_id is not None: 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( raise ConfigError(
f"Invalid `projects.{alias}.chat_id` in {config_path}; " f"Invalid `projects.{alias}.chat_id` in {config_path}; "
"must not match transports.telegram.chat_id." "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)." "(telegram only for now)."
) )
tg = settings.transports.telegram tg = settings.transports.telegram
if tg is None:
raise ConfigError(f"Missing [transports.telegram] in {config_path}.")
return tg.bot_token, tg.chat_id 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) chat.sessions[token.engine] = _SessionState(resume=token.value)
self._save_locked() 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 def clear_sessions(self, chat_id: int, owner_id: int | None) -> None:
async with self._lock: async with self._lock:
self._reload_locked_if_needed() self._reload_locked_if_needed()
+10
View File
@@ -717,6 +717,11 @@ class ResumeResolver:
topic_key[1], topic_key[1],
engine_for_session, 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: if stored is not None:
resume_token = stored resume_token = stored
if ( if (
@@ -729,6 +734,11 @@ class ResumeResolver:
chat_session_key[1], chat_session_key[1],
engine_for_session, 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: if stored is not None:
resume_token = stored resume_token = stored
return ResumeDecision(resume_token=resume_token, handled_by_running_task=False) 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() settings, config_path = load_settings()
if transport_override: if transport_override:
settings = settings.model_copy(update={"transport": transport_override}) settings = settings.model_copy(update={"transport": transport_override})
try: if settings.transport == "telegram":
require_telegram(settings, config_path) try:
except ConfigError: require_telegram(settings, config_path)
issues.append(config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE)) except ConfigError:
issues.append(
config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE)
)
except ConfigError: except ConfigError:
issues.extend(backend_issues) issues.extend(backend_issues)
title = ( if transport_override and transport_override != "telegram":
_CONFIGURE_TELEGRAM_TITLE title = _CREATE_CONFIG_TITLE
if config_path.exists() and config_path.is_file() else:
else _CREATE_CONFIG_TITLE 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)) issues.append(config_issue(config_path, title=title))
return SetupResult(issues=issues, config_path=config_path) return SetupResult(issues=issues, config_path=config_path)
+71 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from urllib.parse import urlparse
from markdown_it import MarkdownIt from markdown_it import MarkdownIt
from sulguk import transform_html from sulguk import transform_html
@@ -14,6 +15,8 @@ MAX_BODY_CHARS = 3500
_MD_RENDERER = MarkdownIt("commonmark", {"html": False}) _MD_RENDERER = MarkdownIt("commonmark", {"html": False})
_BULLET_RE = re.compile(r"(?m)^(\s*)•") _BULLET_RE = re.compile(r"(?m)^(\s*)•")
_FENCE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<fence>[`~]{3,})(?P<info>.*)$") _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) @dataclass(frozen=True, slots=True)
@@ -23,16 +26,82 @@ class _FenceState:
header: str 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]]]: 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) rendered = transform_html(html)
text = _BULLET_RE.sub(r"\1-", rendered.text) text = _BULLET_RE.sub(r"\1-", rendered.text)
entities = [dict(e) for e in rendered.entities] entities = _sanitize_entities(rendered.entities)
return text, 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]: def _split_line_ending(line: str) -> tuple[str, str]:
if line.endswith("\r\n"): if line.endswith("\r\n"):
return line[:-2], "\r\n" return line[:-2], "\r\n"
+13
View File
@@ -174,6 +174,19 @@ class TopicStateStore(JsonStateStore[_TopicState]):
return None return None
return ResumeToken(engine=engine, value=entry.resume) 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 def get_default_engine(self, chat_id: int, thread_id: int) -> str | None:
async with self._lock: async with self._lock:
self._reload_locked_if_needed() 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 exc.value.exit_code == 1
assert not transport.build_calls 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 result.exit_code == 0
assert "chat_id = 321" in result.output 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 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: class _FakeBot:
def __init__(self, me: User | None, chat: Chat | None) -> None: def __init__(self, me: User | None, chat: Chat | None) -> None:
self._me = me self._me = me
+13 -7
View File
@@ -5,7 +5,7 @@ import pytest
from takopi import cli from takopi import cli
from takopi.config import ConfigError from takopi.config import ConfigError
from takopi.lockfile import LockError from takopi.lockfile import LockError
from takopi.settings import TakopiSettings from takopi.settings import TakopiSettings, TelegramTransportSettings
def _settings(overrides: dict | None = None) -> TakopiSettings: def _settings(overrides: dict | None = None) -> TakopiSettings:
@@ -18,6 +18,12 @@ def _settings(overrides: dict | None = None) -> TakopiSettings:
return TakopiSettings.model_validate(payload) 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: def test_parse_key_path_valid() -> None:
assert cli._parse_key_path("transports.telegram.chat_id") == [ assert cli._parse_key_path("transports.telegram.chat_id") == [
"transports", "transports",
@@ -98,7 +104,7 @@ def test_resolve_transport_id_override(monkeypatch) -> None:
def test_doctor_file_checks() -> None: def test_doctor_file_checks() -> None:
settings = _settings() settings = _settings()
checks = cli._doctor_file_checks(settings) checks = cli._doctor_file_checks(_telegram_settings(settings))
assert checks[0].detail == "disabled" assert checks[0].detail == "disabled"
settings = _settings( 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" assert checks[0].status == "warning"
def test_doctor_voice_checks(monkeypatch) -> None: def test_doctor_voice_checks(monkeypatch) -> None:
settings = _settings() settings = _settings()
checks = cli._doctor_voice_checks(settings) checks = cli._doctor_voice_checks(_telegram_settings(settings))
assert checks[0].detail == "disabled" assert checks[0].detail == "disabled"
settings = _settings( settings = _settings(
@@ -133,7 +139,7 @@ def test_doctor_voice_checks(monkeypatch) -> None:
} }
) )
monkeypatch.delenv("OPENAI_API_KEY", raising=False) 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].status == "error"
assert checks[0].detail == "API key not set" 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].status == "ok"
assert checks[0].detail == "voice_transcription_api_key set" assert checks[0].detail == "voice_transcription_api_key set"
monkeypatch.setenv("OPENAI_API_KEY", "key") 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" 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) require_telegram(settings, config_path)
def test_load_settings_rejects_string_chat_id(tmp_path) -> None: def test_load_settings_accepts_string_chat_id(tmp_path) -> None:
from takopi.config import ConfigError from takopi.settings import require_telegram
config_path = tmp_path / "takopi.toml" config_path = tmp_path / "takopi.toml"
config_path.write_text( config_path.write_text(
@@ -102,8 +102,9 @@ def test_load_settings_rejects_string_chat_id(tmp_path) -> None:
encoding="utf-8", encoding="utf-8",
) )
with pytest.raises(ConfigError, match="chat_id"): settings, _ = load_settings(config_path)
load_settings(config_path) _, chat_id = require_telegram(settings, config_path)
assert chat_id == 123
def test_codex_extract_resume_finds_command() -> None: 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} titles = {issue.title for issue in result.issues}
assert "configure telegram" in titles 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: def test_projects_relative_path_resolves(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml" config_path = tmp_path / "takopi.toml"
settings = TakopiSettings.model_validate( settings = TakopiSettings.model_validate(
+46
View File
@@ -1,3 +1,5 @@
import re
from takopi.telegram.render import render_markdown, split_markdown_body 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) 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: def test_split_markdown_body_closes_and_reopens_fence() -> None:
body = "```py\n" + ("line\n" * 10) + "```\n\npost" 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) 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: def test_bot_token_none_rejected(tmp_path: Path) -> None:
config_path = tmp_path / "takopi.toml" config_path = tmp_path / "takopi.toml"
data = { data = {
@@ -198,6 +207,15 @@ def test_require_telegram_rejects_non_telegram_transport(tmp_path: Path) -> None
require_telegram(settings, config_path) 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: def test_load_settings_if_exists_missing(tmp_path: Path) -> None:
config_path = tmp_path / "missing.toml" config_path = tmp_path / "missing.toml"
assert load_settings_if_exists(config_path) is None 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() config_path.mkdir()
with pytest.raises(ConfigError, match="exists but is not a file"): with pytest.raises(ConfigError, match="exists but is not a file"):
load_settings(config_path) 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"): with pytest.raises(ConfigError, match="chat_id"):
validate_settings_data(data, config_path=tmp_path / "takopi.toml") 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 import pytest
from takopi.settings import TelegramFilesSettings
from takopi.telegram import files as tg_files from takopi.telegram import files as tg_files
from takopi.telegram.files import ZipTooLargeError, zip_directory 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" 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: def test_format_bytes_various_units() -> None:
assert tg_files.format_bytes(0) == "0 b" assert tg_files.format_bytes(0) == "0 b"
assert tg_files.format_bytes(1536) == "1.5 kb" 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]] [[package]]
name = "takopi" name = "takopi"
version = "0.22.0" version = "0.22.3"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "anyio" }, { name = "anyio" },