Add GitHub webhook receiver

This commit is contained in:
2026-07-01 21:41:01 +02:00
parent 36170f83d1
commit 62ea12033f
9 changed files with 706 additions and 7 deletions

View File

@@ -68,10 +68,6 @@ class CommandDispatcher:
definition = self.registry.resolve(parsed.name)
if definition is None:
if getattr(getattr(message, "from_user", None), "id", None) == getattr(
getattr(account, "me", None), "id", None
):
await message.reply_text(f"Unknown command: {parsed.prefix}{parsed.alias}")
return True
actor_id = getattr(getattr(message, "from_user", None), "id", None)

View File

@@ -101,6 +101,14 @@ class AIConfig:
page_extract: AIPageExtractConfig = field(default_factory=AIPageExtractConfig)
@dataclass(slots=True)
class GitHubWebhookConfig:
enabled: bool = False
host: str = "0.0.0.0"
port: int = 8080
secret: str | None = None
@dataclass(slots=True)
class AppConfig:
api_id: int | None
@@ -108,6 +116,7 @@ class AppConfig:
session_dir: Path
accounts: list[AccountConfig]
ai: AIConfig | None = None
github_webhook: GitHubWebhookConfig = field(default_factory=GitHubWebhookConfig)
def _read_json_file(path: Path) -> dict[str, Any]:
@@ -298,6 +307,52 @@ def _parse_page_extract_config(raw_ai: dict[str, Any]) -> AIPageExtractConfig:
)
def _parse_github_webhook_config(raw: Any) -> GitHubWebhookConfig:
raw_config = _parse_mapping(raw, field_name="github_webhook")
enabled = _parse_bool(
raw_config.get("enabled", False),
field_name="github_webhook.enabled",
)
host = str(raw_config.get("host", "0.0.0.0") or "0.0.0.0").strip()
if not host:
raise ValueError("github_webhook.host is required")
port = _parse_int(raw_config.get("port", 8080), field_name="github_webhook.port")
if port < 1 or port > 65535:
raise ValueError("github_webhook.port must be between 1 and 65535")
raw_secret = raw_config.get("secret")
secret = str(raw_secret).strip() if raw_secret is not None else None
secret = secret or None
if os.environ.get("GITHUB_WEBHOOK_ENABLED") is not None:
enabled = _parse_bool(
os.environ["GITHUB_WEBHOOK_ENABLED"],
field_name="GITHUB_WEBHOOK_ENABLED",
)
if os.environ.get("GITHUB_WEBHOOK_HOST") is not None:
host = os.environ["GITHUB_WEBHOOK_HOST"].strip()
if not host:
raise ValueError("GITHUB_WEBHOOK_HOST is required")
if os.environ.get("GITHUB_WEBHOOK_PORT") is not None:
port = _parse_int(os.environ["GITHUB_WEBHOOK_PORT"], field_name="GITHUB_WEBHOOK_PORT")
if port < 1 or port > 65535:
raise ValueError("GITHUB_WEBHOOK_PORT must be between 1 and 65535")
if os.environ.get("GITHUB_WEBHOOK_SECRET") is not None:
secret = os.environ["GITHUB_WEBHOOK_SECRET"].strip() or None
if enabled and not secret:
raise ValueError("github_webhook.secret is required when github_webhook.enabled is true")
return GitHubWebhookConfig(
enabled=enabled,
host=host,
port=port,
secret=secret,
)
def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_api_hash: str | None, default_session_dir: Path) -> list[AccountConfig]:
if raw_accounts is None:
return []
@@ -552,6 +607,7 @@ def load_app_config(root: Path) -> AppConfig:
)
ai = _parse_ai_config(raw.get("ai"), root=root)
github_webhook = _parse_github_webhook_config(raw.get("github_webhook"))
if not accounts:
raise ValueError("At least one account must be configured")
@@ -572,4 +628,5 @@ def load_app_config(root: Path) -> AppConfig:
session_dir=session_dir,
accounts=accounts,
ai=ai,
github_webhook=github_webhook,
)

388
app/github_webhook.py Normal file
View File

