feat: telegram forum topics support (#80)

This commit is contained in:
banteg
2026-01-10 22:51:31 +04:00
committed by GitHub
parent 5c1635ccb5
commit c06a0abc17
26 changed files with 2718 additions and 113 deletions
+70
View File
@@ -0,0 +1,70 @@
from pathlib import Path
from typer.testing import CliRunner
from takopi import cli
from takopi.settings import TakopiSettings
from takopi.telegram import onboarding
def test_chat_id_command_updates_project_chat_id(monkeypatch, tmp_path) -> None:
config_path = tmp_path / "takopi.toml"
config_path.write_text(
'[projects.z80]\npath = "/tmp/repo"\n',
encoding="utf-8",
)
monkeypatch.setattr("takopi.config.HOME_CONFIG_PATH", config_path)
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (None, None))
def _capture(*, token: str | None = None):
assert token == "token"
return onboarding.ChatInfo(
chat_id=123,
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", "--token", "token", "--project", "z80"],
)
assert result.exit_code == 0
saved = config_path.read_text(encoding="utf-8")
assert "chat_id = 123" in saved
assert "updated projects.z80.chat_id = 123" in result.output
def test_chat_id_command_uses_config_token(monkeypatch) -> None:
settings = TakopiSettings.model_validate(
{
"transport": "telegram",
"transports": {"telegram": {"bot_token": "config-token"}},
}
)
monkeypatch.setattr(cli, "_load_settings_optional", lambda: (settings, Path("x")))
def _capture(*, token: str | None = None):
assert token == "config-token"
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
+48
View File
@@ -226,3 +226,51 @@ def test_interactive_setup_recovers_from_malformed_toml(monkeypatch, tmp_path) -
saved = config_path.read_text(encoding="utf-8")
assert "[transports.telegram]" in saved
assert 'bot_token = "123456789:ABCdef"' in saved
def test_capture_chat_id_with_token(monkeypatch) -> None:
def _fake_run(func, *args, **kwargs):
if func is onboarding._get_bot_info:
return {"username": "my_bot"}
if func is onboarding._wait_for_chat:
return onboarding.ChatInfo(
chat_id=456,
username=None,
title="takopi",
first_name=None,
last_name=None,
chat_type="supergroup",
)
raise AssertionError(f"unexpected anyio.run target: {func}")
monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
chat = onboarding.capture_chat_id(token="123456789:ABCdef")
assert chat is not None
assert chat.chat_id == 456
def test_capture_chat_id_prompts_for_token(monkeypatch) -> None:
monkeypatch.setattr(
onboarding, "_prompt_token", lambda _console: ("token", {"username": "bot"})
)
def _fake_run(func, *args, **kwargs):
if func is onboarding._wait_for_chat:
return onboarding.ChatInfo(
chat_id=789,
username="alice",
title=None,
first_name="Alice",
last_name=None,
chat_type="private",
)
raise AssertionError(f"unexpected anyio.run target: {func}")
monkeypatch.setattr(onboarding.anyio, "run", _fake_run)
chat = onboarding.capture_chat_id()
assert chat is not None
assert chat.chat_id == 789
+416
View File
@@ -0,0 +1,416 @@
import re
from collections.abc import AsyncIterator
from typing import Any
import pytest
import takopi.runner as runner_module
from takopi.model import (
ActionEvent,
CompletedEvent,
EngineId,
ResumeToken,
StartedEvent,
TakopiEvent,
)
from takopi.runner import (
BaseRunner,
JsonlRunState,
JsonlSubprocessRunner,
ResumeTokenMixin,
)
class _DummyRunner(ResumeTokenMixin, BaseRunner):
engine = EngineId("dummy")
resume_re = re.compile(r"(?im)^`?dummy resume (?P<token>[^`\s]+)`?$")
async def run_impl(
self, prompt: str, resume: ResumeToken | None
) -> AsyncIterator[StartedEvent | CompletedEvent]:
token = resume or ResumeToken(engine=self.engine, value="token")
yield StartedEvent(engine=self.engine, resume=token, title="dummy")
yield CompletedEvent(
engine=self.engine,
ok=True,
answer=prompt,
resume=token,
)
class _DummyJsonlRunner(JsonlSubprocessRunner):
engine = EngineId("dummy-jsonl")
def command(self) -> str:
return "dummy"
def build_args(
self,
prompt: str,
resume: ResumeToken | None,
*,
state: object,
) -> list[str]:
_ = prompt, resume, state
return []
def translate(
self,
data: Any,
*,
state: Any,
resume: ResumeToken | None,
found_session: ResumeToken | None,
) -> list[TakopiEvent]:
_ = data, state, resume, found_session
return []
class _BareJsonlRunner(JsonlSubprocessRunner):
engine = EngineId("bare-jsonl")
class _RunJsonlRunner(_DummyJsonlRunner):
def stdin_payload(
self,
prompt: str,
resume: ResumeToken | None,
*,
state: Any,
) -> bytes | None:
_ = prompt, resume, state
return None
async def iter_json_lines(self, stream: Any) -> AsyncIterator[bytes]:
_ = stream
yield b'{"type": "started", "resume": "sid"}'
yield b'{"type": "completed", "resume": "sid"}'
def translate(
self,
data: Any,
*,
state: Any,
resume: ResumeToken | None,
found_session: ResumeToken | None,
) -> list[TakopiEvent]:
_ = state, resume, found_session
token_value = "sid"
if isinstance(data, dict) and isinstance(data.get("resume"), str):
token_value = data["resume"]
token = ResumeToken(engine=self.engine, value=token_value)
if isinstance(data, dict) and data.get("type") == "started":
return [StartedEvent(engine=self.engine, resume=token, title="t")]
if isinstance(data, dict) and data.get("type") == "completed":
return [
CompletedEvent(engine=self.engine, ok=True, answer="done", resume=token)
]
return []
class _BranchingJsonlRunner(_DummyJsonlRunner):
def stdin_payload(
self,
prompt: str,
resume: ResumeToken | None,
*,
state: Any,
) -> bytes | None:
_ = prompt, resume, state
return None
async def iter_json_lines(self, stream: Any) -> AsyncIterator[bytes]:
_ = stream
yield b"raise"
yield b""
yield b"invalid"
yield b'{"type": "translate_error"}'
yield b'{"type": "started", "resume": "sid"}'
yield b'{"type": "started", "resume": "sid"}'
yield b'{"type": "completed", "resume": "sid"}'
yield b'{"type": "after"}'
def decode_jsonl(self, *, line: bytes) -> Any | None:
if line == b"raise":
raise ValueError("boom")
if line == b"invalid":
return None
return super().decode_jsonl(line=line)
def translate(
self,
data: Any,
*,
state: Any,
resume: ResumeToken | None,
found_session: ResumeToken | None,
) -> list[TakopiEvent]:
_ = state, resume, found_session
if isinstance(data, dict) and data.get("type") == "translate_error":
raise RuntimeError("nope")
token_value = "sid"
if isinstance(data, dict) and isinstance(data.get("resume"), str):
token_value = data["resume"]
token = ResumeToken(engine=self.engine, value=token_value)
if isinstance(data, dict) and data.get("type") == "started":
return [StartedEvent(engine=self.engine, resume=token, title="t")]
if isinstance(data, dict) and data.get("type") == "completed":
return [
CompletedEvent(engine=self.engine, ok=True, answer="done", resume=token)
]
return []
@pytest.mark.anyio
async def test_base_runner_run_locked_handles_resume() -> None:
runner = _DummyRunner()
events = [evt async for evt in runner.run("hello", None)]
assert isinstance(events[0], StartedEvent)
assert isinstance(events[-1], CompletedEvent)
resume = ResumeToken(engine=runner.engine, value="resume")
resumed = [evt async for evt in runner.run("again", resume)]
assert isinstance(resumed[0], StartedEvent)
assert resumed[0].resume == resume
@pytest.mark.anyio
async def test_base_runner_rejects_wrong_resume_engine() -> None:
runner = _DummyRunner()
bad_resume = ResumeToken(engine=EngineId("other"), value="oops")
with pytest.raises(RuntimeError):
_ = [evt async for evt in runner.run("hello", bad_resume)]
@pytest.mark.anyio
async def test_base_runner_run_impl_not_implemented() -> None:
class _BareRunner(BaseRunner):
engine = EngineId("bare")
runner = _BareRunner()
with pytest.raises(NotImplementedError):
_ = [evt async for evt in runner.run_impl("hello", None)]
def test_resume_token_format_and_extract() -> None:
runner = _DummyRunner()
token = ResumeToken(engine=runner.engine, value="abc")
assert runner.format_resume(token) == "`dummy resume abc`"
assert runner.is_resume_line("`dummy resume abc`") is True
text = "`dummy resume first`\n`dummy resume second`"
assert runner.extract_resume(text) == ResumeToken(
engine=runner.engine, value="second"
)
assert runner.extract_resume(None) is None
with pytest.raises(RuntimeError):
runner.format_resume(ResumeToken(engine=EngineId("other"), value="bad"))
def test_session_lock_reuse() -> None:
runner = _DummyRunner()
token = ResumeToken(engine=runner.engine, value="one")
lock1 = runner.lock_for(token)
lock2 = runner.lock_for(token)
other = runner.lock_for(ResumeToken(engine=runner.engine, value="two"))
assert lock1 is lock2
assert other is not lock1
@pytest.mark.anyio
async def test_run_with_resume_lock_passthrough() -> None:
runner = _DummyRunner()
events = [
evt async for evt in runner.run_with_resume_lock("hello", None, runner.run_impl)
]
assert events
def test_jsonl_helpers() -> None:
runner = _DummyJsonlRunner()
state = JsonlRunState()
note1 = runner.next_note_id(state)
note2 = runner.next_note_id(state)
assert note1.endswith(".1")
assert note2.endswith(".2")
event = runner.note_event("warn", state=state)
assert isinstance(event, ActionEvent)
assert event.action.detail == {}
invalid = runner.invalid_json_events(raw="x", line="{}", state=state)
invalid_event = invalid[0]
assert isinstance(invalid_event, ActionEvent)
assert invalid_event.action.detail["line"] == "{}"
assert runner.decode_jsonl(line=b'{"a": 1}') == {"a": 1}
assert runner.decode_jsonl(line=b"{") is None
err_events = runner.decode_error_events(
raw="oops", line="{}", error=ValueError("nope"), state=state
)
err_event = err_events[0]
assert isinstance(err_event, ActionEvent)
assert err_event.action.detail["error"] == "nope"
translated = runner.translate_error_events(
data={"type": "foo", "item": {"type": "bar"}},
error=ValueError("boom"),
state=state,
)
translated_event = translated[0]
assert isinstance(translated_event, ActionEvent)
detail = translated_event.action.detail
assert detail["type"] == "foo"
assert detail["item_type"] == "bar"
resume = ResumeToken(engine=runner.engine, value="sid")
processed = runner.process_error_events(
2, resume=resume, found_session=None, state=state
)
processed_event = processed[-1]
assert isinstance(processed_event, CompletedEvent)
assert processed_event.ok is False
assert processed_event.resume == resume
stream_end = runner.stream_end_events(
resume=None, found_session=resume, state=state
)
stream_event = stream_end[-1]
assert isinstance(stream_event, CompletedEvent)
assert stream_event.resume == resume
started = StartedEvent(engine=runner.engine, resume=resume, title="t")
found, emit = runner.handle_started_event(
started, expected_session=None, found_session=None
)
assert found == resume
assert emit is True
found, emit = runner.handle_started_event(
started, expected_session=None, found_session=resume
)
assert found == resume
assert emit is False
mismatch = StartedEvent(engine=EngineId("other"), resume=resume, title="t")
with pytest.raises(RuntimeError):
runner.handle_started_event(mismatch, expected_session=None, found_session=None)
other_resume = ResumeToken(engine=runner.engine, value="other")
with pytest.raises(RuntimeError):
runner.handle_started_event(
StartedEvent(engine=runner.engine, resume=other_resume, title="t"),
expected_session=resume,
found_session=None,
)
with pytest.raises(RuntimeError):
runner.handle_started_event(
StartedEvent(engine=runner.engine, resume=other_resume, title="t"),
expected_session=None,
found_session=resume,
)
def test_next_note_id_requires_state_field() -> None:
runner = _DummyJsonlRunner()
with pytest.raises(RuntimeError):
runner.next_note_id(object())
def test_jsonl_base_methods_raise_and_defaults() -> None:
runner = _BareJsonlRunner()
with pytest.raises(NotImplementedError):
runner.command()
with pytest.raises(NotImplementedError):
runner.build_args("hi", None, state=None)
with pytest.raises(NotImplementedError):
runner.translate(data={}, state=None, resume=None, found_session=None)
assert runner.pipes_error_message().startswith("bare-jsonl")
state = runner.new_state("hi", None)
assert isinstance(state, JsonlRunState)
assert runner.start_run("hi", None, state=state) is None
assert runner.env(state=state) is None
assert runner.stdin_payload("hi", None, state=state) == b"hi"
@pytest.mark.anyio
async def test_jsonl_run_impl_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.stdin = None
self.pid = 123
async def wait(self) -> int:
return 0
class _FakeManager:
def __init__(self, proc: _FakeProc) -> None:
self._proc = proc
async def __aenter__(self) -> _FakeProc:
return self._proc
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
proc = _FakeProc()
def fake_manage_subprocess(*args: Any, **kwargs: Any) -> _FakeManager:
_ = args, kwargs
return _FakeManager(proc)
async def fake_drain_stderr(*args: Any, **kwargs: Any) -> None:
_ = args, kwargs
return None
monkeypatch.setattr(runner_module, "manage_subprocess", fake_manage_subprocess)
monkeypatch.setattr(runner_module, "drain_stderr", fake_drain_stderr)
runner = _RunJsonlRunner()
events = [evt async for evt in runner.run_impl("hello", None)]
assert any(isinstance(evt, CompletedEvent) for evt in events)
@pytest.mark.anyio
async def test_jsonl_run_impl_branches(monkeypatch: pytest.MonkeyPatch) -> None:
class _FakeProc:
def __init__(self) -> None:
self.stdout = object()
self.stderr = object()
self.stdin = None
self.pid = 456
async def wait(self) -> int:
return 0
class _FakeManager:
def __init__(self, proc: _FakeProc) -> None:
self._proc = proc
async def __aenter__(self) -> _FakeProc:
return self._proc
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
proc = _FakeProc()
def fake_manage_subprocess(*args: Any, **kwargs: Any) -> _FakeManager:
_ = args, kwargs
return _FakeManager(proc)
async def fake_drain_stderr(*args: Any, **kwargs: Any) -> None:
_ = args, kwargs
return None
monkeypatch.setattr(runner_module, "manage_subprocess", fake_manage_subprocess)
monkeypatch.setattr(runner_module, "drain_stderr", fake_drain_stderr)
runner = _BranchingJsonlRunner()
events = [evt async for evt in runner.run_impl("hello", None)]
assert any(isinstance(evt, CompletedEvent) for evt in events)
+246 -25
View File
@@ -1,5 +1,6 @@
from dataclasses import replace
from pathlib import Path
from typing import cast
from typing import Any, cast
import anyio
import pytest
@@ -18,6 +19,8 @@ from takopi.telegram.bridge import (
_send_with_resume,
run_main_loop,
)
from takopi.telegram.client import BotClient
from takopi.telegram.topic_state import TopicStateStore, resolve_state_path
from takopi.context import RunContext
from takopi.config import ProjectConfig, ProjectsConfig, empty_projects_config
from takopi.runner_bridge import ExecBridgeConfig, RunningTask
@@ -92,12 +95,13 @@ class _FakeTransport:
return None
class _FakeBot:
class _FakeBot(BotClient):
def __init__(self) -> None:
self.command_calls: list[dict] = []
self.callback_calls: list[dict] = []
self.send_calls: list[dict] = []
self.edit_calls: list[dict] = []
self.edit_topic_calls: list[dict[str, Any]] = []
self.delete_calls: list[dict] = []
async def get_updates(
@@ -105,13 +109,13 @@ class _FakeBot:
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
) -> list[dict[str, Any]] | None:
_ = offset
_ = timeout_s
_ = allowed_updates
return []
async def get_file(self, file_id: str) -> dict | None:
async def get_file(self, file_id: str) -> dict[str, Any] | None:
_ = file_id
return None
@@ -125,18 +129,20 @@ class _FakeBot:
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
message_thread_id: int | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
replace_message_id: int | None = None,
) -> dict:
) -> dict[str, Any]:
self.send_calls.append(
{
"chat_id": chat_id,
"text": text,
"reply_to_message_id": reply_to_message_id,
"disable_notification": disable_notification,
"message_thread_id": message_thread_id,
"entities": entities,
"parse_mode": parse_mode,
"reply_markup": reply_markup,
@@ -150,12 +156,12 @@ class _FakeBot:
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
wait: bool = True,
) -> dict:
) -> dict[str, Any]:
self.edit_calls.append(
{
"chat_id": chat_id,
@@ -175,9 +181,9 @@ class _FakeBot:
async def set_my_commands(
self,
commands: list[dict],
commands: list[dict[str, Any]],
*,
scope: dict | None = None,
scope: dict[str, Any] | None = None,
language_code: str | None = None,
) -> bool:
self.command_calls.append(
@@ -189,9 +195,39 @@ class _FakeBot:
)
return True
async def get_me(self) -> dict | None:
async def get_me(self) -> dict[str, Any] | None:
return {"id": 1}
async def get_chat(self, chat_id: int) -> dict[str, Any] | None:
_ = chat_id
return {"id": chat_id, "type": "supergroup", "is_forum": True}
async def get_chat_member(
self, chat_id: int, user_id: int
) -> dict[str, Any] | None:
_ = chat_id
_ = user_id
return {"status": "administrator", "can_manage_topics": True}
async def create_forum_topic(
self, chat_id: int, name: str
) -> dict[str, Any] | None:
_ = chat_id
_ = name
return {"message_thread_id": 1}
async def edit_forum_topic(
self, chat_id: int, message_thread_id: int, name: str
) -> bool:
self.edit_topic_calls.append(
{
"chat_id": chat_id,
"message_thread_id": message_thread_id,
"name": name,
}
)
return True
async def close(self) -> None:
return None
@@ -457,19 +493,19 @@ async def test_telegram_transport_passes_reply_markup() -> None:
@pytest.mark.anyio
async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
class _OutboxBot:
class _OutboxBot(BotClient):
def __init__(self) -> None:
self.edit_calls: list[dict[str, object]] = []
self.edit_calls: list[dict[str, Any]] = []
async def get_updates(
self,
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
) -> list[dict[str, Any]] | None:
return None
async def get_file(self, file_id: str) -> dict | None:
async def get_file(self, file_id: str) -> dict[str, Any] | None:
_ = file_id
return None
@@ -483,7 +519,8 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
message_thread_id: int | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
@@ -497,7 +534,7 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
@@ -527,14 +564,14 @@ async def test_telegram_transport_edit_wait_false_returns_ref() -> None:
async def set_my_commands(
self,
commands: list[dict[str, object]],
commands: list[dict[str, Any]],
*,
scope: dict[str, object] | None = None,
scope: dict[str, Any] | None = None,
language_code: str | None = None,
) -> bool:
return False
async def get_me(self) -> dict | None:
async def get_me(self) -> dict[str, Any] | None:
return None
async def close(self) -> None:
@@ -755,11 +792,115 @@ def test_resolve_message_accepts_backticked_ctx_line() -> None:
assert resolved.context == RunContext(project="takopi", branch="feat/api")
def test_topic_title_matches_command_syntax() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
title = bridge._topic_title(
cfg=cfg,
runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"),
)
assert title == "takopi @master"
title = bridge._topic_title(
cfg=cfg,
runtime=cfg.runtime,
context=RunContext(project="takopi", branch=None),
)
assert title == "takopi"
title = bridge._topic_title(
cfg=cfg,
runtime=cfg.runtime,
context=RunContext(project=None, branch="main"),
)
assert title == "@main"
def test_topic_title_per_project_chat_includes_project() -> None:
transport = _FakeTransport()
cfg = replace(
_make_cfg(transport),
topics=bridge.TelegramTopicsConfig(
enabled=True,
mode="per_project_chat",
),
)
title = bridge._topic_title(
cfg=cfg,
runtime=cfg.runtime,
context=RunContext(project="takopi", branch="master"),
)
assert title == "takopi @master"
@pytest.mark.anyio
async def test_maybe_rename_topic_updates_title(tmp_path: Path) -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
store = TopicStateStore(tmp_path / "telegram_topics_state.json")
await store.set_context(
123,
77,
RunContext(project="takopi", branch="old"),
topic_title="takopi @old",
)
await bridge._maybe_rename_topic(
cfg,
store,
chat_id=123,
thread_id=77,
context=RunContext(project="takopi", branch="new"),
)
bot = cast(_FakeBot, cfg.bot)
assert bot.edit_topic_calls
assert bot.edit_topic_calls[-1]["name"] == "takopi @new"
snapshot = await store.get_thread(123, 77)
assert snapshot is not None
assert snapshot.topic_title == "takopi @new"
@pytest.mark.anyio
async def test_maybe_rename_topic_skips_when_title_matches(tmp_path: Path) -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
store = TopicStateStore(tmp_path / "telegram_topics_state.json")
await store.set_context(
123,
77,
RunContext(project="takopi", branch="main"),
topic_title="takopi @main",
)
snapshot = await store.get_thread(123, 77)
await bridge._maybe_rename_topic(
cfg,
store,
chat_id=123,
thread_id=77,
context=RunContext(project="takopi", branch="main"),
snapshot=snapshot,
)
bot = cast(_FakeBot, cfg.bot)
assert bot.edit_topic_calls == []
@pytest.mark.anyio
async def test_send_with_resume_waits_for_token() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken, RunContext | None]] = []
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
async def enqueue(
chat_id: int,
@@ -767,8 +908,9 @@ async def test_send_with_resume_waits_for_token() -> None:
text: str,
resume: ResumeToken,
context: RunContext | None,
thread_id: int | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume, context))
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
running_task = RunningTask()
@@ -785,11 +927,19 @@ async def test_send_with_resume_waits_for_token() -> None:
running_task,
123,
10,
None,
"hello",
)
assert sent == [
(123, 10, "hello", ResumeToken(engine=CODEX_ENGINE, value="abc123"), None)
(
123,
10,
"hello",
ResumeToken(engine=CODEX_ENGINE, value="abc123"),
None,
None,
)
]
assert transport.send_calls == []
@@ -798,7 +948,7 @@ async def test_send_with_resume_waits_for_token() -> None:
async def test_send_with_resume_reports_when_missing() -> None:
transport = _FakeTransport()
cfg = _make_cfg(transport)
sent: list[tuple[int, int, str, ResumeToken, RunContext | None]] = []
sent: list[tuple[int, int, str, ResumeToken, RunContext | None, int | None]] = []
async def enqueue(
chat_id: int,
@@ -806,8 +956,9 @@ async def test_send_with_resume_reports_when_missing() -> None:
text: str,
resume: ResumeToken,
context: RunContext | None,
thread_id: int | None,
) -> None:
sent.append((chat_id, user_msg_id, text, resume, context))
sent.append((chat_id, user_msg_id, text, resume, context, thread_id))
running_task = RunningTask()
running_task.done.set()
@@ -818,6 +969,7 @@ async def test_send_with_resume_reports_when_missing() -> None:
running_task,
123,
10,
None,
"hello",
)
@@ -903,6 +1055,75 @@ async def test_run_main_loop_routes_reply_to_running_resume() -> None:
tg.cancel_scope.cancel()
@pytest.mark.anyio
async def test_run_main_loop_persists_topic_sessions_in_per_project_chat(
tmp_path: Path,
) -> None:
project_chat_id = -100
resume_value = "resume-123"
transport = _FakeTransport()
bot = _FakeBot()
runner = ScriptRunner(
[Return(answer="ok")],
engine=CODEX_ENGINE,
resume_value=resume_value,
)
exec_cfg = ExecBridgeConfig(
transport=transport,
presenter=MarkdownPresenter(),
final_notify=True,
)
projects = ProjectsConfig(
projects={
"takopi": ProjectConfig(
alias="takopi",
path=Path("."),
worktrees_dir=Path(".worktrees"),
chat_id=project_chat_id,
)
},
default_project=None,
chat_map={project_chat_id: "takopi"},
)
runtime = TransportRuntime(
router=_make_router(runner),
projects=projects,
config_path=tmp_path / "takopi.toml",
)
cfg = TelegramBridgeConfig(
bot=bot,
runtime=runtime,
chat_id=123,
startup_msg="",
exec_cfg=exec_cfg,
topics=bridge.TelegramTopicsConfig(
enabled=True,
mode="per_project_chat",
),
)
async def poller(_cfg: TelegramBridgeConfig):
yield TelegramIncomingMessage(
transport="telegram",
chat_id=project_chat_id,
message_id=1,
text="hello",
reply_to_message_id=None,
reply_to_text=None,
sender_id=123,
thread_id=77,
)
with anyio.fail_after(2):
await run_main_loop(cfg, poller)
state_path = resolve_state_path(runtime.config_path or tmp_path / "takopi.toml")
store = TopicStateStore(state_path)
stored = await store.get_session_resume(project_chat_id, 77, CODEX_ENGINE)
assert stored == ResumeToken(engine=CODEX_ENGINE, value=resume_value)
@pytest.mark.anyio
async def test_run_main_loop_handles_command_plugins(monkeypatch) -> None:
class _Command:
+25 -1
View File
@@ -11,7 +11,7 @@ def test_parse_incoming_update_maps_fields() -> None:
"message": {
"message_id": 10,
"text": "hello",
"chat": {"id": 123},
"chat": {"id": 123, "type": "supergroup", "is_forum": True},
"from": {"id": 99},
"reply_to_message": {"message_id": 5, "text": "prev"},
},
@@ -27,6 +27,10 @@ def test_parse_incoming_update_maps_fields() -> None:
assert msg.reply_to_message_id == 5
assert msg.reply_to_text == "prev"
assert msg.sender_id == 99
assert msg.thread_id is None
assert msg.is_topic_message is None
assert msg.chat_type == "supergroup"
assert msg.is_forum is True
assert msg.voice is None
assert msg.raw == update["message"]
@@ -102,3 +106,23 @@ def test_parse_incoming_update_callback_query() -> None:
assert msg.callback_query_id == "cbq-1"
assert msg.data == "takopi:cancel"
assert msg.sender_id == 321
def test_parse_incoming_update_topic_fields() -> None:
update = {
"update_id": 1,
"message": {
"message_id": 10,
"text": "hello",
"message_thread_id": 77,
"is_topic_message": True,
"chat": {"id": -100, "type": "supergroup", "is_forum": True},
},
}
msg = parse_incoming_update(update, chat_id=-100)
assert isinstance(msg, TelegramIncomingMessage)
assert msg.thread_id == 77
assert msg.is_topic_message is True
assert msg.chat_type == "supergroup"
assert msg.is_forum is True
+37 -11
View File
@@ -1,14 +1,17 @@
from typing import Any
import anyio
import pytest
from takopi.telegram.client import TelegramClient, TelegramRetryAfter
from takopi.telegram.client import BotClient, TelegramClient, TelegramRetryAfter
class _FakeBot:
class _FakeBot(BotClient):
def __init__(self) -> None:
self.calls: list[str] = []
self.edit_calls: list[str] = []
self.delete_calls: list[tuple[int, int]] = []
self.topic_calls: list[tuple[int, int, str]] = []
self._edit_attempts = 0
self._updates_attempts = 0
self.retry_after: float | None = None
@@ -20,14 +23,16 @@ class _FakeBot:
text: str,
reply_to_message_id: int | None = None,
disable_notification: bool | None = False,
entities: list[dict] | None = None,
message_thread_id: int | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
replace_message_id: int | None = None,
) -> dict:
) -> dict[str, Any]:
_ = reply_to_message_id
_ = disable_notification
_ = message_thread_id
_ = entities
_ = parse_mode
_ = reply_markup
@@ -40,12 +45,12 @@ class _FakeBot:
chat_id: int,
message_id: int,
text: str,
entities: list[dict] | None = None,
entities: list[dict[str, Any]] | None = None,
parse_mode: str | None = None,
reply_markup: dict | None = None,
*,
wait: bool = True,
) -> dict:
) -> dict[str, Any]:
_ = chat_id
_ = message_id
_ = entities
@@ -71,9 +76,9 @@ class _FakeBot:
async def set_my_commands(
self,
commands: list[dict],
commands: list[dict[str, Any]],
*,
scope: dict | None = None,
scope: dict[str, Any] | None = None,
language_code: str | None = None,
) -> bool:
_ = commands
@@ -86,7 +91,7 @@ class _FakeBot:
offset: int | None,
timeout_s: int = 50,
allowed_updates: list[str] | None = None,
) -> list[dict] | None:
) -> list[dict[str, Any]] | None:
_ = offset
_ = timeout_s
_ = allowed_updates
@@ -96,7 +101,7 @@ class _FakeBot:
self._updates_attempts += 1
return []
async def get_file(self, file_id: str) -> dict | None:
async def get_file(self, file_id: str) -> dict[str, Any] | None:
_ = file_id
return None
@@ -107,7 +112,7 @@ class _FakeBot:
async def close(self) -> None:
return None
async def get_me(self) -> dict | None:
async def get_me(self) -> dict[str, Any] | None:
return {"id": 1}
async def answer_callback_query(
@@ -119,6 +124,27 @@ class _FakeBot:
_ = callback_query_id, text, show_alert
return True
async def edit_forum_topic(
self, chat_id: int, message_thread_id: int, name: str
) -> bool:
self.calls.append("edit_forum_topic")
self.topic_calls.append((chat_id, message_thread_id, name))
return True
@pytest.mark.anyio
async def test_edit_forum_topic_uses_outbox() -> None:
bot = _FakeBot()
client = TelegramClient(client=bot, private_chat_rps=0.0, group_chat_rps=0.0)
result = await client.edit_forum_topic(
chat_id=7, message_thread_id=42, name="takopi @main"
)
assert result is True
assert bot.calls == ["edit_forum_topic"]
assert bot.topic_calls == [(7, 42, "takopi @main")]
@pytest.mark.anyio
async def test_edits_coalesce_latest() -> None:
+49
View File
@@ -0,0 +1,49 @@
import pytest
from takopi.context import RunContext
from takopi.model import ResumeToken
from takopi.telegram.topic_state import TopicStateStore
@pytest.mark.anyio
async def test_topic_state_store_roundtrip(tmp_path) -> None:
path = tmp_path / "telegram_topics_state.json"
store = TopicStateStore(path)
context = RunContext(project="proj", branch="feat/topic")
await store.set_context(1, 10, context)
await store.set_session_resume(1, 10, ResumeToken(engine="codex", value="abc123"))
snapshot = await store.get_thread(1, 10)
assert snapshot is not None
assert snapshot.context == context
assert snapshot.sessions == {"codex": "abc123"}
store2 = TopicStateStore(path)
snapshot2 = await store2.get_thread(1, 10)
assert snapshot2 is not None
assert snapshot2.context == context
assert snapshot2.sessions == {"codex": "abc123"}
@pytest.mark.anyio
async def test_topic_state_store_clear_and_find(tmp_path) -> None:
path = tmp_path / "telegram_topics_state.json"
store = TopicStateStore(path)
context = RunContext(project="proj", branch="main")
await store.set_context(2, 20, context)
await store.set_session_resume(
2, 20, ResumeToken(engine="claude", value="resume-token")
)
found = await store.find_thread_for_context(2, context)
assert found == 20
await store.clear_sessions(2, 20)
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.sessions == {}
await store.clear_context(2, 20)
snapshot = await store.get_thread(2, 20)
assert snapshot is not None
assert snapshot.context is None
+90
View File
@@ -71,3 +71,93 @@ def test_resolve_message_defaults_to_chat_project() -> None:
)
assert resolved.context == RunContext(project="proj", branch=None)
def test_resolve_message_uses_ambient_context() -> None:
runtime = _make_runtime()
ambient = RunContext(project="proj", branch="feat/ambient")
resolved = runtime.resolve_message(
text="hello",
reply_text=None,
ambient_context=ambient,
)
assert resolved.context == ambient
assert resolved.context_source == "ambient"
def test_resolve_message_reply_ctx_overrides_ambient() -> None:
runtime = _make_runtime()
ambient = RunContext(project="proj", branch="feat/ambient")
resolved = runtime.resolve_message(
text="hello",
reply_text="`ctx: proj @ reply`",
ambient_context=ambient,
)
assert resolved.context == RunContext(project="proj", branch="reply")
assert resolved.context_source == "reply_ctx"
def test_resolve_message_directives_override_ambient() -> None:
runtime = _make_runtime()
ambient = RunContext(project="proj", branch="feat/ambient")
resolved = runtime.resolve_message(
text="/proj @main do it",
reply_text=None,
ambient_context=ambient,
)
assert resolved.context == RunContext(project="proj", branch="main")
assert resolved.context_source == "directives"
def test_resolve_message_branch_directive_merges_with_ambient_project() -> None:
runtime = _make_runtime()
ambient = RunContext(project="proj", branch="feat/ambient")
resolved = runtime.resolve_message(
text="@hotfix do it",
reply_text=None,
ambient_context=ambient,
)
assert resolved.context == RunContext(project="proj", branch="hotfix")
assert resolved.context_source == "directives"
def test_resolve_message_project_directive_clears_ambient_branch() -> None:
codex = ScriptRunner([Return(answer="ok")], engine="codex")
router = AutoRouter(
entries=[RunnerEntry(engine=codex.engine, runner=codex)],
default_engine=codex.engine,
)
projects = ProjectsConfig(
projects={
"proj": ProjectConfig(
alias="proj",
path=Path("."),
worktrees_dir=Path(".worktrees"),
),
"other": ProjectConfig(
alias="other",
path=Path("."),
worktrees_dir=Path(".worktrees"),
),
},
default_project=None,
)
runtime = TransportRuntime(router=router, projects=projects)
ambient = RunContext(project="proj", branch="feat/ambient")
resolved = runtime.resolve_message(
text="/other do it",
reply_text=None,
ambient_context=ambient,
)
assert resolved.context == RunContext(project="other", branch=None)
assert resolved.context_source == "directives"