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
+47 -19
View File
@@ -568,6 +568,20 @@ def init_db():
telegram_links.create_column_by_example(column, example)
_index(db, "telegram_links", "idx_telegram_links_chat", ["chat_id"])
_index(db, "telegram_links", "idx_telegram_links_user", ["user_uid"])
telegram_outbox = get_table("telegram_outbox")
for column, example in (
("uid", ""),
("user_uid", ""),
("chat_id", 0),
("text", ""),
("status", "pending"),
("attempts", 0),
("created_at", ""),
("sent_at", ""),
):
if not telegram_outbox.has_column(column):
telegram_outbox.create_column_by_example(column, example)
_index(db, "telegram_outbox", "idx_telegram_outbox_status", ["status", "id"])
_index(db, "comments", "idx_comments_post_uid", ["post_uid"])
_index(db, "comments", "idx_comments_target", ["target_type", "target_uid"])
_index(db, "comments", "idx_comments_user_uid", ["user_uid"])
@@ -964,6 +978,7 @@ def init_db():
("notification_type", ""),
("in_app_enabled", 1),
("push_enabled", 1),
("telegram_enabled", 1),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
@@ -2628,15 +2643,21 @@ NOTIFICATION_TYPES = [
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
]
NOTIFICATION_CHANNELS = ("in_app", "push")
_NOTIFICATION_CHANNEL_COLUMNS = {"in_app": "in_app_enabled", "push": "push_enabled"}
NOTIFICATION_CHANNELS = ("in_app", "push", "telegram")
_NOTIFICATION_CHANNEL_COLUMNS = {
"in_app": "in_app_enabled",
"push": "push_enabled",
"telegram": "telegram_enabled",
}
_NOTIFICATION_CHANNEL_DEFAULTS = {"in_app": 1, "push": 1, "telegram": 0}
_NOTIFICATION_TYPE_KEYS = {entry["key"] for entry in NOTIFICATION_TYPES}
_notification_prefs_cache = TTLCache(ttl=300, max_size=500)
def _notification_default(notification_type: str, channel: str) -> bool:
return get_int_setting(f"notif_default_{notification_type}_{channel}", 1) != 0
fallback = _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1)
return get_int_setting(f"notif_default_{notification_type}_{channel}", fallback) != 0
def get_notification_default(notification_type: str, channel: str) -> bool:
@@ -2662,8 +2683,10 @@ def _notification_overrides(user_uid: str) -> dict:
user_uid=user_uid, deleted_at=None
):
overrides[row["notification_type"]] = {
"in_app": bool(row.get("in_app_enabled", 1)),
"push": bool(row.get("push_enabled", 1)),
channel: bool(
row.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(channel, 1))
)
for channel, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
_notification_prefs_cache.set(user_uid, overrides)
return overrides
@@ -2684,17 +2707,18 @@ def get_notification_prefs(user_uid: str) -> list:
for entry in NOTIFICATION_TYPES:
key = entry["key"]
override = overrides.get(key)
in_app = (
bool(override["in_app"]) if override else _notification_default(key, "in_app")
)
push = bool(override["push"]) if override else _notification_default(key, "push")
channels = {
channel: bool(override[channel])
if override
else _notification_default(key, channel)
for channel in _NOTIFICATION_CHANNEL_COLUMNS
}
result.append(
{
"key": key,
"label": entry["label"],
"description": entry["description"],
"in_app": in_app,
"push": push,
**channels,
"customized": override is not None,
}
)
@@ -2715,20 +2739,25 @@ def set_notification_pref(
existing = table.find_one(user_uid=user_uid, notification_type=notification_type)
if existing:
values = {
"in_app": bool(existing.get("in_app_enabled", 1)),
"push": bool(existing.get("push_enabled", 1)),
name: bool(
existing.get(column, _NOTIFICATION_CHANNEL_DEFAULTS.get(name, 1))
)
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
else:
values = {
"in_app": _notification_default(notification_type, "in_app"),
"push": _notification_default(notification_type, "push"),
name: _notification_default(notification_type, name)
for name in _NOTIFICATION_CHANNEL_COLUMNS
}
values[channel] = enabled
columns = {
column: 1 if values[name] else 0
for name, column in _NOTIFICATION_CHANNEL_COLUMNS.items()
}
if existing:
record = {
"id": existing["id"],
"in_app_enabled": 1 if values["in_app"] else 0,
"push_enabled": 1 if values["push"] else 0,
**columns,
"updated_at": now,
"deleted_at": None,
"deleted_by": None,
@@ -2740,8 +2769,7 @@ def set_notification_pref(
"uid": generate_uid(),
"user_uid": user_uid,
"notification_type": notification_type,
"in_app_enabled": 1 if values["in_app"] else 0,
"push_enabled": 1 if values["push"] else 0,
**columns,
"created_at": now,
"updated_at": now,
"deleted_at": None,
+2 -2
View File
@@ -1932,7 +1932,7 @@ four ways to sign requests.
"string",
True,
"push",
"Either in_app or push.",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
@@ -4869,7 +4869,7 @@ four ways to sign requests.
"string",
True,
"push",
"Either in_app or push.",
"One of in_app, push or telegram (telegram is off by default and requires a paired Telegram account).",
),
field(
"value",
+2
View File
@@ -108,6 +108,7 @@ from devplacepy.services.xmlrpc import XmlrpcService
from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit
from devplacepy.services.telegram import TelegramService
from devplacepy.services.telegram.outbox_service import TelegramOutboxService
logging.basicConfig(
level=logging.INFO,
@@ -253,6 +254,7 @@ async def lifespan(app: FastAPI):
service_manager.register(XmlrpcService())
service_manager.register(AuditService())
service_manager.register(TelegramService())
service_manager.register(TelegramOutboxService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
await background.start()
if acquire_service_lock():
+2 -2
View File
@@ -238,13 +238,13 @@ class CustomizationToggleForm(BaseModel):
class NotificationPrefForm(BaseModel):
notification_type: str = Field(min_length=1, max_length=40)
channel: Literal["in_app", "push"]
channel: Literal["in_app", "push", "telegram"]
value: bool = False
class NotificationDefaultForm(BaseModel):
notification_type: str = Field(min_length=1, max_length=40)
channel: Literal["in_app", "push"]
channel: Literal["in_app", "push", "telegram"]
value: bool = False
@@ -31,6 +31,7 @@ def _notification_defaults_view() -> list:
"description": entry["description"],
"in_app": get_notification_default(entry["key"], "in_app"),
"push": get_notification_default(entry["key"], "push"),
"telegram": get_notification_default(entry["key"], "telegram"),
}
)
return defaults
+6
View File
@@ -302,6 +302,11 @@ async def profile_page(
if (tab == "notifications" and can_manage_customization)
else []
)
notif_telegram_paired = (
telegram_store.is_paired(profile_user["uid"])
if (tab == "notifications" and can_manage_customization)
else False
)
base = site_url(request)
robots = "noindex,follow" if posts_count < 2 else "index,follow"
@@ -363,6 +368,7 @@ async def profile_page(
"ai_modifier_sync": ai_modifier_sync,
"ai_modifier_prompt": ai_modifier_prompt,
"telegram_paired": telegram_paired,
"notif_telegram_paired": notif_telegram_paired,
"can_manage_customization": can_manage_customization,
"cust_disable_global": customization_prefs["disable_global"],
"cust_disable_pagetype": customization_prefs["disable_pagetype"],
+3
View File
@@ -764,6 +764,7 @@ class ProfileOut(_Out):
ai_modifier_sync: bool = False
ai_modifier_prompt: Optional[str] = None
telegram_paired: bool = False
notif_telegram_paired: bool = False
can_manage_customization: bool = False
cust_disable_global: bool = False
cust_disable_pagetype: bool = False
@@ -1258,6 +1259,8 @@ class GameLeaderboardEntryOut(_Out):
xp: int = 0
coins: int = 0
total_harvests: int = 0
prestige: int = 0
score: int = 0
class GameLeaderboardOut(_Out):
@@ -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
+6 -1
View File
@@ -1216,7 +1216,12 @@ body:has(.page-messages) {
.card-link-host a:not(.card-link),
.card-link-host button,
.card-link-host form {
.card-link-host form,
.card-link-host label,
.card-link-host input,
.card-link-host select,
.card-link-host textarea,
.card-link-host summary {
position: relative;
z-index: 2;
}
+15
View File
@@ -233,6 +233,7 @@
}
.game-lb-rank {
flex-shrink: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.8rem;
@@ -241,6 +242,10 @@
.game-lb-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
@@ -252,10 +257,20 @@
}
.game-lb-level {
flex-shrink: 0;
color: var(--text-secondary);
font-size: 0.8rem;
}
.game-lb-score {
flex-shrink: 0;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 0.8rem;
min-width: 4em;
text-align: right;
}
.game-lb-empty {
color: var(--text-muted);
font-size: 0.85rem;
+132
View File
@@ -506,3 +506,135 @@
text-align: center;
}
}
.landing-game {
max-width: 960px;
margin: 0 auto 3rem;
padding: 0 2rem;
width: 100%;
position: relative;
z-index: 1;
}
.landing-game-body {
position: relative;
overflow: hidden;
border: 1px solid var(--accent);
border-radius: var(--radius-xl);
background: var(--bg-card);
padding: 2.25rem 2rem;
text-align: center;
box-shadow: var(--shadow-lg);
}
.landing-game-glow {
position: absolute;
inset: -40% -10% auto -10%;
height: 280px;
background: radial-gradient(circle at 50% 0%, var(--accent) 0%, transparent 70%);
opacity: 0.18;
pointer-events: none;
z-index: 0;
}
.landing-game-body > * {
position: relative;
z-index: 1;
}
.landing-game-eyebrow {
display: inline-block;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.75rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
}
.landing-game-title {
margin: 0 0 0.75rem;
font-size: 1.9rem;
font-weight: 800;
color: var(--text-primary);
letter-spacing: -0.02em;
}
.landing-game-title span {
color: var(--text-secondary);
font-weight: 600;
}
.landing-game-tagline {
max-width: 640px;
margin: 0 auto 1.25rem;
color: var(--text-secondary);
font-size: 1.02rem;
line-height: 1.6;
}
.landing-game-crops {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.75rem;
font-size: 1.85rem;
margin-bottom: 1.25rem;
}
.landing-game-points {
list-style: none;
padding: 0;
margin: 0 auto 1.5rem;
max-width: 620px;
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
}
.landing-game-points li {
position: relative;
padding-left: 1.5rem;
color: var(--text-primary);
font-size: 0.95rem;
line-height: 1.5;
}
.landing-game-points li::before {
content: "\2714";
position: absolute;
left: 0;
color: var(--accent);
font-weight: 700;
}
.landing-game-cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.9rem 2.25rem;
font-size: 1.05rem;
border-radius: var(--radius-lg);
background: var(--accent);
color: var(--white);
font-weight: 700;
text-decoration: none;
transition: filter 0.15s ease, transform 0.15s ease;
}
.landing-game-cta:hover {
filter: brightness(1.08);
transform: translateY(-2px);
}
@media (max-width: 480px) {
.landing-game-title {
font-size: 1.5rem;
}
.landing-game-cta {
width: 100%;
justify-content: center;
}
}
+50 -1
View File
@@ -185,6 +185,7 @@ a.profile-stat-value:hover {
border: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scroll-margin-top: calc(var(--nav-height) + 1rem);
}
.profile-tabs.tabs-managed {
@@ -218,6 +219,13 @@ a.profile-stat-value:hover {
gap: 1rem;
}
.profile-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
@media (max-width: 768px) {
.profile-layout {
grid-template-columns: 1fr;
@@ -688,7 +696,7 @@ a.profile-stat-value:hover {
}
.notif-prefs {
padding: 0.5rem 0;
padding: 1.5rem;
}
.notif-prefs-intro {
@@ -788,10 +796,51 @@ a.profile-stat-value:hover {
opacity: 0.5;
}
.notif-switch-disabled {
cursor: not-allowed;
}
.notif-prefs-hint {
color: var(--accent);
}
.notif-prefs-scroll {
overflow-x: auto;
}
.notif-prefs-actions {
margin-top: 1rem;
}
@media (max-width: 640px) {
.notif-prefs-table th,
.notif-prefs-table td {
padding: 0.6rem 0.35rem;
}
.notif-prefs-col {
width: 3.25rem;
}
.notif-prefs-desc {
display: none;
}
.notif-switch-track {
width: 2.1rem;
height: 1.15rem;
}
.notif-switch-track::after {
width: 0.9rem;
height: 0.9rem;
}
.notif-switch input:checked + .notif-switch-track::after {
transform: translateX(0.95rem);
}
}
.profile-achievements {
background: var(--bg-card);
border: 1px solid var(--border);
+2 -2
View File
@@ -68,10 +68,10 @@ class Application {
this.polls = new PollManager();
this.apiKey = new ApiKeyManager();
this.customizationToggle = new CustomizationToggle();
this.notificationPrefs = new NotificationPrefs();
this.notificationPrefs = new NotificationPrefs(this.pubsub);
this.aiCorrection = new AiCorrection();
this.aiModifier = new AiModifier();
this.telegramPairing = new TelegramPairing();
this.telegramPairing = new TelegramPairing(this.pubsub);
this.adminNotificationDefaults = new AdminNotificationDefaults();
this.userAiUsage = new UserAiUsage();
this.tabs = new Tabs();
+1 -1
View File
@@ -247,7 +247,7 @@ export class GameFarm {
list.innerHTML = data.entries
.map(
(entry) =>
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span></li>`
`<li class="game-lb-row${entry.username === this.username ? " game-lb-self" : ""}"><span class="game-lb-rank">#${entry.rank}</span><a class="game-lb-name" href="/game/farm/${entry.username}">${entry.username}</a><span class="game-lb-level">Lv ${entry.level}</span><span class="game-lb-score">${entry.score.toLocaleString()}</span></li>`
)
.join("");
} catch (error) {
+23 -1
View File
@@ -3,15 +3,37 @@
import { Http } from "./Http.js";
class NotificationPrefs {
constructor() {
constructor(pubsub) {
this.root = document.querySelector("[data-notif-prefs]");
if (!this.root) return;
this.pubsub = pubsub;
this.username = this.root.dataset.username;
this.root
.querySelectorAll("[data-notif-toggle]")
.forEach((input) => this.bind(input));
const reset = this.root.querySelector("[data-notif-reset]");
if (reset) reset.addEventListener("click", () => this.reset(reset));
this.subscribe();
}
subscribe() {
const uid = document.body.dataset.userUid || "";
if (!uid || !this.pubsub) return;
this.pubsub.subscribe(`user.${uid}.telegram`, (data) => {
if (data && data.paired) this.enableTelegram();
});
}
enableTelegram() {
this.root
.querySelectorAll('[data-notif-toggle][data-channel="telegram"]')
.forEach((input) => {
input.disabled = false;
const label = input.closest(".notif-switch");
if (label) label.classList.remove("notif-switch-disabled");
});
const hint = this.root.querySelector(".notif-prefs-hint");
if (hint) hint.hidden = true;
}
bind(input) {
+17 -1
View File
@@ -3,9 +3,10 @@
import { Http } from "./Http.js";
class TelegramPairing {
constructor() {
constructor(pubsub) {
this.root = document.querySelector("[data-telegram-pairing]");
if (!this.root) return;
this.pubsub = pubsub;
this.username = this.root.dataset.username;
this.statusLabel = this.root.querySelector("[data-telegram-status]");
this.requestBtn = this.root.querySelector("[data-telegram-request]");
@@ -16,6 +17,21 @@ class TelegramPairing {
this.message = this.root.querySelector("[data-telegram-status-msg]");
if (this.requestBtn) this.requestBtn.addEventListener("click", () => this.request());
if (this.unpairBtn) this.unpairBtn.addEventListener("click", () => this.unpair());
this.subscribe();
}
subscribe() {
const uid = document.body.dataset.userUid || "";
if (!uid || !this.pubsub) return;
this.pubsub.subscribe(`user.${uid}.telegram`, (data) => {
if (data && data.paired) this.onPaired();
});
}
onPaired() {
this.setPaired(true);
this.hideCode();
this.setMessage("Telegram connected.", "success");
}
async request() {
+5 -2
View File
@@ -1,5 +1,8 @@
{% from "_macros.html" import content_url %}
<article class="post-card">
<article class="post-card card-link-host">
{% set _href = content_url(item.post, 'posts') %}
{% set _label = item.post.get('title') or 'View post' %}
{% include "_card_link.html" %}
{% set _created = item.post.get('created_at') %}{% include "_post_header.html" %}
<div class="post-topic">
@@ -12,7 +15,7 @@
</a>
{% endif %}
<div class="post-content rendered-content" data-card-href="{{ content_url(item.post, 'posts') }}">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else '')) }}</div>
<div class="post-content rendered-content">{{ render_content(item.post['content'][:300] ~ ('...' if item.post['content']|length > 300 else '')) }}</div>
{% if item.attachments %}
{% include "_attachment_display.html" %}
+10 -1
View File
@@ -8,8 +8,9 @@
</div>
<div class="notif-prefs" data-admin-notif>
<div class="notif-prefs-intro">
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice.</p>
<p>These are the platform-wide defaults applied to every user who has not customized a notification on their own profile. Changing a default does not override a user's explicit choice. Telegram delivery only reaches users who paired their Telegram account.</p>
</div>
<div class="notif-prefs-scroll">
<table class="notif-prefs-table">
<caption class="sr-only">Platform-wide default notification channels per notification type</caption>
<thead>
@@ -17,6 +18,7 @@
<th scope="col">Notification</th>
<th scope="col" class="notif-prefs-col">In-app</th>
<th scope="col" class="notif-prefs-col">Push</th>
<th scope="col" class="notif-prefs-col">Telegram</th>
</tr>
</thead>
<tbody>
@@ -38,9 +40,16 @@
<span class="notif-switch-track"></span>
</label>
</td>
<td class="notif-prefs-col">
<label class="notif-switch">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="telegram" {% if pref.telegram %}checked{% endif %} aria-label="Telegram default for {{ pref.label }}">
<span class="notif-switch-track"></span>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+6 -1
View File
@@ -86,7 +86,12 @@
</div>
<aside class="feed-right" role="complementary" aria-label="Community">
<div class="daily-topic-card">
<div class="daily-topic-card card-link-host">
{% if daily_topic.get('slug') %}
{% set _href = "/news/" ~ daily_topic['slug'] %}
{% set _label = daily_topic.get('title', '') %}
{% include "_card_link.html" %}
{% endif %}
<div class="daily-topic-label">Daily Topic</div>
{% if daily_topic.get('image_url', '') %}
<img src="{{ daily_topic['image_url'] }}" alt="{{ daily_topic.get('title', '') }}" loading="lazy" class="daily-topic-image">
+43 -2
View File
@@ -36,6 +36,7 @@
<a href="/feed">New post</a>
<a href="/projects">Projects</a>
<a href="/gists">Gists</a>
<a href="/game">Code Farm</a>
<a href="/notifications">Notifications</a>
</div>
</section>
@@ -70,6 +71,40 @@
</section>
{% endif %}
<section class="landing-game" aria-labelledby="landing-game-heading">
<div class="landing-game-glow"></div>
<div class="landing-game-body">
<span class="landing-game-eyebrow">New on DevPlace</span>
<h2 id="landing-game-heading" class="landing-game-title">
Code Farm <span>- grow software, not vegetables</span>
</h2>
<p class="landing-game-tagline">
Plant projects that build in real time, harvest coins and XP, upgrade your CI pipeline,
and unlock the prestige grind. Then water your friends' builds - or raid the ones who left
a harvest sitting too long.
</p>
<div class="landing-game-crops" aria-hidden="true">
<span title="Shell Script">&#x1F41A;</span>
<span title="Python Script">&#x1F40D;</span>
<span title="Web App">&#x1F4DC;</span>
<span title="Go Service">&#x1F439;</span>
<span title="Rust Engine">&#x1F980;</span>
<span title="Compiler">&#x03BB;</span>
<span title="Kernel">&#x2699;&#xFE0F;</span>
</div>
<ul class="landing-game-points">
<li>7 project tiers, from 30-second scripts to 2-hour kernels</li>
<li>CI upgrades, a four-branch perk tree, and stacking prestige boosts</li>
<li>Co-op watering, PvP raids, daily streaks, quests, and a global leaderboard</li>
</ul>
{% if user %}
<a href="/game" class="landing-game-cta"><span class="icon">&#x1F33E;</span> Play Code Farm &rarr;</a>
{% else %}
<a href="/auth/signup" class="landing-game-cta"><span class="icon">&#x1F33E;</span> Join free to play &rarr;</a>
{% endif %}
</div>
</section>
{% if landing_posts %}
<section class="landing-section" aria-labelledby="landing-posts-heading">
<div class="landing-section-header">
@@ -78,7 +113,10 @@
</div>
<div class="landing-posts-grid">
{% for item in landing_posts %}
<article class="landing-post-card">
<article class="landing-post-card card-link-host">
{% set _href = "/posts/" ~ item.slug %}
{% set _label = item.post.get('title') or 'View post' %}
{% include "_card_link.html" %}
<div class="landing-post-header">
{% set _user = item.author %}
{% set _size = 28 %}
@@ -115,7 +153,10 @@
</div>
<div class="news-grid">
{% for item in landing_articles %}
<article class="news-card">
<article class="news-card card-link-host">
{% set _href = "/news/" ~ (item.slug or item.uid) %}
{% set _label = item.title %}
{% include "_card_link.html" %}
{% if item.image_url %}
<div class="news-card-image">
<img src="{{ item.image_url }}" alt="{{ item.title }}" loading="lazy" class="image-fallback">
+4 -1
View File
@@ -49,7 +49,10 @@
<ol class="leaderboard-list">
{% for entry in entries %}
<li class="leaderboard-row{% if is_self(user, entry['uid']) %} leaderboard-row-self{% endif %}">
<li class="leaderboard-row card-link-host{% if is_self(user, entry['uid']) %} leaderboard-row-self{% endif %}">
{% set _href = "/profile/" ~ entry['username'] %}
{% set _label = entry['username'] %}
{% include "_card_link.html" %}
<span class="leaderboard-rank">#{{ entry['rank'] }}</span>
{% set _user = entry %}{% set _size = 32 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% set _user = entry %}{% set _class = "leaderboard-name" %}{% include "_user_link.html" %}
+4 -1
View File
@@ -14,7 +14,10 @@
{% if articles %}
<div class="news-grid">
{% for item in articles %}
<article class="news-card">
<article class="news-card card-link-host">
{% set _href = "/news/" ~ (item.article['slug'] or item.article['uid']) %}
{% set _label = item.article['title'] %}
{% include "_card_link.html" %}
{% if item.image_url %}
<div class="news-card-image">
<img src="{{ item.image_url }}" alt="{{ item.article['title'] }}" loading="lazy" class="image-fallback">
+28 -12
View File
@@ -250,7 +250,7 @@
<p class="customization-hint">Runs only when your text contains an inline `@ai instruction` directive: it executes that instruction and removes the `@ai` marker, using your API key. Applies to posts, comments, projects, gists, direct messages and your bio. Code and source files are never touched.</p>
</div>
<div class="customization-card" data-telegram-pairing data-username="{{ profile_user['username'] }}">
<div class="customization-card" id="telegram-pairing" data-telegram-pairing data-username="{{ profile_user['username'] }}">
<div class="customization-head">
<span class="customization-title">Telegram</span>
</div>
@@ -468,16 +468,16 @@
{% endfor %}
</details>
<div class="profile-tabs" data-profile-tabs>
<a href="/profile/{{ profile_user['username'] }}?tab=posts" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}" data-menu-icon="&#x1F4DD;" data-menu-label="Posts"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}" data-menu-icon="&#x1F680;" data-menu-label="Projects"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}" data-menu-icon="&#x1F4DD;" data-menu-label="Gists"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=media" class="profile-tab {% if current_tab == 'media' %}active{% endif %}" data-menu-icon="&#x1F5BC;&#xFE0F;" data-menu-label="Media"><span class="icon">&#x1F5BC;&#xFE0F;</span> Media</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}" data-menu-icon="&#x1F4CA;" data-menu-label="Activity"><span class="icon">&#x1F4CA;</span> Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=followers" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}" data-menu-icon="&#x1F465;" data-menu-label="Followers ({{ followers_count }})"><span class="icon">&#x1F465;</span> Followers ({{ followers_count }})</a>
<a href="/profile/{{ profile_user['username'] }}?tab=following" class="profile-tab {% if current_tab == 'following' %}active{% endif %}" data-menu-icon="&#x1F464;" data-menu-label="Following ({{ following_count }})"><span class="icon">&#x1F464;</span> Following ({{ following_count }})</a>
<div class="profile-tabs" id="profile-tabs" data-profile-tabs>
<a href="/profile/{{ profile_user['username'] }}?tab=posts#profile-tabs" class="profile-tab {% if current_tab == 'posts' %}active{% endif %}" data-menu-icon="&#x1F4DD;" data-menu-label="Posts"><span class="icon">&#x1F4DD;</span> Posts</a>
<a href="/profile/{{ profile_user['username'] }}?tab=projects#profile-tabs" class="profile-tab {% if current_tab == 'projects' %}active{% endif %}" data-menu-icon="&#x1F680;" data-menu-label="Projects"><span class="icon">&#x1F680;</span> Projects</a>
<a href="/profile/{{ profile_user['username'] }}?tab=gists#profile-tabs" class="profile-tab {% if current_tab == 'gists' %}active{% endif %}" data-menu-icon="&#x1F4DD;" data-menu-label="Gists"><span class="icon">&#x1F4DD;</span> Gists</a>
<a href="/profile/{{ profile_user['username'] }}?tab=media#profile-tabs" class="profile-tab {% if current_tab == 'media' %}active{% endif %}" data-menu-icon="&#x1F5BC;&#xFE0F;" data-menu-label="Media"><span class="icon">&#x1F5BC;&#xFE0F;</span> Media</a>
<a href="/profile/{{ profile_user['username'] }}?tab=activity#profile-tabs" class="profile-tab {% if current_tab == 'activity' %}active{% endif %}" data-menu-icon="&#x1F4CA;" data-menu-label="Activity"><span class="icon">&#x1F4CA;</span> Activity</a>
<a href="/profile/{{ profile_user['username'] }}?tab=followers#profile-tabs" class="profile-tab {% if current_tab == 'followers' %}active{% endif %}" data-menu-icon="&#x1F465;" data-menu-label="Followers ({{ followers_count }})"><span class="icon">&#x1F465;</span> Followers ({{ followers_count }})</a>
<a href="/profile/{{ profile_user['username'] }}?tab=following#profile-tabs" class="profile-tab {% if current_tab == 'following' %}active{% endif %}" data-menu-icon="&#x1F464;" data-menu-label="Following ({{ following_count }})"><span class="icon">&#x1F464;</span> Following ({{ following_count }})</a>
{% if is_owner or viewer_is_admin %}
<a href="/profile/{{ profile_user['username'] }}?tab=notifications" class="profile-tab {% if current_tab == 'notifications' %}active{% endif %}" data-menu-icon="&#x1F514;" data-menu-label="Notifications"><span class="icon">&#x1F514;</span> Notifications</a>
<a href="/profile/{{ profile_user['username'] }}?tab=notifications#profile-tabs" class="profile-tab {% if current_tab == 'notifications' %}active{% endif %}" data-menu-icon="&#x1F514;" data-menu-label="Notifications"><span class="icon">&#x1F514;</span> Notifications</a>
{% endif %}
<button type="button" class="profile-tab profile-tabs-more" aria-haspopup="menu" aria-label="More tabs" hidden><span class="icon">&#x22EF;</span> More</button>
</div>
@@ -540,6 +540,9 @@
{% set pagination_query = "tab=media&" %}
{% include "_pagination.html" %}
{% elif current_tab == 'followers' or current_tab == 'following' %}
{% if people %}
<div class="profile-panel">
{% endif %}
{% for person in people %}
<div class="follow-row">
<a href="/profile/{{ person['username'] }}" class="follow-user">
@@ -564,6 +567,9 @@
{% else %}
<div class="empty-state">{% if current_tab == 'followers' %}No followers yet.{% else %}Not following anyone yet.{% endif %}</div>
{% endfor %}
{% if people %}
</div>
{% endif %}
{% if follow_pagination and follow_pagination.total_pages > 1 %}
<div class="follow-pagination">
{% if follow_pagination.has_prev %}<a class="btn btn-ghost btn-sm" href="/profile/{{ profile_user['username'] }}?tab={{ current_tab }}&page={{ follow_pagination.prev_page }}">Previous</a>{% endif %}
@@ -573,17 +579,20 @@
{% endif %}
{% elif current_tab == 'notifications' %}
{% if is_owner or viewer_is_admin %}
<div class="notif-prefs" data-notif-prefs data-username="{{ profile_user['username'] }}">
<div class="notif-prefs profile-panel" data-notif-prefs data-username="{{ profile_user['username'] }}">
<div class="notif-prefs-intro">
{% if not is_owner %}<p class="notif-prefs-admin">Editing notification settings for <strong>{{ profile_user['username'] }}</strong>.</p>{% endif %}
<p>Choose how you want to be notified for each event. <strong>In-app</strong> shows the notification on DevPlace; <strong>Push</strong> sends it to your subscribed devices.</p>
<p>Choose how you want to be notified for each event. <strong>In-app</strong> shows the notification on DevPlace; <strong>Push</strong> sends it to your subscribed devices; <strong>Telegram</strong> delivers it to your paired Telegram chat.</p>
{% if not notif_telegram_paired %}<p class="notif-prefs-hint">{% if is_owner %}Connect Telegram from the <a href="#telegram-pairing">Telegram panel</a> to enable Telegram notifications.{% else %}This user has not connected Telegram, so Telegram notifications cannot be delivered yet.{% endif %}</p>{% endif %}
</div>
<div class="notif-prefs-scroll">
<table class="notif-prefs-table">
<thead>
<tr>
<th>Notification</th>
<th class="notif-prefs-col">In-app</th>
<th class="notif-prefs-col">Push</th>
<th class="notif-prefs-col">Telegram</th>
</tr>
</thead>
<tbody>
@@ -605,10 +614,17 @@
<span class="notif-switch-track"></span>
</label>
</td>
<td class="notif-prefs-col">
<label class="notif-switch{% if not notif_telegram_paired %} notif-switch-disabled{% endif %}">
<input type="checkbox" data-notif-toggle data-type="{{ pref.key }}" data-channel="telegram" aria-label="Telegram notifications for {{ pref.label }}" {% if pref.telegram %}checked{% endif %} {% if not notif_telegram_paired %}disabled{% endif %}>
<span class="notif-switch-track"></span>
</label>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="notif-prefs-actions">
<button type="button" class="btn btn-ghost btn-sm" data-notif-reset>Reset to defaults</button>
</div>
+18 -1
View File
@@ -477,6 +477,15 @@ def _schedule_push(user_uid: str, message: str, target_url: str | None) -> None:
task.add_done_callback(_push_tasks.discard)
def _schedule_telegram(user_uid: str, message: str) -> None:
from devplacepy.services.telegram import store as telegram_store
try:
telegram_store.enqueue_outbox(user_uid, message)
except Exception as e:
logger.warning("Telegram delivery enqueue failed for %s: %s", user_uid, e)
def create_notification(
user_uid: str,
notification_type: str,
@@ -508,6 +517,7 @@ def _deliver_notification(
in_app = notification_enabled(user_uid, notification_type, "in_app")
push = notification_enabled(user_uid, notification_type, "push")
telegram = notification_enabled(user_uid, notification_type, "telegram")
if in_app:
get_table("notifications").insert(
{
@@ -524,6 +534,8 @@ def _deliver_notification(
clear_unread_cache(user_uid)
if push:
_schedule_push(user_uid, message, target_url)
if telegram:
_schedule_telegram(user_uid, message)
from devplacepy.services.audit import record as audit
audit.record_system(
@@ -532,7 +544,12 @@ def _deliver_notification(
target_type="notification",
target_uid=related_uid,
summary=f"notification created: {message}",
metadata={"type": notification_type, "in_app": in_app, "push": push},
metadata={
"type": notification_type,
"in_app": in_app,
"push": push,
"telegram": telegram,
},
links=[
audit.recipient(user_uid),
audit.link("source", "notification", related_uid),