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

+ +
+ +
+

Telegram API Facts

+ +
+ +
+

Desired Behavior

+ +
+ +
+

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

+ +
+ +
+

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

+ +
+ +
+

Test Plan

+ +
+ +
+

Manual QA Checklist

+ +
+ +
+

Risks and Edge Cases

+ +
+ +
+

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

+ +
+ +
+

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

+ + +

Checks Run

+ + +

Known Limitations

+ + +

Recommended Follow-Up

+ +
+
+ + 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..b1e23c6 --- /dev/null +++ b/internal/notify/report_rich.go @@ -0,0 +1,343 @@ +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 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 := "—", "—" + 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", richEsc(recoveryValue), richText(stripSimpleTags(recoveryRead))) + sb.WriteString("
SignalNowRead
⚡ %s%s%s
%s %s%d/100%s
😴 Sleep%s%s
❤️ Recovery%s%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..3804303 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,189 @@ 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 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") + 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..c217a7a 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,51 @@ 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, 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) + } + 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 +135,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 +174,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), } }
Vs yesterday