feat: add telegram notification channel with outbox service and per-user preferences

Extend the notification system with a third channel (telegram) alongside existing in_app and push channels. Add `telegram_enabled` column to `notification_preferences` table, update `NOTIFICATION_CHANNELS` and `_NOTIFICATION_CHANNEL_COLUMNS` mappings, and set telegram default to off (`_NOTIFICATION_CHANNEL_DEFAULTS`). Create `telegram_outbox` table with columns for uid, user_uid, chat_id, text, status, attempts, created_at, and sent_at, plus an index on status/id for efficient polling. Register `TelegramOutboxService` in the service manager lifecycle. Update API documentation strings to describe the new channel and its pairing requirement. Extend admin notification defaults view to include telegram column. Pass `notif_telegram_paired` flag to profile template based on `telegram_store.is_paired()` check. Update `NotificationPrefForm` and `NotificationDefaultForm` model literals to accept "telegram" as a valid channel value.
This commit is contained in:
2026-06-22 20:52:02 +00:00
parent a9d0814c47
commit e0e64c8d9e
36 changed files with 720 additions and 78 deletions
@@ -26,10 +26,10 @@ NOTIFICATION_ACTIONS: tuple[Action, ...] = (
name="notification_list",
method="LOCAL",
path="",
summary="List the user's notification preferences (in-app and push per type)",
summary="List the user's notification preferences (in-app, push and telegram per type)",
description=(
"Returns every notification type with the user's current in-app and push setting and "
"whether it has been customized. Use this before changing a setting."
"Returns every notification type with the user's current in-app, push and telegram "
"setting and whether it has been customized. Use this before changing a setting."
),
handler="notification",
requires_auth=True,
@@ -41,14 +41,15 @@ NOTIFICATION_ACTIONS: tuple[Action, ...] = (
path="",
summary="Enable or disable one notification type on one channel",
description=(
"Sets whether the user receives a given notification type on a given channel. The two "
"channels are independent: in-app (shown on DevPlace) and push (sent to subscribed devices)."
"Sets whether the user receives a given notification type on a given channel. The three "
"channels are independent: in-app (shown on DevPlace), push (sent to subscribed devices) "
"and telegram (delivered to the user's paired Telegram chat, off by default)."
),
handler="notification",
requires_auth=True,
params=(
arg("notification_type", TYPES, required=True),
arg("channel", "Either 'in_app' or 'push'.", required=True),
arg("channel", "Either 'in_app', 'push' or 'telegram'.", required=True),
arg(
"value",
"true to deliver this notification on this channel, false to suppress it.",
@@ -40,7 +40,7 @@ class NotificationController:
def _channel(self, arguments: dict[str, Any]) -> str:
channel = str(arguments.get("channel", "")).strip().lower()
if channel not in NOTIFICATION_CHANNELS:
raise ToolInputError("'channel' must be 'in_app' or 'push'.")
raise ToolInputError("'channel' must be 'in_app', 'push' or 'telegram'.")
return channel
def _value(self, arguments: dict[str, Any]) -> bool:
+28
View File
@@ -128,6 +128,34 @@ def level_progress(xp: int) -> dict:
}
SCORE_PRESTIGE = 5000
SCORE_HARVEST = 10
SCORE_COIN_DIVISOR = 20
SCORE_CI = 250
SCORE_PLOT = 200
SCORE_PERK = 120
SCORE_STREAK = 15
SCORE_STREAK_CAP = 30
def farm_score(farm: dict) -> int:
def value(key: str, default: int = 0) -> int:
raw = farm.get(key)
return int(raw) if raw is not None else default
perk_total = sum(value(f"perk_{perk.key}") for perk in PERKS)
return (
value("xp")
+ value("prestige") * SCORE_PRESTIGE
+ value("total_harvests") * SCORE_HARVEST
+ value("coins") // SCORE_COIN_DIVISOR
+ (value("ci_tier", 1) - 1) * SCORE_CI
+ (value("plot_count", STARTING_PLOTS) - STARTING_PLOTS) * SCORE_PLOT
+ perk_total * SCORE_PERK
+ min(value("streak"), SCORE_STREAK_CAP) * SCORE_STREAK
)
def unlocked_crops(level: int) -> list[Crop]:
return [crop for crop in CROPS if crop.min_level <= level]
+3 -1
View File
@@ -475,7 +475,7 @@ def steal(thief: dict, owner: dict, slot: int) -> dict:
def leaderboard(limit: int = 25) -> list[dict]:
farms = sorted(
_farms().find(),
key=lambda row: (int(row.get("level", 1)), int(row.get("xp", 0)), int(row.get("total_harvests", 0))),
key=economy.farm_score,
reverse=True,
)[:limit]
if not farms:
@@ -496,6 +496,8 @@ def leaderboard(limit: int = 25) -> list[dict]:
"xp": int(farm.get("xp", 0)),
"coins": int(farm.get("coins", 0)),
"total_harvests": int(farm.get("total_harvests", 0)),
"prestige": int(farm.get("prestige") or 0),
"score": economy.farm_score(farm),
}
)
return entries
+9
View File
@@ -173,6 +173,7 @@ class TelegramBridge:
summary=f"Telegram paired for {user.get('username', user['uid'])}",
metadata={"chat_id": chat_id},
)
await self._publish_pairing(user["uid"], True)
return
self._register_attempt(chat_id)
await self._service.send(chat_id, BAD_CODE)
@@ -198,6 +199,14 @@ class TelegramBridge:
def _register_attempt(self, chat_id: int) -> None:
self._attempts.setdefault(chat_id, []).append(time.monotonic())
async def _publish_pairing(self, user_uid: str, paired: bool) -> None:
try:
from devplacepy.services.pubsub import publish
await publish(f"user.{user_uid}.telegram", {"paired": paired})
except Exception: # noqa: BLE001 - live pairing update is best-effort
pass
async def _run_turn(
self, chat_id: int, user: dict[str, Any], text: str, images: list[str]
) -> None:
@@ -0,0 +1,57 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
from devplacepy.services.base import BaseService
from . import store
BATCH_LIMIT = 50
class TelegramOutboxService(BaseService):
title = "Telegram notifications"
description = (
"Delivers notifications to users who paired Telegram and opted a "
"notification type into the Telegram channel. Drains the telegram_outbox "
"queue on the service lock owner, where the Telegram bot worker runs, and "
"sends each message as markdown. Rows wait when the bot is not running, so "
"nothing is lost. Off by default for every notification type."
)
default_enabled = True
def __init__(self) -> None:
super().__init__(name="telegram_outbox", interval_seconds=2)
self._delivered = 0
self._failed = 0
async def run_once(self) -> None:
from devplacepy.services.manager import service_manager
telegram = service_manager.get_service("telegram")
if (
telegram is None
or not telegram.is_enabled()
or not telegram.worker_alive()
):
return
rows = store.pending_outbox(BATCH_LIMIT)
for row in rows:
delivered = await telegram.send_markdown(int(row["chat_id"]), row["text"])
if delivered:
store.mark_outbox_sent(row["id"])
self._delivered += 1
else:
store.mark_outbox_failed(row["id"], int(row.get("attempts", 0)))
self._failed += 1
store.prune_outbox()
if rows:
self.log(f"drained {len(rows)} telegram notification(s)")
def collect_metrics(self) -> dict:
return {
"stats": [
{"label": "Delivered", "value": self._delivered},
{"label": "Failed", "value": self._failed},
]
}
+55
View File
@@ -14,6 +14,9 @@ CODE_TTL_SETTING = "telegram_code_ttl_minutes"
DEFAULT_CODE_TTL_MINUTES = 60
PAIRINGS = "telegram_pairings"
LINKS = "telegram_links"
OUTBOX = "telegram_outbox"
MAX_OUTBOX_ATTEMPTS = 3
OUTBOX_RETENTION_HOURS = 24
def _now() -> datetime:
@@ -149,3 +152,55 @@ def unpair(user_uid: str) -> int:
def is_paired(user_uid: str) -> bool:
return link_for_user(user_uid) is not None
def enqueue_outbox(user_uid: str, text: str) -> bool:
link = link_for_user(user_uid)
if not link:
return False
get_table(OUTBOX).insert(
{
"uid": generate_uid(),
"user_uid": user_uid,
"chat_id": int(link["chat_id"]),
"text": text,
"status": "pending",
"attempts": 0,
"created_at": _iso(_now()),
"sent_at": None,
}
)
return True
def pending_outbox(limit: int) -> list[dict[str, Any]]:
table = get_table(OUTBOX)
return list(table.find(status="pending", _limit=limit, order_by=["id"]))
def mark_outbox_sent(row_id: int) -> None:
get_table(OUTBOX).update(
{"id": row_id, "status": "sent", "sent_at": _iso(_now())}, ["id"]
)
def mark_outbox_failed(row_id: int, attempts: int) -> None:
table = get_table(OUTBOX)
attempts += 1
status = "failed" if attempts >= MAX_OUTBOX_ATTEMPTS else "pending"
table.update({"id": row_id, "status": status, "attempts": attempts}, ["id"])
def prune_outbox() -> int:
table = get_table(OUTBOX)
cutoff = _iso(_now() - timedelta(hours=OUTBOX_RETENTION_HOURS))
removed = 0
for row in table.find(status="sent"):
if (row.get("created_at") or "") < cutoff:
table.delete(id=row["id"])
removed += 1
for row in table.find(status="failed"):
if (row.get("created_at") or "") < cutoff:
table.delete(id=row["id"])
removed += 1
return removed