refactor: simplify telegram loop and jsonl runner (#155)
This commit is contained in:
-1090
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ruff: noqa: F401
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..config import (
|
||||||
|
ConfigError,
|
||||||
|
HOME_CONFIG_PATH,
|
||||||
|
load_or_init_config,
|
||||||
|
write_config,
|
||||||
|
)
|
||||||
|
from ..config_migrations import migrate_config
|
||||||
|
from ..commands import get_command
|
||||||
|
from ..engines import get_backend, list_backend_ids
|
||||||
|
from ..ids import RESERVED_CHAT_COMMANDS, RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
|
||||||
|
from ..lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
||||||
|
from ..logging import setup_logging
|
||||||
|
from ..runtime_loader import build_runtime_spec, resolve_plugins_allowlist
|
||||||
|
from ..settings import (
|
||||||
|
TakopiSettings,
|
||||||
|
load_settings,
|
||||||
|
load_settings_if_exists,
|
||||||
|
validate_settings_data,
|
||||||
|
)
|
||||||
|
from ..plugins import (
|
||||||
|
COMMAND_GROUP,
|
||||||
|
ENGINE_GROUP,
|
||||||
|
TRANSPORT_GROUP,
|
||||||
|
entrypoint_distribution_name,
|
||||||
|
get_load_errors,
|
||||||
|
is_entrypoint_allowed,
|
||||||
|
list_entrypoints,
|
||||||
|
normalize_allowlist,
|
||||||
|
)
|
||||||
|
from ..transports import get_transport
|
||||||
|
from ..utils.git import resolve_default_base, resolve_main_worktree_root
|
||||||
|
from ..telegram import onboarding
|
||||||
|
from ..telegram.client import TelegramClient
|
||||||
|
from ..telegram.topics import _validate_topics_setup_for
|
||||||
|
from .doctor import (
|
||||||
|
DoctorCheck,
|
||||||
|
DoctorStatus,
|
||||||
|
_doctor_file_checks,
|
||||||
|
_doctor_telegram_checks,
|
||||||
|
_doctor_voice_checks,
|
||||||
|
run_doctor,
|
||||||
|
)
|
||||||
|
from .init import (
|
||||||
|
_default_alias_from_path,
|
||||||
|
_ensure_projects_table,
|
||||||
|
_prompt_alias,
|
||||||
|
run_init,
|
||||||
|
)
|
||||||
|
from .onboarding_cmd import chat_id, onboarding_paths
|
||||||
|
from .plugins import plugins_cmd
|
||||||
|
from .run import (
|
||||||
|
_default_engine_for_setup,
|
||||||
|
_print_version_and_exit,
|
||||||
|
_resolve_setup_engine,
|
||||||
|
_resolve_transport_id,
|
||||||
|
_run_auto_router,
|
||||||
|
_setup_needs_config,
|
||||||
|
_should_run_interactive,
|
||||||
|
_version_callback,
|
||||||
|
acquire_config_lock,
|
||||||
|
app_main,
|
||||||
|
make_engine_cmd,
|
||||||
|
)
|
||||||
|
from .config import (
|
||||||
|
_CONFIG_PATH_OPTION,
|
||||||
|
_config_path_display,
|
||||||
|
_exit_config_error,
|
||||||
|
_fail_missing_config,
|
||||||
|
_flatten_config,
|
||||||
|
_load_config_or_exit,
|
||||||
|
_normalized_value_from_settings,
|
||||||
|
_parse_key_path,
|
||||||
|
_parse_value,
|
||||||
|
_resolve_config_path_override,
|
||||||
|
_toml_literal,
|
||||||
|
config_get,
|
||||||
|
config_list,
|
||||||
|
config_path_cmd,
|
||||||
|
config_set,
|
||||||
|
config_unset,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
||||||
|
try:
|
||||||
|
loaded = load_settings_if_exists()
|
||||||
|
except ConfigError:
|
||||||
|
return None, None
|
||||||
|
if loaded is None:
|
||||||
|
return None, None
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def init(
|
||||||
|
alias: str | None = typer.Argument(
|
||||||
|
None, help="Project alias (used as /alias in messages)."
|
||||||
|
),
|
||||||
|
default: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--default",
|
||||||
|
help="Set this project as the default_project.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Register the current repo as a Takopi project."""
|
||||||
|
run_init(
|
||||||
|
alias=alias,
|
||||||
|
default=default,
|
||||||
|
load_or_init_config_fn=load_or_init_config,
|
||||||
|
resolve_main_worktree_root_fn=resolve_main_worktree_root,
|
||||||
|
resolve_default_base_fn=resolve_default_base,
|
||||||
|
list_backend_ids_fn=list_backend_ids,
|
||||||
|
resolve_plugins_allowlist_fn=resolve_plugins_allowlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def doctor() -> None:
|
||||||
|
"""Run configuration checks for the active transport."""
|
||||||
|
setup_logging(debug=False, cache_logger_on_first_use=False)
|
||||||
|
run_doctor(
|
||||||
|
load_settings_fn=load_settings,
|
||||||
|
telegram_checks=_doctor_telegram_checks,
|
||||||
|
file_checks=_doctor_file_checks,
|
||||||
|
voice_checks=_doctor_voice_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _engine_ids_for_cli() -> list[str]:
|
||||||
|
allowlist: list[str] | None = None
|
||||||
|
try:
|
||||||
|
config, _ = load_or_init_config()
|
||||||
|
except ConfigError:
|
||||||
|
return list_backend_ids()
|
||||||
|
raw_plugins = config.get("plugins")
|
||||||
|
if isinstance(raw_plugins, dict):
|
||||||
|
enabled = raw_plugins.get("enabled")
|
||||||
|
if isinstance(enabled, list):
|
||||||
|
allowlist = [
|
||||||
|
value.strip()
|
||||||
|
for value in enabled
|
||||||
|
if isinstance(value, str) and value.strip()
|
||||||
|
]
|
||||||
|
if not allowlist:
|
||||||
|
allowlist = None
|
||||||
|
return list_backend_ids(allowlist=allowlist)
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> typer.Typer:
|
||||||
|
app = typer.Typer(
|
||||||
|
add_completion=False,
|
||||||
|
invoke_without_command=True,
|
||||||
|
help="Telegram bridge for coding agents. Docs: https://takopi.dev/",
|
||||||
|
)
|
||||||
|
config_app = typer.Typer(help="Read and modify takopi config.")
|
||||||
|
config_app.command(name="path")(config_path_cmd)
|
||||||
|
config_app.command(name="list")(config_list)
|
||||||
|
config_app.command(name="get")(config_get)
|
||||||
|
config_app.command(name="set")(config_set)
|
||||||
|
config_app.command(name="unset")(config_unset)
|
||||||
|
app.command(name="init")(init)
|
||||||
|
app.command(name="chat-id")(chat_id)
|
||||||
|
app.command(name="doctor")(doctor)
|
||||||
|
app.command(name="onboarding-paths")(onboarding_paths)
|
||||||
|
app.command(name="plugins")(plugins_cmd)
|
||||||
|
app.add_typer(config_app, name="config")
|
||||||
|
app.callback()(app_main)
|
||||||
|
for engine_id in _engine_ids_for_cli():
|
||||||
|
help_text = f"Run with the {engine_id} engine."
|
||||||
|
app.command(name=engine_id, help=help_text)(make_engine_cmd(engine_id))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
app = create_app()
|
||||||
|
app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from ..config import (
|
||||||
|
ConfigError,
|
||||||
|
HOME_CONFIG_PATH,
|
||||||
|
dump_toml,
|
||||||
|
read_config,
|
||||||
|
write_config,
|
||||||
|
)
|
||||||
|
from ..config_migrations import migrate_config
|
||||||
|
from ..settings import TakopiSettings, validate_settings_data
|
||||||
|
|
||||||
|
_KEY_SEGMENT_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
||||||
|
_MISSING = object()
|
||||||
|
_CONFIG_PATH_OPTION = typer.Option(
|
||||||
|
None,
|
||||||
|
"--config-path",
|
||||||
|
help="Override the default config path.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path_display(path: Path) -> str:
|
||||||
|
home = Path.home()
|
||||||
|
try:
|
||||||
|
return f"~/{path.relative_to(home)}"
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_missing_config(path: Path) -> None:
|
||||||
|
display = _config_path_display(path)
|
||||||
|
if path.exists():
|
||||||
|
typer.echo(f"error: invalid takopi config at {display}", err=True)
|
||||||
|
else:
|
||||||
|
typer.echo(f"error: missing takopi config at {display}", err=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_config_path_override(value: Path | None) -> Path:
|
||||||
|
if value is None:
|
||||||
|
return _resolve_home_config_path()
|
||||||
|
return value.expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_home_config_path() -> Path:
|
||||||
|
cli_module = sys.modules.get("takopi.cli")
|
||||||
|
if cli_module is not None:
|
||||||
|
override = getattr(cli_module, "HOME_CONFIG_PATH", None)
|
||||||
|
if override is not None:
|
||||||
|
return Path(override)
|
||||||
|
return HOME_CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _exit_config_error(exc: ConfigError, *, code: int = 2) -> None:
|
||||||
|
typer.echo(f"error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=code) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_key_path(raw: str) -> list[str]:
|
||||||
|
value = raw.strip()
|
||||||
|
if not value:
|
||||||
|
raise ConfigError("Invalid key path; expected a non-empty value.")
|
||||||
|
segments = value.split(".")
|
||||||
|
for segment in segments:
|
||||||
|
if not segment:
|
||||||
|
raise ConfigError(f"Invalid key path {raw!r}; empty segment.")
|
||||||
|
if not _KEY_SEGMENT_RE.fullmatch(segment):
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid key segment {segment!r} in {raw!r}; "
|
||||||
|
"use only letters, numbers, '_' or '-'."
|
||||||
|
)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_value(raw: str) -> Any:
|
||||||
|
value = raw.strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return tomllib.loads(f"__v__ = {value}")["__v__"]
|
||||||
|
except tomllib.TOMLDecodeError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _toml_literal(value: Any) -> str:
|
||||||
|
dumped = dump_toml({"__v__": value})
|
||||||
|
prefix = "__v__ = "
|
||||||
|
if dumped.startswith(prefix):
|
||||||
|
return dumped[len(prefix) :].rstrip("\n")
|
||||||
|
raise ConfigError("Unsupported config value; unable to render TOML literal.")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalized_value_from_settings(
|
||||||
|
settings: TakopiSettings, segments: list[str]
|
||||||
|
) -> Any:
|
||||||
|
node: Any = settings
|
||||||
|
for segment in segments:
|
||||||
|
if isinstance(node, BaseModel):
|
||||||
|
if segment in node.__class__.model_fields:
|
||||||
|
node = getattr(node, segment)
|
||||||
|
else:
|
||||||
|
extra = node.model_extra or {}
|
||||||
|
node = extra.get(segment, _MISSING)
|
||||||
|
elif isinstance(node, dict):
|
||||||
|
node = node.get(segment, _MISSING)
|
||||||
|
else:
|
||||||
|
return _MISSING
|
||||||
|
if node is _MISSING:
|
||||||
|
return _MISSING
|
||||||
|
if isinstance(node, BaseModel):
|
||||||
|
return node.model_dump(exclude_unset=True)
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_config(config: dict[str, Any]) -> list[tuple[str, Any]]:
|
||||||
|
items: list[tuple[str, Any]] = []
|
||||||
|
|
||||||
|
def _walk(node: Any, prefix: str) -> None:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
for key in sorted(node):
|
||||||
|
value = node[key]
|
||||||
|
path = f"{prefix}.{key}" if prefix else key
|
||||||
|
if isinstance(value, dict):
|
||||||
|
_walk(value, path)
|
||||||
|
else:
|
||||||
|
items.append((path, value))
|
||||||
|
elif prefix:
|
||||||
|
items.append((prefix, node))
|
||||||
|
|
||||||
|
_walk(config, "")
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config_or_exit(path: Path, *, missing_code: int) -> dict[str, Any]:
|
||||||
|
if not path.exists():
|
||||||
|
_fail_missing_config(path)
|
||||||
|
raise typer.Exit(code=missing_code)
|
||||||
|
try:
|
||||||
|
return read_config(path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def config_path_cmd(
|
||||||
|
config_path: Path | None = _CONFIG_PATH_OPTION,
|
||||||
|
) -> None:
|
||||||
|
"""Print the resolved config path."""
|
||||||
|
path = _resolve_config_path_override(config_path)
|
||||||
|
typer.echo(_config_path_display(path))
|
||||||
|
|
||||||
|
|
||||||
|
def config_list(
|
||||||
|
config_path: Path | None = _CONFIG_PATH_OPTION,
|
||||||
|
) -> None:
|
||||||
|
"""List config keys as flattened dot-paths."""
|
||||||
|
path = _resolve_config_path_override(config_path)
|
||||||
|
config = _load_config_or_exit(path, missing_code=1)
|
||||||
|
try:
|
||||||
|
for key, value in _flatten_config(config):
|
||||||
|
literal = _toml_literal(value)
|
||||||
|
typer.echo(f"{key} = {literal}")
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def config_get(
|
||||||
|
key: str = typer.Argument(..., help="Dot-path key to fetch."),
|
||||||
|
config_path: Path | None = _CONFIG_PATH_OPTION,
|
||||||
|
) -> None:
|
||||||
|
"""Fetch a single config key."""
|
||||||
|
path = _resolve_config_path_override(config_path)
|
||||||
|
config = _load_config_or_exit(path, missing_code=2)
|
||||||
|
try:
|
||||||
|
segments = _parse_key_path(key)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
node: Any = config
|
||||||
|
for index, segment in enumerate(segments):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
prefix = ".".join(segments[:index])
|
||||||
|
_exit_config_error(
|
||||||
|
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
|
||||||
|
)
|
||||||
|
if segment not in node:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
node = node[segment]
|
||||||
|
|
||||||
|
if isinstance(node, dict):
|
||||||
|
typer.echo(
|
||||||
|
f"error: {'.'.join(segments)!r} is a table; pick a leaf node.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise typer.Exit(code=2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
typer.echo(_toml_literal(node))
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def config_set(
|
||||||
|
key: str = typer.Argument(..., help="Dot-path key to set."),
|
||||||
|
value: str = typer.Argument(..., help="Value to assign (auto-parsed)."),
|
||||||
|
config_path: Path | None = _CONFIG_PATH_OPTION,
|
||||||
|
) -> None:
|
||||||
|
"""Set a config value."""
|
||||||
|
path = _resolve_config_path_override(config_path)
|
||||||
|
config = _load_config_or_exit(path, missing_code=2)
|
||||||
|
try:
|
||||||
|
segments = _parse_key_path(key)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_config(config, config_path=path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
parsed = _parse_value(value)
|
||||||
|
node: Any = config
|
||||||
|
for index, segment in enumerate(segments[:-1]):
|
||||||
|
next_node = node.get(segment)
|
||||||
|
if next_node is None:
|
||||||
|
created: dict[str, Any] = {}
|
||||||
|
node[segment] = created
|
||||||
|
node = created
|
||||||
|
continue
|
||||||
|
if not isinstance(next_node, dict):
|
||||||
|
prefix = ".".join(segments[: index + 1])
|
||||||
|
_exit_config_error(
|
||||||
|
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
|
||||||
|
)
|
||||||
|
node = next_node
|
||||||
|
node[segments[-1]] = parsed
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = validate_settings_data(config, config_path=path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
normalized = _normalized_value_from_settings(settings, segments)
|
||||||
|
if normalized is not _MISSING:
|
||||||
|
node[segments[-1]] = normalized
|
||||||
|
parsed = normalized
|
||||||
|
|
||||||
|
try:
|
||||||
|
write_config(config, path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
rendered = _toml_literal(parsed)
|
||||||
|
typer.echo(f"updated {'.'.join(segments)} = {rendered}")
|
||||||
|
|
||||||
|
|
||||||
|
def config_unset(
|
||||||
|
key: str = typer.Argument(..., help="Dot-path key to remove."),
|
||||||
|
config_path: Path | None = _CONFIG_PATH_OPTION,
|
||||||
|
) -> None:
|
||||||
|
"""Remove a config key."""
|
||||||
|
path = _resolve_config_path_override(config_path)
|
||||||
|
config = _load_config_or_exit(path, missing_code=2)
|
||||||
|
try:
|
||||||
|
segments = _parse_key_path(key)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_config(config, config_path=path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
|
|
||||||
|
node: Any = config
|
||||||
|
stack: list[tuple[dict[str, Any], str]] = []
|
||||||
|
for index, segment in enumerate(segments[:-1]):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
prefix = ".".join(segments[:index])
|
||||||
|
_exit_config_error(
|
||||||
|
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
|
||||||
|
)
|
||||||
|
next_node = node.get(segment)
|
||||||
|
if next_node is None:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if not isinstance(next_node, dict):
|
||||||
|
prefix = ".".join(segments[: index + 1])
|
||||||
|
_exit_config_error(
|
||||||
|
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
|
||||||
|
)
|
||||||
|
stack.append((node, segment))
|
||||||
|
node = next_node
|
||||||
|
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
prefix = ".".join(segments[:-1])
|
||||||
|
_exit_config_error(
|
||||||
|
ConfigError(f"Invalid `{prefix}` in {path}; expected a table.")
|
||||||
|
)
|
||||||
|
leaf = segments[-1]
|
||||||
|
if leaf not in node:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
node.pop(leaf, None)
|
||||||
|
|
||||||
|
while stack and not node:
|
||||||
|
parent, key_name = stack.pop()
|
||||||
|
parent.pop(key_name, None)
|
||||||
|
node = parent
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_settings_data(config, config_path=path)
|
||||||
|
write_config(config, path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
_exit_config_error(exc)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from ..config import ConfigError
|
||||||
|
from ..engines import list_backend_ids
|
||||||
|
from ..ids import RESERVED_CHAT_COMMANDS
|
||||||
|
from ..runtime_loader import resolve_plugins_allowlist
|
||||||
|
from ..settings import TakopiSettings, TelegramTopicsSettings
|
||||||
|
from ..telegram.client import TelegramClient
|
||||||
|
from ..telegram.topics import _validate_topics_setup_for
|
||||||
|
|
||||||
|
DoctorStatus = Literal["ok", "warning", "error"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DoctorCheck:
|
||||||
|
label: str
|
||||||
|
status: DoctorStatus
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
if self.detail:
|
||||||
|
return f"- {self.label}: {self.status} ({self.detail})"
|
||||||
|
return f"- {self.label}: {self.status}"
|
||||||
|
|
||||||
|
|
||||||
|
def _doctor_file_checks(settings: TakopiSettings) -> list[DoctorCheck]:
|
||||||
|
files = settings.transports.telegram.files
|
||||||
|
if not files.enabled:
|
||||||
|
return [DoctorCheck("file transfer", "ok", "disabled")]
|
||||||
|
if files.allowed_user_ids:
|
||||||
|
count = len(files.allowed_user_ids)
|
||||||
|
detail = f"restricted to {count} user id(s)"
|
||||||
|
return [DoctorCheck("file transfer", "ok", detail)]
|
||||||
|
return [DoctorCheck("file transfer", "warning", "enabled for all users")]
|
||||||
|
|
||||||
|
|
||||||
|
def _doctor_voice_checks(settings: TakopiSettings) -> list[DoctorCheck]:
|
||||||
|
if not settings.transports.telegram.voice_transcription:
|
||||||
|
return [DoctorCheck("voice transcription", "ok", "disabled")]
|
||||||
|
if os.environ.get("OPENAI_API_KEY"):
|
||||||
|
return [DoctorCheck("voice transcription", "ok", "OPENAI_API_KEY set")]
|
||||||
|
return [DoctorCheck("voice transcription", "error", "OPENAI_API_KEY not set")]
|
||||||
|
|
||||||
|
|
||||||
|
async def _doctor_telegram_checks(
|
||||||
|
token: str,
|
||||||
|
chat_id: int,
|
||||||
|
topics: TelegramTopicsSettings,
|
||||||
|
project_chat_ids: tuple[int, ...],
|
||||||
|
) -> list[DoctorCheck]:
|
||||||
|
checks: list[DoctorCheck] = []
|
||||||
|
client_factory = _resolve_cli_attr("TelegramClient") or TelegramClient
|
||||||
|
validate_topics = (
|
||||||
|
_resolve_cli_attr("_validate_topics_setup_for") or _validate_topics_setup_for
|
||||||
|
)
|
||||||
|
bot = client_factory(token)
|
||||||
|
try:
|
||||||
|
me = await bot.get_me()
|
||||||
|
if me is None:
|
||||||
|
checks.append(
|
||||||
|
DoctorCheck("telegram token", "error", "failed to fetch bot info")
|
||||||
|
)
|
||||||
|
checks.append(DoctorCheck("chat_id", "error", "skipped (token invalid)"))
|
||||||
|
if topics.enabled:
|
||||||
|
checks.append(DoctorCheck("topics", "error", "skipped (token invalid)"))
|
||||||
|
else:
|
||||||
|
checks.append(DoctorCheck("topics", "ok", "disabled"))
|
||||||
|
return checks
|
||||||
|
bot_label = f"@{me.username}" if me.username else f"id={me.id}"
|
||||||
|
checks.append(DoctorCheck("telegram token", "ok", bot_label))
|
||||||
|
chat = await bot.get_chat(chat_id)
|
||||||
|
if chat is None:
|
||||||
|
checks.append(DoctorCheck("chat_id", "error", f"unreachable ({chat_id})"))
|
||||||
|
else:
|
||||||
|
checks.append(DoctorCheck("chat_id", "ok", f"{chat.type} ({chat_id})"))
|
||||||
|
if topics.enabled:
|
||||||
|
try:
|
||||||
|
await validate_topics(
|
||||||
|
bot=bot,
|
||||||
|
topics=topics,
|
||||||
|
chat_id=chat_id,
|
||||||
|
project_chat_ids=project_chat_ids,
|
||||||
|
)
|
||||||
|
checks.append(DoctorCheck("topics", "ok", f"scope={topics.scope}"))
|
||||||
|
except ConfigError as exc:
|
||||||
|
checks.append(DoctorCheck("topics", "error", str(exc)))
|
||||||
|
else:
|
||||||
|
checks.append(DoctorCheck("topics", "ok", "disabled"))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
checks.append(DoctorCheck("telegram", "error", str(exc)))
|
||||||
|
finally:
|
||||||
|
await bot.close()
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def run_doctor(
|
||||||
|
*,
|
||||||
|
load_settings_fn: Callable[[], tuple[TakopiSettings, Path]],
|
||||||
|
telegram_checks: Callable[
|
||||||
|
[str, int, TelegramTopicsSettings, tuple[int, ...]],
|
||||||
|
Awaitable[list[DoctorCheck]],
|
||||||
|
],
|
||||||
|
file_checks: Callable[[TakopiSettings], list[DoctorCheck]],
|
||||||
|
voice_checks: Callable[[TakopiSettings], list[DoctorCheck]],
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
settings, config_path = load_settings_fn()
|
||||||
|
except ConfigError as exc:
|
||||||
|
typer.echo(f"error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
if settings.transport != "telegram":
|
||||||
|
typer.echo(
|
||||||
|
"error: takopi doctor currently supports the telegram transport only.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
allowlist = resolve_plugins_allowlist(settings)
|
||||||
|
engine_ids = list_backend_ids(allowlist=allowlist)
|
||||||
|
try:
|
||||||
|
projects_cfg = settings.to_projects_config(
|
||||||
|
config_path=config_path,
|
||||||
|
engine_ids=engine_ids,
|
||||||
|
reserved=RESERVED_CHAT_COMMANDS,
|
||||||
|
)
|
||||||
|
except ConfigError as exc:
|
||||||
|
typer.echo(f"error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
tg = settings.transports.telegram
|
||||||
|
project_chat_ids = projects_cfg.project_chat_ids()
|
||||||
|
telegram_checks_result = anyio.run(
|
||||||
|
telegram_checks,
|
||||||
|
tg.bot_token,
|
||||||
|
tg.chat_id,
|
||||||
|
tg.topics,
|
||||||
|
project_chat_ids,
|
||||||
|
)
|
||||||
|
if telegram_checks_result is None:
|
||||||
|
telegram_checks_result = []
|
||||||
|
checks = [
|
||||||
|
*telegram_checks_result,
|
||||||
|
*file_checks(settings),
|
||||||
|
*voice_checks(settings),
|
||||||
|
]
|
||||||
|
typer.echo("takopi doctor")
|
||||||
|
for check in checks:
|
||||||
|
typer.echo(check.render())
|
||||||
|
if any(check.status == "error" for check in checks):
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli_attr(name: str) -> object | None:
|
||||||
|
cli_module = sys.modules.get("takopi.cli")
|
||||||
|
if cli_module is None:
|
||||||
|
return None
|
||||||
|
return getattr(cli_module, name, None)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from ..config import ConfigError, write_config
|
||||||
|
from ..config_migrations import migrate_config
|
||||||
|
from ..ids import RESERVED_CHAT_COMMANDS
|
||||||
|
from ..settings import TakopiSettings, validate_settings_data
|
||||||
|
from .config import _config_path_display
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_alias(value: str | None, *, default_alias: str | None = None) -> str:
|
||||||
|
if value is not None:
|
||||||
|
alias = value
|
||||||
|
elif default_alias:
|
||||||
|
alias = typer.prompt("project alias", default=default_alias)
|
||||||
|
else:
|
||||||
|
alias = typer.prompt("project alias")
|
||||||
|
alias = alias.strip()
|
||||||
|
if not alias:
|
||||||
|
typer.echo("error: project alias cannot be empty", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
return alias
|
||||||
|
|
||||||
|
|
||||||
|
def _default_alias_from_path(path: Path) -> str | None:
|
||||||
|
name = path.name
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
name = name.removesuffix(".git")
|
||||||
|
return name or None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_projects_table(config: dict, config_path: Path) -> dict:
|
||||||
|
projects = config.setdefault("projects", {})
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
raise ConfigError(f"Invalid `projects` in {config_path}; expected a table.")
|
||||||
|
return projects
|
||||||
|
|
||||||
|
|
||||||
|
def run_init(
|
||||||
|
*,
|
||||||
|
alias: str | None,
|
||||||
|
default: bool,
|
||||||
|
load_or_init_config_fn: Callable[[], tuple[dict, Path]],
|
||||||
|
resolve_main_worktree_root_fn: Callable[[Path], Path | None],
|
||||||
|
resolve_default_base_fn: Callable[[Path], str | None],
|
||||||
|
list_backend_ids_fn: Callable[..., list[str]],
|
||||||
|
resolve_plugins_allowlist_fn: Callable[[TakopiSettings], list[str] | None],
|
||||||
|
) -> None:
|
||||||
|
config, config_path = load_or_init_config_fn()
|
||||||
|
if config_path.exists():
|
||||||
|
applied = migrate_config(config, config_path=config_path)
|
||||||
|
if applied:
|
||||||
|
write_config(config, config_path)
|
||||||
|
|
||||||
|
cwd = Path.cwd()
|
||||||
|
project_path = resolve_main_worktree_root_fn(cwd) or cwd
|
||||||
|
default_alias = _default_alias_from_path(project_path)
|
||||||
|
alias = _prompt_alias(alias, default_alias=default_alias)
|
||||||
|
|
||||||
|
settings = validate_settings_data(config, config_path=config_path)
|
||||||
|
allowlist = resolve_plugins_allowlist_fn(settings)
|
||||||
|
engine_ids = list_backend_ids_fn(allowlist=allowlist)
|
||||||
|
projects_cfg = settings.to_projects_config(
|
||||||
|
config_path=config_path,
|
||||||
|
engine_ids=engine_ids,
|
||||||
|
reserved=RESERVED_CHAT_COMMANDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
alias_key = alias.lower()
|
||||||
|
if alias_key in {engine.lower() for engine in engine_ids}:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid project alias {alias!r}; aliases must not match engine ids."
|
||||||
|
)
|
||||||
|
if alias_key in RESERVED_CHAT_COMMANDS:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid project alias {alias!r}; aliases must not match reserved commands."
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = projects_cfg.projects.get(alias_key)
|
||||||
|
if existing is not None:
|
||||||
|
overwrite = typer.confirm(
|
||||||
|
f"project {existing.alias!r} already exists, overwrite?",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
if not overwrite:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
|
projects = _ensure_projects_table(config, config_path)
|
||||||
|
if existing is not None and existing.alias in projects:
|
||||||
|
projects.pop(existing.alias, None)
|
||||||
|
|
||||||
|
default_engine = settings.default_engine
|
||||||
|
worktree_base = resolve_default_base_fn(project_path)
|
||||||
|
|
||||||
|
entry: dict[str, object] = {
|
||||||
|
"path": str(project_path),
|
||||||
|
"worktrees_dir": ".worktrees",
|
||||||
|
"default_engine": default_engine,
|
||||||
|
}
|
||||||
|
if worktree_base:
|
||||||
|
entry["worktree_base"] = worktree_base
|
||||||
|
|
||||||
|
projects[alias] = entry
|
||||||
|
if default:
|
||||||
|
config["default_project"] = alias
|
||||||
|
|
||||||
|
write_config(config, config_path)
|
||||||
|
typer.echo(f"saved project {alias!r} to {_config_path_display(config_path)}")
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from ..config import ConfigError, load_or_init_config, write_config
|
||||||
|
from ..config_migrations import migrate_config
|
||||||
|
from ..logging import setup_logging
|
||||||
|
from ..settings import TakopiSettings
|
||||||
|
from ..telegram import onboarding
|
||||||
|
from .init import _ensure_projects_table
|
||||||
|
from .run import _load_settings_optional
|
||||||
|
|
||||||
|
|
||||||
|
def chat_id(
|
||||||
|
token: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--token",
|
||||||
|
help="Telegram bot token (defaults to config if available).",
|
||||||
|
),
|
||||||
|
project: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--project",
|
||||||
|
help="Project alias to print a chat_id snippet for.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Capture a Telegram chat id and exit."""
|
||||||
|
setup_logging_fn = cast(
|
||||||
|
Callable[..., None],
|
||||||
|
_resolve_cli_attr("setup_logging") or setup_logging,
|
||||||
|
)
|
||||||
|
load_settings_optional_fn = cast(
|
||||||
|
Callable[[], tuple[TakopiSettings | None, Path | None]],
|
||||||
|
_resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
|
||||||
|
)
|
||||||
|
onboarding_mod = cast(
|
||||||
|
Any,
|
||||||
|
_resolve_cli_attr("onboarding") or onboarding,
|
||||||
|
)
|
||||||
|
load_or_init_config_fn = cast(
|
||||||
|
Callable[[], tuple[dict, Path]],
|
||||||
|
_resolve_cli_attr("load_or_init_config") or load_or_init_config,
|
||||||
|
)
|
||||||
|
ensure_projects_table_fn = cast(
|
||||||
|
Callable[[dict, Path], dict],
|
||||||
|
_resolve_cli_attr("_ensure_projects_table") or _ensure_projects_table,
|
||||||
|
)
|
||||||
|
migrate_config_fn = cast(
|
||||||
|
Callable[..., object],
|
||||||
|
_resolve_cli_attr("migrate_config") or migrate_config,
|
||||||
|
)
|
||||||
|
write_config_fn = cast(
|
||||||
|
Callable[[dict, Path], None],
|
||||||
|
_resolve_cli_attr("write_config") or write_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
setup_logging_fn(debug=False, cache_logger_on_first_use=False)
|
||||||
|
if token is None:
|
||||||
|
settings, _ = load_settings_optional_fn()
|
||||||
|
if settings is not None:
|
||||||
|
tg = settings.transports.telegram
|
||||||
|
token = tg.bot_token or None
|
||||||
|
chat = anyio.run(partial(onboarding_mod.capture_chat_id, token=token))
|
||||||
|
if chat is None:
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if project:
|
||||||
|
project = project.strip()
|
||||||
|
if not project:
|
||||||
|
raise ConfigError("Invalid `--project`; expected a non-empty string.")
|
||||||
|
|
||||||
|
config, config_path = load_or_init_config_fn()
|
||||||
|
if config_path.exists():
|
||||||
|
applied = migrate_config_fn(config, config_path=config_path)
|
||||||
|
if applied:
|
||||||
|
write_config_fn(config, config_path)
|
||||||
|
|
||||||
|
projects = ensure_projects_table_fn(config, config_path)
|
||||||
|
entry = projects.get(project)
|
||||||
|
if entry is None:
|
||||||
|
lowered = project.lower()
|
||||||
|
for key, value in projects.items():
|
||||||
|
if isinstance(key, str) and key.lower() == lowered:
|
||||||
|
entry = value
|
||||||
|
project = key
|
||||||
|
break
|
||||||
|
if entry is None:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Unknown project {project!r}; run `takopi init {project}` first."
|
||||||
|
)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ConfigError(
|
||||||
|
f"Invalid `projects.{project}` in {config_path}; expected a table."
|
||||||
|
)
|
||||||
|
entry["chat_id"] = chat.chat_id
|
||||||
|
write_config_fn(config, config_path)
|
||||||
|
typer.echo(f"updated projects.{project}.chat_id = {chat.chat_id}")
|
||||||
|
return
|
||||||
|
|
||||||
|
typer.echo(f"chat_id = {chat.chat_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def onboarding_paths() -> None:
|
||||||
|
"""Print all possible onboarding paths."""
|
||||||
|
setup_logging_fn = cast(
|
||||||
|
Callable[..., None],
|
||||||
|
_resolve_cli_attr("setup_logging") or setup_logging,
|
||||||
|
)
|
||||||
|
onboarding_mod = cast(
|
||||||
|
Any,
|
||||||
|
_resolve_cli_attr("onboarding") or onboarding,
|
||||||
|
)
|
||||||
|
setup_logging_fn(debug=False, cache_logger_on_first_use=False)
|
||||||
|
onboarding_mod.debug_onboarding_paths()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli_attr(name: str) -> object | None:
|
||||||
|
cli_module = sys.modules.get("takopi.cli")
|
||||||
|
if cli_module is None:
|
||||||
|
return None
|
||||||
|
return getattr(cli_module, name, None)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from importlib.metadata import EntryPoint
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from ..commands import get_command
|
||||||
|
from ..config import ConfigError
|
||||||
|
from ..engines import get_backend
|
||||||
|
from ..ids import RESERVED_COMMAND_IDS, RESERVED_ENGINE_IDS
|
||||||
|
from ..plugins import (
|
||||||
|
COMMAND_GROUP,
|
||||||
|
ENGINE_GROUP,
|
||||||
|
PluginLoadError,
|
||||||
|
TRANSPORT_GROUP,
|
||||||
|
entrypoint_distribution_name,
|
||||||
|
get_load_errors,
|
||||||
|
is_entrypoint_allowed,
|
||||||
|
list_entrypoints,
|
||||||
|
normalize_allowlist,
|
||||||
|
)
|
||||||
|
from ..runtime_loader import resolve_plugins_allowlist
|
||||||
|
from ..settings import TakopiSettings, load_settings_if_exists
|
||||||
|
from ..transports import get_transport
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
||||||
|
try:
|
||||||
|
loaded = load_settings_if_exists()
|
||||||
|
except ConfigError:
|
||||||
|
return None, None
|
||||||
|
if loaded is None:
|
||||||
|
return None, None
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _print_entrypoints(
|
||||||
|
label: str,
|
||||||
|
entrypoints: list[EntryPoint],
|
||||||
|
*,
|
||||||
|
allowlist: set[str] | None,
|
||||||
|
entrypoint_distribution_name_fn: Callable[[EntryPoint], str | None],
|
||||||
|
is_entrypoint_allowed_fn: Callable[[EntryPoint, set[str] | None], bool],
|
||||||
|
) -> None:
|
||||||
|
typer.echo(f"{label}:")
|
||||||
|
if not entrypoints:
|
||||||
|
typer.echo(" (none)")
|
||||||
|
return
|
||||||
|
for ep in entrypoints:
|
||||||
|
dist = entrypoint_distribution_name_fn(ep) or "unknown"
|
||||||
|
status = ""
|
||||||
|
if allowlist is not None:
|
||||||
|
allowed = is_entrypoint_allowed_fn(ep, allowlist)
|
||||||
|
status = " enabled" if allowed else " disabled"
|
||||||
|
typer.echo(f" {ep.name} ({dist}){status}")
|
||||||
|
|
||||||
|
|
||||||
|
def plugins_cmd(
|
||||||
|
load: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--load/--no-load",
|
||||||
|
help="Load plugins to validate and surface import errors.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""List discovered plugins and optionally validate them."""
|
||||||
|
load_settings_optional = cast(
|
||||||
|
Callable[[], tuple[TakopiSettings | None, Path | None]],
|
||||||
|
_resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
|
||||||
|
)
|
||||||
|
resolve_plugins_allowlist_fn = cast(
|
||||||
|
Callable[[TakopiSettings | None], list[str] | None],
|
||||||
|
_resolve_cli_attr("resolve_plugins_allowlist") or resolve_plugins_allowlist,
|
||||||
|
)
|
||||||
|
list_entrypoints_fn = cast(
|
||||||
|
Callable[..., list[EntryPoint]],
|
||||||
|
_resolve_cli_attr("list_entrypoints") or list_entrypoints,
|
||||||
|
)
|
||||||
|
get_backend_fn = cast(
|
||||||
|
Callable[..., object],
|
||||||
|
_resolve_cli_attr("get_backend") or get_backend,
|
||||||
|
)
|
||||||
|
get_transport_fn = cast(
|
||||||
|
Callable[..., object],
|
||||||
|
_resolve_cli_attr("get_transport") or get_transport,
|
||||||
|
)
|
||||||
|
get_command_fn = cast(
|
||||||
|
Callable[..., object],
|
||||||
|
_resolve_cli_attr("get_command") or get_command,
|
||||||
|
)
|
||||||
|
get_load_errors_fn = cast(
|
||||||
|
Callable[[], tuple[PluginLoadError, ...]],
|
||||||
|
_resolve_cli_attr("get_load_errors") or get_load_errors,
|
||||||
|
)
|
||||||
|
entrypoint_distribution_name_fn = cast(
|
||||||
|
Callable[[EntryPoint], str | None],
|
||||||
|
_resolve_cli_attr("entrypoint_distribution_name")
|
||||||
|
or entrypoint_distribution_name,
|
||||||
|
)
|
||||||
|
is_entrypoint_allowed_fn = cast(
|
||||||
|
Callable[[EntryPoint, set[str] | None], bool],
|
||||||
|
_resolve_cli_attr("is_entrypoint_allowed") or is_entrypoint_allowed,
|
||||||
|
)
|
||||||
|
normalize_allowlist_fn = cast(
|
||||||
|
Callable[[list[str] | None], set[str] | None],
|
||||||
|
_resolve_cli_attr("normalize_allowlist") or normalize_allowlist,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_hint, _ = load_settings_optional()
|
||||||
|
allowlist = resolve_plugins_allowlist_fn(settings_hint)
|
||||||
|
|
||||||
|
allowlist_set = normalize_allowlist_fn(allowlist)
|
||||||
|
engine_eps = list_entrypoints_fn(
|
||||||
|
ENGINE_GROUP,
|
||||||
|
reserved_ids=RESERVED_ENGINE_IDS,
|
||||||
|
)
|
||||||
|
transport_eps = list_entrypoints_fn(TRANSPORT_GROUP)
|
||||||
|
command_eps = list_entrypoints_fn(
|
||||||
|
COMMAND_GROUP,
|
||||||
|
reserved_ids=RESERVED_COMMAND_IDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
_print_entrypoints(
|
||||||
|
"engine backends",
|
||||||
|
engine_eps,
|
||||||
|
allowlist=allowlist_set,
|
||||||
|
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
||||||
|
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
||||||
|
)
|
||||||
|
_print_entrypoints(
|
||||||
|
"transport backends",
|
||||||
|
transport_eps,
|
||||||
|
allowlist=allowlist_set,
|
||||||
|
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
||||||
|
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
||||||
|
)
|
||||||
|
_print_entrypoints(
|
||||||
|
"command backends",
|
||||||
|
command_eps,
|
||||||
|
allowlist=allowlist_set,
|
||||||
|
entrypoint_distribution_name_fn=entrypoint_distribution_name_fn,
|
||||||
|
is_entrypoint_allowed_fn=is_entrypoint_allowed_fn,
|
||||||
|
)
|
||||||
|
|
||||||
|
if load:
|
||||||
|
for ep in engine_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_backend_fn(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
for ep in transport_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_transport_fn(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
for ep in command_eps:
|
||||||
|
if allowlist_set is not None and not is_entrypoint_allowed_fn(
|
||||||
|
ep, allowlist_set
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
get_command_fn(ep.name, allowlist=allowlist)
|
||||||
|
except ConfigError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
errors = get_load_errors_fn()
|
||||||
|
if errors:
|
||||||
|
typer.echo("errors:")
|
||||||
|
for err in errors:
|
||||||
|
group = err.group
|
||||||
|
if group == ENGINE_GROUP:
|
||||||
|
group = "engine"
|
||||||
|
elif group == TRANSPORT_GROUP:
|
||||||
|
group = "transport"
|
||||||
|
elif group == COMMAND_GROUP:
|
||||||
|
group = "command"
|
||||||
|
dist = err.distribution or "unknown"
|
||||||
|
typer.echo(f" {group} {err.name} ({dist}): {err.error}")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli_attr(name: str) -> object | None:
|
||||||
|
cli_module = sys.modules.get("takopi.cli")
|
||||||
|
if cli_module is None:
|
||||||
|
return None
|
||||||
|
return getattr(cli_module, name, None)
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import typer
|
||||||
|
|
||||||
|
from .. import __version__
|
||||||
|
from ..backends import EngineBackend
|
||||||
|
from ..config import ConfigError, load_or_init_config
|
||||||
|
from ..engines import get_backend
|
||||||
|
from ..ids import RESERVED_CHAT_COMMANDS
|
||||||
|
from ..lockfile import LockError, LockHandle, acquire_lock, token_fingerprint
|
||||||
|
from ..logging import get_logger, setup_logging
|
||||||
|
from ..runtime_loader import build_runtime_spec, resolve_plugins_allowlist
|
||||||
|
from ..settings import TakopiSettings, load_settings, load_settings_if_exists
|
||||||
|
from ..transports import SetupResult, get_transport
|
||||||
|
from .config import _config_path_display, _fail_missing_config
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_settings_optional() -> tuple[TakopiSettings | None, Path | None]:
|
||||||
|
try:
|
||||||
|
loaded = load_settings_if_exists()
|
||||||
|
except ConfigError:
|
||||||
|
return None, None
|
||||||
|
if loaded is None:
|
||||||
|
return None, None
|
||||||
|
return loaded
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_transport_id(override: str | None) -> str:
|
||||||
|
if override is not None:
|
||||||
|
value = override.strip()
|
||||||
|
if not value:
|
||||||
|
raise ConfigError("Invalid `--transport`; expected a non-empty string.")
|
||||||
|
return value
|
||||||
|
load_or_init_config_fn = cast(
|
||||||
|
Callable[[], tuple[dict, Path]],
|
||||||
|
_resolve_cli_attr("load_or_init_config") or load_or_init_config,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
config, _ = load_or_init_config_fn()
|
||||||
|
except ConfigError:
|
||||||
|
return "telegram"
|
||||||
|
raw = config.get("transport")
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
return "telegram"
|
||||||
|
return raw.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_config_lock(config_path: Path, token: str | None) -> LockHandle:
|
||||||
|
fingerprint = token_fingerprint(token) if token else None
|
||||||
|
acquire_lock_fn = cast(
|
||||||
|
Callable[..., LockHandle],
|
||||||
|
_resolve_cli_attr("acquire_lock") or acquire_lock,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return acquire_lock_fn(
|
||||||
|
config_path=config_path,
|
||||||
|
token_fingerprint=fingerprint,
|
||||||
|
)
|
||||||
|
except LockError as exc:
|
||||||
|
lines = str(exc).splitlines()
|
||||||
|
if lines:
|
||||||
|
typer.echo(lines[0], err=True)
|
||||||
|
if len(lines) > 1:
|
||||||
|
typer.echo("\n".join(lines[1:]), err=True)
|
||||||
|
else:
|
||||||
|
typer.echo("error: unknown error", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _default_engine_for_setup(
|
||||||
|
override: str | None,
|
||||||
|
*,
|
||||||
|
settings: TakopiSettings | None,
|
||||||
|
config_path: Path | None,
|
||||||
|
) -> str:
|
||||||
|
if override:
|
||||||
|
return override
|
||||||
|
if settings is None or config_path is None:
|
||||||
|
return "codex"
|
||||||
|
value = settings.default_engine
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_setup_engine(
|
||||||
|
default_engine_override: str | None,
|
||||||
|
) -> tuple[
|
||||||
|
TakopiSettings | None,
|
||||||
|
Path | None,
|
||||||
|
list[str] | None,
|
||||||
|
str,
|
||||||
|
EngineBackend,
|
||||||
|
]:
|
||||||
|
load_settings_optional_fn = cast(
|
||||||
|
Callable[[], tuple[TakopiSettings | None, Path | None]],
|
||||||
|
_resolve_cli_attr("_load_settings_optional") or _load_settings_optional,
|
||||||
|
)
|
||||||
|
resolve_plugins_allowlist_fn = cast(
|
||||||
|
Callable[[TakopiSettings | None], list[str] | None],
|
||||||
|
_resolve_cli_attr("resolve_plugins_allowlist") or resolve_plugins_allowlist,
|
||||||
|
)
|
||||||
|
default_engine_for_setup_fn = cast(
|
||||||
|
Callable[..., str],
|
||||||
|
_resolve_cli_attr("_default_engine_for_setup") or _default_engine_for_setup,
|
||||||
|
)
|
||||||
|
get_backend_fn = cast(
|
||||||
|
Callable[..., EngineBackend],
|
||||||
|
_resolve_cli_attr("get_backend") or get_backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
settings_hint, config_hint = load_settings_optional_fn()
|
||||||
|
allowlist = resolve_plugins_allowlist_fn(settings_hint)
|
||||||
|
default_engine = default_engine_for_setup_fn(
|
||||||
|
default_engine_override,
|
||||||
|
settings=settings_hint,
|
||||||
|
config_path=config_hint,
|
||||||
|
)
|
||||||
|
engine_backend = get_backend_fn(default_engine, allowlist=allowlist)
|
||||||
|
return settings_hint, config_hint, allowlist, default_engine, engine_backend
|
||||||
|
|
||||||
|
|
||||||
|
def _should_run_interactive() -> bool:
|
||||||
|
if os.environ.get("TAKOPI_NO_INTERACTIVE"):
|
||||||
|
return False
|
||||||
|
return sys.stdin.isatty() and sys.stdout.isatty()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_needs_config(setup: SetupResult) -> bool:
|
||||||
|
config_titles = {"create a config", "configure telegram"}
|
||||||
|
return any(issue.title in config_titles for issue in setup.issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_auto_router(
|
||||||
|
*,
|
||||||
|
default_engine_override: str | None,
|
||||||
|
transport_override: str | None,
|
||||||
|
final_notify: bool,
|
||||||
|
debug: bool,
|
||||||
|
onboard: bool,
|
||||||
|
) -> None:
|
||||||
|
setup_logging_fn = cast(
|
||||||
|
Callable[..., None],
|
||||||
|
_resolve_cli_attr("setup_logging") or setup_logging,
|
||||||
|
)
|
||||||
|
resolve_setup_engine_fn = cast(
|
||||||
|
Callable[
|
||||||
|
[str | None],
|
||||||
|
tuple[
|
||||||
|
TakopiSettings | None,
|
||||||
|
Path | None,
|
||||||
|
list[str] | None,
|
||||||
|
str,
|
||||||
|
EngineBackend,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
_resolve_cli_attr("_resolve_setup_engine") or _resolve_setup_engine,
|
||||||
|
)
|
||||||
|
resolve_transport_id_fn = cast(
|
||||||
|
Callable[[str | None], str],
|
||||||
|
_resolve_cli_attr("_resolve_transport_id") or _resolve_transport_id,
|
||||||
|
)
|
||||||
|
get_transport_fn = cast(
|
||||||
|
Callable[..., Any],
|
||||||
|
_resolve_cli_attr("get_transport") or get_transport,
|
||||||
|
)
|
||||||
|
should_run_interactive_fn = cast(
|
||||||
|
Callable[[], bool],
|
||||||
|
_resolve_cli_attr("_should_run_interactive") or _should_run_interactive,
|
||||||
|
)
|
||||||
|
setup_needs_config_fn = cast(
|
||||||
|
Callable[[SetupResult], bool],
|
||||||
|
_resolve_cli_attr("_setup_needs_config") or _setup_needs_config,
|
||||||
|
)
|
||||||
|
config_path_display_fn = cast(
|
||||||
|
Callable[[Path], str],
|
||||||
|
_resolve_cli_attr("_config_path_display") or _config_path_display,
|
||||||
|
)
|
||||||
|
fail_missing_config_fn = cast(
|
||||||
|
Callable[[Path], None],
|
||||||
|
_resolve_cli_attr("_fail_missing_config") or _fail_missing_config,
|
||||||
|
)
|
||||||
|
load_settings_fn = cast(
|
||||||
|
Callable[[], tuple[TakopiSettings, Path]],
|
||||||
|
_resolve_cli_attr("load_settings") or load_settings,
|
||||||
|
)
|
||||||
|
build_runtime_spec_fn = cast(
|
||||||
|
Callable[..., Any],
|
||||||
|
_resolve_cli_attr("build_runtime_spec") or build_runtime_spec,
|
||||||
|
)
|
||||||
|
acquire_config_lock_fn = cast(
|
||||||
|
Callable[[Path, str | None], LockHandle],
|
||||||
|
_resolve_cli_attr("acquire_config_lock") or acquire_config_lock,
|
||||||
|
)
|
||||||
|
|
||||||
|
if debug:
|
||||||
|
os.environ.setdefault("TAKOPI_LOG_FILE", "debug.log")
|
||||||
|
setup_logging_fn(debug=debug)
|
||||||
|
lock_handle: LockHandle | None = None
|
||||||
|
try:
|
||||||
|
(
|
||||||
|
settings_hint,
|
||||||
|
config_hint,
|
||||||
|
allowlist,
|
||||||
|
default_engine,
|
||||||
|
engine_backend,
|
||||||
|
) = resolve_setup_engine_fn(default_engine_override)
|
||||||
|
transport_id = resolve_transport_id_fn(transport_override)
|
||||||
|
transport_backend = get_transport_fn(transport_id, allowlist=allowlist)
|
||||||
|
except ConfigError as exc:
|
||||||
|
typer.echo(f"error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
if onboard:
|
||||||
|
if not should_run_interactive_fn():
|
||||||
|
typer.echo("error: --onboard requires a TTY", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
if not anyio.run(partial(transport_backend.interactive_setup, force=True)):
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
(
|
||||||
|
settings_hint,
|
||||||
|
config_hint,
|
||||||
|
allowlist,
|
||||||
|
default_engine,
|
||||||
|
engine_backend,
|
||||||
|
) = resolve_setup_engine_fn(default_engine_override)
|
||||||
|
setup = transport_backend.check_setup(
|
||||||
|
engine_backend,
|
||||||
|
transport_override=transport_override,
|
||||||
|
)
|
||||||
|
if not setup.ok:
|
||||||
|
if setup_needs_config_fn(setup) and should_run_interactive_fn():
|
||||||
|
if setup.config_path.exists():
|
||||||
|
display = config_path_display_fn(setup.config_path)
|
||||||
|
run_onboard = typer.confirm(
|
||||||
|
f"config at {display} is missing/invalid for "
|
||||||
|
f"{transport_backend.id}, run onboarding now?",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
if run_onboard and anyio.run(
|
||||||
|
partial(transport_backend.interactive_setup, force=True)
|
||||||
|
):
|
||||||
|
(
|
||||||
|
settings_hint,
|
||||||
|
config_hint,
|
||||||
|
allowlist,
|
||||||
|
default_engine,
|
||||||
|
engine_backend,
|
||||||
|
) = resolve_setup_engine_fn(default_engine_override)
|
||||||
|
setup = transport_backend.check_setup(
|
||||||
|
engine_backend,
|
||||||
|
transport_override=transport_override,
|
||||||
|
)
|
||||||
|
elif anyio.run(partial(transport_backend.interactive_setup, force=False)):
|
||||||
|
(
|
||||||
|
settings_hint,
|
||||||
|
config_hint,
|
||||||
|
allowlist,
|
||||||
|
default_engine,
|
||||||
|
engine_backend,
|
||||||
|
) = resolve_setup_engine_fn(default_engine_override)
|
||||||
|
setup = transport_backend.check_setup(
|
||||||
|
engine_backend,
|
||||||
|
transport_override=transport_override,
|
||||||
|
)
|
||||||
|
if not setup.ok:
|
||||||
|
if setup_needs_config_fn(setup):
|
||||||
|
fail_missing_config_fn(setup.config_path)
|
||||||
|
else:
|
||||||
|
first = setup.issues[0]
|
||||||
|
typer.echo(f"error: {first.title}", err=True)
|
||||||
|
raise typer.Exit(code=1)
|
||||||
|
try:
|
||||||
|
settings, config_path = load_settings_fn()
|
||||||
|
if transport_override and transport_override != settings.transport:
|
||||||
|
settings = settings.model_copy(update={"transport": transport_override})
|
||||||
|
spec = build_runtime_spec_fn(
|
||||||
|
settings=settings,
|
||||||
|
config_path=config_path,
|
||||||
|
default_engine_override=default_engine_override,
|
||||||
|
reserved=RESERVED_CHAT_COMMANDS,
|
||||||
|
)
|
||||||
|
if settings.transport == "telegram":
|
||||||
|
transport_config = settings.transports.telegram
|
||||||
|
else:
|
||||||
|
transport_config = settings.transport_config(
|
||||||
|
settings.transport, config_path=config_path
|
||||||
|
)
|
||||||
|
lock_token = transport_backend.lock_token(
|
||||||
|
transport_config=transport_config,
|
||||||
|
_config_path=config_path,
|
||||||
|
)
|
||||||
|
lock_handle = acquire_config_lock_fn(config_path, lock_token)
|
||||||
|
runtime = spec.to_runtime(config_path=config_path)
|
||||||
|
transport_backend.build_and_run(
|
||||||
|
final_notify=final_notify,
|
||||||
|
default_engine_override=default_engine_override,
|
||||||
|
config_path=config_path,
|
||||||
|
transport_config=transport_config,
|
||||||
|
runtime=runtime,
|
||||||
|
)
|
||||||
|
except ConfigError as exc:
|
||||||
|
typer.echo(f"error: {exc}", err=True)
|
||||||
|
raise typer.Exit(code=1) from exc
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info("shutdown.interrupted")
|
||||||
|
raise typer.Exit(code=130) from None
|
||||||
|
finally:
|
||||||
|
if lock_handle is not None:
|
||||||
|
lock_handle.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_version_and_exit() -> None:
|
||||||
|
typer.echo(__version__)
|
||||||
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
|
def _version_callback(value: bool) -> None:
|
||||||
|
if value:
|
||||||
|
_print_version_and_exit()
|
||||||
|
|
||||||
|
|
||||||
|
def app_main(
|
||||||
|
ctx: typer.Context,
|
||||||
|
version: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--version",
|
||||||
|
help="Show the version and exit.",
|
||||||
|
callback=_version_callback,
|
||||||
|
is_eager=True,
|
||||||
|
),
|
||||||
|
final_notify: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--final-notify/--no-final-notify",
|
||||||
|
help="Send the final response as a new message (not an edit).",
|
||||||
|
),
|
||||||
|
onboard: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--onboard/--no-onboard",
|
||||||
|
help="Run the interactive setup wizard before starting.",
|
||||||
|
),
|
||||||
|
transport: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--transport",
|
||||||
|
help="Override the transport backend id.",
|
||||||
|
),
|
||||||
|
debug: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--debug/--no-debug",
|
||||||
|
help="Log engine JSONL, Telegram requests, and rendered messages.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
"""Takopi CLI."""
|
||||||
|
if ctx.invoked_subcommand is None:
|
||||||
|
run_auto_router = cast(
|
||||||
|
Callable[..., None],
|
||||||
|
_resolve_cli_attr("_run_auto_router") or _run_auto_router,
|
||||||
|
)
|
||||||
|
run_auto_router(
|
||||||
|
default_engine_override=None,
|
||||||
|
transport_override=transport,
|
||||||
|
final_notify=final_notify,
|
||||||
|
debug=debug,
|
||||||
|
onboard=onboard,
|
||||||
|
)
|
||||||
|
raise typer.Exit()
|
||||||
|
|
||||||
|
|
||||||
|
def make_engine_cmd(engine_id: str) -> Callable[..., None]:
|
||||||
|
def _cmd(
|
||||||
|
final_notify: bool = typer.Option(
|
||||||
|
True,
|
||||||
|
"--final-notify/--no-final-notify",
|
||||||
|
help="Send the final response as a new message (not an edit).",
|
||||||
|
),
|
||||||
|
onboard: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--onboard/--no-onboard",
|
||||||
|
help="Run the interactive setup wizard before starting.",
|
||||||
|
),
|
||||||
|
transport: str | None = typer.Option(
|
||||||
|
None,
|
||||||
|
"--transport",
|
||||||
|
help="Override the transport backend id.",
|
||||||
|
),
|
||||||
|
debug: bool = typer.Option(
|
||||||
|
False,
|
||||||
|
"--debug/--no-debug",
|
||||||
|
help="Log engine JSONL, Telegram requests, and rendered messages.",
|
||||||
|
),
|
||||||
|
) -> None:
|
||||||
|
run_auto_router = cast(
|
||||||
|
Callable[..., None],
|
||||||
|
_resolve_cli_attr("_run_auto_router") or _run_auto_router,
|
||||||
|
)
|
||||||
|
run_auto_router(
|
||||||
|
default_engine_override=engine_id,
|
||||||
|
transport_override=transport,
|
||||||
|
final_notify=final_notify,
|
||||||
|
debug=debug,
|
||||||
|
onboard=onboard,
|
||||||
|
)
|
||||||
|
|
||||||
|
_cmd.__name__ = f"run_{engine_id}"
|
||||||
|
return _cmd
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cli_attr(name: str) -> object | None:
|
||||||
|
cli_module = sys.modules.get("takopi.cli")
|
||||||
|
if cli_module is None:
|
||||||
|
return None
|
||||||
|
return getattr(cli_module, name, None)
|
||||||
+268
-168
@@ -131,6 +131,15 @@ class JsonlRunState:
|
|||||||
note_seq: int = 0
|
note_seq: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class JsonlStreamState:
|
||||||
|
expected_session: ResumeToken | None
|
||||||
|
found_session: ResumeToken | None = None
|
||||||
|
did_emit_completed: bool = False
|
||||||
|
ignored_after_completed: bool = False
|
||||||
|
jsonl_seq: int = 0
|
||||||
|
|
||||||
|
|
||||||
class JsonlSubprocessRunner(BaseRunner):
|
class JsonlSubprocessRunner(BaseRunner):
|
||||||
def get_logger(self) -> Any:
|
def get_logger(self) -> Any:
|
||||||
return getattr(self, "logger", get_logger(__name__))
|
return getattr(self, "logger", get_logger(__name__))
|
||||||
@@ -340,6 +349,250 @@ class JsonlSubprocessRunner(BaseRunner):
|
|||||||
raise RuntimeError(message)
|
raise RuntimeError(message)
|
||||||
return found_session, False
|
return found_session, False
|
||||||
|
|
||||||
|
async def _send_payload(
|
||||||
|
self,
|
||||||
|
proc: Any,
|
||||||
|
payload: bytes | None,
|
||||||
|
*,
|
||||||
|
logger: Any,
|
||||||
|
resume: ResumeToken | None,
|
||||||
|
) -> None:
|
||||||
|
if payload is not None:
|
||||||
|
assert proc.stdin is not None
|
||||||
|
await proc.stdin.send(payload)
|
||||||
|
await proc.stdin.aclose()
|
||||||
|
logger.info(
|
||||||
|
"subprocess.stdin.send",
|
||||||
|
pid=proc.pid,
|
||||||
|
resume=resume.value if resume else None,
|
||||||
|
bytes=len(payload),
|
||||||
|
)
|
||||||
|
elif proc.stdin is not None:
|
||||||
|
await proc.stdin.aclose()
|
||||||
|
|
||||||
|
def _decode_jsonl_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
raw_line: bytes,
|
||||||
|
line: bytes,
|
||||||
|
jsonl_seq: int,
|
||||||
|
state: Any,
|
||||||
|
resume: ResumeToken | None,
|
||||||
|
found_session: ResumeToken | None,
|
||||||
|
logger: Any,
|
||||||
|
pid: int,
|
||||||
|
) -> list[TakopiEvent]:
|
||||||
|
raw_text = raw_line.decode("utf-8", errors="replace")
|
||||||
|
line_text = line.decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
decoded = self.decode_jsonl(line=line)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"jsonl.parse.error",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
line=line_text,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return self.decode_error_events(
|
||||||
|
raw=raw_text,
|
||||||
|
line=line_text,
|
||||||
|
error=exc,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
if decoded is None:
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"jsonl.parse.invalid",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
line=line_text,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"runner.jsonl.invalid",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
line=line_text,
|
||||||
|
)
|
||||||
|
return self.invalid_json_events(
|
||||||
|
raw=raw_text,
|
||||||
|
line=line_text,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return self.translate(
|
||||||
|
decoded,
|
||||||
|
state=state,
|
||||||
|
resume=resume,
|
||||||
|
found_session=found_session,
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"runner.translate.error",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
return self.translate_error_events(
|
||||||
|
data=decoded,
|
||||||
|
error=exc,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _process_started_event(
|
||||||
|
self,
|
||||||
|
event: StartedEvent,
|
||||||
|
*,
|
||||||
|
expected_session: ResumeToken | None,
|
||||||
|
found_session: ResumeToken | None,
|
||||||
|
logger: Any,
|
||||||
|
pid: int,
|
||||||
|
jsonl_seq: int,
|
||||||
|
) -> tuple[ResumeToken | None, bool]:
|
||||||
|
prior_found = found_session
|
||||||
|
try:
|
||||||
|
found_session, emit = self.handle_started_event(
|
||||||
|
event,
|
||||||
|
expected_session=expected_session,
|
||||||
|
found_session=found_session,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"runner.started.error",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
resume=event.resume.value,
|
||||||
|
expected_session=expected_session.value if expected_session else None,
|
||||||
|
found_session=prior_found.value if prior_found else None,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
if prior_found is None and emit:
|
||||||
|
reason = (
|
||||||
|
"matched_expected" if expected_session is not None else "first_seen"
|
||||||
|
)
|
||||||
|
elif prior_found is not None and not emit:
|
||||||
|
reason = "duplicate"
|
||||||
|
else:
|
||||||
|
reason = "unknown"
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"runner.started.seen",
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=jsonl_seq,
|
||||||
|
resume=event.resume.value,
|
||||||
|
expected_session=expected_session.value if expected_session else None,
|
||||||
|
found_session=found_session.value if found_session else None,
|
||||||
|
emit=emit,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
return found_session, emit
|
||||||
|
|
||||||
|
def _log_completed_event(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
logger: Any,
|
||||||
|
pid: int,
|
||||||
|
event: CompletedEvent,
|
||||||
|
jsonl_seq: int | None = None,
|
||||||
|
source: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"pid": pid,
|
||||||
|
"ok": event.ok,
|
||||||
|
"has_answer": bool(event.answer.strip()),
|
||||||
|
"emit": True,
|
||||||
|
}
|
||||||
|
if jsonl_seq is not None:
|
||||||
|
payload["jsonl_seq"] = jsonl_seq
|
||||||
|
if source is not None:
|
||||||
|
payload["source"] = source
|
||||||
|
log_pipeline(logger, "runner.completed.seen", **payload)
|
||||||
|
|
||||||
|
def _handle_jsonl_line(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
raw_line: bytes,
|
||||||
|
stream: JsonlStreamState,
|
||||||
|
state: Any,
|
||||||
|
resume: ResumeToken | None,
|
||||||
|
logger: Any,
|
||||||
|
pid: int,
|
||||||
|
) -> list[TakopiEvent]:
|
||||||
|
if stream.did_emit_completed:
|
||||||
|
if not stream.ignored_after_completed:
|
||||||
|
log_pipeline(
|
||||||
|
logger,
|
||||||
|
"runner.drop.jsonl_after_completed",
|
||||||
|
pid=pid,
|
||||||
|
)
|
||||||
|
stream.ignored_after_completed = True
|
||||||
|
return []
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line:
|
||||||
|
return []
|
||||||
|
stream.jsonl_seq += 1
|
||||||
|
seq = stream.jsonl_seq
|
||||||
|
events = self._decode_jsonl_events(
|
||||||
|
raw_line=raw_line,
|
||||||
|
line=line,
|
||||||
|
jsonl_seq=seq,
|
||||||
|
state=state,
|
||||||
|
resume=resume,
|
||||||
|
found_session=stream.found_session,
|
||||||
|
logger=logger,
|
||||||
|
pid=pid,
|
||||||
|
)
|
||||||
|
output: list[TakopiEvent] = []
|
||||||
|
for evt in events:
|
||||||
|
if isinstance(evt, StartedEvent):
|
||||||
|
stream.found_session, emit = self._process_started_event(
|
||||||
|
evt,
|
||||||
|
expected_session=stream.expected_session,
|
||||||
|
found_session=stream.found_session,
|
||||||
|
logger=logger,
|
||||||
|
pid=pid,
|
||||||
|
jsonl_seq=seq,
|
||||||
|
)
|
||||||
|
if not emit:
|
||||||
|
continue
|
||||||
|
if isinstance(evt, CompletedEvent):
|
||||||
|
stream.did_emit_completed = True
|
||||||
|
self._log_completed_event(
|
||||||
|
logger=logger,
|
||||||
|
pid=pid,
|
||||||
|
event=evt,
|
||||||
|
jsonl_seq=seq,
|
||||||
|
)
|
||||||
|
output.append(evt)
|
||||||
|
break
|
||||||
|
output.append(evt)
|
||||||
|
return output
|
||||||
|
|
||||||
|
async def _iter_jsonl_events(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
stdout: Any,
|
||||||
|
stream: JsonlStreamState,
|
||||||
|
state: Any,
|
||||||
|
resume: ResumeToken | None,
|
||||||
|
logger: Any,
|
||||||
|
pid: int,
|
||||||
|
) -> AsyncIterator[TakopiEvent]:
|
||||||
|
async for raw_line in self.iter_json_lines(stdout):
|
||||||
|
for evt in self._handle_jsonl_line(
|
||||||
|
raw_line=raw_line,
|
||||||
|
stream=stream,
|
||||||
|
state=state,
|
||||||
|
resume=resume,
|
||||||
|
logger=logger,
|
||||||
|
pid=pid,
|
||||||
|
):
|
||||||
|
yield evt
|
||||||
|
|
||||||
async def run_impl(
|
async def run_impl(
|
||||||
self, prompt: str, resume: ResumeToken | None
|
self, prompt: str, resume: ResumeToken | None
|
||||||
) -> AsyncIterator[TakopiEvent]:
|
) -> AsyncIterator[TakopiEvent]:
|
||||||
@@ -381,25 +634,10 @@ class JsonlSubprocessRunner(BaseRunner):
|
|||||||
pid=proc.pid,
|
pid=proc.pid,
|
||||||
)
|
)
|
||||||
|
|
||||||
if payload is not None:
|
await self._send_payload(proc, payload, logger=logger, resume=resume)
|
||||||
assert proc.stdin is not None
|
|
||||||
await proc.stdin.send(payload)
|
|
||||||
await proc.stdin.aclose()
|
|
||||||
logger.info(
|
|
||||||
"subprocess.stdin.send",
|
|
||||||
pid=proc.pid,
|
|
||||||
resume=resume.value if resume else None,
|
|
||||||
bytes=len(payload),
|
|
||||||
)
|
|
||||||
elif proc.stdin is not None:
|
|
||||||
await proc.stdin.aclose()
|
|
||||||
|
|
||||||
rc: int | None = None
|
rc: int | None = None
|
||||||
expected_session: ResumeToken | None = resume
|
stream = JsonlStreamState(expected_session=resume)
|
||||||
found_session: ResumeToken | None = None
|
|
||||||
did_emit_completed = False
|
|
||||||
ignored_after_completed = False
|
|
||||||
jsonl_seq = 0
|
|
||||||
|
|
||||||
async with anyio.create_task_group() as tg:
|
async with anyio.create_task_group() as tg:
|
||||||
tg.start_soon(
|
tg.start_soon(
|
||||||
@@ -408,154 +646,22 @@ class JsonlSubprocessRunner(BaseRunner):
|
|||||||
logger,
|
logger,
|
||||||
tag,
|
tag,
|
||||||
)
|
)
|
||||||
async for raw_line in self.iter_json_lines(proc.stdout):
|
async for evt in self._iter_jsonl_events(
|
||||||
if did_emit_completed:
|
stdout=proc.stdout,
|
||||||
if not ignored_after_completed:
|
stream=stream,
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"runner.drop.jsonl_after_completed",
|
|
||||||
pid=proc.pid,
|
|
||||||
)
|
|
||||||
ignored_after_completed = True
|
|
||||||
continue
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
jsonl_seq += 1
|
|
||||||
seq = jsonl_seq
|
|
||||||
raw_text = raw_line.decode("utf-8", errors="replace")
|
|
||||||
line_text = line.decode("utf-8", errors="replace")
|
|
||||||
try:
|
|
||||||
decoded = self.decode_jsonl(line=line)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"jsonl.parse.error",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
line=line_text,
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
events = self.decode_error_events(
|
|
||||||
raw=raw_text,
|
|
||||||
line=line_text,
|
|
||||||
error=exc,
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if decoded is None:
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"jsonl.parse.invalid",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
line=line_text,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"runner.jsonl.invalid",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
line=line_text,
|
|
||||||
)
|
|
||||||
events = self.invalid_json_events(
|
|
||||||
raw=raw_text,
|
|
||||||
line=line_text,
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
events = self.translate(
|
|
||||||
decoded,
|
|
||||||
state=state,
|
state=state,
|
||||||
resume=resume,
|
resume=resume,
|
||||||
found_session=found_session,
|
logger=logger,
|
||||||
)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"runner.translate.error",
|
|
||||||
pid=proc.pid,
|
pid=proc.pid,
|
||||||
jsonl_seq=seq,
|
):
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
events = self.translate_error_events(
|
|
||||||
data=decoded,
|
|
||||||
error=exc,
|
|
||||||
state=state,
|
|
||||||
)
|
|
||||||
|
|
||||||
for evt in events:
|
|
||||||
if isinstance(evt, StartedEvent):
|
|
||||||
prior_found = found_session
|
|
||||||
try:
|
|
||||||
found_session, emit = self.handle_started_event(
|
|
||||||
evt,
|
|
||||||
expected_session=expected_session,
|
|
||||||
found_session=found_session,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"runner.started.error",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
resume=evt.resume.value,
|
|
||||||
expected_session=expected_session.value
|
|
||||||
if expected_session
|
|
||||||
else None,
|
|
||||||
found_session=prior_found.value
|
|
||||||
if prior_found
|
|
||||||
else None,
|
|
||||||
error=str(exc),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
if prior_found is None and emit:
|
|
||||||
reason = (
|
|
||||||
"matched_expected"
|
|
||||||
if expected_session is not None
|
|
||||||
else "first_seen"
|
|
||||||
)
|
|
||||||
elif prior_found is not None and not emit:
|
|
||||||
reason = "duplicate"
|
|
||||||
else:
|
|
||||||
reason = "unknown"
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"runner.started.seen",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
resume=evt.resume.value,
|
|
||||||
expected_session=expected_session.value
|
|
||||||
if expected_session
|
|
||||||
else None,
|
|
||||||
found_session=found_session.value
|
|
||||||
if found_session
|
|
||||||
else None,
|
|
||||||
emit=emit,
|
|
||||||
reason=reason,
|
|
||||||
)
|
|
||||||
if not emit:
|
|
||||||
continue
|
|
||||||
if isinstance(evt, CompletedEvent):
|
|
||||||
did_emit_completed = True
|
|
||||||
log_pipeline(
|
|
||||||
logger,
|
|
||||||
"runner.completed.seen",
|
|
||||||
pid=proc.pid,
|
|
||||||
jsonl_seq=seq,
|
|
||||||
ok=evt.ok,
|
|
||||||
has_answer=bool(evt.answer.strip()),
|
|
||||||
emit=True,
|
|
||||||
)
|
|
||||||
yield evt
|
|
||||||
break
|
|
||||||
yield evt
|
yield evt
|
||||||
|
|
||||||
rc = await proc.wait()
|
rc = await proc.wait()
|
||||||
|
|
||||||
logger.info("subprocess.exit", pid=proc.pid, rc=rc)
|
logger.info("subprocess.exit", pid=proc.pid, rc=rc)
|
||||||
if did_emit_completed:
|
if stream.did_emit_completed:
|
||||||
return
|
return
|
||||||
|
found_session = stream.found_session
|
||||||
if rc is not None and rc != 0:
|
if rc is not None and rc != 0:
|
||||||
events = self.process_error_events(
|
events = self.process_error_events(
|
||||||
rc,
|
rc,
|
||||||
@@ -565,13 +671,10 @@ class JsonlSubprocessRunner(BaseRunner):
|
|||||||
)
|
)
|
||||||
for evt in events:
|
for evt in events:
|
||||||
if isinstance(evt, CompletedEvent):
|
if isinstance(evt, CompletedEvent):
|
||||||
log_pipeline(
|
self._log_completed_event(
|
||||||
logger,
|
logger=logger,
|
||||||
"runner.completed.seen",
|
|
||||||
pid=proc.pid,
|
pid=proc.pid,
|
||||||
ok=evt.ok,
|
event=evt,
|
||||||
has_answer=bool(evt.answer.strip()),
|
|
||||||
emit=True,
|
|
||||||
source="process_error",
|
source="process_error",
|
||||||
)
|
)
|
||||||
yield evt
|
yield evt
|
||||||
@@ -584,13 +687,10 @@ class JsonlSubprocessRunner(BaseRunner):
|
|||||||
)
|
)
|
||||||
for evt in events:
|
for evt in events:
|
||||||
if isinstance(evt, CompletedEvent):
|
if isinstance(evt, CompletedEvent):
|
||||||
log_pipeline(
|
self._log_completed_event(
|
||||||
logger,
|
logger=logger,
|
||||||
"runner.completed.seen",
|
|
||||||
pid=proc.pid,
|
pid=proc.pid,
|
||||||
ok=evt.ok,
|
event=evt,
|
||||||
has_answer=bool(evt.answer.strip()),
|
|
||||||
emit=True,
|
|
||||||
source="stream_end",
|
source="stream_end",
|
||||||
)
|
)
|
||||||
yield evt
|
yield evt
|
||||||
|
|||||||
@@ -27,10 +27,7 @@ async def _check_agent_permissions(
|
|||||||
if sender_id is None:
|
if sender_id is None:
|
||||||
await reply(text="cannot verify sender for agent defaults.")
|
await reply(text="cannot verify sender for agent defaults.")
|
||||||
return False
|
return False
|
||||||
is_private = msg.chat_type == "private"
|
if msg.is_private:
|
||||||
if msg.chat_type is None:
|
|
||||||
is_private = msg.chat_id > 0
|
|
||||||
if is_private:
|
|
||||||
return True
|
return True
|
||||||
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
||||||
if member is None:
|
if member is None:
|
||||||
|
|||||||
@@ -107,10 +107,7 @@ async def _check_file_permissions(
|
|||||||
await reply(text="file transfer is not allowed for this user.")
|
await reply(text="file transfer is not allowed for this user.")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
is_private = msg.chat_type == "private"
|
if msg.is_private:
|
||||||
if msg.chat_type is None:
|
|
||||||
is_private = msg.chat_id > 0
|
|
||||||
if is_private:
|
|
||||||
return True
|
return True
|
||||||
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
||||||
if member is None:
|
if member is None:
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# ruff: noqa: F401
|
||||||
|
|
||||||
|
from .agent import _handle_agent_command as handle_agent_command
|
||||||
|
from .dispatch import _dispatch_command as dispatch_command
|
||||||
|
from .executor import _run_engine as run_engine
|
||||||
|
from .executor import _should_show_resume_line as should_show_resume_line
|
||||||
|
from .file_transfer import _handle_file_command as handle_file_command
|
||||||
|
from .file_transfer import _handle_file_put_default as handle_file_put_default
|
||||||
|
from .file_transfer import _save_file_put as save_file_put
|
||||||
|
from .media import _handle_media_group as handle_media_group
|
||||||
|
from .menu import _reserved_commands as get_reserved_commands
|
||||||
|
from .menu import _set_command_menu as set_command_menu
|
||||||
|
from .model import _handle_model_command as handle_model_command
|
||||||
|
from .parse import _parse_slash_command as parse_slash_command
|
||||||
|
from .reasoning import _handle_reasoning_command as handle_reasoning_command
|
||||||
|
from .topics import _handle_chat_new_command as handle_chat_new_command
|
||||||
|
from .topics import _handle_ctx_command as handle_ctx_command
|
||||||
|
from .topics import _handle_new_command as handle_new_command
|
||||||
|
from .topics import _handle_topic_command as handle_topic_command
|
||||||
|
from .trigger import _handle_trigger_command as handle_trigger_command
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"dispatch_command",
|
||||||
|
"get_reserved_commands",
|
||||||
|
"handle_agent_command",
|
||||||
|
"handle_chat_new_command",
|
||||||
|
"handle_ctx_command",
|
||||||
|
"handle_file_command",
|
||||||
|
"handle_file_put_default",
|
||||||
|
"handle_media_group",
|
||||||
|
"handle_model_command",
|
||||||
|
"handle_new_command",
|
||||||
|
"handle_reasoning_command",
|
||||||
|
"handle_topic_command",
|
||||||
|
"handle_trigger_command",
|
||||||
|
"parse_slash_command",
|
||||||
|
"run_engine",
|
||||||
|
"save_file_put",
|
||||||
|
"set_command_menu",
|
||||||
|
"should_show_resume_line",
|
||||||
|
]
|
||||||
@@ -3,14 +3,20 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ...context import RunContext
|
from ...context import RunContext
|
||||||
from ...directives import DirectiveError
|
|
||||||
from ..chat_prefs import ChatPrefsStore
|
from ..chat_prefs import ChatPrefsStore
|
||||||
from ..engine_defaults import resolve_engine_for_message
|
|
||||||
from ..engine_overrides import EngineOverrides, resolve_override_value
|
from ..engine_overrides import EngineOverrides, resolve_override_value
|
||||||
from ..files import split_command_args
|
from ..files import split_command_args
|
||||||
from ..topic_state import TopicStateStore
|
from ..topic_state import TopicStateStore
|
||||||
from ..topics import _topic_key
|
from ..topics import _topic_key
|
||||||
from ..types import TelegramIncomingMessage
|
from ..types import TelegramIncomingMessage
|
||||||
|
from .overrides import (
|
||||||
|
ENGINE_SOURCE_LABELS,
|
||||||
|
OVERRIDE_SOURCE_LABELS,
|
||||||
|
apply_engine_override,
|
||||||
|
parse_set_args,
|
||||||
|
require_admin_or_private,
|
||||||
|
resolve_engine_selection,
|
||||||
|
)
|
||||||
from .reply import make_reply
|
from .reply import make_reply
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -22,79 +28,6 @@ MODEL_USAGE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_model_permissions(
|
|
||||||
cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
|
|
||||||
) -> bool:
|
|
||||||
reply = make_reply(cfg, msg)
|
|
||||||
sender_id = msg.sender_id
|
|
||||||
if sender_id is None:
|
|
||||||
await reply(text="cannot verify sender for model overrides.")
|
|
||||||
return False
|
|
||||||
is_private = msg.chat_type == "private"
|
|
||||||
if msg.chat_type is None:
|
|
||||||
is_private = msg.chat_id > 0
|
|
||||||
if is_private:
|
|
||||||
return True
|
|
||||||
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
|
||||||
if member is None:
|
|
||||||
await reply(text="failed to verify model override permissions.")
|
|
||||||
return False
|
|
||||||
if member.status in {"creator", "administrator"}:
|
|
||||||
return True
|
|
||||||
await reply(text="changing model overrides is restricted to group admins.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_engine_selection(
|
|
||||||
cfg: TelegramBridgeConfig,
|
|
||||||
msg: TelegramIncomingMessage,
|
|
||||||
*,
|
|
||||||
ambient_context: RunContext | None,
|
|
||||||
topic_store: TopicStateStore | None,
|
|
||||||
chat_prefs: ChatPrefsStore | None,
|
|
||||||
topic_key: tuple[int, int] | None,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
reply = make_reply(cfg, msg)
|
|
||||||
try:
|
|
||||||
resolved = cfg.runtime.resolve_message(
|
|
||||||
text="",
|
|
||||||
reply_text=msg.reply_to_text,
|
|
||||||
ambient_context=ambient_context,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
)
|
|
||||||
except DirectiveError as exc:
|
|
||||||
await reply(text=f"error:\n{exc}")
|
|
||||||
return None
|
|
||||||
selection = await resolve_engine_for_message(
|
|
||||||
runtime=cfg.runtime,
|
|
||||||
context=resolved.context,
|
|
||||||
explicit_engine=None,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
topic_key=topic_key,
|
|
||||||
topic_store=topic_store,
|
|
||||||
chat_prefs=chat_prefs,
|
|
||||||
)
|
|
||||||
return selection.engine, selection.source
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_set_args(
|
|
||||||
tokens: tuple[str, ...], *, engine_ids: set[str]
|
|
||||||
) -> tuple[str | None, str | None]:
|
|
||||||
if len(tokens) < 2:
|
|
||||||
return None, None
|
|
||||||
if len(tokens) == 2:
|
|
||||||
maybe_engine = tokens[1].strip().lower()
|
|
||||||
if maybe_engine in engine_ids:
|
|
||||||
return None, None
|
|
||||||
return None, tokens[1].strip()
|
|
||||||
maybe_engine = tokens[1].strip().lower()
|
|
||||||
if maybe_engine in engine_ids:
|
|
||||||
model = " ".join(tokens[2:]).strip()
|
|
||||||
return maybe_engine, model or None
|
|
||||||
model = " ".join(tokens[1:]).strip()
|
|
||||||
return None, model or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_model_command(
|
async def _handle_model_command(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
msg: TelegramIncomingMessage,
|
msg: TelegramIncomingMessage,
|
||||||
@@ -117,7 +50,7 @@ async def _handle_model_command(
|
|||||||
engine_ids = {engine.lower() for engine in cfg.runtime.engine_ids}
|
engine_ids = {engine.lower() for engine in cfg.runtime.engine_ids}
|
||||||
|
|
||||||
if action in {"show", ""}:
|
if action in {"show", ""}:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -141,21 +74,11 @@ async def _handle_model_command(
|
|||||||
chat_override=chat_override,
|
chat_override=chat_override,
|
||||||
field="model",
|
field="model",
|
||||||
)
|
)
|
||||||
source_labels = {
|
engine_line = f"engine: {engine} ({ENGINE_SOURCE_LABELS[engine_source]})"
|
||||||
"directive": "directive",
|
|
||||||
"topic_default": "topic default",
|
|
||||||
"chat_default": "chat default",
|
|
||||||
"project_default": "project default",
|
|
||||||
"global_default": "global default",
|
|
||||||
}
|
|
||||||
override_labels = {
|
|
||||||
"topic_override": "topic override",
|
|
||||||
"chat_default": "chat default",
|
|
||||||
"default": "no override",
|
|
||||||
}
|
|
||||||
engine_line = f"engine: {engine} ({source_labels[engine_source]})"
|
|
||||||
model_value = resolution.value or "default"
|
model_value = resolution.value or "default"
|
||||||
model_line = f"model: {model_value} ({override_labels[resolution.source]})"
|
model_line = (
|
||||||
|
f"model: {model_value} ({OVERRIDE_SOURCE_LABELS[resolution.source]})"
|
||||||
|
)
|
||||||
topic_label = resolution.topic_value or "none"
|
topic_label = resolution.topic_value or "none"
|
||||||
if tkey is None:
|
if tkey is None:
|
||||||
topic_label = "none"
|
topic_label = "none"
|
||||||
@@ -170,14 +93,20 @@ async def _handle_model_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action == "set":
|
if action == "set":
|
||||||
engine_arg, model = _parse_set_args(tokens, engine_ids=engine_ids)
|
engine_arg, model = parse_set_args(tokens, engine_ids=engine_ids)
|
||||||
if model is None:
|
if model is None:
|
||||||
await reply(text=MODEL_USAGE)
|
await reply(text=MODEL_USAGE)
|
||||||
return
|
return
|
||||||
if not await _check_model_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for model overrides.",
|
||||||
|
failed_member="failed to verify model override permissions.",
|
||||||
|
denied="changing model overrides is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if engine_arg is None:
|
if engine_arg is None:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -196,16 +125,23 @@ async def _handle_model_command(
|
|||||||
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
scope = await apply_engine_override(
|
||||||
if topic_store is None:
|
reply=reply,
|
||||||
await reply(text="topic model overrides are unavailable.")
|
tkey=tkey,
|
||||||
return
|
topic_store=topic_store,
|
||||||
current = await topic_store.get_engine_override(tkey[0], tkey[1], engine)
|
chat_prefs=chat_prefs,
|
||||||
updated = EngineOverrides(
|
chat_id=msg.chat_id,
|
||||||
|
engine=engine,
|
||||||
|
update=lambda current: EngineOverrides(
|
||||||
model=model,
|
model=model,
|
||||||
reasoning=current.reasoning if current is not None else None,
|
reasoning=current.reasoning if current is not None else None,
|
||||||
|
),
|
||||||
|
topic_unavailable="topic model overrides are unavailable.",
|
||||||
|
chat_unavailable="chat model overrides are unavailable (no config path).",
|
||||||
)
|
)
|
||||||
await topic_store.set_engine_override(tkey[0], tkey[1], engine, updated)
|
if scope is None:
|
||||||
|
return
|
||||||
|
if scope == "topic":
|
||||||
await reply(
|
await reply(
|
||||||
text=(
|
text=(
|
||||||
f"topic model override set to `{model}` for `{engine}`.\n"
|
f"topic model override set to `{model}` for `{engine}`.\n"
|
||||||
@@ -213,15 +149,6 @@ async def _handle_model_command(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if chat_prefs is None:
|
|
||||||
await reply(text="chat model overrides are unavailable (no config path).")
|
|
||||||
return
|
|
||||||
current = await chat_prefs.get_engine_override(msg.chat_id, engine)
|
|
||||||
updated = EngineOverrides(
|
|
||||||
model=model,
|
|
||||||
reasoning=current.reasoning if current is not None else None,
|
|
||||||
)
|
|
||||||
await chat_prefs.set_engine_override(msg.chat_id, engine, updated)
|
|
||||||
await reply(
|
await reply(
|
||||||
text=(
|
text=(
|
||||||
f"chat model override set to `{model}` for `{engine}`.\n"
|
f"chat model override set to `{model}` for `{engine}`.\n"
|
||||||
@@ -237,10 +164,16 @@ async def _handle_model_command(
|
|||||||
return
|
return
|
||||||
if len(tokens) == 2:
|
if len(tokens) == 2:
|
||||||
engine = tokens[1].strip().lower() or None
|
engine = tokens[1].strip().lower() or None
|
||||||
if not await _check_model_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for model overrides.",
|
||||||
|
failed_member="failed to verify model override permissions.",
|
||||||
|
denied="changing model overrides is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if engine is None:
|
if engine is None:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -257,27 +190,25 @@ async def _handle_model_command(
|
|||||||
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
scope = await apply_engine_override(
|
||||||
if topic_store is None:
|
reply=reply,
|
||||||
await reply(text="topic model overrides are unavailable.")
|
tkey=tkey,
|
||||||
return
|
topic_store=topic_store,
|
||||||
current = await topic_store.get_engine_override(tkey[0], tkey[1], engine)
|
chat_prefs=chat_prefs,
|
||||||
updated = EngineOverrides(
|
chat_id=msg.chat_id,
|
||||||
|
engine=engine,
|
||||||
|
update=lambda current: EngineOverrides(
|
||||||
model=None,
|
model=None,
|
||||||
reasoning=current.reasoning if current is not None else None,
|
reasoning=current.reasoning if current is not None else None,
|
||||||
|
),
|
||||||
|
topic_unavailable="topic model overrides are unavailable.",
|
||||||
|
chat_unavailable="chat model overrides are unavailable (no config path).",
|
||||||
)
|
)
|
||||||
await topic_store.set_engine_override(tkey[0], tkey[1], engine, updated)
|
if scope is None:
|
||||||
|
return
|
||||||
|
if scope == "topic":
|
||||||
await reply(text="topic model override cleared (using chat default).")
|
await reply(text="topic model override cleared (using chat default).")
|
||||||
return
|
return
|
||||||
if chat_prefs is None:
|
|
||||||
await reply(text="chat model overrides are unavailable (no config path).")
|
|
||||||
return
|
|
||||||
current = await chat_prefs.get_engine_override(msg.chat_id, engine)
|
|
||||||
updated = EngineOverrides(
|
|
||||||
model=None,
|
|
||||||
reasoning=current.reasoning if current is not None else None,
|
|
||||||
)
|
|
||||||
await chat_prefs.set_engine_override(msg.chat_id, engine, updated)
|
|
||||||
await reply(text="chat model override cleared.")
|
await reply(text="chat model override cleared.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import TYPE_CHECKING, Literal
|
||||||
|
|
||||||
|
from ...context import RunContext
|
||||||
|
from ...directives import DirectiveError
|
||||||
|
from ..chat_prefs import ChatPrefsStore
|
||||||
|
from ..engine_defaults import resolve_engine_for_message
|
||||||
|
from ..engine_overrides import EngineOverrides
|
||||||
|
from ..topic_state import TopicStateStore
|
||||||
|
from ..types import TelegramIncomingMessage
|
||||||
|
from .reply import make_reply
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from ..bridge import TelegramBridgeConfig
|
||||||
|
|
||||||
|
ENGINE_SOURCE_LABELS = {
|
||||||
|
"directive": "directive",
|
||||||
|
"topic_default": "topic default",
|
||||||
|
"chat_default": "chat default",
|
||||||
|
"project_default": "project default",
|
||||||
|
"global_default": "global default",
|
||||||
|
}
|
||||||
|
OVERRIDE_SOURCE_LABELS = {
|
||||||
|
"topic_override": "topic override",
|
||||||
|
"chat_default": "chat default",
|
||||||
|
"default": "no override",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin_or_private(
|
||||||
|
cfg: TelegramBridgeConfig,
|
||||||
|
msg: TelegramIncomingMessage,
|
||||||
|
*,
|
||||||
|
missing_sender: str,
|
||||||
|
failed_member: str,
|
||||||
|
denied: str,
|
||||||
|
) -> bool:
|
||||||
|
reply = make_reply(cfg, msg)
|
||||||
|
sender_id = msg.sender_id
|
||||||
|
if sender_id is None:
|
||||||
|
await reply(text=missing_sender)
|
||||||
|
return False
|
||||||
|
if msg.is_private:
|
||||||
|
return True
|
||||||
|
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
||||||
|
if member is None:
|
||||||
|
await reply(text=failed_member)
|
||||||
|
return False
|
||||||
|
if member.status in {"creator", "administrator"}:
|
||||||
|
return True
|
||||||
|
await reply(text=denied)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_engine_selection(
|
||||||
|
cfg: TelegramBridgeConfig,
|
||||||
|
msg: TelegramIncomingMessage,
|
||||||
|
*,
|
||||||
|
ambient_context: RunContext | None,
|
||||||
|
topic_store: TopicStateStore | None,
|
||||||
|
chat_prefs: ChatPrefsStore | None,
|
||||||
|
topic_key: tuple[int, int] | None,
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
reply = make_reply(cfg, msg)
|
||||||
|
try:
|
||||||
|
resolved = cfg.runtime.resolve_message(
|
||||||
|
text="",
|
||||||
|
reply_text=msg.reply_to_text,
|
||||||
|
ambient_context=ambient_context,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
)
|
||||||
|
except DirectiveError as exc:
|
||||||
|
await reply(text=f"error:\n{exc}")
|
||||||
|
return None
|
||||||
|
selection = await resolve_engine_for_message(
|
||||||
|
runtime=cfg.runtime,
|
||||||
|
context=resolved.context,
|
||||||
|
explicit_engine=None,
|
||||||
|
chat_id=msg.chat_id,
|
||||||
|
topic_key=topic_key,
|
||||||
|
topic_store=topic_store,
|
||||||
|
chat_prefs=chat_prefs,
|
||||||
|
)
|
||||||
|
return selection.engine, selection.source
|
||||||
|
|
||||||
|
|
||||||
|
def parse_set_args(
|
||||||
|
tokens: tuple[str, ...], *, engine_ids: set[str]
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
if len(tokens) < 2:
|
||||||
|
return None, None
|
||||||
|
if len(tokens) == 2:
|
||||||
|
maybe_engine = tokens[1].strip().lower()
|
||||||
|
if maybe_engine in engine_ids:
|
||||||
|
return None, None
|
||||||
|
return None, tokens[1].strip()
|
||||||
|
maybe_engine = tokens[1].strip().lower()
|
||||||
|
if maybe_engine in engine_ids:
|
||||||
|
value = " ".join(tokens[2:]).strip()
|
||||||
|
return maybe_engine, value or None
|
||||||
|
value = " ".join(tokens[1:]).strip()
|
||||||
|
return None, value or None
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_engine_override(
|
||||||
|
*,
|
||||||
|
reply: Callable[..., Awaitable[None]],
|
||||||
|
tkey: tuple[int, int] | None,
|
||||||
|
topic_store: TopicStateStore | None,
|
||||||
|
chat_prefs: ChatPrefsStore | None,
|
||||||
|
chat_id: int,
|
||||||
|
engine: str,
|
||||||
|
update: Callable[[EngineOverrides | None], EngineOverrides],
|
||||||
|
topic_unavailable: str,
|
||||||
|
chat_unavailable: str,
|
||||||
|
) -> Literal["topic", "chat"] | None:
|
||||||
|
if tkey is not None:
|
||||||
|
if topic_store is None:
|
||||||
|
await reply(text=topic_unavailable)
|
||||||
|
return None
|
||||||
|
current = await topic_store.get_engine_override(tkey[0], tkey[1], engine)
|
||||||
|
updated = update(current)
|
||||||
|
await topic_store.set_engine_override(tkey[0], tkey[1], engine, updated)
|
||||||
|
return "topic"
|
||||||
|
if chat_prefs is None:
|
||||||
|
await reply(text=chat_unavailable)
|
||||||
|
return None
|
||||||
|
current = await chat_prefs.get_engine_override(chat_id, engine)
|
||||||
|
updated = update(current)
|
||||||
|
await chat_prefs.set_engine_override(chat_id, engine, updated)
|
||||||
|
return "chat"
|
||||||
@@ -3,9 +3,7 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from ...context import RunContext
|
from ...context import RunContext
|
||||||
from ...directives import DirectiveError
|
|
||||||
from ..chat_prefs import ChatPrefsStore
|
from ..chat_prefs import ChatPrefsStore
|
||||||
from ..engine_defaults import resolve_engine_for_message
|
|
||||||
from ..engine_overrides import (
|
from ..engine_overrides import (
|
||||||
EngineOverrides,
|
EngineOverrides,
|
||||||
allowed_reasoning_levels,
|
allowed_reasoning_levels,
|
||||||
@@ -15,6 +13,14 @@ from ..files import split_command_args
|
|||||||
from ..topic_state import TopicStateStore
|
from ..topic_state import TopicStateStore
|
||||||
from ..topics import _topic_key
|
from ..topics import _topic_key
|
||||||
from ..types import TelegramIncomingMessage
|
from ..types import TelegramIncomingMessage
|
||||||
|
from .overrides import (
|
||||||
|
ENGINE_SOURCE_LABELS,
|
||||||
|
OVERRIDE_SOURCE_LABELS,
|
||||||
|
apply_engine_override,
|
||||||
|
parse_set_args,
|
||||||
|
require_admin_or_private,
|
||||||
|
resolve_engine_selection,
|
||||||
|
)
|
||||||
from .reply import make_reply
|
from .reply import make_reply
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -26,79 +32,6 @@ REASONING_USAGE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_reasoning_permissions(
|
|
||||||
cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
|
|
||||||
) -> bool:
|
|
||||||
reply = make_reply(cfg, msg)
|
|
||||||
sender_id = msg.sender_id
|
|
||||||
if sender_id is None:
|
|
||||||
await reply(text="cannot verify sender for reasoning overrides.")
|
|
||||||
return False
|
|
||||||
is_private = msg.chat_type == "private"
|
|
||||||
if msg.chat_type is None:
|
|
||||||
is_private = msg.chat_id > 0
|
|
||||||
if is_private:
|
|
||||||
return True
|
|
||||||
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
|
||||||
if member is None:
|
|
||||||
await reply(text="failed to verify reasoning override permissions.")
|
|
||||||
return False
|
|
||||||
if member.status in {"creator", "administrator"}:
|
|
||||||
return True
|
|
||||||
await reply(text="changing reasoning overrides is restricted to group admins.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_engine_selection(
|
|
||||||
cfg: TelegramBridgeConfig,
|
|
||||||
msg: TelegramIncomingMessage,
|
|
||||||
*,
|
|
||||||
ambient_context: RunContext | None,
|
|
||||||
topic_store: TopicStateStore | None,
|
|
||||||
chat_prefs: ChatPrefsStore | None,
|
|
||||||
topic_key: tuple[int, int] | None,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
reply = make_reply(cfg, msg)
|
|
||||||
try:
|
|
||||||
resolved = cfg.runtime.resolve_message(
|
|
||||||
text="",
|
|
||||||
reply_text=msg.reply_to_text,
|
|
||||||
ambient_context=ambient_context,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
)
|
|
||||||
except DirectiveError as exc:
|
|
||||||
await reply(text=f"error:\n{exc}")
|
|
||||||
return None
|
|
||||||
selection = await resolve_engine_for_message(
|
|
||||||
runtime=cfg.runtime,
|
|
||||||
context=resolved.context,
|
|
||||||
explicit_engine=None,
|
|
||||||
chat_id=msg.chat_id,
|
|
||||||
topic_key=topic_key,
|
|
||||||
topic_store=topic_store,
|
|
||||||
chat_prefs=chat_prefs,
|
|
||||||
)
|
|
||||||
return selection.engine, selection.source
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_set_args(
|
|
||||||
tokens: tuple[str, ...], *, engine_ids: set[str]
|
|
||||||
) -> tuple[str | None, str | None]:
|
|
||||||
if len(tokens) < 2:
|
|
||||||
return None, None
|
|
||||||
if len(tokens) == 2:
|
|
||||||
maybe_engine = tokens[1].strip().lower()
|
|
||||||
if maybe_engine in engine_ids:
|
|
||||||
return None, None
|
|
||||||
return None, tokens[1].strip()
|
|
||||||
maybe_engine = tokens[1].strip().lower()
|
|
||||||
if maybe_engine in engine_ids:
|
|
||||||
level = " ".join(tokens[2:]).strip()
|
|
||||||
return maybe_engine, level or None
|
|
||||||
level = " ".join(tokens[1:]).strip()
|
|
||||||
return None, level or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_reasoning_command(
|
async def _handle_reasoning_command(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
msg: TelegramIncomingMessage,
|
msg: TelegramIncomingMessage,
|
||||||
@@ -121,7 +54,7 @@ async def _handle_reasoning_command(
|
|||||||
engine_ids = {engine.lower() for engine in cfg.runtime.engine_ids}
|
engine_ids = {engine.lower() for engine in cfg.runtime.engine_ids}
|
||||||
|
|
||||||
if action in {"show", ""}:
|
if action in {"show", ""}:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -145,22 +78,11 @@ async def _handle_reasoning_command(
|
|||||||
chat_override=chat_override,
|
chat_override=chat_override,
|
||||||
field="reasoning",
|
field="reasoning",
|
||||||
)
|
)
|
||||||
source_labels = {
|
engine_line = f"engine: {engine} ({ENGINE_SOURCE_LABELS[engine_source]})"
|
||||||
"directive": "directive",
|
|
||||||
"topic_default": "topic default",
|
|
||||||
"chat_default": "chat default",
|
|
||||||
"project_default": "project default",
|
|
||||||
"global_default": "global default",
|
|
||||||
}
|
|
||||||
override_labels = {
|
|
||||||
"topic_override": "topic override",
|
|
||||||
"chat_default": "chat default",
|
|
||||||
"default": "no override",
|
|
||||||
}
|
|
||||||
engine_line = f"engine: {engine} ({source_labels[engine_source]})"
|
|
||||||
reasoning_value = resolution.value or "default"
|
reasoning_value = resolution.value or "default"
|
||||||
reasoning_line = (
|
reasoning_line = (
|
||||||
f"reasoning: {reasoning_value} ({override_labels[resolution.source]})"
|
f"reasoning: {reasoning_value} "
|
||||||
|
f"({OVERRIDE_SOURCE_LABELS[resolution.source]})"
|
||||||
)
|
)
|
||||||
topic_label = resolution.topic_value or "none"
|
topic_label = resolution.topic_value or "none"
|
||||||
if tkey is None:
|
if tkey is None:
|
||||||
@@ -179,14 +101,20 @@ async def _handle_reasoning_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action == "set":
|
if action == "set":
|
||||||
engine_arg, level = _parse_set_args(tokens, engine_ids=engine_ids)
|
engine_arg, level = parse_set_args(tokens, engine_ids=engine_ids)
|
||||||
if level is None:
|
if level is None:
|
||||||
await reply(text=REASONING_USAGE)
|
await reply(text=REASONING_USAGE)
|
||||||
return
|
return
|
||||||
if not await _check_reasoning_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for reasoning overrides.",
|
||||||
|
failed_member="failed to verify reasoning override permissions.",
|
||||||
|
denied="changing reasoning overrides is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if engine_arg is None:
|
if engine_arg is None:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -215,16 +143,23 @@ async def _handle_reasoning_command(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
scope = await apply_engine_override(
|
||||||
if topic_store is None:
|
reply=reply,
|
||||||
await reply(text="topic reasoning overrides are unavailable.")
|
tkey=tkey,
|
||||||
return
|
topic_store=topic_store,
|
||||||
current = await topic_store.get_engine_override(tkey[0], tkey[1], engine)
|
chat_prefs=chat_prefs,
|
||||||
updated = EngineOverrides(
|
chat_id=msg.chat_id,
|
||||||
|
engine=engine,
|
||||||
|
update=lambda current: EngineOverrides(
|
||||||
model=current.model if current is not None else None,
|
model=current.model if current is not None else None,
|
||||||
reasoning=normalized_level,
|
reasoning=normalized_level,
|
||||||
|
),
|
||||||
|
topic_unavailable="topic reasoning overrides are unavailable.",
|
||||||
|
chat_unavailable="chat reasoning overrides are unavailable (no config path).",
|
||||||
)
|
)
|
||||||
await topic_store.set_engine_override(tkey[0], tkey[1], engine, updated)
|
if scope is None:
|
||||||
|
return
|
||||||
|
if scope == "topic":
|
||||||
await reply(
|
await reply(
|
||||||
text=(
|
text=(
|
||||||
f"topic reasoning override set to `{normalized_level}` "
|
f"topic reasoning override set to `{normalized_level}` "
|
||||||
@@ -233,17 +168,6 @@ async def _handle_reasoning_command(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if chat_prefs is None:
|
|
||||||
await reply(
|
|
||||||
text="chat reasoning overrides are unavailable (no config path)."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
current = await chat_prefs.get_engine_override(msg.chat_id, engine)
|
|
||||||
updated = EngineOverrides(
|
|
||||||
model=current.model if current is not None else None,
|
|
||||||
reasoning=normalized_level,
|
|
||||||
)
|
|
||||||
await chat_prefs.set_engine_override(msg.chat_id, engine, updated)
|
|
||||||
await reply(
|
await reply(
|
||||||
text=(
|
text=(
|
||||||
f"chat reasoning override set to `{normalized_level}` for `{engine}`.\n"
|
f"chat reasoning override set to `{normalized_level}` for `{engine}`.\n"
|
||||||
@@ -259,10 +183,16 @@ async def _handle_reasoning_command(
|
|||||||
return
|
return
|
||||||
if len(tokens) == 2:
|
if len(tokens) == 2:
|
||||||
engine = tokens[1].strip().lower() or None
|
engine = tokens[1].strip().lower() or None
|
||||||
if not await _check_reasoning_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for reasoning overrides.",
|
||||||
|
failed_member="failed to verify reasoning override permissions.",
|
||||||
|
denied="changing reasoning overrides is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if engine is None:
|
if engine is None:
|
||||||
selection = await _resolve_engine_selection(
|
selection = await resolve_engine_selection(
|
||||||
cfg,
|
cfg,
|
||||||
msg,
|
msg,
|
||||||
ambient_context=ambient_context,
|
ambient_context=ambient_context,
|
||||||
@@ -279,29 +209,25 @@ async def _handle_reasoning_command(
|
|||||||
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
text=f"unknown engine `{engine}`.\navailable agents: `{available}`"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
scope = await apply_engine_override(
|
||||||
if topic_store is None:
|
reply=reply,
|
||||||
await reply(text="topic reasoning overrides are unavailable.")
|
tkey=tkey,
|
||||||
return
|
topic_store=topic_store,
|
||||||
current = await topic_store.get_engine_override(tkey[0], tkey[1], engine)
|
chat_prefs=chat_prefs,
|
||||||
updated = EngineOverrides(
|
chat_id=msg.chat_id,
|
||||||
|
engine=engine,
|
||||||
|
update=lambda current: EngineOverrides(
|
||||||
model=current.model if current is not None else None,
|
model=current.model if current is not None else None,
|
||||||
reasoning=None,
|
reasoning=None,
|
||||||
|
),
|
||||||
|
topic_unavailable="topic reasoning overrides are unavailable.",
|
||||||
|
chat_unavailable="chat reasoning overrides are unavailable (no config path).",
|
||||||
)
|
)
|
||||||
await topic_store.set_engine_override(tkey[0], tkey[1], engine, updated)
|
if scope is None:
|
||||||
|
return
|
||||||
|
if scope == "topic":
|
||||||
await reply(text="topic reasoning override cleared (using chat default).")
|
await reply(text="topic reasoning override cleared (using chat default).")
|
||||||
return
|
return
|
||||||
if chat_prefs is None:
|
|
||||||
await reply(
|
|
||||||
text="chat reasoning overrides are unavailable (no config path)."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
current = await chat_prefs.get_engine_override(msg.chat_id, engine)
|
|
||||||
updated = EngineOverrides(
|
|
||||||
model=current.model if current is not None else None,
|
|
||||||
reasoning=None,
|
|
||||||
)
|
|
||||||
await chat_prefs.set_engine_override(msg.chat_id, engine, updated)
|
|
||||||
await reply(text="chat reasoning override cleared.")
|
await reply(text="chat reasoning override cleared.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from ..topic_state import TopicStateStore
|
|||||||
from ..topics import _topic_key
|
from ..topics import _topic_key
|
||||||
from ..trigger_mode import resolve_trigger_mode
|
from ..trigger_mode import resolve_trigger_mode
|
||||||
from ..types import TelegramIncomingMessage
|
from ..types import TelegramIncomingMessage
|
||||||
|
from .overrides import require_admin_or_private
|
||||||
from .reply import make_reply
|
from .reply import make_reply
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -18,29 +19,6 @@ TRIGGER_USAGE = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_trigger_permissions(
|
|
||||||
cfg: TelegramBridgeConfig, msg: TelegramIncomingMessage
|
|
||||||
) -> bool:
|
|
||||||
reply = make_reply(cfg, msg)
|
|
||||||
sender_id = msg.sender_id
|
|
||||||
if sender_id is None:
|
|
||||||
await reply(text="cannot verify sender for trigger settings.")
|
|
||||||
return False
|
|
||||||
is_private = msg.chat_type == "private"
|
|
||||||
if msg.chat_type is None:
|
|
||||||
is_private = msg.chat_id > 0
|
|
||||||
if is_private:
|
|
||||||
return True
|
|
||||||
member = await cfg.bot.get_chat_member(msg.chat_id, sender_id)
|
|
||||||
if member is None:
|
|
||||||
await reply(text="failed to verify trigger permissions.")
|
|
||||||
return False
|
|
||||||
if member.status in {"creator", "administrator"}:
|
|
||||||
return True
|
|
||||||
await reply(text="changing trigger mode is restricted to group admins.")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_trigger_command(
|
async def _handle_trigger_command(
|
||||||
cfg: TelegramBridgeConfig,
|
cfg: TelegramBridgeConfig,
|
||||||
msg: TelegramIncomingMessage,
|
msg: TelegramIncomingMessage,
|
||||||
@@ -91,7 +69,13 @@ async def _handle_trigger_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action in {"all", "mentions"}:
|
if action in {"all", "mentions"}:
|
||||||
if not await _check_trigger_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for trigger settings.",
|
||||||
|
failed_member="failed to verify trigger permissions.",
|
||||||
|
denied="changing trigger mode is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
if tkey is not None:
|
||||||
if topic_store is None:
|
if topic_store is None:
|
||||||
@@ -108,7 +92,13 @@ async def _handle_trigger_command(
|
|||||||
return
|
return
|
||||||
|
|
||||||
if action == "clear":
|
if action == "clear":
|
||||||
if not await _check_trigger_permissions(cfg, msg):
|
if not await require_admin_or_private(
|
||||||
|
cfg,
|
||||||
|
msg,
|
||||||
|
missing_sender="cannot verify sender for trigger settings.",
|
||||||
|
failed_member="failed to verify trigger permissions.",
|
||||||
|
denied="changing trigger mode is restricted to group admins.",
|
||||||
|
):
|
||||||
return
|
return
|
||||||
if tkey is not None:
|
if tkey is not None:
|
||||||
if topic_store is None:
|
if topic_store is None:
|
||||||
|
|||||||
+778
-548
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,12 @@ class TelegramIncomingMessage:
|
|||||||
document: TelegramDocument | None = None
|
document: TelegramDocument | None = None
|
||||||
raw: dict[str, Any] | None = None
|
raw: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_private(self) -> bool:
|
||||||
|
if self.chat_type is not None:
|
||||||
|
return self.chat_type == "private"
|
||||||
|
return self.chat_id > 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class TelegramCallbackQuery:
|
class TelegramCallbackQuery:
|
||||||
|
|||||||
Reference in New Issue
Block a user