Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .codex/skills/telegram-plugin-maintenance/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
name: telegram-plugin-maintenance
description: Maintain speech115/telegram-plugin: GitHub radar, safe PR flow, MCP/control-plane checks, plugin docs sync.
---

# Telegram Plugin Maintenance

Use this skill for `/Users/sereja/Projects/tools/telegram` when the task touches
GitHub state, commits/PRs, MCP tool behavior, control-plane commands, plugin
packaging, or release readiness.

## First Pass

1. Inspect local state with `git status --short --branch`.
2. For GitHub state, use `github-radar`:
- `repobar pulls speech115/telegram-plugin --limit 20 --json`
- `repobar issues speech115/telegram-plugin --limit 20 --json`
- `repobar ci speech115/telegram-plugin --limit 20 --json`
3. Read `AGENTS.md`, then the narrower `mcp/AGENTS.md` or
`control-plane/AGENTS.md` for files you will touch.

## Safe Change Flow

- Create a `codex/...` branch before repo changes.
- Commit with `safe-commit`, listing exact paths.
- Use `safe-pr` only when the user asked to publish a branch/PR.
- Keep merge, issue close, PR comments, tags, and releases as separate explicit
user-approved actions.

## Checks

Run `scripts/safe-gate` before commit. For narrower edits, the focused checks
are:

```bash
mcp/.venv/bin/python -m pytest -q mcp/tests
control-plane/.venv/bin/python -m pytest -q control-plane/tests
mcp/bin/sync-agent-docs --plugin-dir plugin --check --no-restart --json
```

Run `scripts/ci-release-gate.sh` for release or packaging changes.

## Telegram-Specific Boundaries

- Default MCP expansion is read-only.
- Do not add or expose read-state mutators unless the user explicitly asks.
- Do not copy sessions, rewrite LaunchAgents, sync plugin caches, or start
mirror/backfill jobs as incidental cleanup.
- After MCP tool metadata or surface changes, sync agent docs:

```bash
mcp/bin/sync-agent-docs --plugin-dir plugin --no-restart --json
```

Treat plugin drift checks as packaging evidence only; they do not prove live MCP
runtime health.
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Telegram Plugin Repo Rules

## Scope

This repo contains three coupled surfaces:

- `mcp/`: Telegram MCP server and live tool behavior.
- `control-plane/`: local operator commands, release checks, and agent docs.
- `plugin/`: packaged Codex plugin and bundled Telegram skill.

Prefer the more specific `mcp/AGENTS.md` and `control-plane/AGENTS.md` when
working inside those directories.

## GitHub Workflow

- Use the `github-radar` skill before PR, merge, release, or issue cleanup work.
- Use the `git-safe-workflow` skill for commits and PRs.
- Default path is branch -> local checks -> exact-file commit -> PR.
- Do not push directly to `main` unless the user explicitly asks for direct-main
behavior.
- Do not merge PRs, close issues, post public GitHub comments, tag, or release
without a separate explicit user request.

## Local Gate

Run `scripts/safe-gate` before committing non-trivial changes. It checks:

- whitespace diff hygiene;
- MCP tests;
- control-plane tests in portable mode;
- agent docs sync drift.

For full release verification, run `scripts/ci-release-gate.sh`.

## Product Safety

- Keep Telegram read-surface expansion read-only by default.
- Do not add read-state mutators, direct sends, cache sync, session copying,
LaunchAgent rewrites, or mirror jobs without an explicit request.
- After MCP tool surface changes, run `mcp/bin/sync-agent-docs --plugin-dir plugin --no-restart --json`.
- A clean plugin drift report is not enough; verify the actual runtime/tool
surface when the change affects MCP behavior.
5 changes: 4 additions & 1 deletion mcp/docs/agent/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ The restricted plugin profile exposes task-shaped tools only. Prefer these names
- `download_media`
- `download_media_batch`
- `download_dialog_media`
- `telegram_export_members`

## Not on default surface

Low-level aliases such as `read_today_dialog`, `send_dialog_message`, and admin
mutations require an explicit full/admin profile. Agents on the default surface
must not call them.

`telegram_export_members` is an explicit owner/local privacy export. It remains
available in the full owner surface, but it is not routine default-facade context
gathering.

## Modes for `telegram_read`

