forked from retoor/devplacepy
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:
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user