@@ -0,0 +1,388 @@
from __future__ import annotations
import hashlib
import hmac
import html
import json
import logging
from dataclasses import dataclass
from textwrap import wrap
from typing import TYPE_CHECKING, Any
from aiohttp import web
from pyrogram import enums
from app.config import GitHubWebhookConfig
if TYPE_CHECKING:
from app.manager import AccountManager, AccountState
log = logging.getLogger(__name__)
MAX_TELEGRAM_HTML = 3900
MAX_FALLBACK_JSON = 2000
class GitHubWebhookError(Exception):
def __init__(self, status: int, message: str) -> None:
super().__init__(message)
self.status = status
self.message = message
@dataclass(slots=True)
class GitHubWebhookServer:
manager: "AccountManager"
config: GitHubWebhookConfig
runner: web.AppRunner | None = None
site: web.TCPSite | None = None
async def start(self) -> None:
if not self.config.enabled:
return
app = web.Application()
app.add_routes([web.post("/github/{account}/{chat_id}", self.handle)])
self.runner = web.AppRunner(app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, self.config.host, self.config.port)
await self.site.start()
log.info(
"GitHub webhook server listening on %s:%s",
self.config.host,
self.config.port,
)
async def stop(self) -> None:
if self.runner is None:
return
await self.runner.cleanup()
self.runner = None
self.site = None
async def handle(self, request: web.Request) -> web.Response:
try:
body = await request.read()
verify_signature(body, request.headers.get("X-Hub-Signature-256"), self.config.secret)
event = require_header(request, "X-GitHub-Event")
delivery = require_header(request, "X-GitHub-Delivery")
payload = parse_json_body(body)
state = self.resolve_account(request.match_info["account"])
chat_id = parse_chat_id(request.match_info["chat_id"])
if state.client is None or state.status != "running":
raise GitHubWebhookError(503, "account is not running")
text = format_github_event(event, payload, delivery=delivery)
for chunk in chunk_text(text):
await state.client.send_message(
chat_id,
chunk,
parse_mode=enums.ParseMode.HTML,
disable_web_page_preview=True,
)
except GitHubWebhookError as exc:
return web.json_response({"ok": False, "error": exc.message}, status=exc.status)
except Exception:
log.exception("GitHub webhook delivery failed")
return web.json_response({"ok": False, "error": "internal server error"}, status=500)
return web.json_response({"ok": True})
def resolve_account(self, account_name: str) -> "AccountState":
for state in self.manager.states:
if account_name in {state.config.name, state.config.session}:
return state
raise GitHubWebhookError(404, "account not found")
def require_header(request: web.Request, name: str) -> str:
value = request.headers.get(name)
if not value:
raise GitHubWebhookError(400, f"missing {name} header")
return value
def verify_signature(body: bytes, signature: str | None, secret: str | None) -> None:
if not secret:
raise GitHubWebhookError(500, "github webhook secret is not configured")
if not signature:
raise GitHubWebhookError(401, "missing X-Hub-Signature-256 header")
if not signature.startswith("sha256="):
raise GitHubWebhookError(401, "invalid X-Hub-Signature-256 header")
expected = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise GitHubWebhookError(401, "invalid signature")
def parse_json_body(body: bytes) -> dict[str, Any]:
try:
payload = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise GitHubWebhookError(400, "request body must be valid JSON") from exc
if not isinstance(payload, dict):
raise GitHubWebhookError(400, "request JSON must be an object")
return payload
def parse_chat_id(value: str) -> int | str:
chat_id = value.strip()
if not chat_id:
raise GitHubWebhookError(400, "chat_id is required")
if chat_id.startswith("@"):
return chat_id
try:
return int(chat_id)
except ValueError as exc:
raise GitHubWebhookError(400, "chat_id must be an integer or @username") from exc
def chunk_text(text: str, limit: int = MAX_TELEGRAM_HTML) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
current: list[str] = []
current_len = 0
for paragraph in text.split("\n"):
piece = paragraph if not current else "\n" + paragraph
if current_len + len(piece) <= limit:
current.append(piece.lstrip("\n") if not current else piece)
current_len += len(piece)
continue
if current:
chunks.append("".join(current))
current = []
current_len = 0
if len(paragraph) <= limit:
current = [paragraph]
current_len = len(paragraph)
continue
chunks.extend(wrap(paragraph, width=limit) or [paragraph[:limit]])
if current:
chunks.append("".join(current))
return [chunk for chunk in chunks if chunk]
def format_github_event(event: str, payload: dict[str, Any], *, delivery: str | None = None) -> str:
formatter = {
"push": _format_push,
"pull_request": _format_pull_request,
"issues": _format_issue,
"issue_comment": _format_issue_comment,
"release": _format_release,
"workflow_run": _format_workflow_run,
}.get(event, _format_fallback)
lines = formatter(event, payload)
if delivery:
lines.append("")
lines.append(f"<code>delivery: {html.escape(delivery)}</code>")
return "\n".join(line for line in lines if line is not None)
def _format_push(event: str, payload: dict[str, Any]) -> list[str]:
repo = _repo_name(payload)
sender = _sender_name(payload)
ref = _short_ref(_text(payload.get("ref"), "unknown"))
commits = _list(payload.get("commits"))
compare_url = _text(payload.get("compare"))
head_commit = _dict(payload.get("head_commit"))
lines = [
f"<b>GitHub push</b> in {_link(repo, _repo_url(payload))}",
f"Branch: <code>{_e(ref)}</code>",
f"Sender: {_e(sender)}",
f"Commits: <b>{len(commits)}</b>",
]
if compare_url:
lines.append(f"Compare: {_link('open diff', compare_url)}")
for commit in commits[:5]:
commit_data = _dict(commit)
sha = _text(commit_data.get("id"))[:7]
message = _first_line(_text(commit_data.get("message"), "No commit message"))
author = _text(_dict(commit_data.get("author")).get("name"), "unknown")
url = _text(commit_data.get("url"))
label = f"{sha} - {message}" if sha else message
lines.append(f"- {_link(label, url)} by {_e(author)}")
if len(commits) > 5:
lines.append(f"- ...and {len(commits) - 5} more")
if not commits and head_commit:
message = _first_line(_text(head_commit.get("message"), "No commit message"))
lines.append(f"Head: {_link(message, _text(head_commit.get('url')))}")
return lines
def _format_pull_request(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "updated")
repo = _repo_name(payload)
pr = _dict(payload.get("pull_request"))
number = _text(pr.get("number") or payload.get("number"), "?")
title = _text(pr.get("title"), "Untitled pull request")
state = _text(pr.get("state"), "unknown")
merged = pr.get("merged")
user = _login(pr.get("user")) or _sender_name(payload)
url = _text(pr.get("html_url"))
additions = pr.get("additions")
deletions = pr.get("deletions")
changed_files = pr.get("changed_files")
lines = [
f"<b>GitHub pull request {html.escape(action)}</b> in {_link(repo, _repo_url(payload))}",
f"PR: {_link('#' + number + ' ' + title, url)}",
f"State: <code>{_e(state)}</code>{' merged' if merged else ''}",
f"Author: {_e(user)}",
]
stats = []
if changed_files is not None:
stats.append(f"{changed_files} files")
if additions is not None:
stats.append(f"+{additions}")
if deletions is not None:
stats.append(f"-{deletions}")
if stats:
lines.append("Changes: " + ", ".join(stats))
return lines
def _format_issue(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "updated")
repo = _repo_name(payload)
issue = _dict(payload.get("issue"))
number = _text(issue.get("number") or payload.get("number"), "?")
title = _text(issue.get("title"), "Untitled issue")
state = _text(issue.get("state"), "unknown")
user = _login(issue.get("user")) or _sender_name(payload)
return [
f"<b>GitHub issue {html.escape(action)}</b> in {_link(repo, _repo_url(payload))}",
f"Issue: {_link('#' + number + ' ' + title, _text(issue.get('html_url')))}",
f"State: <code>{_e(state)}</code>",
f"Author: {_e(user)}",
]
def _format_issue_comment(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "updated")
repo = _repo_name(payload)
issue = _dict(payload.get("issue"))
comment = _dict(payload.get("comment"))
number = _text(issue.get("number"), "?")
title = _text(issue.get("title"), "Untitled issue")
user = _login(comment.get("user")) or _sender_name(payload)
body = _first_line(_text(comment.get("body"), ""), limit=220)
lines = [
f"<b>GitHub issue comment {html.escape(action)}</b> in {_link(repo, _repo_url(payload))}",
f"Thread: {_link('#' + number + ' ' + title, _text(issue.get('html_url')))}",
f"Comment: {_link('open comment', _text(comment.get('html_url')))} by {_e(user)}",
]
if body:
lines.append(f"<blockquote>{_e(body)}</blockquote>")
return lines
def _format_release(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "updated")
repo = _repo_name(payload)
release = _dict(payload.get("release"))
name = _text(release.get("name") or release.get("tag_name"), "Untitled release")
tag = _text(release.get("tag_name"), "unknown")
author = _login(release.get("author")) or _sender_name(payload)
return [
f"<b>GitHub release {html.escape(action)}</b> in {_link(repo, _repo_url(payload))}",
f"Release: {_link(name, _text(release.get('html_url')))}",
f"Tag: <code>{_e(tag)}</code>",
f"Author: {_e(author)}",
]
def _format_workflow_run(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "updated")
repo = _repo_name(payload)
run = _dict(payload.get("workflow_run"))
name = _text(run.get("name"), "Workflow")
status = _text(run.get("status"), "unknown")
conclusion = _text(run.get("conclusion"), "")
branch = _text(run.get("head_branch"), "unknown")
actor = _login(run.get("actor")) or _sender_name(payload)
state = conclusion if conclusion else status
return [
f"<b>GitHub workflow {html.escape(action)}</b> in {_link(repo, _repo_url(payload))}",
f"Run: {_link(name, _text(run.get('html_url')))}",
f"State: <code>{_e(state)}</code>",
f"Branch: <code>{_e(branch)}</code>",
f"Actor: {_e(actor)}",
]
def _format_fallback(event: str, payload: dict[str, Any]) -> list[str]:
action = _text(payload.get("action"), "")
repo = _repo_name(payload)
sender = _sender_name(payload)
pretty = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
if len(pretty) > MAX_FALLBACK_JSON:
pretty = pretty[:MAX_FALLBACK_JSON].rstrip() + "\n..."
lines = [
f"<b>GitHub {html.escape(event)}</b> in {_link(repo, _repo_url(payload))}",
f"Sender: {_e(sender)}",
]
if action:
lines.append(f"Action: <code>{_e(action)}</code>")
lines.append(f"<pre><code>{_e(pretty)}</code></pre>")
return lines
def _repo_name(payload: dict[str, Any]) -> str:
repo = _dict(payload.get("repository"))
return _text(repo.get("full_name") or repo.get("name"), "unknown/repository")
def _repo_url(payload: dict[str, Any]) -> str:
return _text(_dict(payload.get("repository")).get("html_url"))
def _sender_name(payload: dict[str, Any]) -> str:
return _login(payload.get("sender")) or "unknown"
def _login(value: Any) -> str | None:
data = _dict(value)
login = _text(data.get("login"))
return login or None
def _dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _list(value: Any) -> list[Any]:
return value if isinstance(value, list) else []
def _text(value: Any, default: str = "") -> str:
if value is None:
return default
return str(value)
def _first_line(value: str, *, limit: int = 140) -> str:
line = value.strip().splitlines()[0] if value.strip() else ""
if len(line) <= limit:
return line
return line[: limit - 1].rstrip() + "..."
def _short_ref(ref: str) -> str:
for prefix in ("refs/heads/", "refs/tags/"):
if ref.startswith(prefix):
return ref[len(prefix) :]
return ref
def _link(label: str, url: str) -> str:
safe_label = _e(label)
if not url:
return safe_label
return f'<a href="{html.escape(url, quote=True)}">{safe_label}</a>'
def _e(value: Any) -> str:
return html.escape(str(value), quote=False)

