feat(email): add rich scheduled report delivery

Signed-off-by: zeus229 <69399886+zeus229@users.noreply.github.com>
This commit is contained in:
zeus229 committed 2026-10-02 14:53:45 +00:00
1 parent f21aa13d90
commit b3b1da052f
24 files changed
+491 -12

No files matched your search

+2
View File
@@ -65,4 +65,6 @@ ddgs>=6.0.0
# Shadow Account HTML/PDF Report
jinja2>=3.1.0
matplotlib>=3.7.0
mistune>=3.0.0
nh3>=0.2.18
weasyprint>=60.0
+18 -3
View File
@@ -10,7 +10,7 @@ import re
import sys as _sys
import time
import uuid
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Optional
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Optional
from fastapi import Depends, FastAPI, HTTPException, Query, status
from pydantic import BaseModel, Field
@@ -115,13 +115,16 @@ def _read_scheduled_briefing(session_id: str) -> Optional[tuple[str, str]]:
return None
async def _send_scheduled_briefing(channel: str, target: Optional[str], text: str):
async def _send_scheduled_briefing(
channel: str, target: Optional[str], text: str, delivery_format: Optional[str] = None
):
"""Deliver one briefing through the configured IM channel.
Args:
channel: Channel id as configured in the channel runtime.
target: Address within that channel, or ``None`` for its default.
text: The briefing to deliver.
delivery_format: Optional presentation hint for channels that support it.
Raises:
RuntimeError: If the channel runtime is unavailable or has no such
@@ -139,8 +142,9 @@ async def _send_scheduled_briefing(channel: str, target: Optional[str], text: st
raise RuntimeError(f"channel {channel!r} is not configured")
if not target:
raise RuntimeError(f"channel {channel!r} has no delivery target configured")
metadata = {"delivery_format": delivery_format} if delivery_format else {}
return await adapter.send_with_receipt(
OutboundMessage(channel=channel, chat_id=target, content=text)
OutboundMessage(channel=channel, chat_id=target, content=text, metadata=metadata)
)
@@ -226,6 +230,9 @@ class CreateScheduledRunRequest(BaseModel):
delivery_target_ref: Optional[str] = Field(
None, description="Opaque operator-configured target ref; preferred over raw target ids"
)
delivery_format: Optional[Literal["html", "pdf"]] = Field(
None, description="Email report presentation: HTML body or PDF attachment"
)
end_at: Optional[int] = Field(
None, description="Epoch-ms boundary after which no further run is dispatched"
)
@@ -316,6 +323,7 @@ class ScheduledRunResponse(BaseModel):
delivery_target: Optional[str] = None
delivery_target_ref: Optional[str] = None
delivery_target_label: Optional[str] = None
delivery_format: Optional[str] = None
delivery_status: str = "none"
delivery_error: Optional[str] = None
delivery_updated_at: Optional[int] = None
@@ -471,6 +479,12 @@ def register_scheduled_routes(
delivery_target = resolved_target.target
delivery_target_label = resolved_target.label
if request.delivery_format is not None and delivery_channel != "email":
raise HTTPException(
status_code=422,
detail="delivery_format is supported only for email delivery",
)
job = ScheduledResearchJob(
id=request.id or str(uuid.uuid4()),
title=request.title or "",
@@ -486,6 +500,7 @@ def register_scheduled_routes(
delivery_target=delivery_target,
delivery_target_ref=request.delivery_target_ref,
delivery_target_label=delivery_target_label,
delivery_format=request.delivery_format,
)
_get_scheduled_research_store().upsert(job)
return _job_to_response(job)
+32 -1
View File
@@ -18,6 +18,8 @@ from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Literal
from src.channels.rich_text import render_email_html
import logging; logger = logging.getLogger(__name__)
from pydantic import Field
@@ -305,11 +307,33 @@ class EmailChannel(BaseChannel):
fallback = "\n".join(failed_attachments)
content = f"{content.rstrip()}\n\n{fallback}" if content.strip() else fallback
delivery_format = (msg.metadata or {}).get("delivery_format")
if delivery_format not in {"html", "pdf"}:
delivery_format = None
email_msg = EmailMessage()
email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username
email_msg["To"] = to_addr
email_msg["Subject"] = subject
email_msg.set_content(content)
generated_pdf: bytes | None = None
if delivery_format == "pdf":
# Keep the report out of the message body when PDF delivery is
# requested; a render failure must not silently send it as plain text.
notice = "Report attached as PDF."
email_msg.set_content(notice)
email_msg.add_alternative(render_email_html(notice), subtype="html")
try:
from weasyprint import HTML
generated_pdf = HTML(string=render_email_html(content)).write_pdf()
except Exception:
self.logger.exception("Failed to render required PDF attachment")
raise
else:
email_msg.set_content(content)
if delivery_format == "html":
email_msg.add_alternative(render_email_html(content), subtype="html")
for data, maintype, subtype, filename in attachments:
email_msg.add_attachment(
@@ -318,6 +342,13 @@ class EmailChannel(BaseChannel):
subtype=subtype,
filename=filename,
)
if generated_pdf is not None:
email_msg.add_attachment(
generated_pdf,
maintype="application",
subtype="pdf",
filename="vibe-trading-report.pdf",
)
in_reply_to = self._last_message_id_by_chat.get(to_addr)
if in_reply_to:
+1
View File
@@ -33,6 +33,7 @@ _INTERNAL = frozenset(
"pairing",
"qq_probe",
"registry",
"rich_text",
"runtime",
"targets",
"token_probe",
+55
View File
@@ -0,0 +1,55 @@
"""Safe rich-text rendering for outbound email reports."""
from __future__ import annotations
import nh3
from mistune import create_markdown
_MARKDOWN = create_markdown(escape=True, plugins=["table", "strikethrough", "url"])
_ALLOWED_TAGS = {
"p", "a", "strong", "em", "del", "code", "pre", "blockquote",
"ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "br", "table", "thead", "tbody", "tr", "th", "td",
}
_ALLOWED_ATTRIBUTES: dict[str, set[str]] = {
"a": {"href"},
"code": {"class"},
"ol": {"start"},
}
_CLEANER = nh3.Cleaner(
tags=_ALLOWED_TAGS,
attributes=_ALLOWED_ATTRIBUTES,
url_schemes={"https", "http", "mailto"},
strip_comments=True,
link_rel="noopener noreferrer",
)
_EMAIL_CSS = """
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
color: #1f2937; line-height: 1.55; max-width: 860px; margin: 0 auto; padding: 24px; }
h1, h2, h3 { color: #111827; line-height: 1.25; }
h1 { font-size: 26px; } h2 { font-size: 21px; margin-top: 28px; }
h3 { font-size: 17px; margin-top: 22px; }
a { color: #2563eb; }
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; }
th { background: #f3f4f6; }
blockquote { border-left: 4px solid #d1d5db; margin-left: 0; padding-left: 14px; color: #4b5563; }
code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
pre { background: #f3f4f6; padding: 12px; overflow-wrap: anywhere; white-space: pre-wrap; }
"""
def render_markdown_fragment(text: str) -> str:
"""Render Markdown to a conservative, sanitized HTML fragment."""
return _CLEANER.clean(_MARKDOWN(text or "")).strip()
def render_email_html(text: str) -> str:
"""Render Markdown as a standalone HTML document suitable for email/PDF."""
body = render_markdown_fragment(text)
return (
'<!doctype html><html><head><meta charset="utf-8">'
f"<style>{_EMAIL_CSS}</style></head><body>{body}</body></html>"
)
+13 -7
View File
@@ -53,10 +53,8 @@ NowFn = Callable[[], int]
DispatchCallback = Callable[[ScheduledResearchJob], Awaitable[Optional[str]]]
#: session_id -> (terminal status, briefing text), or None while in flight.
BriefingReader = Callable[[str], Optional[tuple[str, str]]]
#: (channel, target, text) -> delivered.
ChannelSender = Callable[
[str, Optional[str], str], Awaitable[DeliveryReceipt | None]
]
#: (channel, target, text[, delivery_format]) -> delivered.
ChannelSender = Callable[..., Awaitable[DeliveryReceipt | None]]
_TRUE_VALUES = {"1", "true", "yes", "on"}
# Search by day, not by minute, so an impossible date (e.g. Feb 31) fails fast
@@ -649,9 +647,17 @@ class ScheduledResearchExecutor:
self._store.upsert(current, validate=False)
try:
receipt = await self._channel_sender(
job.delivery_channel, job.delivery_target, text
)
if job.delivery_format is None:
receipt = await self._channel_sender(
job.delivery_channel, job.delivery_target, text
)
else:
receipt = await self._channel_sender(
job.delivery_channel,
job.delivery_target,
text,
job.delivery_format,
)
except asyncio.CancelledError:
raise
except Exception as exc:
+6
View File
@@ -380,6 +380,7 @@ class ScheduledResearchJob:
delivery_target: Optional[str] = None
delivery_target_ref: Optional[str] = None
delivery_target_label: Optional[str] = None
delivery_format: Optional[str] = None
delivery: DeliveryRecord = field(default_factory=DeliveryRecord)
last_verdict: Optional[VerdictRecord] = None
@@ -411,6 +412,7 @@ class ScheduledResearchJob:
"delivery_target": self.delivery_target,
"delivery_target_ref": self.delivery_target_ref,
"delivery_target_label": self.delivery_target_label,
"delivery_format": self.delivery_format,
"delivery": self.delivery.to_dict(),
"last_verdict": self.last_verdict.to_dict() if self.last_verdict else None,
}
@@ -488,6 +490,9 @@ class ScheduledResearchJob:
delivery_target = data.get("delivery_target")
delivery_target_ref = data.get("delivery_target_ref")
delivery_target_label = data.get("delivery_target_label")
delivery_format = data.get("delivery_format")
if delivery_format not in {None, "html", "pdf"}:
raise ValueError("'delivery_format' must be 'html', 'pdf', or null")
for name, value in (
("delivery_channel", delivery_channel),
("delivery_target", delivery_target),
@@ -517,6 +522,7 @@ class ScheduledResearchJob:
delivery_target=delivery_target,
delivery_target_ref=delivery_target_ref,
delivery_target_label=delivery_target_label,
delivery_format=delivery_format,
delivery=DeliveryRecord.from_dict(data.get("delivery")),
last_verdict=_verdict_record_or_none(data.get("last_verdict"), job_id),
)
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
import sys
from types import SimpleNamespace
from src.channels.bus.events import OutboundMessage
from src.channels.bus.queue import MessageBus
from src.channels.email import EmailChannel
from src.channels.rich_text import render_email_html
def _channel() -> EmailChannel:
return EmailChannel(
{
"consent_granted": True,
"smtp_host": "smtp.example.test",
"smtp_username": "bot@example.test",
"smtp_password": "secret",
"from_address": "bot@example.test",
},
MessageBus(),
)
def test_rich_email_renderer_preserves_markdown_and_sanitizes_html():
rendered = render_email_html(
"# Portfolio\n\n**Return:** 1.2%\n\n| Asset | Weight |\n|---|---:|\n| ABC | 10% |\n"
"<script>alert(1)</script>\n[jump](javascript:alert(1))"
)
assert "<h1>Portfolio</h1>" in rendered
assert "<strong>Return:</strong>" in rendered
assert "<table>" in rendered
assert "<script>" not in rendered
assert "javascript:" not in rendered
def test_default_delivery_preserves_plain_body(monkeypatch):
channel = _channel()
sent = []
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
asyncio.run(
channel.send(
OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="Legacy plain body",
)
)
)
assert sent[0].get_content_type() == "text/plain"
assert sent[0].get_content().strip() == "Legacy plain body"
def test_per_message_html_keeps_plain_fallback(monkeypatch):
channel = _channel()
sent = []
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
asyncio.run(
channel.send(
OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="# Scheduled report\n\n**Return:** 1.2%",
metadata={"delivery_format": "html"},
)
)
)
message = sent[0]
assert message.get_body(preferencelist=("plain",)).get_content().startswith("# Scheduled report")
assert "<h1>Scheduled report</h1>" in message.get_body(preferencelist=("html",)).get_content()
assert list(message.iter_attachments()) == []
def test_per_message_pdf_keeps_report_out_of_body(monkeypatch):
channel = _channel()
sent = []
monkeypatch.setattr(channel, "_smtp_send", lambda message: sent.append(message))
class FakeHTML:
def __init__(self, *, string: str):
self.string = string
def write_pdf(self) -> bytes:
assert "Portfolio value: 123456" in self.string
return b"%PDF-1.7\n%%EOF\n"
monkeypatch.setitem(sys.modules, "weasyprint", SimpleNamespace(HTML=FakeHTML))
asyncio.run(
channel.send(
OutboundMessage(
channel="email",
chat_id="reader@example.test",
content="# Sensitive report\n\nPortfolio value: 123456",
metadata={"delivery_format": "pdf"},
)
)
)
message = sent[0]
plain_body = message.get_body(preferencelist=("plain",)).get_content()
html_body = message.get_body(preferencelist=("html",)).get_content()
assert "Report attached as PDF." in plain_body
assert "123456" not in plain_body
assert "123456" not in html_body
pdf_parts = [
part for part in message.iter_attachments()
if part.get_content_type() == "application/pdf"
]
assert len(pdf_parts) == 1
assert pdf_parts[0].get_filename() == "vibe-trading-report.pdf"
assert pdf_parts[0].get_payload(decode=True).startswith(b"%PDF")
@@ -0,0 +1,56 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import api_server
from src.api import scheduled_routes
from src.scheduled_research.store import ScheduledResearchJobStore
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
store = ScheduledResearchJobStore(path=tmp_path / "scheduled_jobs.json")
monkeypatch.setattr(scheduled_routes, "_scheduled_research_store", store)
monkeypatch.delenv("API_AUTH_KEY", raising=False)
monkeypatch.setattr(api_server, "_API_KEY", "")
return TestClient(api_server.app, client=("127.0.0.1", 50000))
def test_create_email_job_persists_report_format(client: TestClient):
response = client.post(
"/scheduled-runs",
json={
"id": "email-html",
"prompt": "prepare the daily report",
"schedule": "60000",
"delivery_channel": "email",
"delivery_target": "reader@example.test",
"delivery_format": "html",
},
)
assert response.status_code == 201
assert response.json()["delivery_format"] == "html"
listed = client.get("/scheduled-runs").json()
assert listed[0]["delivery_format"] == "html"
def test_create_rejects_report_format_for_non_email_channel(client: TestClient):
response = client.post(
"/scheduled-runs",
json={
"id": "telegram-pdf",
"prompt": "prepare the daily report",
"schedule": "60000",
"delivery_channel": "telegram",
"delivery_target": "123",
"delivery_format": "pdf",
},
)
assert response.status_code == 422
assert "only for email" in response.json()["detail"]
@@ -1906,6 +1906,10 @@ mini-racer==0.14.1 \
--hash=sha256:ad4b5c15993caab571fac3d67e0d55157b358bd439e629e0fef26c45a12c8bab \
--hash=sha256:cdf3a088e1363f16a695288f882abf76b3705b8e1df21418208b87ed010037a4
# via akshare
mistune==3.3.4 \
--hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \
--hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a
# via -r agent/requirements.txt
more-itertools==11.1.0 \
--hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
--hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
@@ -2098,6 +2102,35 @@ numba==0.66.0 \
--hash=sha256:fb601841d9e02e6237bb6522e36d0741614be3cfe2b482a6f00a41b5ba209443 \
--hash=sha256:fc6629becb21a867d85401ec89f426dd24c484a4193ade8a38309debfd1529ca
# via smartmoneyconcepts
nh3==0.3.7 \
--hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \
--hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \
--hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \
--hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \
--hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \
--hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \
--hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \
--hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \
--hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \
--hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \
--hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \
--hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \
--hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \
--hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \
--hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \
--hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \
--hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \
--hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \
--hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \
--hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \
--hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \
--hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \
--hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \
--hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \
--hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \
--hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \
--hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac
# via -r agent/requirements.txt
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \
+7
View File
@@ -185,6 +185,13 @@
"deliveryChannelPlaceholder": "telegram أو feishu … (اتركه فارغًا لإبقاء النتائج داخل التطبيق)",
"deliveryTargetLabel": "وجهة القناة",
"deliveryTargetPlaceholder": "معرّف محادثة / مجموعة / مستخدم",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "مُعطّل افتراضيًا. يُرسل الملخص **بعد انتهاء** التشغيل، لا عند جدولته.",
"deliveryTargetRequired": "حدّد وجهة لهذه القناة أو أفرغ حقل القناة.",
"delivery_none": "سيُرسل إلى {{channel}} بعد التشغيل القادم",
+7
View File
@@ -190,6 +190,13 @@
"deliveryChannelPlaceholder": "telegram, feishu … (leer lassen, dann bleibt alles in der App)",
"deliveryTargetLabel": "Ziel im Kanal",
"deliveryTargetPlaceholder": "Chat- / Gruppen- / Benutzer-ID",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "Standardmäßig aus. Das Briefing wird **nach dem Lauf** gesendet, nicht bei der Planung.",
"deliveryTargetRequired": "Geben Sie ein Ziel für diesen Kanal an, oder leeren Sie das Kanalfeld.",
"delivery_none": "Wird nach dem nächsten Lauf an {{channel}} gesendet",
+7
View File
@@ -190,6 +190,13 @@
"deliveryChannelPlaceholder": "telegram, feishu, … (leave empty to keep results in the app)",
"deliveryTargetLabel": "Channel target",
"deliveryTargetPlaceholder": "chat / group / user id",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "Off by default. A briefing is pushed once the run finishes, not when it is scheduled.",
"deliveryTargetRequired": "Choose a target for that channel, or clear the channel.",
"delivery_none": "Delivering to {{channel}} after the next run",
+7
View File
@@ -190,6 +190,13 @@
"deliveryChannelPlaceholder": "telegram, feishu… (vacío para que los resultados queden en la app)",
"deliveryTargetLabel": "Destino en el canal",
"deliveryTargetPlaceholder": "id de chat / grupo / usuario",
"deliveryFormatLabel": "Formato del informe por email",
"deliveryFormatDefault": "Predeterminado del canal (texto plano)",
"deliveryFormatHtml": "Email HTML",
"deliveryFormatPdf": "PDF adjunto",
"deliveryFormatDefaultHint": "Mantiene el comportamiento de texto plano existente.",
"deliveryFormatHtmlHint": "Muestra el informe completo como HTML sanitizado en el cuerpo del email.",
"deliveryFormatPdfHint": "Adjunta el informe completo como PDF y deja solo un aviso breve en el cuerpo.",
"deliveryHint": "Desactivado por defecto. El informe se envía **cuando la ejecución termina**, no cuando se programa.",
"deliveryTargetRequired": "Indica un destino para ese canal, o deja el canal vacío.",
"delivery_none": "Se enviará a {{channel}} tras la próxima ejecución",
+7
View File
@@ -190,6 +190,13 @@
"deliveryChannelPlaceholder": "telegram, feishu, … (kosongkan agar hasil tetap di aplikasi)",
"deliveryTargetLabel": "Target channel",
"deliveryTargetPlaceholder": "ID chat / grup / pengguna",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "Nonaktif secara default. Ringkasan dikirim setelah run selesai, bukan saat dijadwalkan.",
"deliveryTargetRequired": "Pilih target untuk channel tersebut, atau kosongkan channel.",
"delivery_none": "Akan dikirim ke {{channel}} setelah run berikutnya",
+7
View File
@@ -185,6 +185,13 @@
"deliveryChannelPlaceholder": "telegram、feishu など(空欄ならアプリ内のみ)",
"deliveryTargetLabel": "チャンネル送信先",
"deliveryTargetPlaceholder": "チャット / グループ / ユーザー ID",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "既定はオフです。ブリーフィングは実行の**完了後**に配信され、スケジュール時ではありません。",
"deliveryTargetRequired": "そのチャンネルの送信先を指定するか、チャンネルを空にしてください。",
"delivery_none": "次回の実行完了後に {{channel}} へ配信します",
+7
View File
@@ -185,6 +185,13 @@
"deliveryChannelPlaceholder": "telegram, feishu … (비우면 앱 안에만 남습니다)",
"deliveryTargetLabel": "채널 대상",
"deliveryTargetPlaceholder": "채팅 / 그룹 / 사용자 ID",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "기본은 꺼짐입니다. 브리핑은 예약 시점이 아니라 실행이 **끝난 뒤** 전달됩니다.",
"deliveryTargetRequired": "해당 채널의 대상을 지정하거나 채널을 비워 주세요.",
"delivery_none": "다음 실행이 끝나면 {{channel}}(으)로 전달합니다",
+7
View File
@@ -190,6 +190,13 @@
"deliveryChannelPlaceholder": "telegram, feishu,… (deixe em branco para manter os resultados no aplicativo)",
"deliveryTargetLabel": "Segmentação do canal",
"deliveryTargetPlaceholder": "chat / grupo / ID de usuário",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "Desativado por padrão. Um briefing é enviado assim que a execução termina, não quando está agendada.",
"deliveryTargetRequired": "Escolha um alvo para esse canal ou limpe o canal.",
"delivery_none": "Entregando para {{channel}} após a próxima execução",
+7
View File
@@ -185,6 +185,13 @@
"deliveryChannelPlaceholder": "telegram、feishu…(留空则结果只留在应用内)",
"deliveryTargetLabel": "频道目标",
"deliveryTargetPlaceholder": "会话 / 群 / 用户 id",
"deliveryFormatLabel": "Email report format",
"deliveryFormatDefault": "Channel default (plain text)",
"deliveryFormatHtml": "HTML email",
"deliveryFormatPdf": "PDF attachment",
"deliveryFormatDefaultHint": "Keeps the existing plain-text delivery behavior.",
"deliveryFormatHtmlHint": "Shows the full report as sanitized HTML in the email body.",
"deliveryFormatPdfHint": "Attaches the full report as PDF and keeps only a short notice in the body.",
"deliveryHint": "默认关闭。简报在运行**结束后**推送,而不是在排定时推送。",
"deliveryTargetRequired": "请为该频道填写目标,或清空频道。",
"delivery_none": "下次运行结束后推送到 {{channel}}",
+2
View File
@@ -673,6 +673,7 @@ export interface ScheduledRun {
// app, which is what every monitor created before this did.
delivery_channel: string | null;
delivery_target: string | null;
delivery_format: "html" | "pdf" | null;
delivery_target_ref: string | null;
delivery_target_label: string | null;
delivery_status: string;
@@ -695,6 +696,7 @@ export interface CreateScheduledRunRequest {
config?: Record<string, unknown>;
delivery_channel?: string | null;
delivery_target?: string | null;
delivery_format?: "html" | "pdf" | null;
delivery_target_ref?: string | null;
}
+33 -1
View File
@@ -19,6 +19,7 @@ const POLL_MS = 15_000;
type DaysChoice = "every" | "weekdays";
type ComposerMode = "time" | "advanced";
type EmailDeliveryFormat = "" | "html" | "pdf";
function browserTimezone(): string {
try {
@@ -91,6 +92,7 @@ export function Scheduled() {
// it means the briefing stays in the app.
const [deliveryChannel, setDeliveryChannel] = useState("");
const [deliveryTarget, setDeliveryTarget] = useState("");
const [deliveryFormat, setDeliveryFormat] = useState<EmailDeliveryFormat>("");
const [saving, setSaving] = useState(false);
const [composerError, setComposerError] = useState<string | null>(null);
@@ -175,8 +177,10 @@ export function Scheduled() {
timezone,
delivery_channel: channel || null,
delivery_target: channel ? target : null,
delivery_format: channel === "email" && deliveryFormat ? deliveryFormat : null,
});
setPrompt("");
setDeliveryFormat("");
await refresh();
} catch (error) {
setComposerError(error instanceof ApiError ? error.message : String(error));
@@ -392,7 +396,11 @@ export function Scheduled() {
<input
id="scheduled-delivery-channel"
value={deliveryChannel}
onChange={(e) => setDeliveryChannel(e.target.value)}
onChange={(e) => {
const value = e.target.value;
setDeliveryChannel(value);
if (value.trim() !== "email") setDeliveryFormat("");
}}
placeholder={t("scheduled.deliveryChannelPlaceholder")}
className={fieldClass}
/>
@@ -410,6 +418,30 @@ export function Scheduled() {
className={fieldClass}
/>
</div>
{deliveryChannel.trim() === "email" && (
<div className="space-y-1.5 sm:col-span-2">
<label htmlFor="scheduled-delivery-format" className={labelClass}>
{t("scheduled.deliveryFormatLabel")}
</label>
<select
id="scheduled-delivery-format"
value={deliveryFormat}
onChange={(e) => setDeliveryFormat(e.target.value as EmailDeliveryFormat)}
className={fieldClass}
>
<option value="">{t("scheduled.deliveryFormatDefault")}</option>
<option value="html">{t("scheduled.deliveryFormatHtml")}</option>
<option value="pdf">{t("scheduled.deliveryFormatPdf")}</option>
</select>
<p className={hintClass}>
{deliveryFormat === "html"
? t("scheduled.deliveryFormatHtmlHint")
: deliveryFormat === "pdf"
? t("scheduled.deliveryFormatPdfHint")
: t("scheduled.deliveryFormatDefaultHint")}
</p>
</div>
)}
</div>
{composerError && (
@@ -38,6 +38,7 @@ function run(overrides: Partial<ScheduledRun> = {}): ScheduledRun {
timezone: "Pacific/Auckland",
delivery_channel: null,
delivery_target: null,
delivery_format: null,
delivery_status: "none",
delivery_error: null,
delivery_updated_at: null,
@@ -192,6 +193,29 @@ describe("briefing delivery", () => {
expect(body.delivery_target).toBe("chat-9");
});
it("offers HTML and PDF presentation only for email delivery", async () => {
render(<Scheduled />);
fireEvent.change(screen.getByLabelText(/prompt/i), {
target: { value: "daily report" },
});
expect(screen.queryByLabelText(/email report format/i)).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText(/deliver to channel/i), {
target: { value: "email" },
});
fireEvent.change(screen.getByLabelText(/channel target/i), {
target: { value: "reader@example.test" },
});
fireEvent.change(screen.getByLabelText(/email report format/i), {
target: { value: "pdf" },
});
fireEvent.click(screen.getByRole("button", { name: /schedule|create/i }));
await waitFor(() => expect(mocked.createScheduledRun).toHaveBeenCalled());
const body = mocked.createScheduledRun.mock.calls[0][0];
expect(body.delivery_format).toBe("pdf");
});
it("shows a monitor's delivery state, and shows nothing when it has none", async () => {
mocked.listScheduledRuns.mockResolvedValue([
run({ id: "with", delivery_channel: "telegram", delivery_status: "sent" }),
+2
View File
@@ -71,6 +71,8 @@ dependencies = [
"ddgs>=6.0.0",
"jinja2>=3.1.0",
"matplotlib>=3.7.0",
"mistune>=3.0.0",
"nh3>=0.2.18",
"weasyprint>=60.0",
"prompt_toolkit>=3.0.0",
]
+33
View File
@@ -1991,6 +1991,10 @@ mini-racer==0.14.1 ; sys_platform != 'linux' \
--hash=sha256:ad4b5c15993caab571fac3d67e0d55157b358bd439e629e0fef26c45a12c8bab \
--hash=sha256:cdf3a088e1363f16a695288f882abf76b3705b8e1df21418208b87ed010037a4
# via akshare
mistune==3.3.4 \
--hash=sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe \
--hash=sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a
# via -r agent/requirements.txt
more-itertools==11.1.0 \
--hash=sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d \
--hash=sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192
@@ -2156,6 +2160,35 @@ narwhals==2.25.0 \
--hash=sha256:1f0f403e8c7e4463cde9bfe78b12fdd809e3ae3dda6d9b2f802934fb9c7a6a8f \
--hash=sha256:62c036c810662bf7820b7737077176313bc59350eeeefb808510f388c743e4b2
# via scikit-learn
nh3==0.3.7 \
--hash=sha256:157ec1eb7a62f3d9a7badb8d82d89aa810e3e24e097eedfa481a25d0c8a99877 \
--hash=sha256:15f5fbf090f5c88d61c820e1fc1fceecb6520cca9fe85649c06b57ef9dc9ff62 \
--hash=sha256:18f4278ecd157d43cb35acd5aae9f35cfa79f546b4922bd86536adc0f6312102 \
--hash=sha256:19f288c938ec6eef1f5d2c6cab47838e71fef8097e1c1233802be5a6230ba086 \
--hash=sha256:4968fe8d2db97c6f047659bf46a449fd8ec377f44ebf3e0a1b96c0d3a333ae32 \
--hash=sha256:5ffdfcb9a686ffb12765376bcfb6b5b55728516d3c0ee317d29982381ded3df8 \
--hash=sha256:614dac4a4c36ad084e78447d16fe898dedd762e354a7ab9cda2984e82f67883d \
--hash=sha256:618e3059caf41ccdf5dcccb3fa9df4cf6e4efe23d1382a8bbfca272a8a4f8bfc \
--hash=sha256:6698a822132beedab80f131c08d8d0ac5a178ddeb488d02ca4b67716ecfac7af \
--hash=sha256:6c3aa50eb26e9228238271db9f983cbc3b006dfbfeca2d4dc34c33ddc6ac5ea5 \
--hash=sha256:6e4280115d44c3b278eef712a86748c1a723105cd79feec46952383117ab4e59 \
--hash=sha256:70f5ac8626e899a4bab0ef74ca2f5bd602f49c7b739e6e5026b4afc6d63dac42 \
--hash=sha256:71860d01c16f4d8c72e334e0674beb2b0899dbd0bf760de18932ef4390303848 \
--hash=sha256:808def0c8c07843e6e50dc84f532457bfa2cfd17417b219a5d9e7c773709331a \
--hash=sha256:874b7d67a067bd29a59223f6270fc30da4edd8e6d87fd219fc93bcbaa662c946 \
--hash=sha256:91a4dab4e94d9fc54b9f67b1adfb23e81fab7ab43f33c3b8c97be9aa38f789ba \
--hash=sha256:94fd6e59553fbb9ffd8ba71bbd5a54e3126ba01799a097ae30d5341d750bc6ac \
--hash=sha256:9b7279d43323a25225df23576af6594a16693f61431170848b8b2ac21ad4f174 \
--hash=sha256:bc42bb1193c1e28a1e74c2cabaca178e118a7103e8832699fef8a2b3e2496493 \
--hash=sha256:be53a4825585f701955cb9baf49f478f56eb81e20294329fe4bc689dd5dd81fa \
--hash=sha256:d56e76bd3cadb09b6b0cef364850811663734b348a25f5f587a2819c495367bd \
--hash=sha256:de2b2aab32ea303405debefdcfc58043d3e635fa3f67b9eb140d2b0e0c0d2563 \
--hash=sha256:e8fd1ab205258b29254f72db377d99e2c96aa7653ef3b015ccab0420b094b506 \
--hash=sha256:eae64328e46a25785535afcb6885b6f182ecaf5ee8c88f8c075422db8aacc65b \
--hash=sha256:f04b7d333b27f13ca439da3cf1c75c2fba34f104969f6ce4ac8e7079699c2f4a \
--hash=sha256:f266d3f1b3647449923a8e406524632220dd5d8b647078dfe45b885d33d10479 \
--hash=sha256:fd4a70efb45d5372174f718878eb7a35c12677626a63b2f103b23b833457dcac
# via -r agent/requirements.txt
numpy==2.4.6 \
--hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \
--hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \