Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6c8e63f4e | |||
| 7e3bc363f9 | |||
| b3f7e26675 | |||
| 10775bf9eb | |||
| 058092c1a1 | |||
| 6cf469c8ac | |||
| eedfa0bba5 | |||
| ebc823f616 | |||
| 3e85848292 | |||
| 56bc1681c6 |
@@ -11,3 +11,5 @@ mutants/
|
|||||||
research/
|
research/
|
||||||
_site/
|
_site/
|
||||||
docs/reference/changelog.md
|
docs/reference/changelog.md
|
||||||
|
|
||||||
|
.bkit/
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 assistant’s final answer
|
- Token-by-token streaming of the assistant’s 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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ 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
|
||||||
|
if tg is not None:
|
||||||
token = tg.bot_token or 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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +459,10 @@ class ClaudeRunner(ResumeTokenMixin, JsonlSubprocessRunner):
|
|||||||
|
|
||||||
|
|
||||||
def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
|
def build_runner(config: EngineConfig, _config_path: Path) -> Runner:
|
||||||
|
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"
|
claude_cmd = shutil.which("claude") or "claude"
|
||||||
|
|
||||||
model = config.get("model")
|
model = config.get("model")
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -207,12 +207,18 @@ 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})
|
||||||
|
if settings.transport == "telegram":
|
||||||
try:
|
try:
|
||||||
require_telegram(settings, config_path)
|
require_telegram(settings, config_path)
|
||||||
except ConfigError:
|
except ConfigError:
|
||||||
issues.append(config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE))
|
issues.append(
|
||||||
|
config_issue(config_path, title=_CONFIGURE_TELEGRAM_TITLE)
|
||||||
|
)
|
||||||
except ConfigError:
|
except ConfigError:
|
||||||
issues.extend(backend_issues)
|
issues.extend(backend_issues)
|
||||||
|
if transport_override and transport_override != "telegram":
|
||||||
|
title = _CREATE_CONFIG_TITLE
|
||||||
|
else:
|
||||||
title = (
|
title = (
|
||||||
_CONFIGURE_TELEGRAM_TITLE
|
_CONFIGURE_TELEGRAM_TITLE
|
||||||
if config_path.exists() and config_path.is_file()
|
if config_path.exists() and config_path.is_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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Executable
+13
@@ -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
|
||||||
Reference in New Issue
Block a user