forked from retoor/devplacepy
docs: document server-side rendering pipeline, response timing middleware, and Telegram pairing API
- Add comprehensive documentation for backend content rendering in AGENTS.md, detailing the new `render_content` and `render_title` Jinja globals built on mistune with media processing, emoji shortcodes, and XSS protection
- Document the `X-Response-Time` header and bottom-left render time indicator in README.md
- Update bot token pricing documentation to clarify fallback vs gateway cost headers
- Add `email_accounts` to soft-delete tables and `idx_users_role` composite index in database schema
- Implement `telegram_pairings` and `telegram_links` table creation with column migration and indexes
- Add `/profile/{username}/telegram` endpoint to docs API with request/unpair actions
- Register `TelegramService` in main.py lifespan and add `response_timing` middleware emitting `X-Response-Time` header
- Introduce `TelegramPairForm` model and `guard_public_host_sync` synchronous host validation function
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -0,0 +1,53 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.telegram.format import (
|
||||
markdown_to_telegram_html,
|
||||
split_for_telegram,
|
||||
)
|
||||
|
||||
|
||||
def test_markdown_basic_formatting():
|
||||
out = markdown_to_telegram_html("Hello **bold** and _em_ and `code`")
|
||||
assert "<b>bold</b>" in out
|
||||
assert "<i>em</i>" in out
|
||||
assert "<code>code</code>" in out
|
||||
|
||||
|
||||
def test_markdown_link_kept():
|
||||
out = markdown_to_telegram_html("see [docs](https://example.com/x)")
|
||||
assert '<a href="https://example.com/x">docs</a>' in out
|
||||
|
||||
|
||||
def test_markdown_escapes_special_characters():
|
||||
out = markdown_to_telegram_html("a < b & c > d")
|
||||
assert "<" in out and "&" in out and ">" in out
|
||||
|
||||
|
||||
def test_markdown_drops_unsupported_tags():
|
||||
out = markdown_to_telegram_html("# Heading\n\ntext")
|
||||
assert "<h1>" not in out
|
||||
assert "<b>Heading</b>" in out
|
||||
|
||||
|
||||
def test_split_respects_limit():
|
||||
chunks = split_for_telegram("a" * 5000, limit=3900)
|
||||
assert all(len(chunk) <= 3900 for chunk in chunks)
|
||||
assert "".join(chunks) == "a" * 5000
|
||||
|
||||
|
||||
def test_split_short_text_single_chunk():
|
||||
assert split_for_telegram("hello") == ["hello"]
|
||||
|
||||
|
||||
def test_split_breaks_single_oversized_line():
|
||||
chunks = split_for_telegram("b" * 9000, limit=4000)
|
||||
assert all(len(chunk) <= 4000 for chunk in chunks)
|
||||
assert "".join(chunks) == "b" * 9000
|
||||
|
||||
|
||||
def test_split_empty_text_returns_single_empty_chunk():
|
||||
assert split_for_telegram("") == [""]
|
||||
|
||||
|
||||
def test_markdown_empty_input_is_empty():
|
||||
assert markdown_to_telegram_html("") == ""
|
||||
@@ -0,0 +1,78 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services.telegram import store
|
||||
|
||||
|
||||
def _seed_user(uid: str) -> None:
|
||||
users = get_table("users")
|
||||
if not users.find_one(uid=uid):
|
||||
users.insert({"uid": uid, "username": uid, "api_key": f"key-{uid}"})
|
||||
|
||||
|
||||
def test_issue_code_is_four_digits(local_db):
|
||||
_seed_user("tg-user-1")
|
||||
issued = store.issue_code("tg-user-1")
|
||||
assert issued["code"].isdigit() and len(issued["code"]) == 4
|
||||
assert issued["ttl_minutes"] >= 1
|
||||
|
||||
|
||||
def test_verify_binds_and_is_single_use(local_db):
|
||||
_seed_user("tg-user-2")
|
||||
code = store.issue_code("tg-user-2")["code"]
|
||||
user = store.verify_code(code, chat_id=555, from_id=777)
|
||||
assert user is not None and user["uid"] == "tg-user-2"
|
||||
assert store.is_paired("tg-user-2")
|
||||
assert store.user_for_chat(555)["user_uid"] == "tg-user-2"
|
||||
assert store.verify_code(code, chat_id=555, from_id=777) is None
|
||||
|
||||
|
||||
def test_reissue_invalidates_previous_code(local_db):
|
||||
_seed_user("tg-user-3")
|
||||
first = store.issue_code("tg-user-3")["code"]
|
||||
store.issue_code("tg-user-3")
|
||||
assert store.verify_code(first, chat_id=10, from_id=11) is None
|
||||
|
||||
|
||||
def test_expired_code_rejected(local_db):
|
||||
_seed_user("tg-user-4")
|
||||
code = store.issue_code("tg-user-4")["code"]
|
||||
pairings = get_table("telegram_pairings")
|
||||
row = pairings.find_one(user_uid="tg-user-4", used=0)
|
||||
past = (datetime.now(timezone.utc) - timedelta(minutes=5)).isoformat()
|
||||
pairings.update({"id": row["id"], "expires_at": past}, ["id"])
|
||||
assert store.verify_code(code, chat_id=1, from_id=2) is None
|
||||
|
||||
|
||||
def test_unpair_removes_link(local_db):
|
||||
_seed_user("tg-user-5")
|
||||
code = store.issue_code("tg-user-5")["code"]
|
||||
store.verify_code(code, chat_id=88, from_id=99)
|
||||
assert store.unpair("tg-user-5") == 1
|
||||
assert not store.is_paired("tg-user-5")
|
||||
|
||||
|
||||
def test_verify_rejects_non_four_digit_code(local_db):
|
||||
assert store.verify_code("abcd", chat_id=1, from_id=2) is None
|
||||
assert store.verify_code("12345", chat_id=1, from_id=2) is None
|
||||
assert store.verify_code("12", chat_id=1, from_id=2) is None
|
||||
|
||||
|
||||
def test_user_for_chat_unknown_is_none(local_db):
|
||||
assert store.user_for_chat(999_999) is None
|
||||
assert store.link_for_user("tg-no-such-user") is None
|
||||
|
||||
|
||||
def test_unpair_unknown_user_removes_nothing(local_db):
|
||||
assert store.unpair("tg-never-paired") == 0
|
||||
|
||||
|
||||
def test_rebind_moves_chat_to_latest_user(local_db):
|
||||
_seed_user("tg-user-6")
|
||||
_seed_user("tg-user-7")
|
||||
store.bind("tg-user-6", chat_id=4242, from_id=1)
|
||||
store.bind("tg-user-7", chat_id=4242, from_id=1)
|
||||
assert store.user_for_chat(4242)["user_uid"] == "tg-user-7"
|
||||
assert not store.is_paired("tg-user-6")
|
||||
@@ -0,0 +1,100 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import asyncio
|
||||
|
||||
from devplacepy.services.telegram.backend import FakeTelegramBackend
|
||||
from devplacepy.services.telegram.worker import TelegramWorker
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.new_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
def _worker(backend):
|
||||
frames = []
|
||||
|
||||
async def emit(frame):
|
||||
frames.append(frame)
|
||||
|
||||
return TelegramWorker(backend, emit), frames
|
||||
|
||||
|
||||
def test_private_text_message_emitted():
|
||||
backend = FakeTelegramBackend()
|
||||
worker, frames = _worker(backend)
|
||||
_run(
|
||||
worker.process_update(
|
||||
{
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"chat": {"id": 42, "type": "private"},
|
||||
"from": {"id": 7},
|
||||
"text": "hello",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
messages = [f for f in frames if f["type"] == "message"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["chat_id"] == 42 and messages[0]["text"] == "hello"
|
||||
|
||||
|
||||
def test_group_message_ignored():
|
||||
backend = FakeTelegramBackend()
|
||||
worker, frames = _worker(backend)
|
||||
_run(
|
||||
worker.process_update(
|
||||
{
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"chat": {"id": 9, "type": "group"},
|
||||
"from": {"id": 7},
|
||||
"text": "x",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert not [f for f in frames if f["type"] == "message"]
|
||||
|
||||
|
||||
def test_photo_becomes_data_uri():
|
||||
backend = FakeTelegramBackend()
|
||||
backend.files["abc"] = b"\xff\xd8\xff" + b"0" * 50
|
||||
worker, frames = _worker(backend)
|
||||
_run(
|
||||
worker.process_update(
|
||||
{
|
||||
"update_id": 1,
|
||||
"message": {
|
||||
"chat": {"id": 42, "type": "private"},
|
||||
"from": {"id": 7},
|
||||
"photo": [{"file_id": "abc", "file_size": 53}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
message = [f for f in frames if f["type"] == "message"][0]
|
||||
assert message["images"][0].startswith("data:image/jpeg;base64,")
|
||||
|
||||
|
||||
def test_html_send_falls_back_to_plain_on_entity_error():
|
||||
class EntityBackend(FakeTelegramBackend):
|
||||
async def send_message(self, chat_id, text, parse_mode):
|
||||
if parse_mode:
|
||||
return {
|
||||
"ok": False,
|
||||
"error_code": 400,
|
||||
"description": "Bad Request: can't parse entities",
|
||||
}
|
||||
return await super().send_message(chat_id, text, None)
|
||||
|
||||
backend = EntityBackend()
|
||||
worker, frames = _worker(backend)
|
||||
_run(
|
||||
worker.handle_command(
|
||||
{"cmd": "send", "req_id": "r1", "chat_id": 42, "text": "<b>x</b>", "parse_mode": "HTML"}
|
||||
)
|
||||
)
|
||||
assert backend.sent[-1]["parse_mode"] is None
|
||||
result = [f for f in frames if f["type"] == "result"][0]
|
||||
assert result["ok"] and result["req_id"] == "r1"
|
||||
Reference in New Issue
Block a user