View File

@@ -10,6 +10,7 @@ from pyrogram import Client, idle
from app.ai import AIService
from app.commands import DEFAULT_REGISTRY, CommandRegistry, RuntimeAclStore
from app.config import AccountConfig, AppConfig
from app.github_webhook import GitHubWebhookServer
from app.handlers import register_common_handlers
log = logging.getLogger(__name__)
@@ -32,11 +33,13 @@ class AccountManager:
ai_service: AIService | None = field(init=False)
command_registry: CommandRegistry = field(init=False)
states: list[AccountState] = field(init=False)
github_webhook_server: GitHubWebhookServer = field(init=False)
def __post_init__(self) -> None:
self.command_registry = DEFAULT_REGISTRY
self.ai_service = AIService(self.config.ai) if self.config.ai is not None else None
self.states = [AccountState(config=account) for account in self.config.accounts]
self.github_webhook_server = GitHubWebhookServer(self, self.config.github_webhook)
def _make_client(self, account: AccountConfig) -> Client:
api_id = account.api_id if account.api_id is not None else self.config.api_id
@@ -147,10 +150,11 @@ class AccountManager:
await self.stop_all()
raise RuntimeError("No accounts were started successfully")
if self.ai_service is not None:
await self.ai_service.initialize()
try:
if self.ai_service is not None:
await self.ai_service.initialize()
await self.github_webhook_server.start()
await idle()
finally:
await self.github_webhook_server.stop()
await self.stop_all()

