mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat: Telegram messages get real formatting + push DMs at draft origination (#568)
* feat(telegram): HTML-styled bot messages + push DMs at held-draft origination * fix(telegram): attr-context escaping, balance-aware truncation, send observability; docs --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -253,7 +253,7 @@ async def test_expired_reply_prompt_sends_notice(
|
||||
)
|
||||
|
||||
client.send_message.assert_awaited_once_with(
|
||||
"That prompt expired — tap the button again."
|
||||
"That prompt expired — tap the button again.", parse_mode="HTML"
|
||||
)
|
||||
dispatch.assert_not_called()
|
||||
assert ("777", 42) not in ti._PENDING_REPLIES
|
||||
@@ -743,3 +743,207 @@ async def test_mark_audit_adds_via_telegram_row() -> None:
|
||||
assert added.agent_id == CEO_UUID
|
||||
assert added.target_id == task_id
|
||||
assert added.details["via"] == "telegram"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /queue rendering — pluralization + HTML formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_queue_empty_says_nothing_awaiting(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
monkeypatch.setattr(engine, "_collect_queue_items", AsyncMock(return_value=[]))
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._send_queue(client)
|
||||
|
||||
client.send_message.assert_awaited_once_with(
|
||||
"✅ Nothing awaiting your approval.", parse_mode="HTML"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_queue_singular_item_pluralization(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_collect_queue_items",
|
||||
AsyncMock(return_value=[("task", "a1b2c3d4", "", "Ship it")]),
|
||||
)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._send_queue(client)
|
||||
|
||||
header = client.send_message.await_args_list[0].args[0]
|
||||
assert header == "<b>🔔 Awaiting your approval</b> — 1 item"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_queue_plural_items_pluralization(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_collect_queue_items",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
("task", "a1b2c3d4", "", "Ship it"),
|
||||
("release", "deadbeef", "", "v1.0.0 ready"),
|
||||
]
|
||||
),
|
||||
)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._send_queue(client)
|
||||
|
||||
header = client.send_message.await_args_list[0].args[0]
|
||||
assert header == "<b>🔔 Awaiting your approval</b> — 2 items"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_queue_item_line_escapes_title_and_carries_keyboard(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Injection regression: a malicious task title must arrive HTML-escaped
|
||||
— never as live markup — in the /queue item line's sent payload."""
|
||||
engine = _engine()
|
||||
monkeypatch.setattr(
|
||||
engine,
|
||||
"_collect_queue_items",
|
||||
AsyncMock(return_value=[("task", "a1b2c3d4", "", "<b>bold&joke</b>")]),
|
||||
)
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._send_queue(client)
|
||||
|
||||
item_call = client.send_message.await_args_list[1]
|
||||
text = item_call.args[0]
|
||||
assert "<b>bold&joke</b>" in text
|
||||
assert "<b>bold&joke</b>" not in text
|
||||
assert text.startswith("📋 <b>Task</b> — ")
|
||||
assert item_call.kwargs["parse_mode"] == "HTML"
|
||||
assert "reply_markup" in item_call.kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /task — link preview disabled, title/status/team escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_command_task_disables_link_preview(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
engine = _engine()
|
||||
monkeypatch.setattr(engine, "_render_task", AsyncMock(return_value="detail"))
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._dispatch_command("task", "a1b2c3d4", client)
|
||||
|
||||
client.send_message.assert_awaited_once_with(
|
||||
"detail", parse_mode="HTML", disable_link_preview=True
|
||||
)
|
||||
|
||||
|
||||
def test_format_task_detail_escapes_html_in_title() -> None:
|
||||
"""Injection regression: a task titled ``<b>bold&joke</b>`` must render
|
||||
HTML-escaped, not as live markup, in /task's detail view."""
|
||||
engine = _engine()
|
||||
task = _fake_task(title="<b>bold&joke</b>")
|
||||
|
||||
rendered = engine._format_task_detail(task)
|
||||
|
||||
assert "<b>bold&joke</b>" in rendered
|
||||
assert "<b>bold&joke</b>" not in rendered
|
||||
|
||||
|
||||
def test_format_task_detail_pr_url_is_a_named_link() -> None:
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
task.pr_url = "https://github.com/example/repo/pull/1"
|
||||
|
||||
rendered = engine._format_task_detail(task)
|
||||
|
||||
assert '<a href="https://github.com/example/repo/pull/1">View PR</a>' in rendered
|
||||
|
||||
|
||||
def test_format_task_detail_pr_url_quote_cannot_break_out_of_href() -> None:
|
||||
"""Injection regression: a pr_url containing a literal '"' must not be
|
||||
able to close the href attribute early and inject a bogus attribute —
|
||||
_esc_attr (quote=True) turns it into '"', keeping the whole value
|
||||
inside the attribute."""
|
||||
engine = _engine()
|
||||
task = _fake_task()
|
||||
task.pr_url = 'https://evil.example/x" onmouseover="alert(1)'
|
||||
|
||||
rendered = engine._format_task_detail(task)
|
||||
|
||||
assert (
|
||||
'<a href="https://evil.example/x" onmouseover="alert(1)">'
|
||||
"View PR</a>" in rendered
|
||||
)
|
||||
assert 'onmouseover="alert(1)"' not in rendered
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# outcome confirmations (_finish_action / _consume_reply) — escaping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_action_escapes_text_and_edits_origin() -> None:
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._finish_action(client, 42, True, "Rejected: <script>xss</script>")
|
||||
|
||||
client.edit_message_reply_markup.assert_awaited_once_with(42, None)
|
||||
call = client.edit_message_text.await_args
|
||||
assert call.args == (42, "✅ Rejected: <script>xss</script>")
|
||||
assert call.kwargs["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finish_action_escapes_text_without_origin() -> None:
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
|
||||
await engine._finish_action(client, None, False, "<script>alert(1)</script>")
|
||||
|
||||
call = client.send_message.await_args
|
||||
assert call.args == ("❌ <script>alert(1)</script>",)
|
||||
assert call.kwargs["parse_mode"] == "HTML"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consume_reply_reject_outcome_arrives_escaped(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""End-to-end regression: a reject reason that reaches the CEO through
|
||||
whatever text a dispatch handler returns must never arrive as live HTML —
|
||||
the funnel (_finish_action) escapes it regardless of the handler."""
|
||||
engine = _engine()
|
||||
client = AsyncMock()
|
||||
dispatch = AsyncMock(return_value=(True, "Rejected: <script>xss</script>"))
|
||||
monkeypatch.setattr(engine, "_dispatch_reject", dispatch)
|
||||
pending = ti._PendingAction(
|
||||
kind="xpost",
|
||||
id8="a1b2c3d4",
|
||||
extra="",
|
||||
action="reject",
|
||||
origin_message_id=None,
|
||||
expires_at=time.monotonic() + 60,
|
||||
)
|
||||
|
||||
await engine._consume_reply(pending, "<script>xss</script>", client)
|
||||
|
||||
dispatch.assert_awaited_once_with("xpost", "a1b2c3d4", "", "<script>xss</script>")
|
||||
sent_text = client.send_message.await_args.args[0]
|
||||
assert "<script>xss</script>" in sent_text
|
||||
assert "<script>xss</script>" not in sent_text
|
||||
|
||||
Reference in New Issue
Block a user