From f357f4957f6c05d04f57404a98f348680e674b80 Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Sun, 14 Jun 2026 15:28:59 +0200 Subject: [PATCH 1/2] Add Telegram rich report rendering --- AGENTS.md | 1 + CLAUDE.md | 1 + cmd/server/main.go | 47 ++- .../2026-06-14-telegram-rich-messages.html | 339 ++++++++++++++++++ internal/notify/digest.go | 2 + internal/notify/report.go | 38 +- internal/notify/report_rich.go | 339 ++++++++++++++++++ internal/notify/report_test.go | 166 +++++++++ internal/notify/telegram.go | 54 ++- internal/notify/telegram_test.go | 53 +++ internal/storage/settings.go | 40 ++- 11 files changed, 1036 insertions(+), 44 deletions(-) create mode 100644 docs/ai-plans/2026-06-14-telegram-rich-messages.html create mode 100644 internal/notify/report_rich.go diff --git a/AGENTS.md b/AGENTS.md index 8ea6d22..dcd0765 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,7 @@ The gate has a **second pass** for sources that emit `sleep_unspecified` only (n | `REPORT_EVENING_WEEKEND` | `21` | Evening report hour on weekends | | `REPORT_MORNING_CAP` | `morningHour+4` (floor 11) | Smart-retry deadline for morning report — past this hour the report force-sends with a stale-data banner regardless of `SleepSettled`. Server prefers an adaptive cap from `GetTypicalWakeTime(14) + 60min` when ≥7 days of per-segment sleep data are available; falls back to this env value otherwise. Override via env or `report_morning_cap` setting. **Floor:** `MorningCapTime` pushes the cap to `now + MinPromptWindow` (60min) whenever the computed cap is already in the past at call time — adaptive cap can land before `morning_hour` for users whose schedule puts the morning report well after typical wake, and without the floor the smart-retry loop enters past cap on first tick and silently skips `MorningActionPrompt`, disabling subjective check-in entirely. Pinned by `TestMorningCapTime_FloorsPastCapsToPromptWindow` | | `REPORT_TZ` | system local | Timezone for report scheduling (e.g. `Europe/Belgrade`) | +| `TELEGRAM_RICH_MESSAGES` | `false` | Opt-in Telegram Bot API Rich Messages for morning/evening reports. DB setting `telegram_rich_messages` uses the same boolean semantics. Fallback stays on legacy `sendMessage` HTML when rich send fails. | | `GEMINI_API_KEY` | — | Gemini API key; enables AI morning briefing | | `GEMINI_MODEL` | `gemini-2.5-flash` | Gemini model; overridable in Admin UI | | `GEMINI_MAX_TOKENS` | `5000` | Max output tokens for AI briefing; overridable in Admin UI | diff --git a/CLAUDE.md b/CLAUDE.md index 329c577..28cb299 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,6 +264,7 @@ The gate has a **second pass** for sources that emit `sleep_unspecified` only (n | `REPORT_EVENING_WEEKEND` | `21` | Evening report hour on weekends | | `REPORT_MORNING_CAP` | `morningHour+4` (floor 11) | Smart-retry deadline for morning report — past this hour the report force-sends with a stale-data banner regardless of `SleepSettled`. Server prefers an adaptive cap from `GetTypicalWakeTime(14) + 60min` when ≥7 days of per-segment sleep data are available; falls back to this env value otherwise. Override via env or `report_morning_cap` setting. **Floor:** `MorningCapTime` pushes the cap to `now + MinPromptWindow` (60min) whenever the computed cap is already in the past at call time — adaptive cap can land before `morning_hour` for users whose schedule puts the morning report well after typical wake, and without the floor the smart-retry loop enters past cap on first tick and silently skips `MorningActionPrompt`, disabling subjective check-in entirely. Pinned by `TestMorningCapTime_FloorsPastCapsToPromptWindow` | | `REPORT_TZ` | system local | Timezone for report scheduling (e.g. `Europe/Belgrade`) | +| `TELEGRAM_RICH_MESSAGES` | `false` | Opt-in Telegram Bot API Rich Messages for morning/evening reports. DB setting `telegram_rich_messages` uses the same boolean semantics. Fallback stays on legacy `sendMessage` HTML when rich send fails. | | `GEMINI_API_KEY` | — | Gemini API key; enables AI morning briefing | | `GEMINI_MODEL` | `gemini-2.5-flash` | Gemini model; overridable in Admin UI | | `GEMINI_MAX_TOKENS` | `5000` | Max output tokens for AI briefing; overridable in Admin UI | diff --git a/cmd/server/main.go b/cmd/server/main.go index 9bea026..03edd13 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -48,15 +48,16 @@ func main() { // Env-level defaults for the first/only tenant. envNotifyDefaults := storage.NotifyConfig{ - Token: os.Getenv("TELEGRAM_TOKEN"), - ChatID: os.Getenv("TELEGRAM_CHAT_ID"), - Lang: getEnv("REPORT_LANG", "en"), - Timezone: getEnv("REPORT_TZ", ""), - MorningWeekdayHour: getEnvInt("REPORT_MORNING_WEEKDAY", 8), - MorningWeekendHour: getEnvInt("REPORT_MORNING_WEEKEND", 9), - EveningWeekdayHour: getEnvInt("REPORT_EVENING_WEEKDAY", 20), - EveningWeekendHour: getEnvInt("REPORT_EVENING_WEEKEND", 21), - MorningCapHour: getEnvInt("REPORT_MORNING_CAP", 0), + Token: os.Getenv("TELEGRAM_TOKEN"), + ChatID: os.Getenv("TELEGRAM_CHAT_ID"), + Lang: getEnv("REPORT_LANG", "en"), + Timezone: getEnv("REPORT_TZ", ""), + MorningWeekdayHour: getEnvInt("REPORT_MORNING_WEEKDAY", 8), + MorningWeekendHour: getEnvInt("REPORT_MORNING_WEEKEND", 9), + EveningWeekdayHour: getEnvInt("REPORT_EVENING_WEEKDAY", 20), + EveningWeekendHour: getEnvInt("REPORT_EVENING_WEEKEND", 21), + TelegramRichMessages: getEnvBool("TELEGRAM_RICH_MESSAGES", false), + MorningCapHour: getEnvInt("REPORT_MORNING_CAP", 0), } envAIDefaults := storage.AIConfig{ APIKey: os.Getenv("GEMINI_API_KEY"), @@ -506,15 +507,16 @@ func tenantLocalNow(db *storage.DB, defaults storage.NotifyConfig) time.Time { // finding all three call sites. func buildNotifyCfg(db *storage.DB, c storage.NotifyConfig) notify.Config { cfg := notify.Config{ - Token: c.Token, - ChatID: c.ChatID, - Lang: c.Lang, - Timezone: c.Timezone, - MorningWeekdayHour: c.MorningWeekdayHour, - MorningWeekendHour: c.MorningWeekendHour, - EveningWeekdayHour: c.EveningWeekdayHour, - EveningWeekendHour: c.EveningWeekendHour, - MorningCapHour: c.MorningCapHour, + Token: c.Token, + ChatID: c.ChatID, + Lang: c.Lang, + Timezone: c.Timezone, + MorningWeekdayHour: c.MorningWeekdayHour, + MorningWeekendHour: c.MorningWeekendHour, + EveningWeekdayHour: c.EveningWeekdayHour, + EveningWeekendHour: c.EveningWeekendHour, + TelegramRichMessages: c.TelegramRichMessages, + MorningCapHour: c.MorningCapHour, } if h, m, ok := db.GetTypicalWakeTime(14); ok { cfg.TypicalWakeHour = h @@ -1230,3 +1232,12 @@ func getEnvInt(key string, fallback int) int { } return fallback } + +func getEnvBool(key string, fallback bool) bool { + if v := os.Getenv(key); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + return b + } + } + return fallback +} diff --git a/docs/ai-plans/2026-06-14-telegram-rich-messages.html b/docs/ai-plans/2026-06-14-telegram-rich-messages.html new file mode 100644 index 0000000..0704494 --- /dev/null +++ b/docs/ai-plans/2026-06-14-telegram-rich-messages.html @@ -0,0 +1,339 @@ + + + + + Implementation Plan: Telegram Rich Messages + + + + +
+

Implementation Plan: Telegram Rich Messages

+

Approval required before implementation

+

Issue: #183 Upgrade Telegram reports to Rich Messages

+ +
+

Task Summary

+

Upgrade the Telegram report delivery path to use Telegram Bot API Rich Messages for the morning and evening reports, while preserving the current sendMessage HTML path as a reliable fallback. The goal is a more scannable daily report surface, not a broad notification rewrite.

+
+ +
+

Inspected Current Behavior

+
    +
  • internal/notify/telegram.go has a minimal Bot client. Regular messages go through Bot.Send, which POSTs to /sendMessage with parse_mode=HTML.
  • +
  • buildInlineKeyboardPayload and SendInlineKeyboard also use /sendMessage with reply_markup for the morning subjective check-in buttons.
  • +
  • internal/notify/report.go builds the morning report in formatMorning as one flat HTML string. It already keeps rule-based bullets authoritative and places AI commentary underneath each section.
  • +
  • SendMorningSmartOpts is the main morning delivery path. It computes sleep-settled status, fetches briefing data and AI blocks, formats the message, prepends stale banners when needed, and calls bot.Send(msg).
  • +
  • SendEvening builds the evening report through formatEvening and sends it through the same Bot.Send path. Weekly quality digest and proactive notifications also share this path, but they should stay unchanged in the first vertical slice.
  • +
  • internal/notify/telegram_test.go currently tests inline keyboard payload shape directly; there are no tests yet for regular send payload shape or rich-message fallback.
  • +
+
+ +
+

Telegram API Facts

+
    +
  • Telegram Bot API 10.1 added Rich Messages on 2026-06-11.
  • +
  • sendRichMessage requires chat_id and rich_message.
  • +
  • InputRichMessage accepts exactly one of html or markdown. This plan uses html to stay close to the current formatter.
  • +
  • Supported rich HTML includes headings, paragraphs, lists, block quotes, tables, details, hr, inline formatting, and anchors.
  • +
  • sendRichMessage still accepts reply_markup, but this first slice does not move the check-in prompt because it does not need richer formatting.
  • +
  • Media blocks support only HTTP/HTTPS URLs. This plan does not add media blocks.
  • +
+
+ +
+

Desired Behavior

+
    +
  • Morning and evening reports can be sent via sendRichMessage when rich sending is enabled.
  • +
  • If rich sending fails with a Telegram API error, transport error, or invalid response, the same report still sends through the current sendMessage HTML path.
  • +
  • The first rich morning report is structured as a compact, readable health briefing: +
      +
    • top heading with date and stale-data banner when present;
    • +
    • summary table for EnergyBank, readiness, sleep, and recovery/watch status;
    • +
    • section headings and lists for rule-based findings;
    • +
    • AI commentary in block quotes so it is visually distinct from rule-based bullets;
    • +
    • details blocks for sleep sources and freshness diagnostics where useful;
    • +
    • final recommendation block near the end.
    • +
    +
  • +
  • The rich evening report is smaller but should use the same visual language: +
      +
    • heading with date and stale-data banner when present;
    • +
    • today-so-far summary table for steps, active energy, and exercise when dashboard data is current;
    • +
    • EnergyBank and readiness sections with concise guidance;
    • +
    • alert and insight lists preserved from the current report;
    • +
    • phone-off or missing-activity banners still replacing misleading dashboard cards.
    • +
    +
  • +
  • Weekly digest, proactive notifications, and check-in buttons remain on the existing path for this issue unless a later approval expands scope.
  • +
+
+ +
+

Message Mockups

+

Static sample only. Values below are illustrative placeholders for layout review, not health data or target thresholds.

+
+
+
+

🌅 Morning Report — 2026-06-14

+

💡 Stable recovery, moderate load recommended
+ Sleep was adequate, but recovery markers are mixed. Keep the day controlled.

+ + + + + + +
SignalNowRead
⚡ EnergyBank64/100Moderate
🟡 Readiness71/100Fair
😴 Sleep7.3hOK
❤️ RecoveryMixedWatch markers present
+ +

😴 Sleep

+
    +
  • Total: 7.3h
  • +
  • Deep: 1.1h
  • +
  • Awake: 0.4h
  • +
+
🤖 Good enough for a normal day, but not a strong recovery signal. Avoid stacking hard training with poor sleep consistency.
+ +
+ Sleep sources +
    +
  • Apple Watch — 7.3h
  • +
  • RingConn — 7.0h
  • +
+
+ +

📅 Yesterday

+
    +
  • Steps: 9,800
  • +
  • Active energy: 620 kcal
  • +
  • Exercise: 42 min
  • +
+ +

🎯 Recommendation

+

Moderate push. Train if planned, but keep intensity below all-out and stop early if HRV/RHR feel off.

+

Sample rich blocks: heading, table, list, blockquote, details.

+
+
+ +
+
+

🌆 Evening Report — 2026-06-14

+

Today so far

+ + + + + +
MetricValueVs yesterday
👟 Steps8,400+12%
🔥 Active kcal540-4%
🏃 Exercise35 min+8%
+ +

⚡ EnergyBank

+

52/100 — Moderate
+ Capacity drained during the day, but not enough to force rest.

+ +

🟡 Readiness

+

71/100 — Fair
+ Trend is usable, but not a recovery peak.

+ +

💡 Insights

+
    +
  • Activity is on track for a normal training day.
  • +
  • No critical health alerts in the evening snapshot.
  • +
  • Keep tonight's wind-down consistent to protect tomorrow's recovery.
  • +
+ +
+ Freshness diagnostics +
    +
  • Steps: fresh
  • +
  • Sleep: from last night
  • +
  • Watch markers: fresh
  • +
+
+

Sample rich blocks: heading, today table, sections, list, details.

+
+
+
+
+ +
+

Assumptions and Unknowns

+
    +
  • Assumption: Telegram clients used by the recipient can display Rich Messages. Fallback protects delivery if the Bot API rejects the call.
  • +
  • Assumption: A transport-level feature flag is useful during rollout. The likely shape is a small config boolean, sourced from an env var and/or existing settings pattern, without adding a UI control in the first slice.
  • +
  • Unknown: Whether Telegram rich HTML will render every supported block consistently on all client platforms. Manual QA must inspect the received message in the real client.
  • +
  • Unknown: Whether the current AI block text can contain characters that need stricter escaping under rich HTML. The rich renderer should escape dynamic text deliberately instead of relying on Telegram parsing tolerance.
  • +
+
+ +
+

Files Likely to Change

+ + + + + + + + + + + + + + +
FileReason
internal/notify/telegram.goAdd a rich-message payload builder and Bot.SendRichHTML or equivalent. Keep Bot.Send unchanged for fallback and other notification surfaces.
internal/notify/telegram_test.goCover rich payload shape, response error handling, and fallback-relevant helpers.
internal/notify/report.goRoute morning and evening delivery through rich send when enabled and keep existing formatting as fallback.
internal/notify/report_rich.go or similar new fileKeep the rich morning and evening renderers separate from the existing flat HTML formatters to reduce churn and make rollback easy.
internal/notify/report_test.goPin rich renderer structure for key sections, stale banners, AI block quotes, evening dashboard table, and fallback behavior where feasible.
cmd/server/main.go or notification config loading codeOnly if a feature flag must be threaded through notify.Config.
AGENTS.md / CLAUDE.mdOnly if implementation establishes a durable new convention for Telegram rich rendering that future agents must follow.
docs/ai-plans/2026-06-14-telegram-rich-messages.htmlUpdate after implementation with actual changes, checks, known limitations, and follow-up recommendations.
+
+ +
+

Implementation Steps

+
    +
  1. Add package-private rich payload builders in internal/notify/telegram.go: + buildRichMessagePayload(chatID, html string, replyMarkup any) or a small options struct. +
  2. +
  3. Add Bot.SendRichHTML(html string) that POSTs to /sendRichMessage, decodes Telegram's {ok, description} response, and returns useful errors on ok=false.
  4. +
  5. Add a send wrapper for reports, for example sendReportHTML(bot, richHTML, fallbackHTML, enabled, label), where rich failure logs the report label and then calls bot.Send(fallbackHTML).
  6. +
  7. Create rich morning and evening renderers in a separate file. Reuse the same source data passed to formatMorning and formatEvening: BriefingResponse, dashboard data, AI blocks, language, location, freshness, and check-in expiry state where applicable.
  8. +
  9. Centralize minimal rich HTML escaping for dynamic text. Numeric values and trusted tags should be assembled by helpers; health labels, notes, AI prose, source names, and translated strings should be escaped before interpolation.
  10. +
  11. Keep the existing formatMorning and formatEvening outputs as fallback bodies. Do not remove or substantially rewrite them in this issue.
  12. +
  13. Thread a small enablement flag through notification config if needed. Prefer a simple default and document the rollout behavior in this plan after implementation.
  14. +
  15. Add focused unit tests for payload shape, rich renderer structure, and fallback path. Avoid live Telegram tests in the automated suite.
  16. +
  17. Run Go tests and, after explicit deploy/send approval, use the admin test-send path or a controlled manual send to inspect the real Telegram rendering.
  18. +
+
+ +
+

Impact

+
    +
  • Data and migrations: no database schema change expected.
  • +
  • API contracts: no inbound HTTP API change expected. Outbound Telegram API usage adds sendRichMessage.
  • +
  • Auth and permissions: no new app permissions. Telegram bot token and chat ID remain the existing credentials.
  • +
  • Deployment: if a feature flag is added, deployment must set or verify it explicitly. No Docker or proxy changes expected.
  • +
  • User experience: morning and evening Telegram reports become more structured and easier to scan. Delivery should remain reliable through fallback.
  • +
  • Operations: logs should distinguish rich-send failure from total notification failure so fallback does not hide compatibility problems.
  • +
+
+ +
+

Test Plan

+
    +
  • go test ./internal/notify -run "Telegram|Morning|Rich" -count=1
  • +
  • go test ./internal/notify -count=1
  • +
  • go test ./...
  • +
  • If config loading changes: add the relevant package test for env/settings parsing.
  • +
  • Manual, after approval: send one controlled test morning report and one controlled test evening report to Telegram. Inspect heading, tables, lists, block quotes/details where present, and fallback logs.
  • +
+
+ +
+

Manual QA Checklist

+
    +
  • Morning report arrives once, not once through rich and once through fallback.
  • +
  • Evening report arrives once, not once through rich and once through fallback.
  • +
  • Morning top summary table is readable on mobile Telegram.
  • +
  • Evening today-so-far table is readable on mobile Telegram.
  • +
  • Rule-based bullets are still visible and not replaced by AI prose.
  • +
  • AI commentary is visually distinct from rule-based output.
  • +
  • Sleep source details render correctly when multiple sources are present.
  • +
  • Stale-data and device-off banners still appear in the right scenarios.
  • +
  • Check-in prompt and callback flow still use the existing inline keyboard path and are not regressed.
  • +
  • Fallback path can be exercised by forcing rich send disabled or simulating an API error in tests.
  • +
+
+ +
+

Risks and Edge Cases

+
    +
  • Telegram rich HTML is new. The Bot API may accept the payload but some client layouts may be less polished than expected.
  • +
  • Dynamic text escaping matters more when assembling richer HTML. A single unescaped <, &, or malformed tag in AI prose could reject the rich send.
  • +
  • Rich reports may be longer than the current flat report once table/details markup is included. Keep the first version compact.
  • +
  • Fallback must not bypass report dedup semantics. For morning reports, fallback should happen inside one send attempt, after the report has already passed the existing HasSentMorningReport and sleep-settled gates.
  • +
  • Do not migrate proactive notifications in the same change. They have their own cadence and persistence rules, so broad changes increase duplicate-send risk.
  • +
+
+ +
+

Rollback Plan

+

Disable the rich-message feature flag if present, or revert the morning and evening sender call sites to bot.Send(formatMorning(...)) and bot.Send(formatEvening(...)). Because the existing flat HTML formatters remain in place and no schema migration is planned, rollback should be a code/config rollback only. If a live deploy shows Telegram rendering problems but fallback still works, leave fallback enabled and disable rich send while preserving notification delivery.

+
+ +
+

Open Questions

+
    +
  • Should rich messages be enabled by default after deploy, or should the first deploy keep them behind an explicit env/settings flag until a live manual send is inspected?
  • +
  • Should the first rich renderer include a details diagnostics block by default, or only when stale/device-off/source-divergence conditions exist?
  • +
  • Should weekly quality digest be the second slice after morning and evening report validation? It is a good candidate for tables but should not be bundled into the first PR.
  • +
+
+ +
+

Approval Gate

+

Implementation must not start until the user explicitly approves this plan. Approval scope is limited to Telegram rich-message transport, rich morning and evening report rendering, fallback behavior, tests, and plan updates. Weekly digest, proactive notifications, check-in prompt transport changes, and deployment are out of scope unless separately approved.

+
+ +
+

Post-Implementation Update

+

Actual Changes

+
    +
  • Added sendRichMessage support in internal/notify/telegram.go via Bot.SendRichHTML and a testable buildRichMessagePayload.
  • +
  • Kept the existing Bot.Send / sendMessage HTML path intact for all legacy notifications and fallback delivery.
  • +
  • Added sendReportHTML, which tries rich send only when enabled and falls back once to legacy HTML on rich-send failure.
  • +
  • Added rich morning and evening renderers in internal/notify/report_rich.go, using headings, tables, lists, block quotes, and details blocks.
  • +
  • Wired morning and evening report send paths to generate both rich and fallback bodies from the same source data.
  • +
  • Added opt-in config flag TELEGRAM_RICH_MESSAGES and DB setting telegram_rich_messages, defaulting to false for conservative rollout.
  • +
  • Updated AGENTS.md and CLAUDE.md with the new Telegram rich-message flag.
  • +
  • Added focused tests for rich payload shape, Telegram ok=false handling, fallback behavior, morning rich structure/escaping, and evening today table rendering.
  • +
+ +

Checks Run

+
    +
  • go test ./internal/notify -run "Telegram|Morning|Rich|SendReport" -count=1 — passed.
  • +
  • go test ./internal/notify -count=1 — passed.
  • +
  • go test ./... — passed.
  • +
  • go vet ./... — passed.
  • +
+ +

Known Limitations

+
    +
  • No live Telegram send was performed in this implementation pass.
  • +
  • Rich messages are opt-in by default. Existing deployments continue sending legacy HTML until TELEGRAM_RICH_MESSAGES=true or telegram_rich_messages=true is set.
  • +
  • Weekly quality digest, proactive notifications, and check-in prompt transport remain on legacy sendMessage.
  • +
  • Telegram client rendering still needs manual inspection after enabling the flag in a controlled environment.
  • +
+ +

Recommended Follow-Up

+
    +
  • Enable rich messages for one controlled test send, inspect mobile Telegram rendering, and leave fallback logs visible during the first production day.
  • +
  • If rendering is acceptable, consider a second issue for weekly quality digest tables.
  • +
  • If rich details/table rendering is inconsistent across clients, keep the transport but simplify the rich renderer to headings, lists, and block quotes only.
  • +
+
+
+ + diff --git a/internal/notify/digest.go b/internal/notify/digest.go index 15c7f53..85d15c9 100644 --- a/internal/notify/digest.go +++ b/internal/notify/digest.go @@ -90,6 +90,8 @@ func prettyMetric(m string) string { return "Steps" case "active_energy": return "Active kcal" + case "apple_exercise_time": + return "Exercise" case "sleep_total": return "Sleep" } diff --git a/internal/notify/report.go b/internal/notify/report.go index 0a5ec53..9dba58c 100644 --- a/internal/notify/report.go +++ b/internal/notify/report.go @@ -2,6 +2,7 @@ package notify import ( "fmt" + "log" "strings" "time" @@ -21,8 +22,9 @@ type Config struct { MorningWeekendHour int // Hour (0–23) at which to send the evening day summary. - EveningWeekdayHour int - EveningWeekendHour int + EveningWeekdayHour int + EveningWeekendHour int + TelegramRichMessages bool // Smart-retry deadline. The morning trigger keeps deferring until sleep // data settles (see storage.SleepSettled); past this hour it force-sends @@ -204,13 +206,20 @@ func SendMorningSmartOpts(bot *Bot, db *storage.DB, cfg Config, opts MorningSend aiBlocks := db.GetAIBlocks(today, cfg.Lang) fresh := computeFreshness(db, time.Now()) msg := formatMorning(briefing, aiBlocks, cfg.Lang, loc, fresh, opts.CheckinExpired) + richMsg := "" + if cfg.TelegramRichMessages { + richMsg = formatMorningRich(briefing, aiBlocks, cfg.Lang, loc, fresh, opts.CheckinExpired, "") + } if !status.Settled { if banner := tr(cfg.Lang, "tg_stale_"+status.Reason); banner != "" && banner != "tg_stale_"+status.Reason { msg = banner + "\n\n" + msg + if cfg.TelegramRichMessages { + richMsg = formatMorningRich(briefing, aiBlocks, cfg.Lang, loc, fresh, opts.CheckinExpired, banner) + } } } - return true, status.Reason, bot.Send(msg) + return true, status.Reason, sendReportHTML(bot, cfg, "morning", richMsg, msg) } // SendEvening sends a "today so far" snapshot. Activity bullets are intentionally @@ -226,7 +235,28 @@ func SendEvening(bot *Bot, db *storage.DB, cfg Config) error { return err } fresh := computeFreshness(db, time.Now()) - return bot.Send(formatEvening(briefing, dash, cfg.Lang, cfg.location(), fresh)) + fallback := formatEvening(briefing, dash, cfg.Lang, cfg.location(), fresh) + rich := "" + if cfg.TelegramRichMessages { + rich = formatEveningRich(briefing, dash, cfg.Lang, cfg.location(), fresh) + } + return sendReportHTML(bot, cfg, "evening", rich, fallback) +} + +type htmlReportSender interface { + Send(text string) error + SendRichHTML(html string) error +} + +func sendReportHTML(bot htmlReportSender, cfg Config, label, richHTML, fallbackHTML string) error { + if cfg.TelegramRichMessages && strings.TrimSpace(richHTML) != "" { + if err := bot.SendRichHTML(richHTML); err == nil { + return nil + } else { + log.Printf("telegram %s rich send failed, falling back to sendMessage HTML: %v", label, err) + } + } + return bot.Send(fallbackHTML) } // ── helpers ────────────────────────────────────────────────────────────────── diff --git a/internal/notify/report_rich.go b/internal/notify/report_rich.go new file mode 100644 index 0000000..b36b2df --- /dev/null +++ b/internal/notify/report_rich.go @@ -0,0 +1,339 @@ +package notify + +import ( + "fmt" + "html" + "strings" + "time" + + "health-receiver/internal/health" + "health-receiver/internal/storage" +) + +func formatMorningRich(b *health.BriefingResponse, aiBlocks map[string]string, lang string, loc *time.Location, f freshness, checkinExpired bool, settleBanner string) string { + var sb strings.Builder + fmt.Fprintf(&sb, "

🌅 %s — %s

\n", richEsc(tr(lang, "tg_morning_header")), richEsc(b.Date)) + + if settleBanner != "" { + richTrustedParagraph(&sb, settleBanner) + } + if d := staleDays(b.Date, loc); d >= 1 { + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_warn_stale"), d)) + } + + renderRichHeadline(&sb, b.Headline) + renderRichMorningSummary(&sb, b, f, lang) + + ai := struct{ Sleep, Yesterday, Recovery, Recommendation string }{ + Sleep: aiBlocks["SLEEP"], + Yesterday: aiBlocks["YESTERDAY"], + Recovery: aiBlocks["RECOVERY"], + Recommendation: aiBlocks["RECOMMENDATION"], + } + + renderRichEnergyBank(&sb, b.EnergyBank, lang) + renderRichReadiness(&sb, b, lang) + renderRichAlerts(&sb, b.Alerts, lang) + + switch { + case f.sleepStale() && f.sleepKnown: + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_sleep_silence"), fmtSilence(f.sleep, lang))) + case b.Sleep == nil: + richTrustedParagraph(&sb, tr(lang, "tg_warn_no_sleep")) + default: + renderRichSection(&sb, findSection(b, "sleep")) + renderRichAITake(&sb, ai.Sleep) + renderRichSleepSources(&sb, b.Sleep, lang) + } + + if f.phoneOff() && f.phoneKnown { + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_phone_off"), fmtSilence(f.phone, lang))) + } else { + actSec := findSection(b, "activity") + cardioSec := findSection(b, "cardio") + if actSec != nil || cardioSec != nil || ai.Yesterday != "" { + fmt.Fprintf(&sb, "

📅 %s

\n", richEsc(tr(lang, "tg_yesterday"))) + renderRichSectionDetails(&sb, actSec) + renderRichSectionDetails(&sb, cardioSec) + renderRichAITake(&sb, ai.Yesterday) + } + } + + if f.watchOff() && f.watchKnown { + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_watch_off"), fmtSilence(f.watch, lang))) + } else if recSec := findSection(b, "recovery"); recSec != nil { + renderRichSection(&sb, recSec) + renderRichAITake(&sb, ai.Recovery) + } + + if ai.Recommendation != "" { + fmt.Fprintf(&sb, "

🎯 %s

\n

%s

\n", richEsc(tr(lang, "tg_recommendation")), richText(ai.Recommendation)) + } + renderRichFreshnessDetails(&sb, f, lang) + + if checkinExpired { + if note := tr(lang, "checkin_expired_note"); note != "" && note != "checkin_expired_note" { + richTrustedParagraph(&sb, note) + } + } + return strings.TrimSpace(sb.String()) +} + +func formatEveningRich(b *health.BriefingResponse, dash *storage.DashboardResponse, lang string, loc *time.Location, f freshness) string { + var sb strings.Builder + fmt.Fprintf(&sb, "

🌆 %s — %s

\n", richEsc(tr(lang, "tg_evening_header")), richEsc(b.Date)) + + if d := staleDays(b.Date, loc); d >= 1 { + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_warn_stale"), d)) + } + + now := time.Now().In(loc) + today := fmt.Sprintf("%d-%02d-%02d", now.Year(), int(now.Month()), now.Day()) + hasDash := dash != nil && dash.Date == today && len(dash.Cards) > 0 + + switch { + case f.phoneOff() && f.phoneKnown: + richTrustedParagraph(&sb, fmt.Sprintf(tr(lang, "tg_phone_off"), fmtSilence(f.phone, lang))) + case !hasDash: + richTrustedParagraph(&sb, tr(lang, "tg_warn_no_activity")) + default: + renderRichTodayTable(&sb, dash, lang) + } + + renderRichEnergyBank(&sb, b.EnergyBank, lang) + + fmt.Fprintf(&sb, "

%s %s

\n

%d/100 — %s

\n", + richEsc(readinessEmoji(b.ReadinessScore)), richEsc(tr(lang, "tg_readiness")), + b.ReadinessScore, richText(b.ReadinessLabel)) + + renderRichAlerts(&sb, b.Alerts, lang) + if len(b.Insights) > 0 { + fmt.Fprintf(&sb, "

💡 %s

\n\n") + } + renderRichFreshnessDetails(&sb, f, lang) + return strings.TrimSpace(sb.String()) +} + +func richEsc(s string) string { + return html.EscapeString(s) +} + +func richText(s string) string { + escaped := html.EscapeString(strings.TrimSpace(s)) + return strings.ReplaceAll(escaped, "\n", "
") +} + +func richTrustedParagraph(sb *strings.Builder, body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + fmt.Fprintf(sb, "

%s

\n", body) +} + +func renderRichHeadline(sb *strings.Builder, h *health.HeadlineSignal) { + if h == nil || h.Title == "" { + return + } + fmt.Fprintf(sb, "

%s %s", richEsc(headlineEmoji(h.Severity)), richText(h.Title)) + if h.Detail != "" { + fmt.Fprintf(sb, "
%s", richText(h.Detail)) + } + sb.WriteString("

\n") +} + +func renderRichMorningSummary(sb *strings.Builder, b *health.BriefingResponse, f freshness, lang string) { + sleepValue := "—" + sleepRead := tr(lang, "tg_warn_no_sleep") + if b.Sleep != nil { + sleepValue = fmt.Sprintf("%.1fh", b.Sleep.TotalAvg) + sleepRead = sectionSummary(findSection(b, "sleep")) + } + recoveryRead := sectionSummary(findSection(b, "recovery")) + if f.watchOff() && f.watchKnown { + recoveryRead = fmt.Sprintf(tr(lang, "tg_watch_off"), fmtSilence(f.watch, lang)) + } + energyValue, energyRead := "—", "—" + if b.EnergyBank != nil { + energyValue = fmt.Sprintf("%d/%d", b.EnergyBank.Current, b.EnergyBank.Capacity) + energyRead = energyVerdictLabel(b.EnergyBank, lang) + } + + sb.WriteString("\n\n") + fmt.Fprintf(sb, "\n", richEsc(tr(lang, "tg_energy")), richEsc(energyValue), richText(energyRead)) + fmt.Fprintf(sb, "\n", richEsc(readinessEmoji(b.ReadinessToday)), richEsc(tr(lang, "tg_readiness")), b.ReadinessToday, richText(b.ReadinessTodayLabel)) + fmt.Fprintf(sb, "\n", richEsc(sleepValue), richText(stripSimpleTags(sleepRead))) + fmt.Fprintf(sb, "\n", b.RecoveryPct, richText(stripSimpleTags(recoveryRead))) + sb.WriteString("
SignalNowRead
⚡ %s%s%s
%s %s%d/100%s
😴 Sleep%s%s
❤️ Recovery%d%%%s
\n") +} + +func renderRichTodayTable(sb *strings.Builder, dash *storage.DashboardResponse, lang string) { + fmt.Fprintf(sb, "

📊 %s

\n", richEsc(tr(lang, "tg_today"))) + dashMap := make(map[string]storage.CardData, len(dash.Cards)) + for _, c := range dash.Cards { + dashMap[c.Metric] = c + } + icons := map[string]string{ + "step_count": "👟", + "active_energy": "🔥", + "apple_exercise_time": "🏃", + } + sb.WriteString("\n\n") + for _, metric := range []string{"step_count", "active_energy", "apple_exercise_time"} { + c, ok := dashMap[metric] + if !ok || c.Value <= 0 { + continue + } + trend := "—" + if c.Prev > 0 { + pct := (c.Value - c.Prev) / c.Prev * 100 + switch { + case pct > 5: + trend = fmt.Sprintf(tr(lang, "tg_vs_yesterday_up"), pct) + case pct < -5: + trend = fmt.Sprintf(tr(lang, "tg_vs_yesterday_down"), pct) + } + } + fmt.Fprintf(sb, "\n", + richEsc(icons[metric]), richEsc(prettyMetric(metric)), c.Value, richEsc(c.Unit), richText(trend)) + } + sb.WriteString("
MetricValueVs yesterday
%s %s%.0f %s%s
\n") +} + +func renderRichSection(sb *strings.Builder, sec *health.BriefingSection) { + if sec == nil { + return + } + fmt.Fprintf(sb, "

%s %s

\n

%s

\n", richEsc(statusEmoji[sec.Status]), richText(sec.Title), richText(sec.Summary)) + renderRichSectionDetails(sb, sec) +} + +func renderRichSectionDetails(sb *strings.Builder, sec *health.BriefingSection) { + if sec == nil || len(sec.Details) == 0 { + return + } + sb.WriteString("\n") +} + +func renderRichAITake(sb *strings.Builder, body string) { + body = strings.TrimSpace(body) + if body == "" { + return + } + fmt.Fprintf(sb, "
🤖 %s
\n", richText(body)) +} + +func renderRichEnergyBank(sb *strings.Builder, eb *health.EnergyBank, lang string) { + if eb == nil || eb.Capacity == 0 { + return + } + fmt.Fprintf(sb, "

⚡ %s

\n

%d/%d — %s", + richEsc(tr(lang, "tg_energy")), eb.Current, eb.Capacity, richText(energyVerdictLabel(eb, lang))) + if eb.VerdictReason != "" { + fmt.Fprintf(sb, "
%s", richText(eb.VerdictReason)) + } + sb.WriteString("

\n") +} + +func renderRichReadiness(sb *strings.Builder, b *health.BriefingResponse, lang string) { + today := b.ReadinessToday + trend := b.ReadinessScore + fmt.Fprintf(sb, "

%s %s

\n", richEsc(readinessEmoji(today)), richEsc(tr(lang, "tg_readiness"))) + if abs(today-trend) <= 2 { + fmt.Fprintf(sb, "

%d/100 — %s

\n", today, richText(b.ReadinessTodayLabel)) + } else { + fmt.Fprintf(sb, "

%s: %d/100 — %s
%s: %d/100 — %s

\n", + richEsc(tr(lang, "tg_readiness_today")), today, richText(b.ReadinessTodayLabel), + richEsc(tr(lang, "tg_readiness_trend")), trend, richText(b.ReadinessLabel)) + } + if b.ReadinessTip != "" { + fmt.Fprintf(sb, "

%s

\n", richText(b.ReadinessTip)) + } +} + +func renderRichAlerts(sb *strings.Builder, alerts []health.Alert, lang string) { + if len(alerts) == 0 { + return + } + fmt.Fprintf(sb, "

⚠️ %s

\n\n") +} + +func renderRichSleepSources(sb *strings.Builder, sleep *health.SleepAnalysis, lang string) { + if sleep == nil || len(sleep.Sources) <= 1 { + return + } + fmt.Fprintf(sb, "
%s\n\n
\n") +} + +func renderRichFreshnessDetails(sb *strings.Builder, f freshness, lang string) { + if !f.sleepKnown && !f.watchKnown && !f.phoneKnown { + return + } + sb.WriteString("
Freshness\n\n
\n") +} + +func sectionSummary(sec *health.BriefingSection) string { + if sec == nil || sec.Summary == "" { + return "—" + } + return sec.Summary +} + +func energyVerdictLabel(eb *health.EnergyBank, lang string) string { + if eb == nil { + return "—" + } + if eb.VerdictLabel != "" { + return eb.VerdictLabel + } + verdict := tr(lang, "energy_verdict_"+eb.ActionVerdict) + if verdict == "energy_verdict_"+eb.ActionVerdict { + return eb.ActionVerdict + } + return verdict +} + +func stripSimpleTags(s string) string { + replacer := strings.NewReplacer( + "", "", "", "", + "", "", "", "", + ) + return replacer.Replace(s) +} diff --git a/internal/notify/report_test.go b/internal/notify/report_test.go index bfa4358..e393c83 100644 --- a/internal/notify/report_test.go +++ b/internal/notify/report_test.go @@ -1,11 +1,13 @@ package notify import ( + "errors" "strings" "testing" "time" "health-receiver/internal/health" + "health-receiver/internal/storage" ) // TestMorningCapTime_FloorsPastCapsToPromptWindow pins the floor that @@ -118,3 +120,167 @@ func TestFormatMorning_AppendsExpiredNote(t *testing.T) { } }) } + +type fakeHTMLReportSender struct { + richErr error + sendCalls int + richCalls int + lastSendText string + lastRichHTML string +} + +func (f *fakeHTMLReportSender) Send(text string) error { + f.sendCalls++ + f.lastSendText = text + return nil +} + +func (f *fakeHTMLReportSender) SendRichHTML(html string) error { + f.richCalls++ + f.lastRichHTML = html + return f.richErr +} + +func TestSendReportHTML_FallbackBehavior(t *testing.T) { + t.Run("rich disabled sends fallback only", func(t *testing.T) { + bot := &fakeHTMLReportSender{} + if err := sendReportHTML(bot, Config{}, "morning", "

rich

", "fallback"); err != nil { + t.Fatalf("send report: %v", err) + } + if bot.richCalls != 0 || bot.sendCalls != 1 || bot.lastSendText != "fallback" { + t.Fatalf("unexpected calls: rich=%d send=%d text=%q", bot.richCalls, bot.sendCalls, bot.lastSendText) + } + }) + + t.Run("rich success does not also send fallback", func(t *testing.T) { + bot := &fakeHTMLReportSender{} + if err := sendReportHTML(bot, Config{TelegramRichMessages: true}, "morning", "

rich

", "fallback"); err != nil { + t.Fatalf("send report: %v", err) + } + if bot.richCalls != 1 || bot.sendCalls != 0 || bot.lastRichHTML != "

rich

" { + t.Fatalf("unexpected calls: rich=%d send=%d richHTML=%q", bot.richCalls, bot.sendCalls, bot.lastRichHTML) + } + }) + + t.Run("rich error falls back once", func(t *testing.T) { + bot := &fakeHTMLReportSender{richErr: errors.New("telegram rejected rich")} + if err := sendReportHTML(bot, Config{TelegramRichMessages: true}, "evening", "

rich

", "fallback"); err != nil { + t.Fatalf("send report: %v", err) + } + if bot.richCalls != 1 || bot.sendCalls != 1 || bot.lastSendText != "fallback" { + t.Fatalf("unexpected calls: rich=%d send=%d text=%q", bot.richCalls, bot.sendCalls, bot.lastSendText) + } + }) +} + +func TestFormatMorningRich_StructureAndEscaping(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + briefing := sampleBriefing() + out := formatMorningRich(briefing, map[string]string{ + "SLEEP": "AI says ", + "YESTERDAY": "AI says move", + "RECOVERY": "AI says recover", + "RECOMMENDATION": "Do not chase ", + }, "en", loc, freshness{}, false, "") + + for _, want := range []string{ + "

🌅 Morning report — 2026-06-14

", + "", + "
🤖 AI says <check sleep>
", + "
Sources", + "Do not chase <max effort>", + } { + if !strings.Contains(out, want) { + t.Fatalf("rich morning missing %q:\n%s", want, out) + } + } +} + +func TestFormatEveningRich_TodayTable(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + now := time.Now().In(loc).Format("2006-01-02") + briefing := sampleBriefing() + briefing.Date = now + dash := &storage.DashboardResponse{ + Date: now, + Cards: []storage.CardData{ + {Metric: "step_count", Value: 8400, Prev: 7500, Unit: "steps"}, + {Metric: "active_energy", Value: 540, Prev: 560, Unit: "kcal"}, + {Metric: "apple_exercise_time", Value: 35, Prev: 25, Unit: "min"}, + }, + } + out := formatEveningRich(briefing, dash, "en", loc, freshness{}) + + for _, want := range []string{ + "

🌆 Day so far — " + now + "

", + "
", + "👟 Steps", + "🏃 Exercise", + "

💡 Insights

", + } { + if !strings.Contains(out, want) { + t.Fatalf("rich evening missing %q:\n%s", want, out) + } + } +} + +func sampleBriefing() *health.BriefingResponse { + return &health.BriefingResponse{ + Date: "2026-06-14", + ReadinessScore: 72, + ReadinessLabel: "Fair", + ReadinessToday: 70, + ReadinessTodayLabel: "Fair", + ReadinessTip: "Keep the day controlled.", + RecoveryPct: 68, + Headline: &health.HeadlineSignal{ + Severity: "info", + Title: "Stable recovery", + Detail: "No major warning signs.", + }, + EnergyBank: &health.EnergyBank{ + Capacity: 100, + Current: 64, + ActionVerdict: "moderate", + VerdictLabel: "Moderate", + VerdictReason: "Useful capacity, not a peak day.", + }, + Sleep: &health.SleepAnalysis{ + TotalAvg: 7.3, + Sources: []health.SleepSourceSummary{ + {Source: "Apple Watch", Total: 7.3}, + {Source: "RingConn", Total: 7.0}, + }, + }, + Sections: []health.BriefingSection{ + { + Key: "sleep", + Title: "Sleep", + Status: "fair", + Summary: "Adequate sleep", + Details: []health.BriefingDetail{ + {Label: "Total", Value: "7.3h"}, + }, + }, + { + Key: "activity", + Title: "Activity", + Status: "good", + Summary: "Normal load", + Details: []health.BriefingDetail{ + {Label: "Steps", Value: "8400"}, + }, + }, + { + Key: "recovery", + Title: "Recovery", + Status: "fair", + Summary: "Mixed markers", + Details: []health.BriefingDetail{ + {Label: "HRV", Value: "42 ms"}, + }, + }, + }, + Insights: []health.Insight{{Text: "Keep tonight consistent.", Type: "positive"}}, + } +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index ca424a4..2b26b64 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -17,6 +17,8 @@ import ( // handler indefinitely. const telegramHTTPTimeout = 5 * time.Second +var telegramAPIBase = "https://api.telegram.org" + // Bot is a minimal Telegram bot client. type Bot struct { token string @@ -27,6 +29,10 @@ func NewBot(token, chatID string) *Bot { return &Bot{token: token, chatID: chatID} } +func botAPIURL(token, method string) string { + return fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method) +} + // postJSON is the shared http.Post-with-timeout used by every Bot // method. Wraps the payload in a request bound to a context so a // stalled connection fails fast. @@ -43,7 +49,7 @@ func postJSON(url string, payload []byte) (*http.Response, error) { // Send sends an HTML-formatted message to the configured chat. func (b *Bot) Send(text string) error { - url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", b.token) + url := botAPIURL(b.token, "sendMessage") payload, _ := json.Marshal(map[string]string{ "chat_id": b.chatID, "text": text, @@ -60,6 +66,48 @@ func (b *Bot) Send(text string) error { return nil } +func buildRichMessagePayload(chatID, html string, replyMarkup any) ([]byte, error) { + payload := map[string]any{ + "chat_id": chatID, + "rich_message": map[string]any{ + "html": html, + }, + } + if replyMarkup != nil { + payload["reply_markup"] = replyMarkup + } + return json.Marshal(payload) +} + +// SendRichHTML sends a Telegram Rich Message using InputRichMessage.html. +func (b *Bot) SendRichHTML(html string) error { + url := botAPIURL(b.token, "sendRichMessage") + payload, err := buildRichMessagePayload(b.chatID, html, nil) + if err != nil { + return err + } + resp, err := postJSON(url, payload) + if err != nil { + return fmt.Errorf("telegram sendRichMessage: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("telegram API: status %d body=%s", resp.StatusCode, body) + } + var parsed struct { + OK bool `json:"ok"` + Description string `json:"description"` + } + if err := json.Unmarshal(body, &parsed); err != nil { + return fmt.Errorf("telegram response decode: %w", err) + } + if !parsed.OK { + return fmt.Errorf("telegram API: ok=false description=%q", parsed.Description) + } + return nil +} + // InlineButton is a single Telegram inline-keyboard button. CallbackData // lands in the webhook update verbatim — keep it short (Telegram caps at // 64 bytes) and self-contained (parseable without session state). @@ -84,7 +132,7 @@ func buildInlineKeyboardPayload(chatID, text string, rows [][]InlineButton) ([]b // markup. Returns the Telegram message_id on success so callers can // persist it (useful for edit-after-answer flows in later PRs). func (b *Bot) SendInlineKeyboard(text string, rows [][]InlineButton) (int64, error) { - url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", b.token) + url := botAPIURL(b.token, "sendMessage") payload, err := buildInlineKeyboardPayload(b.chatID, text, rows) if err != nil { return 0, err @@ -123,7 +171,7 @@ func (b *Bot) SendInlineKeyboard(text string, rows [][]InlineButton) (int64, err // SendInlineKeyboard does. Without that, a 200-with-ok-false would // be silently treated as success. func (b *Bot) AnswerCallbackQuery(callbackQueryID, text string) error { - url := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", b.token) + url := botAPIURL(b.token, "answerCallbackQuery") payload, _ := json.Marshal(map[string]any{ "callback_query_id": callbackQueryID, "text": text, diff --git a/internal/notify/telegram_test.go b/internal/notify/telegram_test.go index 59e4385..e4ed09e 100644 --- a/internal/notify/telegram_test.go +++ b/internal/notify/telegram_test.go @@ -2,6 +2,9 @@ package notify import ( "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -45,3 +48,53 @@ func TestBuildInlineKeyboardPayload(t *testing.T) { t.Errorf("first button mismatch: %v", first) } } + +func TestBuildRichMessagePayload(t *testing.T) { + raw, err := buildRichMessagePayload("9999", "

Morning

", map[string]any{"inline_keyboard": []any{}}) + if err != nil { + t.Fatalf("build rich payload: %v", err) + } + got := map[string]any{} + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("payload is not valid JSON: %v", err) + } + if got["chat_id"] != "9999" { + t.Errorf("chat_id mismatch: %v", got["chat_id"]) + } + rm, ok := got["rich_message"].(map[string]any) + if !ok { + t.Fatalf("rich_message missing or wrong shape: %v", got["rich_message"]) + } + if rm["html"] != "

Morning

" { + t.Errorf("rich html mismatch: %v", rm["html"]) + } + if _, ok := got["reply_markup"]; !ok { + t.Fatal("reply_markup should be preserved when supplied") + } +} + +func TestSendRichHTMLChecksTelegramOKFalse(t *testing.T) { + oldBase := telegramAPIBase + defer func() { telegramAPIBase = oldBase }() + + var path string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":false,"description":"Bad Request: invalid rich message"}`) + })) + defer ts.Close() + telegramAPIBase = ts.URL + + bot := NewBot("token", "chat") + err := bot.SendRichHTML("

Bad

") + if err == nil { + t.Fatal("expected ok=false error") + } + if !strings.Contains(err.Error(), "invalid rich message") { + t.Fatalf("error should include Telegram description, got %v", err) + } + if path != "/bottoken/sendRichMessage" { + t.Fatalf("unexpected Telegram path %q", path) + } +} diff --git a/internal/storage/settings.go b/internal/storage/settings.go index 95d515e..09fd8c5 100644 --- a/internal/storage/settings.go +++ b/internal/storage/settings.go @@ -7,14 +7,15 @@ import ( // NotifyConfig holds Telegram credentials and per-weekday report schedule. // It mirrors notify.Config but lives in storage to avoid import cycles. type NotifyConfig struct { - Token string - ChatID string - Lang string - Timezone string - MorningWeekdayHour int - MorningWeekendHour int - EveningWeekdayHour int - EveningWeekendHour int + Token string + ChatID string + Lang string + Timezone string + MorningWeekdayHour int + MorningWeekendHour int + EveningWeekdayHour int + EveningWeekendHour int + TelegramRichMessages bool // MorningCapHour is the deadline (24h clock, in Timezone) for the smart-retry // loop. Past this hour the morning report fires regardless of whether sleep // data has settled, with a stale-data banner. Defaults to MorningHour+4 with @@ -78,8 +79,8 @@ func (s *DB) SaveSettings(kv map[string]string) error { // AIConfig holds Gemini API credentials and generation parameters. type AIConfig struct { - APIKey string - Model string + APIKey string + Model string MaxOutputTokens int } @@ -102,15 +103,16 @@ func (s *DB) GetAIConfig(defaults AIConfig) AIConfig { // falling back to the supplied env-derived defaults for any unset key. func (s *DB) GetNotifyConfig(defaults NotifyConfig) NotifyConfig { return NotifyConfig{ - Token: s.GetSetting("telegram_token", defaults.Token), - ChatID: s.GetSetting("telegram_chat_id", defaults.ChatID), - Lang: s.GetSetting("report_lang", defaults.Lang), - Timezone: s.GetSetting("timezone", defaults.Timezone), - MorningWeekdayHour: getSettingInt(s, "report_morning_weekday", defaults.MorningWeekdayHour), - MorningWeekendHour: getSettingInt(s, "report_morning_weekend", defaults.MorningWeekendHour), - EveningWeekdayHour: getSettingInt(s, "report_evening_weekday", defaults.EveningWeekdayHour), - EveningWeekendHour: getSettingInt(s, "report_evening_weekend", defaults.EveningWeekendHour), - MorningCapHour: getSettingInt(s, "report_morning_cap", defaults.MorningCapHour), + Token: s.GetSetting("telegram_token", defaults.Token), + ChatID: s.GetSetting("telegram_chat_id", defaults.ChatID), + Lang: s.GetSetting("report_lang", defaults.Lang), + Timezone: s.GetSetting("timezone", defaults.Timezone), + MorningWeekdayHour: getSettingInt(s, "report_morning_weekday", defaults.MorningWeekdayHour), + MorningWeekendHour: getSettingInt(s, "report_morning_weekend", defaults.MorningWeekendHour), + EveningWeekdayHour: getSettingInt(s, "report_evening_weekday", defaults.EveningWeekdayHour), + EveningWeekendHour: getSettingInt(s, "report_evening_weekend", defaults.EveningWeekendHour), + TelegramRichMessages: getSettingBool(s, "telegram_rich_messages", defaults.TelegramRichMessages), + MorningCapHour: getSettingInt(s, "report_morning_cap", defaults.MorningCapHour), } } From bc22fd6940f101039f3877575c965f4b794b844e Mon Sep 17 00:00:00 2001 From: Dzarlax Date: Sun, 14 Jun 2026 15:41:35 +0200 Subject: [PATCH 2/2] Address Telegram rich report review comments --- internal/notify/report_rich.go | 8 ++++++-- internal/notify/report_test.go | 22 ++++++++++++++++++++++ internal/notify/telegram.go | 5 ++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/internal/notify/report_rich.go b/internal/notify/report_rich.go index b36b2df..b1e23c6 100644 --- a/internal/notify/report_rich.go +++ b/internal/notify/report_rich.go @@ -156,12 +156,16 @@ func renderRichHeadline(sb *strings.Builder, h *health.HeadlineSignal) { func renderRichMorningSummary(sb *strings.Builder, b *health.BriefingResponse, f freshness, lang string) { sleepValue := "—" sleepRead := tr(lang, "tg_warn_no_sleep") - if b.Sleep != nil { + if f.sleepStale() && f.sleepKnown { + sleepRead = fmt.Sprintf(tr(lang, "tg_sleep_silence"), fmtSilence(f.sleep, lang)) + } else if b.Sleep != nil { sleepValue = fmt.Sprintf("%.1fh", b.Sleep.TotalAvg) sleepRead = sectionSummary(findSection(b, "sleep")) } + recoveryValue := fmt.Sprintf("%d%%", b.RecoveryPct) recoveryRead := sectionSummary(findSection(b, "recovery")) if f.watchOff() && f.watchKnown { + recoveryValue = "—" recoveryRead = fmt.Sprintf(tr(lang, "tg_watch_off"), fmtSilence(f.watch, lang)) } energyValue, energyRead := "—", "—" @@ -174,7 +178,7 @@ func renderRichMorningSummary(sb *strings.Builder, b *health.BriefingResponse, f fmt.Fprintf(sb, "\n", richEsc(tr(lang, "tg_energy")), richEsc(energyValue), richText(energyRead)) fmt.Fprintf(sb, "\n", richEsc(readinessEmoji(b.ReadinessToday)), richEsc(tr(lang, "tg_readiness")), b.ReadinessToday, richText(b.ReadinessTodayLabel)) fmt.Fprintf(sb, "\n", richEsc(sleepValue), richText(stripSimpleTags(sleepRead))) - fmt.Fprintf(sb, "\n", b.RecoveryPct, richText(stripSimpleTags(recoveryRead))) + fmt.Fprintf(sb, "\n", richEsc(recoveryValue), richText(stripSimpleTags(recoveryRead))) sb.WriteString("
Vs yesterday
⚡ %s%s%s
%s %s%d/100%s
😴 Sleep%s%s
❤️ Recovery%d%%%s
❤️ Recovery%s%s
\n") } diff --git a/internal/notify/report_test.go b/internal/notify/report_test.go index e393c83..3804303 100644 --- a/internal/notify/report_test.go +++ b/internal/notify/report_test.go @@ -196,6 +196,28 @@ func TestFormatMorningRich_StructureAndEscaping(t *testing.T) { } } +func TestFormatMorningRich_SuppressesStaleSummaryMetrics(t *testing.T) { + loc, _ := time.LoadLocation("UTC") + briefing := sampleBriefing() + out := formatMorningRich(briefing, nil, "en", loc, freshness{ + sleep: 48 * time.Hour, + watch: 48 * time.Hour, + sleepKnown: true, + watchKnown: true, + }, false, "") + + for _, staleValue := range []string{"7.3h", "68%"} { + if strings.Contains(out, staleValue) { + t.Fatalf("rich summary should suppress stale value %q:\n%s", staleValue, out) + } + } + for _, want := range []string{"No sleep recorded", "Apple Watch off"} { + if !strings.Contains(out, want) { + t.Fatalf("rich summary should include stale banner %q:\n%s", want, out) + } + } +} + func TestFormatEveningRich_TodayTable(t *testing.T) { loc, _ := time.LoadLocation("UTC") now := time.Now().In(loc).Format("2006-01-02") diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 2b26b64..c217a7a 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -91,7 +91,10 @@ func (b *Bot) SendRichHTML(html string) error { return fmt.Errorf("telegram sendRichMessage: %w", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return fmt.Errorf("telegram sendRichMessage: read body: %w", readErr) + } if resp.StatusCode != http.StatusOK { return fmt.Errorf("telegram API: status %d body=%s", resp.StatusCode, body) }