- `fast` — no voice transcription, no sender names (default for skim)
Expand Down
8 changes: 8 additions & 0 deletions mcp/src/telegram_mcp/approval_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ def _mutate(self, *, token: str, nonce: str, action: str) -> str:
raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
if nonce != record.one_time_nonce:
raise ToolContractError("invalid_confirmation_token", "approval nonce is invalid")
if record.approval_state == "approved":
return "<h1>Уже одобрено</h1><p class='meta'>Можно отправлять через telegram_confirmed_send.</p>"
if record.approval_state == "used":
return "<h1>Уже использовано</h1><p class='meta'>Этот токен уже был отправлен.</p>"
if record.approval_state == "rejected":
return "<h1>Уже отклонено</h1><p class='meta'>Создайте новое превью, если нужно отправить сообщение.</p>"
if record.approval_state == "expired":
raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
if action == "approve":
store.approve(token)
return "<h1>Одобрено</h1><p class='meta'>Можно отправлять через telegram_confirmed_send.</p>"
Expand Down
28 changes: 27 additions & 1 deletion mcp/src/telegram_mcp/fast_read_today.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class FastReadError(RuntimeError):
pass


class FastReadToolError(FastReadError):
def __init__(self, endpoint: str, payload: object | None) -> None:
self.endpoint = endpoint
self.payload = payload
super().__init__(f"MCP tool error at {endpoint}: {payload!r}")