View File

@@ -12,6 +12,12 @@
"session": "work"
}
],
"github_webhook": {
"enabled": false,
"host": "0.0.0.0",
"port": 8080,
"secret": "change-me"
},
"ai": {
"default_model": "mm",
"context_messages": 12,

View File

@@ -3,5 +3,6 @@ tgcrypto
python-dotenv
openai
httpx
aiohttp
playwright
elevenlabs

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from app.config import _parse_github_webhook_config
class ConfigGitHubWebhookTests(unittest.TestCase):
def test_github_webhook_config_parses_values(self) -> None:
with patch.dict("os.environ", {}, clear=True):
config = _parse_github_webhook_config(
{
"enabled": True,
"host": "127.0.0.1",
"port": 9000,
"secret": "config-secret",
}
)
self.assertTrue(config.enabled)
self.assertEqual(config.host, "127.0.0.1")
self.assertEqual(config.port, 9000)
self.assertEqual(config.secret, "config-secret")
def test_github_webhook_env_overrides(self) -> None:
env = {
"GITHUB_WEBHOOK_ENABLED": "true",
"GITHUB_WEBHOOK_HOST": "localhost",
"GITHUB_WEBHOOK_PORT": "7777",
"GITHUB_WEBHOOK_SECRET": "env-secret",
}
with patch.dict("os.environ", env, clear=True):
config = _parse_github_webhook_config(
{
"enabled": False,
"host": "0.0.0.0",
"port": 8080,
"secret": "config-secret",
}
)
self.assertTrue(config.enabled)
self.assertEqual(config.host, "localhost")
self.assertEqual(config.port, 7777)
self.assertEqual(config.secret, "env-secret")
def test_github_webhook_requires_secret_when_enabled(self) -> None:
with patch.dict("os.environ", {}, clear=True):
with self.assertRaisesRegex(ValueError, "github_webhook.secret"):
_parse_github_webhook_config({"enabled": True})
def test_github_webhook_allows_missing_secret_when_disabled(self) -> None:
with patch.dict("os.environ", {}, clear=True):
config = _parse_github_webhook_config({"enabled": False})
self.assertFalse(config.enabled)
self.assertIsNone(config.secret)
if __name__ == "__main__":
unittest.main()

32
tests/test_dispatcher.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from app.commands import CommandDispatcher, CommandRegistry
class CommandDispatcherTests(unittest.IsolatedAsyncioTestCase):
async def test_unknown_owner_command_is_silent(self) -> None:
dispatcher = CommandDispatcher(CommandRegistry())
message = SimpleNamespace(
text=".missing",
from_user=SimpleNamespace(id=123),
reply_text=AsyncMock(),
)
account = SimpleNamespace(me=SimpleNamespace(id=123))
handled = await dispatcher.dispatch(
client=SimpleNamespace(),
message=message,
manager=SimpleNamespace(),
account=account,
)
self.assertTrue(handled)
message.reply_text.assert_not_awaited()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,153 @@
from __future__ import annotations
import hashlib
import hmac
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from app.config import GitHubWebhookConfig
from app.github_webhook import GitHubWebhookServer, chunk_text, format_github_event
def _signature(body: bytes, secret: str = "secret") -> str:
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
return f"sha256={digest}"
def _push_payload() -> dict:
return {
"ref": "refs/heads/main",
"compare": "https://github.com/acme/project/compare/a...b",
"repository": {
"full_name": "acme/project",
"html_url": "https://github.com/acme/project",
},
"sender": {"login": "octocat"},
"commits": [
{
"id": "abcdef123456",
"message": "Add webhook receiver\n\nDetails",
"url": "https://github.com/acme/project/commit/abcdef1",
"author": {"name": "Mona"},
}
],
}
class GitHubWebhookFormattingTests(unittest.TestCase):
def test_format_push_event(self) -> None:
text = format_github_event("push", _push_payload(), delivery="delivery-1")
self.assertIn("GitHub push", text)
self.assertIn("acme/project", text)
self.assertIn("main", text)
self.assertIn("abcdef1", text)
self.assertIn("delivery-1", text)
def test_format_pull_request_event(self) -> None:
text = format_github_event(
"pull_request",
{
"action": "opened",
"repository": {"full_name": "acme/project"},
"sender": {"login": "octocat"},
"pull_request": {
"number": 42,
"title": "Improve webhooks",
"html_url": "https://github.com/acme/project/pull/42",
"state": "open",
"user": {"login": "octocat"},
"changed_files": 3,
"additions": 12,
"deletions": 4,
},
},
)
self.assertIn("pull request opened", text)
self.assertIn("#42 Improve webhooks", text)
self.assertIn("3 files", text)
def test_format_unknown_event_uses_compact_json(self) -> None:
text = format_github_event(
"star",
{
"action": "created",
"repository": {"full_name": "acme/project"},
"sender": {"login": "octocat"},
"extra": "value",
},
)
self.assertIn("GitHub star", text)
self.assertIn("Action:", text)
self.assertIn('"extra": "value"', text)
def test_chunk_text_splits_long_messages(self) -> None:
chunks = chunk_text("a" * 5000, limit=1000)
self.assertGreater(len(chunks), 1)
self.assertTrue(all(len(chunk) <= 1000 for chunk in chunks))
class GitHubWebhookHandlerTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
self.telegram_client = SimpleNamespace(send_message=AsyncMock())
state = SimpleNamespace(
config=SimpleNamespace(name="personal", session="personal_session"),
client=self.telegram_client,
status="running",
)
manager = SimpleNamespace(states=[state])
self.server = GitHubWebhookServer(
manager=manager,
config=GitHubWebhookConfig(enabled=True, secret="secret"),
)
app = web.Application()
app.add_routes([web.post("/github/{account}/{chat_id}", self.server.handle)])
self.client = TestClient(TestServer(app))
await self.client.start_server()
async def asyncTearDown(self) -> None:
await self.client.close()
async def _post(self, payload: dict, *, signature: str | None = None, account: str = "personal"):
body = json.dumps(payload).encode("utf-8")
headers = {
"Content-Type": "application/json",
"X-GitHub-Event": "push",
"X-GitHub-Delivery": "delivery-1",
"X-Hub-Signature-256": signature if signature is not None else _signature(body),
}
return await self.client.post(f"/github/{account}/-100123", data=body, headers=headers)
async def test_handler_accepts_valid_signature_and_sends_message(self) -> None:
response = await self._post(_push_payload())
self.assertEqual(response.status, 200)
self.telegram_client.send_message.assert_awaited_once()
args, kwargs = self.telegram_client.send_message.await_args
self.assertEqual(args[0], -100123)
self.assertIn("GitHub push", args[1])
self.assertIn("parse_mode", kwargs)
async def test_handler_rejects_invalid_signature(self) -> None:
response = await self._post(_push_payload(), signature="sha256=bad")
self.assertEqual(response.status, 401)
self.telegram_client.send_message.assert_not_awaited()
async def test_handler_rejects_unknown_account(self) -> None:
response = await self._post(_push_payload(), account="missing")
self.assertEqual(response.status, 404)
self.telegram_client.send_message.assert_not_awaited()
if __name__ == "__main__":
unittest.main()