init
This commit is contained in:
121 files changed
+17641
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Domain services: settings, timer, task aggregation, reports, notifications."""
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Badges and fun facts — the part of the report you actually read.
|
||||
|
||||
Rules are pure functions of a day's metrics plus a little history, so they are
|
||||
trivial to extend: add a `Rule` to `RULES` and it shows up in the web report and
|
||||
the Telegram message at once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any, Callable
|
||||
|
||||
Metrics = dict[str, Any]
|
||||
Context = dict[str, Any]
|
||||
|
||||
|
||||
def _plural(count: int, singular: str, plural: str | None = None) -> str:
|
||||
""""1 pomodoro" / "6 pomodoros" — the English side; Russian gets its own text."""
|
||||
return f"{count} {singular if count == 1 else (plural or singular + 's')}"
|
||||
|
||||
|
||||
def _ru_pomodoros(count: int) -> str:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return f"{count} помидорка"
|
||||
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
|
||||
return f"{count} помидорки"
|
||||
return f"{count} помидорок"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Achievement:
|
||||
code: str
|
||||
emoji: str
|
||||
title_en: str
|
||||
title_ru: str
|
||||
detail_en: str = ""
|
||||
detail_ru: str = ""
|
||||
|
||||
def render(self, language: str) -> dict[str, str]:
|
||||
ru = language == "ru"
|
||||
return {
|
||||
"code": self.code,
|
||||
"emoji": self.emoji,
|
||||
"title": self.title_ru if ru else self.title_en,
|
||||
"detail": (self.detail_ru if ru else self.detail_en),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rule:
|
||||
code: str
|
||||
emoji: str
|
||||
title_en: str
|
||||
title_ru: str
|
||||
test: Callable[[Metrics, Context], bool]
|
||||
detail: Callable[[Metrics, Context], tuple[str, str]] | None = None
|
||||
|
||||
|
||||
def _nothing(_m: Metrics, _c: Context) -> tuple[str, str]:
|
||||
return "", ""
|
||||
|
||||
|
||||
RULES: list[Rule] = [
|
||||
Rule(
|
||||
"goal_met",
|
||||
"🎯",
|
||||
"Daily goal met",
|
||||
"Дневная цель взята",
|
||||
lambda m, c: m["goal_met"],
|
||||
lambda m, c: (
|
||||
f"{m['pomodoros']}/{m['goal']} pomodoros.",
|
||||
f"{m['pomodoros']}/{m['goal']} помидорок.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"double_goal",
|
||||
"🔥",
|
||||
"Double the goal",
|
||||
"Двойная норма",
|
||||
lambda m, c: m["goal"] > 0 and m["pomodoros"] >= 2 * m["goal"],
|
||||
),
|
||||
Rule(
|
||||
"early_bird",
|
||||
"🌅",
|
||||
"Early bird",
|
||||
"Ранняя пташка",
|
||||
lambda m, c: bool(m["first_start"]) and m["first_start"] < "08:00",
|
||||
lambda m, c: (
|
||||
f"First pomodoro at {m['first_start']}.",
|
||||
f"Первая помидорка в {m['first_start']}.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"night_owl",
|
||||
"🦉",
|
||||
"Night owl",
|
||||
"Ночная сова",
|
||||
lambda m, c: bool(m["last_end"]) and m["last_end"] > "23:00",
|
||||
),
|
||||
Rule(
|
||||
"deep_work",
|
||||
"🧘",
|
||||
"Deep work",
|
||||
"Глубокая работа",
|
||||
lambda m, c: m["longest_stretch_minutes"] >= 90,
|
||||
lambda m, c: (
|
||||
f"{m['longest_stretch_minutes']} minutes without breaking the chain.",
|
||||
f"{m['longest_stretch_minutes']} минут без разрыва цепочки.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"flawless",
|
||||
"💎",
|
||||
"Flawless",
|
||||
"Безупречно",
|
||||
lambda m, c: m["pomodoros"] >= 4 and m["abandoned"] == 0 and m["interruptions"] == 0,
|
||||
),
|
||||
Rule(
|
||||
"task_crusher",
|
||||
"✅",
|
||||
"Task crusher",
|
||||
"Крушитель задач",
|
||||
lambda m, c: m["tasks_completed_count"] >= 5,
|
||||
lambda m, c: (
|
||||
_plural(m["tasks_completed_count"], "task") + " closed.",
|
||||
f"{m['tasks_completed_count']} задач закрыто.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"gatekeeper",
|
||||
"🛡️",
|
||||
"Gatekeeper",
|
||||
"Страж",
|
||||
lambda m, c: m["blocked_attempts"] >= 5,
|
||||
lambda m, c: (
|
||||
f"The blocker turned you away {m['blocked_attempts']} times. It won.",
|
||||
f"Блокировщик развернул тебя {m['blocked_attempts']} раз. И выиграл.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"well_rested",
|
||||
"☕",
|
||||
"Took the breaks",
|
||||
"Отдыхал как надо",
|
||||
lambda m, c: m["pomodoros"] >= 4 and m["breaks_taken"] >= m["pomodoros"] - 1,
|
||||
),
|
||||
Rule(
|
||||
"streak_3",
|
||||
"📆",
|
||||
"3-day streak",
|
||||
"Серия 3 дня",
|
||||
lambda m, c: c.get("streak", {}).get("current", 0) >= 3,
|
||||
lambda m, c: (
|
||||
f"{c['streak']['current']} days in a row with work logged.",
|
||||
f"{c['streak']['current']} дней подряд с работой.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"streak_7",
|
||||
"🏆",
|
||||
"Week-long streak",
|
||||
"Неделя подряд",
|
||||
lambda m, c: c.get("streak", {}).get("current", 0) >= 7,
|
||||
),
|
||||
Rule(
|
||||
"personal_best",
|
||||
"🚀",
|
||||
"Personal best",
|
||||
"Личный рекорд",
|
||||
lambda m, c: m["pomodoros"] > 0 and m["pomodoros"] > c.get("previous_best", 0),
|
||||
lambda m, c: (
|
||||
"Your best day ever: " + _plural(m["pomodoros"], "pomodoro") + ".",
|
||||
f"Лучший день: {_ru_pomodoros(m['pomodoros'])}.",
|
||||
),
|
||||
),
|
||||
Rule(
|
||||
"comeback",
|
||||
"🪃",
|
||||
"Comeback",
|
||||
"Возвращение",
|
||||
lambda m, c: m["pomodoros"] >= 4 and c.get("yesterday_pomodoros", 0) == 0,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def evaluate(metrics: Metrics, context: Context, language: str = "en") -> list[dict[str, str]]:
|
||||
earned: list[dict[str, str]] = []
|
||||
for rule in RULES:
|
||||
try:
|
||||
if not rule.test(metrics, context):
|
||||
continue
|
||||
except Exception: # pragma: no cover - a bad rule must not sink the report
|
||||
logging.getLogger(__name__).debug("rule %s failed", rule.code, exc_info=True)
|
||||
continue
|
||||
detail_en, detail_ru = (rule.detail or _nothing)(metrics, context)
|
||||
earned.append(
|
||||
Achievement(
|
||||
rule.code, rule.emoji, rule.title_en, rule.title_ru, detail_en, detail_ru
|
||||
).render(language)
|
||||
)
|
||||
return earned
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fun facts
|
||||
# --------------------------------------------------------------------------
|
||||
def fun_facts(metrics: Metrics, context: Context, language: str = "en") -> list[str]:
|
||||
"""One-liners that make the numbers land. Only the true ones are returned."""
|
||||
ru = language == "ru"
|
||||
facts: list[str] = []
|
||||
focus = metrics["focus_minutes"]
|
||||
pomodoros = metrics["pomodoros"]
|
||||
|
||||
if focus:
|
||||
episodes = round(focus / 22, 1)
|
||||
facts.append(
|
||||
f"🍅 {focus} минут фокуса — это {episodes} серий ситкома, которые ты не посмотрел."
|
||||
if ru
|
||||
else f"🍅 {focus} minutes of focus — that is {episodes} sitcom episodes you did not watch."
|
||||
)
|
||||
if metrics["blocked_attempts"]:
|
||||
attempts = metrics["blocked_attempts"]
|
||||
top = metrics["blocked_top"][0]["target"] if metrics["blocked_top"] else "?"
|
||||
facts.append(
|
||||
f"🚫 {attempts} попыток сбежать, чаще всего в {top}. Не вышло."
|
||||
if ru
|
||||
else f"🚫 {attempts} escape attempts, mostly to {top}. None made it through."
|
||||
)
|
||||
if metrics["interruptions"]:
|
||||
facts.append(
|
||||
f"📵 {metrics['interruptions']} прерываний. Каждое стоит ~23 минуты концентрации — по науке."
|
||||
if ru
|
||||
else (
|
||||
"📵 " + _plural(metrics["interruptions"], "interruption")
|
||||
+ ". Research prices each one at ~23 minutes of refocusing."
|
||||
)
|
||||
)
|
||||
if metrics["peak_hour"] is not None and pomodoros >= 2:
|
||||
hour = metrics["peak_hour"]
|
||||
facts.append(
|
||||
f"⏰ Пик продуктивности — {hour:02d}:00. Защищай это время."
|
||||
if ru
|
||||
else f"⏰ Your peak hour was {hour:02d}:00. Defend it."
|
||||
)
|
||||
if metrics["wasted_minutes"] >= 10:
|
||||
facts.append(
|
||||
f"💨 {metrics['wasted_minutes']} минут ушло в брошенные помидорки."
|
||||
if ru
|
||||
else f"💨 {metrics['wasted_minutes']} minutes went into pomodoros you dropped."
|
||||
)
|
||||
if pomodoros and metrics["breaks_taken"] < pomodoros - 1:
|
||||
skipped = pomodoros - 1 - metrics["breaks_taken"]
|
||||
facts.append(
|
||||
f"⚠️ Пропущено перерывов: {skipped}. Мозг это запомнит."
|
||||
if ru
|
||||
else "⚠️ You skipped " + _plural(skipped, "break") + ". Your brain keeps score."
|
||||
)
|
||||
average = context.get("average_7d", {}).get("pomodoros", 0)
|
||||
if average and pomodoros:
|
||||
delta = round(100 * (pomodoros - average) / average)
|
||||
if abs(delta) >= 10:
|
||||
better = delta > 0
|
||||
facts.append(
|
||||
(f"📈 На {abs(delta)}% {'выше' if better else 'ниже'} твоего среднего за неделю.")
|
||||
if ru
|
||||
else (
|
||||
f"📈 {abs(delta)}% {'above' if better else 'below'} your 7-day average."
|
||||
)
|
||||
)
|
||||
lifetime_hours = context.get("lifetime", {}).get("focus_hours", 0)
|
||||
if lifetime_hours >= 1:
|
||||
facts.append(
|
||||
f"🏛️ Всего в приложении: {lifetime_hours} часов фокуса."
|
||||
if ru
|
||||
else f"🏛️ All-time focus logged here: {lifetime_hours} hours."
|
||||
)
|
||||
return facts
|
||||
|
||||
|
||||
def verdict(metrics: Metrics, tone: str, language: str = "en") -> str:
|
||||
"""The closing line. Four tones, because motivation is a personal taste."""
|
||||
ru = language == "ru"
|
||||
pct = metrics["goal_pct"]
|
||||
table = {
|
||||
"friendly": [
|
||||
(100, "Goal met. Enjoy the evening — you earned it. 🎉", "Цель взята. Отдыхай, заслужил. 🎉"),
|
||||
(60, "Solid day. Tomorrow you close the gap. 🙂", "Хороший день. Завтра добьёшь остаток. 🙂"),
|
||||
(1, "Something beats nothing. Start tomorrow with one pomodoro.", "Что-то лучше, чем ничего. Завтра начни с одной помидорки."),
|
||||
(0, "Nothing logged today. No drama — just start the timer tomorrow.", "Сегодня пусто. Без драмы — просто запусти таймер завтра."),
|
||||
],
|
||||
"coach": [
|
||||
(100, "Target hit. Now protect the streak. 💪", "Цель закрыта. Теперь держи серию. 💪"),
|
||||
(60, "Close. One more block would have done it. Plan it in.", "Почти. Ещё один блок — и всё. Запланируй."),
|
||||
(1, "You started, which is the hard part. Scale it tomorrow.", "Ты начал — это самое сложное. Завтра увеличивай."),
|
||||
(0, "Zero. Pick the smallest task and run one 10-minute timer tomorrow.", "Ноль. Завтра — самая мелкая задача и таймер на 10 минут."),
|
||||
],
|
||||
"drill_sergeant": [
|
||||
(100, "Acceptable. Do it again tomorrow. 🫡", "Принято. Завтра повторить. 🫡"),
|
||||
(60, "Half-measures. The goal was the goal.", "Полумеры. Цель была целью."),
|
||||
(1, "That was a warm-up, not a day of work.", "Это была разминка, а не рабочий день."),
|
||||
(0, "Nothing. Zero. The timer was right there.", "Ничего. Ноль. Таймер был прямо здесь."),
|
||||
],
|
||||
"dry": [
|
||||
(100, "Goal reached.", "Цель достигнута."),
|
||||
(60, "Goal partially reached.", "Цель достигнута частично."),
|
||||
(1, "Minimal activity recorded.", "Зафиксирована минимальная активность."),
|
||||
(0, "No activity recorded.", "Активность не зафиксирована."),
|
||||
],
|
||||
}
|
||||
rows = table.get(tone, table["friendly"])
|
||||
for threshold, text_en, text_ru in rows:
|
||||
if pct >= threshold:
|
||||
return text_ru if ru else text_en
|
||||
return ""
|
||||
|
||||
|
||||
def previous_best(day_counts: dict[date, int], until: date) -> int:
|
||||
return max((count for day, count in day_counts.items() if day < until), default=0)
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Blocking policy.
|
||||
|
||||
The backend decides *whether* blocking should be on right now and *what* is on
|
||||
the list; the desktop app is the thing that actually enforces it (it is the only
|
||||
piece running on your machine with the privileges to rewrite `/etc/hosts` or
|
||||
kill a process). Keeping the policy here means the web UI can edit the rules,
|
||||
and a second machine running the desktop app obeys the same list.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BlockEvent
|
||||
from app.services import pomodoro
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import ensure_utc, in_window, local_date, local_time
|
||||
|
||||
|
||||
def expand_domains(settings: AppSettings) -> list[str]:
|
||||
"""Every hostname the desktop app should point at the redirect IP.
|
||||
|
||||
A rule like `youtube.com` is meant as "that site", so `www.` and the common
|
||||
subdomains come along; anything in `allowed_domains` is removed again, which
|
||||
is how `youtube.com` blocked + `music.youtube.com` allowed works.
|
||||
"""
|
||||
blocker = settings.blocker
|
||||
allowed = {entry.strip().lower().lstrip(".") for entry in blocker.allowed_domains if entry.strip()}
|
||||
|
||||
hosts: list[str] = []
|
||||
for raw in blocker.blocked_domains:
|
||||
domain = raw.strip().lower().lstrip(".")
|
||||
if not domain or domain in allowed:
|
||||
continue
|
||||
for candidate in (domain, f"www.{domain}", f"m.{domain}"):
|
||||
if candidate not in allowed and candidate not in hosts:
|
||||
hosts.append(candidate)
|
||||
return hosts
|
||||
|
||||
|
||||
def schedule_active(settings: AppSettings, moment: datetime | None = None) -> bool:
|
||||
now_time = local_time(settings.timezone, moment)
|
||||
weekday = (moment or datetime.now()).weekday()
|
||||
for window in settings.blocker.schedule:
|
||||
if weekday in window.days and in_window(now_time, window.start, window.end):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def policy(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"""What the desktop app polls: should I block, and what."""
|
||||
blocker = settings.blocker
|
||||
current = pomodoro.current_session(session)
|
||||
active = False
|
||||
reason = "disabled"
|
||||
|
||||
if blocker.enabled:
|
||||
if blocker.mode == "always":
|
||||
active, reason = True, "always on"
|
||||
elif blocker.mode == "scheduled":
|
||||
active = schedule_active(settings)
|
||||
reason = "inside a blocking window" if active else "outside every window"
|
||||
else: # during_work
|
||||
if current is not None and current.kind == "work" and current.status in ("running", "paused"):
|
||||
active, reason = True, "a work interval is running"
|
||||
elif (
|
||||
current is not None
|
||||
and current.kind != "work"
|
||||
and blocker.block_during_breaks
|
||||
and current.status in ("running", "paused")
|
||||
):
|
||||
active, reason = True, "blocking stays on through breaks"
|
||||
else:
|
||||
reason = "no work interval is running"
|
||||
|
||||
return {
|
||||
"active": active,
|
||||
"reason": reason,
|
||||
"mode": blocker.mode,
|
||||
"enabled": blocker.enabled,
|
||||
"strict": blocker.strict,
|
||||
"redirect_ip": blocker.redirect_ip,
|
||||
"domains": expand_domains(settings) if active else [],
|
||||
"apps": [app.strip() for app in blocker.blocked_apps if app.strip()] if active else [],
|
||||
"kill_apps": blocker.kill_blocked_apps,
|
||||
"notify_on_block": blocker.notify_on_block,
|
||||
"poll_seconds": blocker.app_poll_seconds,
|
||||
"session_id": current.id if current else None,
|
||||
"all_domains": expand_domains(settings),
|
||||
"all_apps": [app.strip() for app in blocker.blocked_apps if app.strip()],
|
||||
}
|
||||
|
||||
|
||||
def record_event(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
*,
|
||||
kind: str,
|
||||
target: str,
|
||||
action: str = "blocked",
|
||||
details: str | None = None,
|
||||
) -> BlockEvent:
|
||||
current = pomodoro.current_session(session)
|
||||
event = BlockEvent(
|
||||
kind=kind,
|
||||
target=target.strip().lower(),
|
||||
action=action,
|
||||
details=details,
|
||||
local_date=local_date(settings.timezone),
|
||||
session_id=current.id if current else None,
|
||||
)
|
||||
session.add(event)
|
||||
session.commit()
|
||||
return event
|
||||
|
||||
|
||||
def events_for_day(session: Session, settings: AppSettings, day: date | None = None) -> dict[str, Any]:
|
||||
day = day or local_date(settings.timezone)
|
||||
rows = list(session.scalars(select(BlockEvent).where(BlockEvent.local_date == day)))
|
||||
by_target = dict(
|
||||
session.execute(
|
||||
select(BlockEvent.target, func.count(BlockEvent.id))
|
||||
.where(BlockEvent.local_date == day)
|
||||
.group_by(BlockEvent.target)
|
||||
.order_by(func.count(BlockEvent.id).desc())
|
||||
).all()
|
||||
)
|
||||
return {
|
||||
"date": day.isoformat(),
|
||||
"total": len(rows),
|
||||
"by_target": [{"target": target, "count": count} for target, count in by_target.items()],
|
||||
"events": [
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"target": row.target,
|
||||
"action": row.action,
|
||||
"at": ensure_utc(row.at).isoformat(),
|
||||
"details": row.details,
|
||||
}
|
||||
for row in sorted(rows, key=lambda row: ensure_utc(row.at), reverse=True)[:100]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_DOMAINS = [
|
||||
"youtube.com",
|
||||
"twitter.com",
|
||||
"x.com",
|
||||
"instagram.com",
|
||||
"tiktok.com",
|
||||
"reddit.com",
|
||||
"facebook.com",
|
||||
"vk.com",
|
||||
"twitch.tv",
|
||||
"netflix.com",
|
||||
"9gag.com",
|
||||
"pikabu.ru",
|
||||
]
|
||||
|
||||
DEFAULT_APPS = ["steam", "discord", "telegram-desktop", "spotify"]
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Outbound messages: Telegram first, Slack next, the log as a last resort.
|
||||
|
||||
The chunking, the HTML parse mode and the "Telegram > Slack > console" order are
|
||||
jira-helper's, because they are the right answers. What is added here is quiet
|
||||
hours and a record of what was sent, so the nudges cannot turn into spam.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import EventLog
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import in_window, local_date, local_time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Telegram rejects messages longer than 4096 characters; leave headroom.
|
||||
_TELEGRAM_MAX_CHARS = 4000
|
||||
_TIMEOUT = 20
|
||||
|
||||
|
||||
class NotifyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Delivery:
|
||||
channel: str
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {"channel": self.channel, "ok": self.ok, "detail": self.detail}
|
||||
|
||||
|
||||
def _chunk(text: str, size: int = _TELEGRAM_MAX_CHARS) -> list[str]:
|
||||
"""Split on line boundaries so a message never exceeds Telegram's limit."""
|
||||
chunks: list[str] = []
|
||||
current = ""
|
||||
for line in text.split("\n"):
|
||||
candidate = f"{current}\n{line}" if current else line
|
||||
if len(candidate) > size and current:
|
||||
chunks.append(current)
|
||||
current = line
|
||||
else:
|
||||
current = candidate
|
||||
if current:
|
||||
chunks.append(current)
|
||||
return chunks or [""]
|
||||
|
||||
|
||||
def send_telegram(
|
||||
settings: AppSettings, text: str, *, reply_markup: dict | None = None
|
||||
) -> Delivery:
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.bot_token and telegram.chat_id):
|
||||
return Delivery("telegram", False, "Telegram is not configured.")
|
||||
|
||||
url = f"https://api.telegram.org/bot{telegram.bot_token}/sendMessage"
|
||||
parts = _chunk(text)
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
for index, part in enumerate(parts):
|
||||
payload = {
|
||||
"chat_id": telegram.chat_id,
|
||||
"text": part,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
# Buttons belong on the last chunk, where the message ends.
|
||||
if reply_markup and index == len(parts) - 1:
|
||||
payload["reply_markup"] = reply_markup
|
||||
resp = client.post(url, json=payload)
|
||||
if not resp.is_success:
|
||||
return Delivery(
|
||||
"telegram", False, f"{resp.status_code}: {resp.text[:200]}"
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("telegram", False, str(exc))
|
||||
return Delivery("telegram", True, f"{len(parts)} message(s)")
|
||||
|
||||
|
||||
def send_slack(settings: AppSettings, text: str) -> Delivery:
|
||||
slack = settings.notifications.slack
|
||||
if not (slack.enabled and slack.webhook_url):
|
||||
return Delivery("slack", False, "Slack is not configured.")
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
resp = client.post(slack.webhook_url, json={"text": _strip_html(text)})
|
||||
if not resp.is_success:
|
||||
return Delivery("slack", False, f"{resp.status_code}: {resp.text[:200]}")
|
||||
except httpx.HTTPError as exc:
|
||||
return Delivery("slack", False, str(exc))
|
||||
return Delivery("slack", True, "ok")
|
||||
|
||||
|
||||
def _strip_html(text: str) -> str:
|
||||
import re
|
||||
|
||||
return re.sub(r"<[^>]+>", "", text)
|
||||
|
||||
|
||||
def in_quiet_hours(settings: AppSettings) -> bool:
|
||||
quiet = settings.notifications.telegram.quiet_hours
|
||||
if not quiet.enabled:
|
||||
return False
|
||||
return in_window(local_time(settings.timezone), quiet.start, quiet.end)
|
||||
|
||||
|
||||
def notify(
|
||||
settings: AppSettings,
|
||||
text: str,
|
||||
*,
|
||||
urgent: bool = False,
|
||||
reply_markup: dict | None = None,
|
||||
) -> list[Delivery]:
|
||||
"""Send to every configured channel. `urgent` ignores quiet hours."""
|
||||
if not urgent and in_quiet_hours(settings):
|
||||
return [Delivery("quiet-hours", False, "suppressed by quiet hours")]
|
||||
|
||||
deliveries: list[Delivery] = []
|
||||
telegram = send_telegram(settings, text, reply_markup=reply_markup)
|
||||
if telegram.ok or telegram.detail != "Telegram is not configured.":
|
||||
deliveries.append(telegram)
|
||||
slack = send_slack(settings, text)
|
||||
if slack.ok or slack.detail != "Slack is not configured.":
|
||||
deliveries.append(slack)
|
||||
if not deliveries:
|
||||
log.info("notification (no channel configured):\n%s", _strip_html(text))
|
||||
deliveries.append(Delivery("log", True, "written to the server log"))
|
||||
return deliveries
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# "Did we already send this today?"
|
||||
# --------------------------------------------------------------------------
|
||||
def already_sent(session: Session, kind: str, day: date) -> bool:
|
||||
return (
|
||||
session.scalar(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def count_sent(session: Session, kind: str, day: date) -> int:
|
||||
return len(
|
||||
list(
|
||||
session.scalars(
|
||||
select(EventLog.id).where(EventLog.kind == kind, EventLog.local_date == day)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def record_sent(session: Session, kind: str, day: date, payload: dict | None = None) -> None:
|
||||
session.add(EventLog(kind=kind, local_date=day, payload=payload or {}))
|
||||
session.commit()
|
||||
|
||||
|
||||
def today(settings: AppSettings) -> date:
|
||||
return local_date(settings.timezone)
|
||||
@@ -0,0 +1,354 @@
|
||||
"""The timer engine.
|
||||
|
||||
The server owns the clock. A browser tab or the desktop window only renders
|
||||
`remaining_seconds`, computed from `started_at` — so reloading the page, closing
|
||||
the laptop lid or switching from the web UI to the desktop app all leave the
|
||||
same interval running, and the history is never a guess.
|
||||
|
||||
One interval is one row. Work and breaks are both intervals, which is what
|
||||
makes "you actually rested 3 of the 7 breaks you were owed" a thing the report
|
||||
can say.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import PomodoroSession, TaskRecord, utcnow
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import day_bounds_utc, ensure_utc, local_date
|
||||
|
||||
Kind = Literal["work", "short_break", "long_break"]
|
||||
|
||||
ACTIVE_STATUSES = ("running", "paused")
|
||||
|
||||
|
||||
class TimerError(RuntimeError):
|
||||
"""An operation that does not fit the timer's current state."""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Reading state
|
||||
# --------------------------------------------------------------------------
|
||||
def current_session(session: Session) -> PomodoroSession | None:
|
||||
return session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(ACTIVE_STATUSES))
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
|
||||
|
||||
def elapsed_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
|
||||
"""Wall-clock seconds spent inside the interval, excluding paused time."""
|
||||
now = now or utcnow()
|
||||
if row.status == "paused" and row.paused_at is not None:
|
||||
end = ensure_utc(row.paused_at)
|
||||
elif row.ended_at is not None:
|
||||
end = ensure_utc(row.ended_at)
|
||||
else:
|
||||
end = now
|
||||
started = ensure_utc(row.started_at)
|
||||
return max(int((end - started).total_seconds()) - row.paused_seconds, 0)
|
||||
|
||||
|
||||
def remaining_seconds(row: PomodoroSession, now: datetime | None = None) -> int:
|
||||
return max(row.planned_seconds - elapsed_seconds(row, now), 0)
|
||||
|
||||
|
||||
def duration_for(settings: AppSettings, kind: Kind) -> int:
|
||||
minutes = {
|
||||
"work": settings.pomodoro.work_minutes,
|
||||
"short_break": settings.pomodoro.short_break_minutes,
|
||||
"long_break": settings.pomodoro.long_break_minutes,
|
||||
}[kind]
|
||||
return minutes * 60
|
||||
|
||||
|
||||
def completed_work_today(session: Session, settings: AppSettings, day: date | None = None) -> int:
|
||||
day = day or local_date(settings.timezone)
|
||||
return int(
|
||||
session.scalar(
|
||||
select(func.count(PomodoroSession.id)).where(
|
||||
PomodoroSession.local_date == day,
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def next_kind(session: Session, settings: AppSettings) -> Kind:
|
||||
"""What the timer should run next, following the long-break cycle.
|
||||
|
||||
After every `cycles_before_long_break` completed work intervals today the
|
||||
next break is the long one. The count resets with the day, so a long break
|
||||
is never "owed" from yesterday.
|
||||
"""
|
||||
last = session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.status.in_(("completed", "abandoned")))
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
if last is None or last.kind != "work" or last.status == "abandoned":
|
||||
return "work"
|
||||
done = completed_work_today(session, settings)
|
||||
if done and done % settings.pomodoro.cycles_before_long_break == 0:
|
||||
return "long_break"
|
||||
return "short_break"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Transitions
|
||||
# --------------------------------------------------------------------------
|
||||
def start(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
*,
|
||||
kind: Kind | None = None,
|
||||
minutes: int | None = None,
|
||||
task_id: int | None = None,
|
||||
note: str | None = None,
|
||||
) -> PomodoroSession:
|
||||
"""Begin an interval, abandoning whatever was still running."""
|
||||
running = current_session(session)
|
||||
if running is not None:
|
||||
_finish(session, settings, running, status="abandoned", reason="superseded")
|
||||
|
||||
kind = kind or next_kind(session, settings)
|
||||
planned = (minutes * 60) if minutes else duration_for(settings, kind)
|
||||
|
||||
task_source = task_external_id = task_title = None
|
||||
if task_id is not None:
|
||||
task = session.get(TaskRecord, task_id)
|
||||
if task is None:
|
||||
raise TimerError(f"no task {task_id}")
|
||||
task_source, task_external_id, task_title = task.source, task.external_id, task.title
|
||||
|
||||
done_today = completed_work_today(session, settings)
|
||||
row = PomodoroSession(
|
||||
kind=kind,
|
||||
status="running",
|
||||
started_at=utcnow(),
|
||||
local_date=local_date(settings.timezone),
|
||||
planned_seconds=planned,
|
||||
cycle_index=(done_today % settings.pomodoro.cycles_before_long_break) + 1,
|
||||
task_source=task_source,
|
||||
task_external_id=task_external_id,
|
||||
task_title=task_title,
|
||||
note=note,
|
||||
)
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def pause(session: Session, settings: AppSettings) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if not settings.pomodoro.allow_pause:
|
||||
raise TimerError("Pausing is switched off in your settings.")
|
||||
if row.status == "paused":
|
||||
return row
|
||||
row.status = "paused"
|
||||
row.paused_at = utcnow()
|
||||
if settings.pomodoro.count_interruptions and row.is_work:
|
||||
row.interruptions += 1
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def resume(session: Session) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if row.status != "paused" or row.paused_at is None:
|
||||
return row
|
||||
row.paused_seconds += max(int((utcnow() - ensure_utc(row.paused_at)).total_seconds()), 0)
|
||||
row.paused_at = None
|
||||
row.status = "running"
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def complete(
|
||||
session: Session, settings: AppSettings, *, auto_next: bool = True
|
||||
) -> tuple[PomodoroSession, PomodoroSession | None]:
|
||||
"""Finish the current interval and, if configured, roll into the next one."""
|
||||
row = _require_active(session)
|
||||
finished = _finish(session, settings, row, status="completed")
|
||||
|
||||
follow_on: PomodoroSession | None = None
|
||||
if auto_next:
|
||||
wants_break = finished.is_work and settings.pomodoro.auto_start_breaks
|
||||
wants_work = (not finished.is_work) and settings.pomodoro.auto_start_work
|
||||
if wants_break or wants_work:
|
||||
follow_on = start(session, settings, kind=next_kind(session, settings))
|
||||
return finished, follow_on
|
||||
|
||||
|
||||
def abandon(
|
||||
session: Session, settings: AppSettings, *, reason: str | None = None
|
||||
) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if settings.pomodoro.strict_mode and row.is_work and not (reason or "").strip():
|
||||
raise TimerError("Strict mode: give a reason for dropping this pomodoro.")
|
||||
return _finish(session, settings, row, status="abandoned", reason=reason)
|
||||
|
||||
|
||||
def add_interruption(session: Session) -> PomodoroSession:
|
||||
""""The phone rang" — counted without stopping the clock."""
|
||||
row = _require_active(session)
|
||||
row.interruptions += 1
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def extend(session: Session, minutes: int) -> PomodoroSession:
|
||||
"""Add time to the running interval ("just five more minutes")."""
|
||||
row = _require_active(session)
|
||||
row.planned_seconds = max(row.planned_seconds + minutes * 60, 60)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def attach_task(session: Session, task_id: int | None) -> PomodoroSession:
|
||||
row = _require_active(session)
|
||||
if task_id is None:
|
||||
row.task_source = row.task_external_id = row.task_title = None
|
||||
else:
|
||||
task = session.get(TaskRecord, task_id)
|
||||
if task is None:
|
||||
raise TimerError(f"no task {task_id}")
|
||||
row.task_source, row.task_external_id, row.task_title = (
|
||||
task.source,
|
||||
task.external_id,
|
||||
task.title,
|
||||
)
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _require_active(session: Session) -> PomodoroSession:
|
||||
row = current_session(session)
|
||||
if row is None:
|
||||
raise TimerError("No interval is running.")
|
||||
return row
|
||||
|
||||
|
||||
def _finish(
|
||||
session: Session,
|
||||
settings: AppSettings,
|
||||
row: PomodoroSession,
|
||||
*,
|
||||
status: str,
|
||||
reason: str | None = None,
|
||||
) -> PomodoroSession:
|
||||
now = utcnow()
|
||||
if row.status == "paused" and row.paused_at is not None:
|
||||
row.paused_seconds += max(int((now - ensure_utc(row.paused_at)).total_seconds()), 0)
|
||||
row.paused_at = None
|
||||
row.actual_seconds = elapsed_seconds(row, now)
|
||||
row.ended_at = now
|
||||
row.status = status
|
||||
if reason:
|
||||
row.abandon_reason = reason
|
||||
session.commit()
|
||||
return row
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Serialisation
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass(slots=True)
|
||||
class TimerState:
|
||||
"""What the clients render. Everything derived, nothing stored twice."""
|
||||
|
||||
session: dict[str, Any] | None
|
||||
next_kind: str
|
||||
completed_today: int
|
||||
goal: int
|
||||
settings_version: str
|
||||
|
||||
|
||||
def session_dict(row: PomodoroSession | None) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"status": row.status,
|
||||
"started_at": ensure_utc(row.started_at).isoformat(),
|
||||
"ended_at": ensure_utc(row.ended_at).isoformat() if row.ended_at else None,
|
||||
"planned_seconds": row.planned_seconds,
|
||||
"elapsed_seconds": elapsed_seconds(row),
|
||||
"remaining_seconds": remaining_seconds(row),
|
||||
"paused_seconds": row.paused_seconds,
|
||||
"interruptions": row.interruptions,
|
||||
"cycle_index": row.cycle_index,
|
||||
"task": (
|
||||
{
|
||||
"source": row.task_source,
|
||||
"external_id": row.task_external_id,
|
||||
"title": row.task_title,
|
||||
}
|
||||
if row.task_title
|
||||
else None
|
||||
),
|
||||
"note": row.note,
|
||||
}
|
||||
|
||||
|
||||
def state(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
row = current_session(session)
|
||||
done = completed_work_today(session, settings)
|
||||
return {
|
||||
"current": session_dict(row),
|
||||
"next_kind": next_kind(session, settings),
|
||||
"completed_today": done,
|
||||
"goal": settings.pomodoro.daily_goal_pomodoros,
|
||||
"cycle_position": (done % settings.pomodoro.cycles_before_long_break) + 1,
|
||||
"cycles_before_long_break": settings.pomodoro.cycles_before_long_break,
|
||||
"durations": {
|
||||
"work": settings.pomodoro.work_minutes,
|
||||
"short_break": settings.pomodoro.short_break_minutes,
|
||||
"long_break": settings.pomodoro.long_break_minutes,
|
||||
},
|
||||
"server_time": utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def sessions_for_day(session: Session, settings: AppSettings, day: date) -> list[PomodoroSession]:
|
||||
start_utc, end_utc = day_bounds_utc(day, settings.timezone)
|
||||
return list(
|
||||
session.scalars(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.started_at >= start_utc, PomodoroSession.started_at < end_utc)
|
||||
.order_by(PomodoroSession.started_at.asc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def reap_stale(session: Session, settings: AppSettings, *, max_overrun_hours: int = 6) -> int:
|
||||
"""Close intervals left running by a crash or a machine that went to sleep.
|
||||
|
||||
Without this one forgotten tab would report a 14-hour pomodoro and poison
|
||||
every average in the stats.
|
||||
"""
|
||||
cutoff = utcnow() - timedelta(hours=max_overrun_hours)
|
||||
stale = session.scalars(
|
||||
select(PomodoroSession).where(
|
||||
PomodoroSession.status.in_(ACTIVE_STATUSES),
|
||||
PomodoroSession.started_at < cutoff,
|
||||
)
|
||||
).all()
|
||||
for row in stale:
|
||||
row.status = "abandoned"
|
||||
row.ended_at = ensure_utc(row.started_at) + timedelta(seconds=row.planned_seconds)
|
||||
row.actual_seconds = row.planned_seconds
|
||||
row.abandon_reason = "auto-closed: left running"
|
||||
if stale:
|
||||
session.commit()
|
||||
return len(stale)
|
||||
@@ -0,0 +1,441 @@
|
||||
"""Reports: the daily wrap-up, the morning briefing, the weekly review.
|
||||
|
||||
A report is built once, stored as JSON, and then *rendered* — to HTML for
|
||||
Telegram or to a structure the web UI draws cards from. Because both come from
|
||||
the same payload, the message in your chat and the page in the browser can
|
||||
never tell you different numbers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import DailyReport, PomodoroSession, utcnow
|
||||
from app.services import achievements as ach
|
||||
from app.services import notify, stats, tasks_service
|
||||
from app.settings_schema import AppSettings
|
||||
from app.sources.registry import SOURCE_LABELS
|
||||
from app.timeutil import humanize_minutes, local_date
|
||||
|
||||
BAR_FULL = "█"
|
||||
BAR_EMPTY = "░"
|
||||
|
||||
|
||||
def _days(count: int, ru: bool) -> str:
|
||||
""""1 day" / "3 days" / "1 день" / "3 дня" — plurals, cheaply."""
|
||||
if ru:
|
||||
if count % 10 == 1 and count % 100 != 11:
|
||||
return f"{count} день"
|
||||
if count % 10 in (2, 3, 4) and count % 100 not in (12, 13, 14):
|
||||
return f"{count} дня"
|
||||
return f"{count} дней"
|
||||
return f"{count} day" if count == 1 else f"{count} days"
|
||||
|
||||
|
||||
def _esc(value: Any) -> str:
|
||||
"""Escape borrowed text for Telegram's HTML parse mode.
|
||||
|
||||
Without this one task summary containing `&` makes Telegram reject the
|
||||
entire message — jira-helper learned that the hard way.
|
||||
"""
|
||||
return html.escape(str(value), quote=False)
|
||||
|
||||
|
||||
def progress_bar(value: int, total: int, width: int = 10) -> str:
|
||||
if total <= 0:
|
||||
return BAR_EMPTY * width
|
||||
filled = min(width, round(width * value / total))
|
||||
return BAR_FULL * filled + BAR_EMPTY * (width - filled)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Daily report
|
||||
# --------------------------------------------------------------------------
|
||||
def build_daily(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
|
||||
"""Assemble everything the daily report shows, as a plain dict."""
|
||||
metrics = stats.day_metrics(session, settings, day)
|
||||
streak = stats.streak(session, settings, until=day)
|
||||
average_7d = stats.averages(session, settings, 7, until=day)
|
||||
lifetime = stats.lifetime(session, settings)
|
||||
|
||||
day_counts = dict(
|
||||
session.execute(
|
||||
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
).all()
|
||||
)
|
||||
yesterday = day - timedelta(days=1)
|
||||
|
||||
context = {
|
||||
"streak": streak,
|
||||
"average_7d": average_7d,
|
||||
"lifetime": lifetime,
|
||||
"previous_best": ach.previous_best(day_counts, day),
|
||||
"yesterday_pomodoros": day_counts.get(yesterday, 0),
|
||||
}
|
||||
|
||||
report_settings = settings.notifications.report
|
||||
language = report_settings.language
|
||||
|
||||
open_tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(open_tasks, day)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"date": day.isoformat(),
|
||||
"generated_at": utcnow().isoformat(),
|
||||
"timezone": settings.timezone,
|
||||
"metrics": metrics,
|
||||
"streak": streak,
|
||||
"average_7d": average_7d,
|
||||
"lifetime": lifetime,
|
||||
"comparison": {
|
||||
"yesterday": day_counts.get(yesterday, 0),
|
||||
"previous_best": context["previous_best"],
|
||||
},
|
||||
"achievements": (
|
||||
ach.evaluate(metrics, context, language)
|
||||
if report_settings.include_achievements
|
||||
else []
|
||||
),
|
||||
"fun_facts": (
|
||||
ach.fun_facts(metrics, context, language)
|
||||
if report_settings.include_funny_stats
|
||||
else []
|
||||
),
|
||||
"verdict": ach.verdict(metrics, report_settings.tone, language),
|
||||
"open_tasks": {
|
||||
"overdue": len(buckets["overdue"]),
|
||||
"today": len(buckets["today"]),
|
||||
"upcoming": len(buckets["upcoming"]),
|
||||
"someday": len(buckets["someday"]),
|
||||
},
|
||||
"score": _score(metrics, streak),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
def _score(metrics: dict[str, Any], streak: dict[str, int]) -> float:
|
||||
"""A single 0-100 number for the dashboard's big dial.
|
||||
|
||||
Goal progress is most of it; finishing tasks, resisting the blocker and
|
||||
keeping a streak nudge it up, dropped pomodoros pull it down.
|
||||
"""
|
||||
score = min(metrics["goal_pct"], 120) * 0.6
|
||||
score += min(metrics["tasks_completed_count"], 6) * 3
|
||||
score += min(streak.get("current", 0), 7) * 1.5
|
||||
score += min(metrics["longest_stretch_minutes"] / 30, 4) * 2
|
||||
score -= metrics["abandoned"] * 3
|
||||
score -= max(metrics["interruptions"] - 2, 0) * 1.5
|
||||
return round(max(0.0, min(100.0, score)), 1)
|
||||
|
||||
|
||||
def render_daily_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
|
||||
"""The daily report as Telegram HTML."""
|
||||
report_settings = settings.notifications.report
|
||||
ru = report_settings.language == "ru"
|
||||
metrics = payload["metrics"]
|
||||
lines: list[str] = []
|
||||
|
||||
header = "🍅 Отчёт за день" if ru else "🍅 Daily report"
|
||||
lines.append(f"<b>{header} — {payload['date']}</b>")
|
||||
lines.append("")
|
||||
|
||||
bar = progress_bar(metrics["pomodoros"], metrics["goal"])
|
||||
goal_line = (
|
||||
f"{bar} <b>{metrics['pomodoros']}/{metrics['goal']}</b> "
|
||||
+ ("помидорок" if ru else "pomodoros")
|
||||
)
|
||||
lines.append(goal_line)
|
||||
lines.append(
|
||||
("⏱ Фокус: " if ru else "⏱ Focus: ")
|
||||
+ f"<b>{humanize_minutes(metrics['focus_minutes'])}</b>"
|
||||
+ (" · перерывы: " if ru else " · breaks: ")
|
||||
+ humanize_minutes(metrics["break_minutes"])
|
||||
)
|
||||
|
||||
detail_bits: list[str] = []
|
||||
if metrics["longest_stretch_minutes"]:
|
||||
detail_bits.append(
|
||||
("🧠 дольше всего без разрыва: " if ru else "🧠 longest stretch: ")
|
||||
+ humanize_minutes(metrics["longest_stretch_minutes"])
|
||||
)
|
||||
if metrics["first_start"]:
|
||||
detail_bits.append(
|
||||
("🕘 с " if ru else "🕘 from ")
|
||||
+ f"{metrics['first_start']}"
|
||||
+ (" до " if ru else " to ")
|
||||
+ f"{metrics['last_end']}"
|
||||
)
|
||||
if metrics["abandoned"]:
|
||||
detail_bits.append(
|
||||
("💨 брошено: " if ru else "💨 dropped: ") + str(metrics["abandoned"])
|
||||
)
|
||||
if metrics["interruptions"]:
|
||||
detail_bits.append(
|
||||
("📵 прерываний: " if ru else "📵 interruptions: ") + str(metrics["interruptions"])
|
||||
)
|
||||
if detail_bits:
|
||||
lines.append(" · ".join(detail_bits))
|
||||
|
||||
streak = payload["streak"]
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
(f"🔥 Серия: {_days(streak['current'], True)} (рекорд {streak['best']})")
|
||||
if ru
|
||||
else f"🔥 Streak: {_days(streak['current'], False)} (best {streak['best']})"
|
||||
)
|
||||
|
||||
if report_settings.include_comparison:
|
||||
avg = payload["average_7d"]["pomodoros"]
|
||||
if avg:
|
||||
lines.append(
|
||||
(f"📊 Среднее за 7 дней: {avg} · вчера: {payload['comparison']['yesterday']}")
|
||||
if ru
|
||||
else (
|
||||
f"📊 7-day average: {avg} · yesterday: {payload['comparison']['yesterday']}"
|
||||
)
|
||||
)
|
||||
|
||||
tasks = metrics["tasks_completed"]
|
||||
if report_settings.include_task_list and tasks:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"<b>{'✅ Закрытые задачи' if ru else '✅ Tasks closed'} ({len(tasks)})</b>"
|
||||
)
|
||||
for task in tasks[: report_settings.max_tasks_listed]:
|
||||
label = SOURCE_LABELS.get(task["source"], task["source"])
|
||||
title = _esc(task["title"])
|
||||
if task.get("url"):
|
||||
lines.append(f"• <a href=\"{_esc(task['url'])}\">{title}</a> <i>({label})</i>")
|
||||
else:
|
||||
lines.append(f"• {title} <i>({label})</i>")
|
||||
if len(tasks) > report_settings.max_tasks_listed:
|
||||
rest = len(tasks) - report_settings.max_tasks_listed
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{rest}" + ("" if ru else " more"))
|
||||
|
||||
if report_settings.include_blocked_sites and metrics["blocked_top"]:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🚫 Соблазны' if ru else '🚫 Temptations blocked'}</b>")
|
||||
for entry in metrics["blocked_top"][:5]:
|
||||
lines.append(f"• {_esc(entry['target'])} × {entry['count']}")
|
||||
|
||||
if payload["achievements"]:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🏅 Достижения' if ru else '🏅 Achievements'}</b>")
|
||||
for item in payload["achievements"]:
|
||||
detail = f" — {_esc(item['detail'])}" if item.get("detail") else ""
|
||||
lines.append(f"{item['emoji']} <b>{_esc(item['title'])}</b>{detail}")
|
||||
|
||||
if payload["fun_facts"]:
|
||||
lines.append("")
|
||||
for fact in payload["fun_facts"]:
|
||||
lines.append(_esc(fact))
|
||||
|
||||
open_tasks = payload["open_tasks"]
|
||||
if open_tasks["overdue"] or open_tasks["today"]:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
(f"📋 Осталось: {open_tasks['overdue']} просрочено, {open_tasks['today']} на сегодня")
|
||||
if ru
|
||||
else (
|
||||
f"📋 Still open: {open_tasks['overdue']} overdue, "
|
||||
f"{open_tasks['today']} due today"
|
||||
)
|
||||
)
|
||||
|
||||
if payload["verdict"]:
|
||||
lines.append("")
|
||||
lines.append(f"<i>{_esc(payload['verdict'])}</i>")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_or_build_daily(
|
||||
session: Session, settings: AppSettings, day: date, *, rebuild: bool = False
|
||||
) -> tuple[DailyReport, dict[str, Any]]:
|
||||
row = session.scalar(select(DailyReport).where(DailyReport.report_date == day))
|
||||
if row is not None and not rebuild:
|
||||
return row, row.payload
|
||||
|
||||
payload = build_daily(session, settings, day)
|
||||
if row is None:
|
||||
row = DailyReport(report_date=day, payload=payload, score=payload["score"])
|
||||
session.add(row)
|
||||
else:
|
||||
row.payload = payload
|
||||
row.score = payload["score"]
|
||||
row.generated_at = utcnow()
|
||||
session.commit()
|
||||
return row, payload
|
||||
|
||||
|
||||
def send_daily(
|
||||
session: Session, settings: AppSettings, day: date, *, force: bool = False
|
||||
) -> dict[str, Any]:
|
||||
"""Build (or reuse) the day's report and push it to the channels."""
|
||||
row, payload = get_or_build_daily(session, settings, day, rebuild=True)
|
||||
if row.sent_at is not None and not force:
|
||||
return {"sent": False, "reason": "already sent", "payload": payload}
|
||||
|
||||
text = render_daily_telegram(payload, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
ok = any(delivery.ok for delivery in deliveries)
|
||||
if ok:
|
||||
row.sent_at = utcnow()
|
||||
row.sent_channel = ",".join(d.channel for d in deliveries if d.ok)
|
||||
session.commit()
|
||||
return {
|
||||
"sent": ok,
|
||||
"deliveries": [d.as_dict() for d in deliveries],
|
||||
"payload": payload,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Morning briefing
|
||||
# --------------------------------------------------------------------------
|
||||
def render_morning_briefing(session: Session, settings: AppSettings) -> str:
|
||||
""""Here is your day" — the todo list, the streak, and the goal to beat."""
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
today = local_date(settings.timezone)
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
streak = stats.streak(session, settings, until=today - timedelta(days=1))
|
||||
limit = settings.notifications.report.max_tasks_listed
|
||||
|
||||
lines = [f"<b>{'☀️ Доброе утро!' if ru else '☀️ Good morning!'}</b>", ""]
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
lines.append(
|
||||
(f"🎯 Цель на сегодня: <b>{goal}</b> помидорок "
|
||||
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} фокуса)")
|
||||
if ru
|
||||
else (
|
||||
f"🎯 Today's goal: <b>{goal}</b> pomodoros "
|
||||
f"(~{humanize_minutes(goal * settings.pomodoro.work_minutes)} of focus)"
|
||||
)
|
||||
)
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
|
||||
+ (" — не разрывай" if ru else " — keep it alive")
|
||||
)
|
||||
|
||||
def section(title: str, records: list, emoji: str) -> None:
|
||||
if not records:
|
||||
return
|
||||
lines.append("")
|
||||
lines.append(f"<b>{emoji} {title} ({len(records)})</b>")
|
||||
for record in records[:limit]:
|
||||
label = SOURCE_LABELS.get(record.source, record.source)
|
||||
title_text = _esc(record.title)
|
||||
due = ""
|
||||
if record.due_date and record.due_date < today:
|
||||
days = (today - record.due_date).days
|
||||
due = (
|
||||
f" — <b>{days} дн. просрочки</b>" if ru else f" — <b>{days}d overdue</b>"
|
||||
)
|
||||
if record.url:
|
||||
lines.append(
|
||||
f"• <a href=\"{_esc(record.url)}\">{title_text}</a> <i>({label})</i>{due}"
|
||||
)
|
||||
else:
|
||||
lines.append(f"• {title_text} <i>({label})</i>{due}")
|
||||
if len(records) > limit:
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{len(records) - limit}")
|
||||
|
||||
section("Просрочено" if ru else "Overdue", buckets["overdue"], "🔴")
|
||||
section("На сегодня" if ru else "Due today", buckets["today"], "🟡")
|
||||
section("Скоро" if ru else "Coming up", buckets["upcoming"][:5], "🔵")
|
||||
|
||||
laundry = [task for task in tasks if task.source == "laundry"]
|
||||
if laundry:
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'🧺 Стирка' if ru else '🧺 Laundry'}</b>")
|
||||
for slot in laundry:
|
||||
lines.append(f"• {_esc(slot.title)}")
|
||||
|
||||
if not any(buckets.values()):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"✨ Входящих нет. Отличный день, чтобы поработать над чем-то важным."
|
||||
if ru
|
||||
else "✨ Nothing queued. A good day to work on something that matters."
|
||||
)
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"<i>/start_work — запустить помидорку, /today — список, /report — отчёт</i>"
|
||||
if ru
|
||||
else "<i>/start_work to begin, /today for the list, /report for the numbers</i>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Weekly review
|
||||
# --------------------------------------------------------------------------
|
||||
def build_weekly(session: Session, settings: AppSettings, end_day: date) -> dict[str, Any]:
|
||||
start_day = end_day - timedelta(days=6)
|
||||
days = stats.range_metrics(session, settings, start_day, end_day)
|
||||
total = sum(day["pomodoros"] for day in days)
|
||||
focus = sum(day["focus_minutes"] for day in days)
|
||||
best = max(days, key=lambda day: day["pomodoros"]) if days else None
|
||||
goal_days = sum(1 for day in days if day["goal_met"])
|
||||
return {
|
||||
"start": start_day.isoformat(),
|
||||
"end": end_day.isoformat(),
|
||||
"days": days,
|
||||
"pomodoros": total,
|
||||
"focus_minutes": focus,
|
||||
"best_day": best,
|
||||
"goal_days": goal_days,
|
||||
"average": round(total / len(days), 1) if days else 0,
|
||||
"streak": stats.streak(session, settings, until=end_day),
|
||||
"lifetime": stats.lifetime(session, settings),
|
||||
}
|
||||
|
||||
|
||||
def render_weekly_telegram(payload: dict[str, Any], settings: AppSettings) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
lines = [
|
||||
f"<b>{'📅 Итоги недели' if ru else '📅 Weekly review'} "
|
||||
f"({payload['start']} → {payload['end']})</b>",
|
||||
"",
|
||||
("🍅 Помидорок: " if ru else "🍅 Pomodoros: ")
|
||||
+ f"<b>{payload['pomodoros']}</b>"
|
||||
+ (" · в среднем " if ru else " · average ")
|
||||
+ f"{payload['average']}/"
|
||||
+ ("день" if ru else "day"),
|
||||
("⏱ Фокус: " if ru else "⏱ Focus: ") + humanize_minutes(payload["focus_minutes"]),
|
||||
("🎯 Дней с выполненной целью: " if ru else "🎯 Days the goal was met: ")
|
||||
+ f"{payload['goal_days']}/7",
|
||||
]
|
||||
if payload["best_day"] and payload["best_day"]["pomodoros"]:
|
||||
best = payload["best_day"]
|
||||
lines.append(
|
||||
("🏆 Лучший день: " if ru else "🏆 Best day: ")
|
||||
+ f"{best['date']} ({best['pomodoros']} 🍅)"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"<b>{'По дням' if ru else 'Day by day'}</b>")
|
||||
for day in payload["days"]:
|
||||
bar = progress_bar(day["pomodoros"], settings.pomodoro.daily_goal_pomodoros, width=8)
|
||||
lines.append(f"<code>{day['date'][5:]} {bar}</code> {day['pomodoros']}")
|
||||
streak = payload["streak"]
|
||||
if streak.get("current"):
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"🔥 {'Серия' if ru else 'Streak'}: {_days(streak['current'], ru)}"
|
||||
+ f" · {'рекорд' if ru else 'best'}: {streak['best']}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Background jobs: the part that makes this an instrument instead of a timer.
|
||||
|
||||
Five jobs, all re-planned whenever the settings change:
|
||||
|
||||
* refresh every todo source on an interval;
|
||||
* close intervals a crashed client left running;
|
||||
* the morning briefing, the daily report and the weekly review, at your times;
|
||||
* the nudge — "you have not started anything in 90 minutes" — which is the one
|
||||
that actually changes behaviour.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import session_scope
|
||||
from app.models import PomodoroSession, utcnow
|
||||
from app.services import notify, pomodoro, report, settings_service, stats, tasks_service
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import ensure_utc, in_window, local_date, local_time, to_local
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
|
||||
JOB_REFRESH = "refresh_tasks"
|
||||
JOB_REAP = "reap_sessions"
|
||||
JOB_MORNING = "morning_briefing"
|
||||
JOB_DAILY = "daily_report"
|
||||
JOB_WEEKLY = "weekly_report"
|
||||
JOB_NUDGE = "nudge"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Jobs
|
||||
# --------------------------------------------------------------------------
|
||||
def job_refresh_tasks() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
try:
|
||||
result = tasks_service.refresh_tasks(session, settings)
|
||||
failed = [item for item in result["sources"] if not item["ok"]]
|
||||
if failed:
|
||||
log.warning("task refresh: %s", "; ".join(f"{i['label']}: {i['error']}" for i in failed))
|
||||
except Exception: # pragma: no cover
|
||||
log.exception("task refresh failed")
|
||||
|
||||
|
||||
def job_reap_sessions() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
closed = pomodoro.reap_stale(session, settings)
|
||||
if closed:
|
||||
log.info("auto-closed %s abandoned interval(s)", closed)
|
||||
|
||||
|
||||
def job_morning_briefing() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.morning_briefing):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
if notify.already_sent(session, JOB_MORNING, today):
|
||||
return
|
||||
text = report.render_morning_briefing(session, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_MORNING, today)
|
||||
|
||||
|
||||
def job_daily_report() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.daily_report):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
report.send_daily(session, settings, today)
|
||||
|
||||
|
||||
def job_weekly_report() -> None:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.weekly_report):
|
||||
return
|
||||
today = local_date(settings.timezone)
|
||||
if today.weekday() != telegram.weekly_report_weekday:
|
||||
return
|
||||
if notify.already_sent(session, JOB_WEEKLY, today):
|
||||
return
|
||||
payload = report.build_weekly(session, settings, today)
|
||||
text = report.render_weekly_telegram(payload, settings)
|
||||
deliveries = notify.notify(settings, text, urgent=True)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_WEEKLY, today)
|
||||
|
||||
|
||||
def job_nudge() -> None:
|
||||
"""Poke the user when the day is passing without any work on it."""
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.nudge_enabled):
|
||||
return
|
||||
now_local = local_time(settings.timezone)
|
||||
if not in_window(now_local, telegram.nudge_window_start, telegram.nudge_window_end):
|
||||
return
|
||||
if notify.in_quiet_hours(settings):
|
||||
return
|
||||
|
||||
today = local_date(settings.timezone)
|
||||
if notify.count_sent(session, JOB_NUDGE, today) >= telegram.nudge_max_per_day:
|
||||
return
|
||||
if pomodoro.current_session(session) is not None:
|
||||
return # already working — the last thing they need is a message
|
||||
|
||||
idle_for = _idle_minutes(session, settings)
|
||||
if idle_for is None or idle_for < telegram.nudge_after_idle_minutes:
|
||||
return
|
||||
|
||||
text = _nudge_text(session, settings, idle_for)
|
||||
deliveries = notify.notify(settings, text)
|
||||
if any(delivery.ok for delivery in deliveries):
|
||||
notify.record_sent(session, JOB_NUDGE, today, {"idle_minutes": idle_for})
|
||||
|
||||
|
||||
def _idle_minutes(session, settings: AppSettings) -> int | None:
|
||||
"""Minutes since the last work interval ended, or since the nudge window opened."""
|
||||
today = local_date(settings.timezone)
|
||||
last = session.scalar(
|
||||
select(PomodoroSession)
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.local_date == today,
|
||||
PomodoroSession.status.in_(("completed", "abandoned")),
|
||||
)
|
||||
.order_by(PomodoroSession.started_at.desc())
|
||||
)
|
||||
now = utcnow()
|
||||
if last is not None and last.ended_at is not None:
|
||||
return int((now - ensure_utc(last.ended_at)).total_seconds() // 60)
|
||||
|
||||
window_start = settings.notifications.telegram.nudge_window_start
|
||||
local_now = to_local(now, settings.timezone)
|
||||
opened = local_now.replace(
|
||||
hour=window_start.hour, minute=window_start.minute, second=0, microsecond=0
|
||||
)
|
||||
if local_now < opened:
|
||||
return None
|
||||
return int((local_now - opened).total_seconds() // 60)
|
||||
|
||||
|
||||
def _nudge_text(session, settings: AppSettings, idle_minutes: int) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
tone = settings.notifications.report.tone
|
||||
today = local_date(settings.timezone)
|
||||
done = pomodoro.completed_work_today(session, settings)
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
streak = stats.streak(session, settings, until=today)
|
||||
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
candidate = (buckets["overdue"] or buckets["today"] or buckets["upcoming"] or tasks)
|
||||
suggestion = candidate[0] if candidate else None
|
||||
|
||||
openers = {
|
||||
"friendly": (
|
||||
f"👋 Прошло {idle_minutes} минут без работы. Запустим одну помидорку?",
|
||||
f"👋 {idle_minutes} minutes without a pomodoro. Shall we start one?",
|
||||
),
|
||||
"coach": (
|
||||
f"⏳ {idle_minutes} минут простоя. {done}/{goal} сделано — ещё один блок, и догоняешь план.",
|
||||
f"⏳ {idle_minutes} idle minutes. You are at {done}/{goal} — one block puts you back on plan.",
|
||||
),
|
||||
"drill_sergeant": (
|
||||
f"🫡 {idle_minutes} минут безделья. {done}/{goal}. Таймер. Сейчас.",
|
||||
f"🫡 {idle_minutes} minutes of nothing. {done}/{goal}. Timer. Now.",
|
||||
),
|
||||
"dry": (
|
||||
f"Простой: {idle_minutes} мин. Выполнено {done} из {goal}.",
|
||||
f"Idle for {idle_minutes} min. {done} of {goal} pomodoros done.",
|
||||
),
|
||||
}
|
||||
opener_ru, opener_en = openers.get(tone, openers["friendly"])
|
||||
lines = [opener_ru if ru else opener_en]
|
||||
|
||||
if suggestion is not None:
|
||||
label = suggestion.title
|
||||
lines.append("")
|
||||
lines.append(
|
||||
(f"Предлагаю: <b>{report._esc(label)}</b>")
|
||||
if ru
|
||||
else f"Suggested next: <b>{report._esc(label)}</b>"
|
||||
)
|
||||
if streak.get("current"):
|
||||
lines.append(
|
||||
(f"🔥 Серия {streak['current']} дн. на кону.")
|
||||
if ru
|
||||
else f"🔥 A {streak['current']}-day streak is on the line."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"<i>/start_work — запустить таймер</i>" if ru else "<i>/start_work to begin</i>"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Wiring
|
||||
# --------------------------------------------------------------------------
|
||||
def _plan(scheduler: BackgroundScheduler, settings: AppSettings) -> None:
|
||||
tz_name = settings.timezone
|
||||
telegram = settings.notifications.telegram
|
||||
|
||||
scheduler.add_job(
|
||||
job_refresh_tasks,
|
||||
IntervalTrigger(minutes=settings.sources.refresh_interval_minutes),
|
||||
id=JOB_REFRESH,
|
||||
replace_existing=True,
|
||||
next_run_time=utcnow() + timedelta(seconds=20),
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_reap_sessions,
|
||||
IntervalTrigger(minutes=15),
|
||||
id=JOB_REAP,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_morning_briefing,
|
||||
CronTrigger(
|
||||
hour=telegram.morning_briefing_time.hour,
|
||||
minute=telegram.morning_briefing_time.minute,
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_MORNING,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_daily_report,
|
||||
CronTrigger(
|
||||
hour=telegram.daily_report_time.hour,
|
||||
minute=telegram.daily_report_time.minute,
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_DAILY,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_weekly_report,
|
||||
CronTrigger(
|
||||
day_of_week=telegram.weekly_report_weekday,
|
||||
hour=telegram.daily_report_time.hour,
|
||||
minute=min(telegram.daily_report_time.minute + 5, 59),
|
||||
timezone=tz_name,
|
||||
),
|
||||
id=JOB_WEEKLY,
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
job_nudge,
|
||||
IntervalTrigger(minutes=10),
|
||||
id=JOB_NUDGE,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start(settings: AppSettings) -> BackgroundScheduler | None:
|
||||
"""Start the scheduler and re-plan it on every settings change."""
|
||||
global _scheduler
|
||||
if not env_settings().scheduler_enabled:
|
||||
log.info("scheduler disabled by configuration")
|
||||
return None
|
||||
if _scheduler is not None:
|
||||
return _scheduler
|
||||
|
||||
scheduler = BackgroundScheduler(timezone=settings.timezone)
|
||||
_plan(scheduler, settings)
|
||||
scheduler.start()
|
||||
_scheduler = scheduler
|
||||
|
||||
settings_service.on_change(lambda new_settings: _plan(scheduler, new_settings))
|
||||
log.info("scheduler started with %s jobs", len(scheduler.get_jobs()))
|
||||
return scheduler
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
|
||||
|
||||
def describe() -> list[dict[str, str]]:
|
||||
if _scheduler is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"id": job.id,
|
||||
"next_run": job.next_run_time.isoformat() if job.next_run_time else "",
|
||||
"trigger": str(job.trigger),
|
||||
}
|
||||
for job in _scheduler.get_jobs()
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Reading and writing the settings document.
|
||||
|
||||
Three jobs:
|
||||
|
||||
* seed the document from environment variables the first time the app boots, so
|
||||
a filled-in `.env` (the same variables jira-helper uses) is a working setup;
|
||||
* hand the API a copy with secrets masked, and merge an edited copy back without
|
||||
the UI ever having to know a real token;
|
||||
* notify the rest of the process when something changed, so the scheduler can
|
||||
re-plan its jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Any, Callable
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import env_settings
|
||||
from app.models import SettingsRow
|
||||
from app.settings_schema import SECRET_FIELDS, SECRET_MASK, AppSettings
|
||||
|
||||
_SETTINGS_ID = 1
|
||||
_listeners: list[Callable[[AppSettings], None]] = []
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def on_change(listener: Callable[[AppSettings], None]) -> None:
|
||||
"""Register a callback fired after every successful settings save."""
|
||||
_listeners.append(listener)
|
||||
|
||||
|
||||
def _csv(value: str) -> list[str]:
|
||||
return [part.strip() for part in value.split(",") if part.strip()]
|
||||
|
||||
|
||||
def settings_from_env() -> AppSettings:
|
||||
"""Build the initial document out of the environment.
|
||||
|
||||
The variable names are deliberately the ones from `~/code/jira-helper`, so
|
||||
an existing `.env` can be copied across untouched.
|
||||
"""
|
||||
env = env_settings()
|
||||
settings = AppSettings(timezone=env.timezone)
|
||||
|
||||
jira = settings.sources.jira
|
||||
if env.jira_base_url and env.jira_email and env.jira_api_token:
|
||||
jira.enabled = True
|
||||
jira.base_url = env.jira_base_url.rstrip("/")
|
||||
jira.email = env.jira_email
|
||||
jira.api_token = env.jira_api_token
|
||||
jira.extra_jql = env.jira_extra_jql
|
||||
jira.start_date_field = env.jira_start_date_field or jira.start_date_field
|
||||
|
||||
google = settings.sources.google_tasks
|
||||
if env.google_client_id and env.google_client_secret and env.google_refresh_token:
|
||||
google.enabled = True
|
||||
google.client_id = env.google_client_id
|
||||
google.client_secret = env.google_client_secret
|
||||
google.refresh_token = env.google_refresh_token
|
||||
google.lists = _csv(env.google_tasks_lists)
|
||||
|
||||
todoist = settings.sources.todoist
|
||||
if env.todoist_api_token:
|
||||
todoist.enabled = True
|
||||
todoist.api_token = env.todoist_api_token
|
||||
todoist.projects = _csv(env.todoist_projects)
|
||||
|
||||
laundry = settings.sources.laundry
|
||||
if env.laundry_session:
|
||||
laundry.enabled = True
|
||||
laundry.session_cookie = env.laundry_session
|
||||
laundry.base_url = env.laundry_base_url.rstrip("/")
|
||||
|
||||
telegram = settings.notifications.telegram
|
||||
if env.telegram_bot_token and env.telegram_chat_id:
|
||||
telegram.enabled = True
|
||||
telegram.bot_token = env.telegram_bot_token
|
||||
telegram.chat_id = env.telegram_chat_id
|
||||
|
||||
if env.slack_webhook_url:
|
||||
settings.notifications.slack.enabled = True
|
||||
settings.notifications.slack.webhook_url = env.slack_webhook_url
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def load_settings(session: Session) -> AppSettings:
|
||||
"""The current settings, creating the row from the environment if absent."""
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
if row is None:
|
||||
settings = settings_from_env()
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=settings.model_dump(mode="json"))
|
||||
session.add(row)
|
||||
session.commit()
|
||||
return settings
|
||||
try:
|
||||
return AppSettings.model_validate(row.data)
|
||||
except Exception:
|
||||
# A settings document written by an older build can be missing keys or
|
||||
# carry ones that no longer exist; defaults fill the gaps rather than
|
||||
# taking the whole app down.
|
||||
return AppSettings.model_validate(_merge(AppSettings().model_dump(mode="json"), row.data))
|
||||
|
||||
|
||||
def save_settings(session: Session, settings: AppSettings) -> AppSettings:
|
||||
row = session.get(SettingsRow, _SETTINGS_ID)
|
||||
data = settings.model_dump(mode="json")
|
||||
if row is None:
|
||||
row = SettingsRow(id=_SETTINGS_ID, data=data)
|
||||
session.add(row)
|
||||
else:
|
||||
row.data = data
|
||||
session.commit()
|
||||
|
||||
with _lock:
|
||||
listeners = list(_listeners)
|
||||
for listener in listeners:
|
||||
try:
|
||||
listener(settings)
|
||||
except Exception: # pragma: no cover - a bad listener must not block saving
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).exception("settings listener failed")
|
||||
return settings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Secret masking
|
||||
# --------------------------------------------------------------------------
|
||||
def _walk(node: Any, fn: Callable[[str, Any], Any]) -> Any:
|
||||
if isinstance(node, dict):
|
||||
return {key: fn(key, _walk(value, fn)) for key, value in node.items()}
|
||||
if isinstance(node, list):
|
||||
return [_walk(item, fn) for item in node]
|
||||
return node
|
||||
|
||||
|
||||
def mask_secrets(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Replace every configured secret that has a value with the mask."""
|
||||
|
||||
def mask(key: str, value: Any) -> Any:
|
||||
if key in SECRET_FIELDS and isinstance(value, str) and value:
|
||||
return SECRET_MASK
|
||||
return value
|
||||
|
||||
return _walk(data, mask)
|
||||
|
||||
|
||||
def _merge(base: Any, incoming: Any) -> Any:
|
||||
"""Deep-merge `incoming` onto `base`, keeping `base` where a key is missing."""
|
||||
if isinstance(base, dict) and isinstance(incoming, dict):
|
||||
merged = dict(base)
|
||||
for key, value in incoming.items():
|
||||
merged[key] = _merge(base.get(key), value) if key in base else value
|
||||
return merged
|
||||
return incoming
|
||||
|
||||
|
||||
def unmask_secrets(stored: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Put the stored secret back wherever the client sent the mask untouched."""
|
||||
|
||||
def restore(node_stored: Any, node_in: Any) -> Any:
|
||||
if isinstance(node_in, dict):
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in node_in.items():
|
||||
old = node_stored.get(key) if isinstance(node_stored, dict) else None
|
||||
if key in SECRET_FIELDS and value == SECRET_MASK:
|
||||
out[key] = old or ""
|
||||
else:
|
||||
out[key] = restore(old, value)
|
||||
return out
|
||||
return node_in
|
||||
|
||||
return restore(stored, incoming)
|
||||
|
||||
|
||||
def apply_patch(session: Session, patch: dict[str, Any]) -> AppSettings:
|
||||
"""Merge a partial settings document (as the UI sends it) and store it."""
|
||||
current = load_settings(session).model_dump(mode="json")
|
||||
patch = unmask_secrets(current, patch)
|
||||
merged = _merge(current, patch)
|
||||
return save_settings(session, AppSettings.model_validate(merged))
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Turning rows into numbers: a day, a range, streaks, and a heatmap.
|
||||
|
||||
Everything the report and the dashboard show comes from here, so the web UI and
|
||||
the Telegram message can never disagree about how many pomodoros you did.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import BlockEvent, PomodoroSession, TaskRecord
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import local_date, to_local
|
||||
|
||||
# Two work intervals closer together than this count as one unbroken stretch.
|
||||
_STREAK_GAP_SECONDS = 20 * 60
|
||||
|
||||
|
||||
def _focus_blocks(rows: list[PomodoroSession], tz_name: str) -> list[tuple[datetime, datetime]]:
|
||||
blocks: list[tuple[datetime, datetime]] = []
|
||||
for row in rows:
|
||||
if row.kind != "work" or row.status != "completed" or row.ended_at is None:
|
||||
continue
|
||||
blocks.append((to_local(row.started_at, tz_name), to_local(row.ended_at, tz_name)))
|
||||
return sorted(blocks)
|
||||
|
||||
|
||||
def longest_focus_stretch_minutes(rows: list[PomodoroSession], tz_name: str) -> int:
|
||||
"""The longest run of back-to-back pomodoros, in minutes of actual focus."""
|
||||
blocks = _focus_blocks(rows, tz_name)
|
||||
if not blocks:
|
||||
return 0
|
||||
best = current = (blocks[0][1] - blocks[0][0]).total_seconds()
|
||||
previous_end = blocks[0][1]
|
||||
for start, end in blocks[1:]:
|
||||
gap = (start - previous_end).total_seconds()
|
||||
length = (end - start).total_seconds()
|
||||
current = current + length if gap <= _STREAK_GAP_SECONDS else length
|
||||
best = max(best, current)
|
||||
previous_end = end
|
||||
return int(best // 60)
|
||||
|
||||
|
||||
def day_metrics(session: Session, settings: AppSettings, day: date) -> dict[str, Any]:
|
||||
"""Everything measurable about one day."""
|
||||
tz_name = settings.timezone
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession)
|
||||
.where(PomodoroSession.local_date == day)
|
||||
.order_by(PomodoroSession.started_at.asc())
|
||||
)
|
||||
)
|
||||
|
||||
work_done = [r for r in rows if r.kind == "work" and r.status == "completed"]
|
||||
work_dropped = [r for r in rows if r.kind == "work" and r.status == "abandoned"]
|
||||
breaks_done = [r for r in rows if r.kind != "work" and r.status == "completed"]
|
||||
|
||||
focus_seconds = sum(r.actual_seconds for r in work_done)
|
||||
break_seconds = sum(r.actual_seconds for r in breaks_done)
|
||||
dropped_seconds = sum(r.actual_seconds for r in work_dropped)
|
||||
|
||||
by_hour = Counter(to_local(r.started_at, tz_name).hour for r in work_done)
|
||||
tasks = list(
|
||||
session.scalars(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.completed_date == day, TaskRecord.completed_here.is_(True)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
blocks = list(
|
||||
session.scalars(select(BlockEvent).where(BlockEvent.local_date == day))
|
||||
)
|
||||
block_counter = Counter(event.target for event in blocks)
|
||||
|
||||
pomodoro_time_by_task: Counter[str] = Counter()
|
||||
for row in work_done:
|
||||
label = row.task_title or "Unassigned"
|
||||
pomodoro_time_by_task[label] += row.actual_seconds
|
||||
|
||||
first = to_local(work_done[0].started_at, tz_name) if work_done else None
|
||||
last_row = work_done[-1] if work_done else None
|
||||
last = to_local(last_row.ended_at or last_row.started_at, tz_name) if last_row else None
|
||||
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
completed = len(work_done)
|
||||
planned_seconds = sum(r.planned_seconds for r in work_done)
|
||||
|
||||
return {
|
||||
"date": day.isoformat(),
|
||||
"pomodoros": completed,
|
||||
"goal": goal,
|
||||
"goal_pct": round(100 * completed / goal) if goal else 0,
|
||||
"goal_met": completed >= goal,
|
||||
"focus_minutes": round(focus_seconds / 60),
|
||||
"break_minutes": round(break_seconds / 60),
|
||||
"wasted_minutes": round(dropped_seconds / 60),
|
||||
"abandoned": len(work_dropped),
|
||||
"abandon_reasons": [r.abandon_reason for r in work_dropped if r.abandon_reason],
|
||||
"breaks_taken": len(breaks_done),
|
||||
"breaks_owed": completed,
|
||||
"interruptions": sum(r.interruptions for r in rows),
|
||||
"completion_rate": (
|
||||
round(100 * focus_seconds / planned_seconds) if planned_seconds else 0
|
||||
),
|
||||
"longest_stretch_minutes": longest_focus_stretch_minutes(rows, tz_name),
|
||||
"first_start": first.strftime("%H:%M") if first else None,
|
||||
"last_end": last.strftime("%H:%M") if last else None,
|
||||
"by_hour": [{"hour": hour, "count": by_hour.get(hour, 0)} for hour in range(24)],
|
||||
"peak_hour": (by_hour.most_common(1)[0][0] if by_hour else None),
|
||||
"tasks_completed": [
|
||||
{
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"source": task.source,
|
||||
"project": task.project,
|
||||
"url": task.url,
|
||||
}
|
||||
for task in tasks
|
||||
],
|
||||
"tasks_completed_count": len(tasks),
|
||||
"time_by_task": [
|
||||
{"title": title, "minutes": round(seconds / 60)}
|
||||
for title, seconds in pomodoro_time_by_task.most_common(10)
|
||||
],
|
||||
"blocked_attempts": sum(block_counter.values()),
|
||||
"blocked_top": [
|
||||
{"target": target, "count": count} for target, count in block_counter.most_common(8)
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"id": row.id,
|
||||
"kind": row.kind,
|
||||
"status": row.status,
|
||||
"started_at": to_local(row.started_at, tz_name).isoformat(),
|
||||
"minutes": round(row.actual_seconds / 60),
|
||||
"task": row.task_title,
|
||||
"interruptions": row.interruptions,
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def range_metrics(
|
||||
session: Session, settings: AppSettings, start: date, end: date
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Per-day metrics for `[start, end]`, inclusive, cheaply (one query)."""
|
||||
rows = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession).where(
|
||||
PomodoroSession.local_date >= start,
|
||||
PomodoroSession.local_date <= end,
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
)
|
||||
)
|
||||
)
|
||||
per_day: dict[date, list[PomodoroSession]] = {}
|
||||
for row in rows:
|
||||
per_day.setdefault(row.local_date, []).append(row)
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
day_rows = per_day.get(cursor, [])
|
||||
out.append(
|
||||
{
|
||||
"date": cursor.isoformat(),
|
||||
"pomodoros": len(day_rows),
|
||||
"focus_minutes": round(sum(r.actual_seconds for r in day_rows) / 60),
|
||||
"goal_met": len(day_rows) >= settings.pomodoro.daily_goal_pomodoros,
|
||||
}
|
||||
)
|
||||
cursor += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def averages(session: Session, settings: AppSettings, days: int, *, until: date) -> dict[str, float]:
|
||||
"""Mean pomodoros/focus over the `days` days ending the day before `until`."""
|
||||
start = until - timedelta(days=days)
|
||||
end = until - timedelta(days=1)
|
||||
if end < start:
|
||||
return {"pomodoros": 0.0, "focus_minutes": 0.0}
|
||||
rows = range_metrics(session, settings, start, end)
|
||||
if not rows:
|
||||
return {"pomodoros": 0.0, "focus_minutes": 0.0}
|
||||
return {
|
||||
"pomodoros": round(sum(r["pomodoros"] for r in rows) / len(rows), 1),
|
||||
"focus_minutes": round(sum(r["focus_minutes"] for r in rows) / len(rows), 1),
|
||||
}
|
||||
|
||||
|
||||
def streak(session: Session, settings: AppSettings, *, until: date | None = None) -> dict[str, int]:
|
||||
"""Consecutive days ending at `until` with at least one pomodoro, and with the goal met.
|
||||
|
||||
Today only breaks a streak once it is over, so a day with nothing on it yet
|
||||
keeps yesterday's streak alive.
|
||||
"""
|
||||
until = until or local_date(settings.timezone)
|
||||
days = list(
|
||||
session.scalars(
|
||||
select(PomodoroSession.local_date)
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
PomodoroSession.local_date <= until,
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
)
|
||||
)
|
||||
counts = dict(
|
||||
session.execute(
|
||||
select(PomodoroSession.local_date, func.count(PomodoroSession.id))
|
||||
.where(
|
||||
PomodoroSession.kind == "work",
|
||||
PomodoroSession.status == "completed",
|
||||
PomodoroSession.local_date <= until,
|
||||
)
|
||||
.group_by(PomodoroSession.local_date)
|
||||
).all()
|
||||
)
|
||||
active = set(days)
|
||||
|
||||
def run(from_day: date, predicate) -> int:
|
||||
length, cursor = 0, from_day
|
||||
while predicate(cursor):
|
||||
length += 1
|
||||
cursor -= timedelta(days=1)
|
||||
return length
|
||||
|
||||
goal = settings.pomodoro.daily_goal_pomodoros
|
||||
anchor = until if until in active else until - timedelta(days=1)
|
||||
goal_anchor = (
|
||||
until if counts.get(until, 0) >= goal else until - timedelta(days=1)
|
||||
)
|
||||
return {
|
||||
"current": run(anchor, lambda day: day in active),
|
||||
"goal_streak": run(goal_anchor, lambda day: counts.get(day, 0) >= goal),
|
||||
"best": _best_streak(sorted(active)),
|
||||
"total_days": len(active),
|
||||
}
|
||||
|
||||
|
||||
def _best_streak(days: list[date]) -> int:
|
||||
best = run = 0
|
||||
previous: date | None = None
|
||||
for day in days:
|
||||
run = run + 1 if previous is not None and (day - previous).days == 1 else 1
|
||||
best = max(best, run)
|
||||
previous = day
|
||||
return best
|
||||
|
||||
|
||||
def heatmap(session: Session, settings: AppSettings, weeks: int = 12) -> list[dict[str, Any]]:
|
||||
"""Per-day counts for the last `weeks` weeks, for the dashboard's calendar."""
|
||||
end = local_date(settings.timezone)
|
||||
start = end - timedelta(weeks=weeks)
|
||||
return range_metrics(session, settings, start, end)
|
||||
|
||||
|
||||
def lifetime(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
total, seconds = session.execute(
|
||||
select(func.count(PomodoroSession.id), func.coalesce(func.sum(PomodoroSession.actual_seconds), 0)).where(
|
||||
PomodoroSession.kind == "work", PomodoroSession.status == "completed"
|
||||
)
|
||||
).one()
|
||||
tasks = session.scalar(
|
||||
select(func.count(TaskRecord.id)).where(TaskRecord.completed_here.is_(True))
|
||||
)
|
||||
blocked = session.scalar(select(func.count(BlockEvent.id)))
|
||||
return {
|
||||
"pomodoros": int(total or 0),
|
||||
"focus_minutes": round(int(seconds or 0) / 60),
|
||||
"focus_hours": round(int(seconds or 0) / 3600, 1),
|
||||
"tasks_completed": int(tasks or 0),
|
||||
"blocked_attempts": int(blocked or 0),
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""The task list: refreshing it from every source, and acting on it.
|
||||
|
||||
External tasks are mirrored into the `tasks` table on each refresh. The mirror
|
||||
buys three things a live call cannot: the UI stays instant, the daily report
|
||||
still works when a source is down, and "I finished this" is recorded locally
|
||||
even if the write-back to Jira fails.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Iterable
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import TaskRecord, utcnow
|
||||
from app.settings_schema import AppSettings
|
||||
from app.sources.base import SourceResult, TodoItem
|
||||
from app.sources.registry import SOURCE_LABELS, complete_remote, fetch_all
|
||||
from app.timeutil import local_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LOCAL_SOURCE = "local"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Refresh
|
||||
# --------------------------------------------------------------------------
|
||||
def _upsert(session: Session, item: TodoItem) -> TaskRecord:
|
||||
record = session.scalar(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.source == item.source,
|
||||
TaskRecord.external_id == item.external_id,
|
||||
)
|
||||
)
|
||||
if record is None:
|
||||
record = TaskRecord(source=item.source, external_id=item.external_id)
|
||||
session.add(record)
|
||||
|
||||
record.title = item.title
|
||||
record.description = item.description
|
||||
record.url = item.url
|
||||
record.project = item.project
|
||||
record.status = item.status
|
||||
record.priority = item.priority
|
||||
record.due_date = item.due_date
|
||||
record.start_date = item.start_date
|
||||
record.labels = item.labels
|
||||
record.raw = item.raw
|
||||
record.last_seen_at = utcnow()
|
||||
record.stale = False
|
||||
if item.is_done and not record.is_done:
|
||||
# Closed in the source, not here: it counts as done but not as "mine".
|
||||
record.is_done = True
|
||||
record.completed_at = record.completed_at or utcnow()
|
||||
return record
|
||||
|
||||
|
||||
def refresh_tasks(session: Session, settings: AppSettings) -> dict[str, Any]:
|
||||
"""Pull every enabled source and reconcile the mirror.
|
||||
|
||||
Tasks a source stops returning are flagged `stale` rather than deleted: the
|
||||
history behind "you finished 6 things today" has to survive a source that
|
||||
quietly drops an item.
|
||||
"""
|
||||
results: list[SourceResult] = fetch_all(settings)
|
||||
today = local_date(settings.timezone)
|
||||
summary: list[dict[str, Any]] = []
|
||||
|
||||
for result in results:
|
||||
if not result.ok:
|
||||
summary.append(
|
||||
{
|
||||
"source": result.source,
|
||||
"label": SOURCE_LABELS.get(result.source, result.source),
|
||||
"ok": False,
|
||||
"error": result.error,
|
||||
"count": 0,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
for item in result.items:
|
||||
_upsert(session, item)
|
||||
seen_ids.add(item.external_id)
|
||||
|
||||
existing = session.scalars(
|
||||
select(TaskRecord).where(
|
||||
TaskRecord.source == result.source, TaskRecord.is_done.is_(False)
|
||||
)
|
||||
).all()
|
||||
for record in existing:
|
||||
if record.external_id not in seen_ids:
|
||||
record.stale = True
|
||||
|
||||
summary.append(
|
||||
{
|
||||
"source": result.source,
|
||||
"label": SOURCE_LABELS.get(result.source, result.source),
|
||||
"ok": True,
|
||||
"error": None,
|
||||
"count": len(result.items),
|
||||
}
|
||||
)
|
||||
|
||||
session.commit()
|
||||
return {
|
||||
"refreshed_at": utcnow().isoformat(),
|
||||
"date": today.isoformat(),
|
||||
"sources": summary,
|
||||
"total": sum(entry["count"] for entry in summary),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Queries
|
||||
# --------------------------------------------------------------------------
|
||||
def list_tasks(
|
||||
session: Session,
|
||||
*,
|
||||
include_done: bool = False,
|
||||
include_stale: bool = False,
|
||||
sources: Iterable[str] | None = None,
|
||||
) -> list[TaskRecord]:
|
||||
query = select(TaskRecord)
|
||||
if not include_done:
|
||||
query = query.where(TaskRecord.is_done.is_(False))
|
||||
if not include_stale:
|
||||
query = query.where(TaskRecord.stale.is_(False))
|
||||
if sources:
|
||||
query = query.where(TaskRecord.source.in_(list(sources)))
|
||||
records = list(session.scalars(query))
|
||||
records.sort(key=_sort_key)
|
||||
return records
|
||||
|
||||
|
||||
def _sort_key(record: TaskRecord) -> tuple:
|
||||
"""Pinned first, then by deadline (undated last), then by source and title."""
|
||||
return (
|
||||
not record.pinned,
|
||||
record.due_date is None,
|
||||
record.due_date or date.max,
|
||||
record.source,
|
||||
(record.title or "").lower(),
|
||||
)
|
||||
|
||||
|
||||
def bucket_tasks(records: Iterable[TaskRecord], today: date) -> dict[str, list[TaskRecord]]:
|
||||
"""Group a task list the way the UI and the report both want it."""
|
||||
buckets: dict[str, list[TaskRecord]] = {
|
||||
"overdue": [],
|
||||
"today": [],
|
||||
"upcoming": [],
|
||||
"someday": [],
|
||||
}
|
||||
for record in records:
|
||||
if record.due_date is None:
|
||||
buckets["someday"].append(record)
|
||||
elif record.due_date < today:
|
||||
buckets["overdue"].append(record)
|
||||
elif record.due_date == today:
|
||||
buckets["today"].append(record)
|
||||
else:
|
||||
buckets["upcoming"].append(record)
|
||||
return buckets
|
||||
|
||||
|
||||
def completed_on(session: Session, day: date, *, only_here: bool = True) -> list[TaskRecord]:
|
||||
query = select(TaskRecord).where(TaskRecord.completed_date == day)
|
||||
if only_here:
|
||||
query = query.where(TaskRecord.completed_here.is_(True))
|
||||
return list(session.scalars(query))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Mutations
|
||||
# --------------------------------------------------------------------------
|
||||
def create_local_task(
|
||||
session: Session,
|
||||
*,
|
||||
title: str,
|
||||
description: str | None = None,
|
||||
due_date: date | None = None,
|
||||
priority: str | None = None,
|
||||
project: str | None = None,
|
||||
estimated_pomodoros: int | None = None,
|
||||
) -> TaskRecord:
|
||||
"""A task that lives only here — the quick "and this too" you type at 9am."""
|
||||
# A random id, not a timestamp: two tasks added in the same millisecond
|
||||
# (a double-clicked Add button) would otherwise collide on the unique key.
|
||||
external_id = f"local-{uuid4().hex}"
|
||||
record = TaskRecord(
|
||||
source=LOCAL_SOURCE,
|
||||
external_id=external_id,
|
||||
title=title.strip(),
|
||||
description=description,
|
||||
due_date=due_date,
|
||||
priority=priority,
|
||||
project=project or "My list",
|
||||
estimated_pomodoros=estimated_pomodoros,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
return record
|
||||
|
||||
|
||||
def complete_task(
|
||||
session: Session, settings: AppSettings, task_id: int, *, done: bool = True
|
||||
) -> tuple[TaskRecord, str | None]:
|
||||
"""Mark a task done (or undone) here, and try to mirror it to its source.
|
||||
|
||||
Returns the record plus a warning when the write-back failed — the local
|
||||
state still changes, because the alternative is losing the fact that you
|
||||
finished something because Jira hiccupped.
|
||||
"""
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
raise LookupError(f"no task {task_id}")
|
||||
|
||||
warning: str | None = None
|
||||
if done:
|
||||
record.is_done = True
|
||||
record.completed_here = True
|
||||
record.completed_at = utcnow()
|
||||
record.completed_date = local_date(settings.timezone)
|
||||
if record.source != LOCAL_SOURCE:
|
||||
item = TodoItem(
|
||||
source=record.source,
|
||||
external_id=record.external_id,
|
||||
title=record.title,
|
||||
raw=record.raw or {},
|
||||
)
|
||||
wrote, error = complete_remote(settings, item)
|
||||
if error:
|
||||
warning = (
|
||||
f"Marked done here, but {SOURCE_LABELS.get(record.source, record.source)}"
|
||||
f" did not accept it: {error}"
|
||||
)
|
||||
elif not wrote:
|
||||
warning = (
|
||||
f"Marked done here only — write-back is off for "
|
||||
f"{SOURCE_LABELS.get(record.source, record.source)}."
|
||||
)
|
||||
else:
|
||||
record.is_done = False
|
||||
record.completed_here = False
|
||||
record.completed_at = None
|
||||
record.completed_date = None
|
||||
|
||||
session.commit()
|
||||
return record, warning
|
||||
|
||||
|
||||
def update_task(session: Session, task_id: int, **changes: Any) -> TaskRecord:
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
raise LookupError(f"no task {task_id}")
|
||||
for key, value in changes.items():
|
||||
if value is not None and hasattr(record, key):
|
||||
setattr(record, key, value)
|
||||
session.commit()
|
||||
return record
|
||||
|
||||
|
||||
def delete_task(session: Session, task_id: int) -> None:
|
||||
record = session.get(TaskRecord, task_id)
|
||||
if record is None:
|
||||
return
|
||||
session.delete(record)
|
||||
session.commit()
|
||||
@@ -0,0 +1,367 @@
|
||||
"""A long-polling Telegram bot, so the chat is a remote control, not just an inbox.
|
||||
|
||||
Runs in a daemon thread next to the API. Commands do exactly what the buttons in
|
||||
the UI do — they call the same services — so starting a pomodoro from your phone
|
||||
and finishing it on the desktop is one unbroken interval.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time as time_module
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import env_settings
|
||||
from app.db import session_scope
|
||||
from app.services import (
|
||||
blocker_service,
|
||||
pomodoro,
|
||||
report,
|
||||
settings_service,
|
||||
stats,
|
||||
tasks_service,
|
||||
)
|
||||
from app.settings_schema import AppSettings
|
||||
from app.timeutil import humanize_minutes, local_date
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
_POLL_TIMEOUT = 25
|
||||
|
||||
HELP_EN = """<b>Kadence</b>
|
||||
|
||||
/start_work [minutes] — start a pomodoro
|
||||
/break — start a break
|
||||
/stop — abandon the current interval
|
||||
/pause · /resume — hold the clock
|
||||
/status — what is running right now
|
||||
/today — your todo list, grouped
|
||||
/done <number> — close task N from /today
|
||||
/report — today's report, now
|
||||
/week — the weekly review
|
||||
/streak — streak and all-time totals
|
||||
/block — what the blocker is doing
|
||||
/help — this message"""
|
||||
|
||||
HELP_RU = """<b>Kadence</b>
|
||||
|
||||
/start_work [минуты] — запустить помидорку
|
||||
/break — перерыв
|
||||
/stop — бросить текущий интервал
|
||||
/pause · /resume — пауза и продолжение
|
||||
/status — что идёт сейчас
|
||||
/today — список задач по группам
|
||||
/done <номер> — закрыть задачу N из /today
|
||||
/report — отчёт за сегодня
|
||||
/week — итоги недели
|
||||
/streak — серия и общая статистика
|
||||
/block — что делает блокировщик
|
||||
/help — это сообщение"""
|
||||
|
||||
|
||||
def _api(token: str, method: str, payload: dict[str, Any], timeout: int = 30) -> dict[str, Any]:
|
||||
url = f"https://api.telegram.org/bot{token}/{method}"
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(url, json=payload)
|
||||
if not resp.is_success:
|
||||
raise RuntimeError(f"{method} -> {resp.status_code}: {resp.text[:200]}")
|
||||
return resp.json() or {}
|
||||
|
||||
|
||||
def _reply(settings: AppSettings, chat_id: str, text: str, buttons: list[list[str]] | None = None) -> None:
|
||||
telegram = settings.notifications.telegram
|
||||
payload: dict[str, Any] = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
if buttons:
|
||||
payload["reply_markup"] = {
|
||||
"inline_keyboard": [
|
||||
[{"text": label, "callback_data": label} for label in row] for row in buttons
|
||||
]
|
||||
}
|
||||
try:
|
||||
_api(telegram.bot_token, "sendMessage", payload)
|
||||
except Exception as exc: # pragma: no cover
|
||||
log.warning("telegram reply failed: %s", exc)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Command handlers
|
||||
# --------------------------------------------------------------------------
|
||||
def _cmd_status(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
state = pomodoro.state(session, settings)
|
||||
current = state["current"]
|
||||
done, goal = state["completed_today"], state["goal"]
|
||||
header = (
|
||||
f"🍅 {done}/{goal} " + ("сегодня" if ru else "today")
|
||||
+ f" · {report.progress_bar(done, goal)}"
|
||||
)
|
||||
if current is None:
|
||||
return header + "\n\n" + ("Таймер стоит." if ru else "The timer is idle.")
|
||||
remaining = humanize_minutes(current["remaining_seconds"] / 60)
|
||||
kind = {
|
||||
"work": "работа" if ru else "work",
|
||||
"short_break": "короткий перерыв" if ru else "short break",
|
||||
"long_break": "длинный перерыв" if ru else "long break",
|
||||
}[current["kind"]]
|
||||
task = f"\n📌 {report._esc(current['task']['title'])}" if current.get("task") else ""
|
||||
status = ("на паузе" if ru else "paused") if current["status"] == "paused" else ("идёт" if ru else "running")
|
||||
return f"{header}\n\n▶️ <b>{kind}</b> ({status}) — {remaining} " + ("осталось" if ru else "left") + task
|
||||
|
||||
|
||||
def _cmd_start_work(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
minutes: int | None = None
|
||||
if args.strip().isdigit():
|
||||
minutes = max(1, min(240, int(args.strip())))
|
||||
row = pomodoro.start(session, settings, kind="work", minutes=minutes)
|
||||
length = humanize_minutes(row.planned_seconds / 60)
|
||||
return (
|
||||
f"▶️ Помидорка на {length} пошла. Удачи!" if ru else f"▶️ {length} pomodoro started. Go."
|
||||
)
|
||||
|
||||
|
||||
def _cmd_break(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
kind = pomodoro.next_kind(session, settings)
|
||||
if kind == "work":
|
||||
kind = "short_break"
|
||||
row = pomodoro.start(session, settings, kind=kind)
|
||||
return (
|
||||
f"☕ Перерыв {humanize_minutes(row.planned_seconds / 60)}."
|
||||
if ru
|
||||
else f"☕ {humanize_minutes(row.planned_seconds / 60)} break."
|
||||
)
|
||||
|
||||
|
||||
def _cmd_stop(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.abandon(session, settings, reason=args.strip() or "stopped from Telegram")
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "⏹ Остановлено." if ru else "⏹ Stopped."
|
||||
|
||||
|
||||
def _cmd_pause(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.pause(session, settings)
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "⏸ Пауза." if ru else "⏸ Paused."
|
||||
|
||||
|
||||
def _cmd_resume(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
try:
|
||||
pomodoro.resume(session)
|
||||
except pomodoro.TimerError as exc:
|
||||
return str(exc)
|
||||
return "▶️ Продолжаем." if ru else "▶️ Resumed."
|
||||
|
||||
|
||||
def _cmd_today(session, settings: AppSettings, _args: str) -> str:
|
||||
"""The todo list, numbered so `/done 3` has something to refer to."""
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
today = local_date(settings.timezone)
|
||||
tasks = tasks_service.list_tasks(session)
|
||||
buckets = tasks_service.bucket_tasks(tasks, today)
|
||||
ordered = buckets["overdue"] + buckets["today"] + buckets["upcoming"] + buckets["someday"]
|
||||
if not ordered:
|
||||
return "✨ Задач нет." if ru else "✨ Nothing on the list."
|
||||
|
||||
_INDEX[settings.notifications.telegram.chat_id] = [task.id for task in ordered]
|
||||
limit = settings.notifications.report.max_tasks_listed
|
||||
lines = [f"<b>{'📋 Задачи' if ru else '📋 Your tasks'}</b>"]
|
||||
marks = {"overdue": "🔴", "today": "🟡", "upcoming": "🔵", "someday": "⚪"}
|
||||
position = 0
|
||||
for bucket_name in ("overdue", "today", "upcoming", "someday"):
|
||||
for task in buckets[bucket_name]:
|
||||
position += 1
|
||||
if position > limit:
|
||||
continue
|
||||
due = f" ({task.due_date.isoformat()})" if task.due_date else ""
|
||||
lines.append(
|
||||
f"{marks[bucket_name]} <b>{position}.</b> {report._esc(task.title)}{due}"
|
||||
)
|
||||
if position > limit:
|
||||
lines.append(("… и ещё " if ru else "… and ") + f"{position - limit}")
|
||||
lines.append("")
|
||||
lines.append("<i>/done N</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_INDEX: dict[str, list[int]] = {}
|
||||
|
||||
|
||||
def _cmd_done(session, settings: AppSettings, args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
chat_id = settings.notifications.telegram.chat_id
|
||||
index = _INDEX.get(chat_id)
|
||||
if not args.strip().isdigit():
|
||||
return "Укажи номер: /done 3" if ru else "Give me a number: /done 3"
|
||||
if not index:
|
||||
return "Сначала /today." if ru else "Run /today first."
|
||||
position = int(args.strip())
|
||||
if not 1 <= position <= len(index):
|
||||
return "Нет такого номера." if ru else "No task with that number."
|
||||
record, warning = tasks_service.complete_task(session, settings, index[position - 1])
|
||||
text = f"✅ {report._esc(record.title)}"
|
||||
return text + (f"\n⚠️ {report._esc(warning)}" if warning else "")
|
||||
|
||||
|
||||
def _cmd_report(session, settings: AppSettings, _args: str) -> str:
|
||||
today = local_date(settings.timezone)
|
||||
_, payload = report.get_or_build_daily(session, settings, today, rebuild=True)
|
||||
return report.render_daily_telegram(payload, settings)
|
||||
|
||||
|
||||
def _cmd_week(session, settings: AppSettings, _args: str) -> str:
|
||||
today = local_date(settings.timezone)
|
||||
payload = report.build_weekly(session, settings, today)
|
||||
return report.render_weekly_telegram(payload, settings)
|
||||
|
||||
|
||||
def _cmd_streak(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
streak = stats.streak(session, settings)
|
||||
lifetime = stats.lifetime(session, settings)
|
||||
return "\n".join(
|
||||
[
|
||||
f"🔥 <b>{'Серия' if ru else 'Streak'}</b>: {streak['current']} "
|
||||
+ ("дн." if ru else "days"),
|
||||
f"🏆 {'Рекорд' if ru else 'Best'}: {streak['best']} · "
|
||||
f"{'дней с целью' if ru else 'goal streak'}: {streak['goal_streak']}",
|
||||
"",
|
||||
f"🍅 {'Всего помидорок' if ru else 'All-time pomodoros'}: {lifetime['pomodoros']}",
|
||||
f"⏱ {'Всего фокуса' if ru else 'All-time focus'}: {lifetime['focus_hours']}h",
|
||||
f"✅ {'Закрыто задач' if ru else 'Tasks closed'}: {lifetime['tasks_completed']}",
|
||||
f"🚫 {'Заблокировано попыток' if ru else 'Blocked attempts'}: {lifetime['blocked_attempts']}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _cmd_block(session, settings: AppSettings, _args: str) -> str:
|
||||
ru = settings.notifications.report.language == "ru"
|
||||
policy = blocker_service.policy(session, settings)
|
||||
events = blocker_service.events_for_day(session, settings)
|
||||
state = ("включён" if ru else "ON") if policy["active"] else ("выключен" if ru else "OFF")
|
||||
lines = [
|
||||
f"🛡 <b>{'Блокировщик' if ru else 'Blocker'}</b>: {state} ({report._esc(policy['reason'])})",
|
||||
f"{'Сайтов' if ru else 'Domains'}: {len(policy['all_domains'])} · "
|
||||
f"{'приложений' if ru else 'apps'}: {len(policy['all_apps'])}",
|
||||
f"{'Попыток сегодня' if ru else 'Attempts today'}: {events['total']}",
|
||||
]
|
||||
for entry in events["by_target"][:5]:
|
||||
lines.append(f"• {report._esc(entry['target'])} × {entry['count']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cmd_help(_session, settings: AppSettings, _args: str) -> str:
|
||||
return HELP_RU if settings.notifications.report.language == "ru" else HELP_EN
|
||||
|
||||
|
||||
COMMANDS: dict[str, Callable] = {
|
||||
"start": _cmd_help,
|
||||
"help": _cmd_help,
|
||||
"status": _cmd_status,
|
||||
"start_work": _cmd_start_work,
|
||||
"work": _cmd_start_work,
|
||||
"break": _cmd_break,
|
||||
"stop": _cmd_stop,
|
||||
"pause": _cmd_pause,
|
||||
"resume": _cmd_resume,
|
||||
"today": _cmd_today,
|
||||
"tasks": _cmd_today,
|
||||
"done": _cmd_done,
|
||||
"report": _cmd_report,
|
||||
"week": _cmd_week,
|
||||
"streak": _cmd_streak,
|
||||
"stats": _cmd_streak,
|
||||
"block": _cmd_block,
|
||||
}
|
||||
|
||||
|
||||
def handle_text(text: str) -> str | None:
|
||||
"""Run one command and return the reply, or None when it is not for us."""
|
||||
text = (text or "").strip()
|
||||
if not text.startswith("/"):
|
||||
return None
|
||||
head, _, args = text[1:].partition(" ")
|
||||
name = head.split("@", 1)[0].lower()
|
||||
handler = COMMANDS.get(name)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
try:
|
||||
return handler(session, settings, args)
|
||||
except Exception as exc: # pragma: no cover - a bad command must not kill the bot
|
||||
log.exception("command /%s failed", name)
|
||||
return f"⚠️ {exc}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Polling loop
|
||||
# --------------------------------------------------------------------------
|
||||
def _loop() -> None:
|
||||
offset: int | None = None
|
||||
while not _stop.is_set():
|
||||
try:
|
||||
with session_scope() as session:
|
||||
settings = settings_service.load_settings(session)
|
||||
telegram = settings.notifications.telegram
|
||||
if not (telegram.enabled and telegram.commands_enabled and telegram.bot_token):
|
||||
_stop.wait(30)
|
||||
continue
|
||||
|
||||
payload: dict[str, Any] = {"timeout": _POLL_TIMEOUT}
|
||||
if offset is not None:
|
||||
payload["offset"] = offset
|
||||
data = _api(
|
||||
telegram.bot_token, "getUpdates", payload, timeout=_POLL_TIMEOUT + 10
|
||||
)
|
||||
for update in data.get("result", []):
|
||||
offset = update["update_id"] + 1
|
||||
message = update.get("message") or update.get("edited_message") or {}
|
||||
chat_id = str((message.get("chat") or {}).get("id", ""))
|
||||
if telegram.chat_id and chat_id and chat_id != str(telegram.chat_id):
|
||||
continue # only the configured chat may drive the timer
|
||||
reply = handle_text(message.get("text", ""))
|
||||
if reply:
|
||||
with session_scope() as session:
|
||||
fresh = settings_service.load_settings(session)
|
||||
_reply(fresh, chat_id or telegram.chat_id, reply)
|
||||
except Exception as exc: # pragma: no cover
|
||||
log.warning("telegram polling hiccup: %s", exc)
|
||||
_stop.wait(10)
|
||||
else:
|
||||
time_module.sleep(0.3)
|
||||
|
||||
|
||||
def start() -> None:
|
||||
"""Start the polling thread if the environment allows it."""
|
||||
global _thread
|
||||
if not env_settings().telegram_bot_enabled or _thread is not None:
|
||||
return
|
||||
_stop.clear()
|
||||
_thread = threading.Thread(target=_loop, name="telegram-bot", daemon=True)
|
||||
_thread.start()
|
||||
log.info("telegram command bot started")
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
global _thread
|
||||
_stop.set()
|
||||
_thread = None
|
||||
Reference in new issue
Block a user