ACCOUNT_ENDPOINTS = {
"main": (8799, "~/.telegram-mcp/launchd.env"),
"crwddy": (8799, "~/.telegram-mcp/launchd.env"),
Expand Down Expand Up @@ -142,9 +149,24 @@ def payload_is_tool_error(payload: object | None) -> bool:


def exception_is_tool_error(exc: Exception) -> bool:
if isinstance(exc, FastReadToolError):
return True
return payload_is_tool_error(str(exc))


def tool_error_code(payload: object | None) -> str | None:
if isinstance(payload, dict):
code = payload.get("code") or payload.get("error_code")
return str(code) if code is not None else None
if isinstance(payload, str):
stripped = payload.strip()
if ":" in stripped:
prefix = stripped.split(":", 1)[0].strip()
if prefix:
return prefix
return None


async def read_once(
*,
attempt: EndpointAttempt,
Expand Down Expand Up @@ -196,7 +218,7 @@ async def read_once(
if bool(getattr(result, "isError", False)) or payload_is_tool_error(payload):
structured = getattr(result, "structuredContent", None)
error_payload = structured if structured is not None else payload
raise FastReadError(f"MCP tool error at {attempt.endpoint}: {error_payload!r}")
raise FastReadToolError(attempt.endpoint, error_payload)

elapsed_seconds = round(time.perf_counter() - started, 3)
from .agent_preflight import observe_fast_read
Expand Down Expand Up @@ -326,6 +348,10 @@ def main(argv: list[str] | None = None) -> int:
"error": "telegram_tool_error",
"message": "Live Telegram read failed inside the MCP tool.",
}
if isinstance(exc, FastReadToolError):
error["endpoint"] = exc.endpoint
error["tool_error_code"] = tool_error_code(exc.payload)
error["tool_error_payload"] = exc.payload
Comment on lines +351 to +354

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Propagate typed fast-read errors to main

In the CLI path this branch never sees tool failures from telegram_read: read_once raises FastReadToolError, but read_with_failover catches it as the base FastReadError and re-raises a generic FastReadError after the loop. For structured MCP errors such as {"code":"permission_denied"}, the final string does not satisfy exception_is_tool_error, so telegram-fast-read-today emits a raw error instead of the new tool_error_code/payload fields.

Useful? React with 👍 / 👎.

else:
error = {
"ok": False,
Expand Down
4 changes: 4 additions & 0 deletions mcp/src/telegram_mcp/send_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ def reject(self, token: str) -> SendConfirmationRecord:
record = self._expire_if_needed(preview_id, record)
if record.approval_state == "expired":
raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
if record.approval_state == "used":
raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
if record.approval_state == "approved":
return record
record.approval_state = "rejected"
return record

Expand Down
13 changes: 13 additions & 0 deletions mcp/tests/test_fast_read_today.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
from telegram_mcp.fast_read_today import (
EndpointAttempt,
FastReadError,
FastReadToolError,
exception_is_tool_error,
endpoint_attempts,
payload_is_tool_error,
read_with_failover,
tool_error_code,
)


Expand Down Expand Up @@ -70,6 +72,17 @@ def test_exception_is_tool_error_detects_nested_tool_failure(self):

self.assertTrue(exception_is_tool_error(exc))

def test_typed_fast_read_tool_error_keeps_payload_code(self):
payload = {
"code": "permission_denied",
"message": "private channel",
"next": "ask user for access",
}
exc = FastReadToolError("http://127.0.0.1:8799/mcp", payload)

self.assertTrue(exception_is_tool_error(exc))
self.assertEqual(tool_error_code(exc.payload), "permission_denied")

def test_read_with_failover_does_not_cross_account_by_default(self):
attempts = [
EndpointAttempt("http://127.0.0.1:8799/mcp", "/tmp/a.env", 8799),
Expand Down
21 changes: 21 additions & 0 deletions mcp/tests/test_send_confirmation.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ def test_approve_by_token_then_consume_removes_token_lookup(self):
self.assertIsNone(store.get(token))
self.assertIsNone(store.get(preview_id))

def test_reject_after_approval_does_not_cancel_approval(self):
store = SendConfirmationStore(ttl_seconds=60)
payload = {"chat": "@x", "text_hash": "abc"}
_preview_id, token, _ = store.mint(payload, preview_text="hi")

store.approve(token)
store.reject(token)

record = store.get(token)
self.assertIsNotNone(record)
self.assertEqual(record.approval_state, "approved") # type: ignore[union-attr]


class ApprovalServerTests(unittest.TestCase):
def tearDown(self):
Expand Down Expand Up @@ -165,6 +177,15 @@ def test_get_does_not_approve_and_post_requires_nonce(self):
self.assertEqual(response.status, 200)
self.assertEqual(store.get(token).approval_state, "approved") # type: ignore[union-attr]

body = urlencode({"token": token, "nonce": record.one_time_nonce, "action": "reject"})
conn = HTTPConnection("127.0.0.1", port)
conn.request("POST", "/telegram/approve", body=body, headers={"Content-Type": "application/x-www-form-urlencoded"})
response = conn.getresponse()
response.read()
conn.close()
self.assertEqual(response.status, 200)
self.assertEqual(store.get(token).approval_state, "approved") # type: ignore[union-attr]


if __name__ == "__main__":
unittest.main()
37 changes: 37 additions & 0 deletions scripts/safe-gate
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"

python_for() {
local package_dir="$1"
if [ -x "${ROOT}/${package_dir}/.venv/bin/python" ]; then
printf '%s\n' "${ROOT}/${package_dir}/.venv/bin/python"
else
command -v python3
fi
}

git -C "${ROOT}" diff --check

MCP_PYTHON="$(python_for mcp)"
CONTROL_PYTHON="$(python_for control-plane)"

(
cd "${ROOT}/mcp"
"${MCP_PYTHON}" -m unittest discover -s tests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use pytest for the MCP leg

This gate is advertised as running the MCP tests, but unittest discover skips the repo's pytest-style module-level tests, such as mcp/tests/test_metadata_reads.py and mcp/tests/test_metadata_scaffold.py. A regression in those files can pass scripts/safe-gate locally while still failing the release/CI pytest run, so the pre-commit gate is not checking the same MCP suite it claims to cover.

Useful? React with 👍 / 👎.

)

(
cd "${ROOT}/control-plane"
export TELEGRAM_CI_PORTABLE=1
export TELEGRAM_MONOREPO_ROOT="${ROOT}"
export TELEGRAM_CONTROL_PLANE_ROOT="${ROOT}/control-plane"
export TELEGRAM_MCP_REPO="${ROOT}/mcp"
export TELEGRAM_PLUGIN_SOURCE="${ROOT}/plugin"
export TELEGRAM_PLUGIN_PACKAGE="${ROOT}/plugin"
export TELEGRAM_PROJECTS_ROOT="${ROOT}"
"${CONTROL_PYTHON}" -m pytest -q tests
)

"${ROOT}/mcp/bin/sync-agent-docs" --plugin-dir "${ROOT}/plugin" --check --no-restart --json
Loading