feat: Implement bot orchestration, command handling, and runtime coordination for background services.
This commit is contained in:
+4
-3
@@ -38,9 +38,10 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
SUPABASE_HTTP_TIMEOUT_SEC=8
|
||||
SUPABASE_AUTH_CACHE_TTL_SEC=30
|
||||
SUPABASE_SUB_CACHE_TTL_SEC=60
|
||||
# Bot command entitlement guard (/city, /deb). Off by default until payment API is wired.
|
||||
POLYWEATHER_BOT_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false
|
||||
# Bot command access guard (/city, /deb):
|
||||
# - Pro entitlement removed
|
||||
# - user only needs to be a member of TELEGRAM_CHAT_ID group
|
||||
POLYWEATHER_BOT_GROUP_INVITE_URL=
|
||||
# Backend entitlement guard (for /api/cities, /api/city/*, /api/history/*)
|
||||
POLYWEATHER_REQUIRE_ENTITLEMENT=false
|
||||
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
|
||||
|
||||
+46
-32
@@ -1,61 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.bot.io_layer import BotIOLayer
|
||||
from src.bot.services.entitlement_service import BotEntitlementService
|
||||
|
||||
|
||||
class CommandGuard:
|
||||
"""Unified command gate: entitlement + points charge."""
|
||||
"""Unified command gate: group membership + points charge."""
|
||||
|
||||
def __init__(self, io_layer: BotIOLayer, entitlement_service: BotEntitlementService):
|
||||
_ALLOWED_MEMBER_STATUSES = {"creator", "administrator", "member", "restricted"}
|
||||
|
||||
def __init__(self, io_layer: BotIOLayer, group_chat_id: str | None = None):
|
||||
self.io_layer = io_layer
|
||||
self.entitlement_service = entitlement_service
|
||||
self.group_chat_id = str(group_chat_id or os.getenv("TELEGRAM_CHAT_ID") or "").strip()
|
||||
self.group_invite_url = str(os.getenv("POLYWEATHER_BOT_GROUP_INVITE_URL") or "").strip()
|
||||
|
||||
def ensure_entitled(self, message: Any, command_label: str) -> bool:
|
||||
def _reply_group_required(self, message: Any) -> None:
|
||||
lines = ["🔒 该指令仅限群成员使用,请先加入官方群。"]
|
||||
if self.group_invite_url:
|
||||
lines.append(f"👉 入群链接: {self.group_invite_url}")
|
||||
self.io_layer.bot.reply_to(message, "\n".join(lines))
|
||||
|
||||
def ensure_group_member(self, message: Any, command_label: str) -> bool:
|
||||
user = getattr(message, "from_user", None)
|
||||
user_id = getattr(user, "id", None)
|
||||
if user_id is None:
|
||||
return False
|
||||
|
||||
decision = self.entitlement_service.check(int(user_id), command_label)
|
||||
if decision.allowed:
|
||||
if not self.group_chat_id:
|
||||
logger.error(
|
||||
"group member blocked command={} user_id={} reason=missing_TELEGRAM_CHAT_ID",
|
||||
command_label,
|
||||
user_id,
|
||||
)
|
||||
self.io_layer.bot.reply_to(
|
||||
message,
|
||||
"⚠️ 机器人未配置群组准入(TELEGRAM_CHAT_ID),请联系管理员。",
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
member = self.io_layer.bot.get_chat_member(self.group_chat_id, int(user_id))
|
||||
status = str(getattr(member, "status", "") or "").strip().lower()
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
"group member blocked command={} user_id={} chat_id={} reason=lookup_failed error={}",
|
||||
command_label,
|
||||
user_id,
|
||||
self.group_chat_id,
|
||||
exc,
|
||||
)
|
||||
self._reply_group_required(message)
|
||||
return False
|
||||
|
||||
if status in self._ALLOWED_MEMBER_STATUSES:
|
||||
return True
|
||||
|
||||
if decision.reason == "bind_required":
|
||||
denial_text = (
|
||||
"🔒 当前指令需要订阅权限。\n"
|
||||
"请先绑定账号后再试:\n"
|
||||
"<code>/bind <supabase_user_id> [email]</code>"
|
||||
)
|
||||
elif decision.reason in {"supabase_subscription_required", "premium_required"}:
|
||||
denial_text = (
|
||||
"🔒 当前指令需要高级权限。\n"
|
||||
"请先开通订阅后再使用。"
|
||||
)
|
||||
else:
|
||||
denial_text = (
|
||||
"🔒 当前指令需要高级权限。\n"
|
||||
"请先开通订阅后再使用。"
|
||||
)
|
||||
|
||||
self.io_layer.bot.reply_to(
|
||||
message,
|
||||
denial_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
"bot entitlement blocked command={} user_id={} reason={}",
|
||||
"group member blocked command={} user_id={} chat_id={} status={}",
|
||||
command_label,
|
||||
user_id,
|
||||
decision.reason,
|
||||
self.group_chat_id,
|
||||
status or "unknown",
|
||||
)
|
||||
self._reply_group_required(message)
|
||||
return False
|
||||
|
||||
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
|
||||
if not self.ensure_entitled(message, command_label):
|
||||
if not self.ensure_group_member(message, command_label):
|
||||
return False
|
||||
return self.io_layer.ensure_query_points(message, cost, command_label)
|
||||
|
||||
+21
-5
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
@@ -60,6 +61,7 @@ class BotIOLayer:
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
"/diag - 查看 Bot 启动诊断\n\n"
|
||||
"/bind - 绑定 Supabase 账号(可选)\n\n"
|
||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
||||
"示例: <code>/city 伦敦</code>\n"
|
||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
@@ -68,21 +70,34 @@ class BotIOLayer:
|
||||
def build_points_rank_text(self, user: Any) -> str:
|
||||
self.db.upsert_user(user.id, self.display_name(user))
|
||||
user_info = self.db.get_user(user.id)
|
||||
now = datetime.now()
|
||||
iso_year, iso_week, _ = now.isocalendar()
|
||||
week_key = f"{iso_year}-W{iso_week:02d}"
|
||||
|
||||
leaderboard = self.db.get_leaderboard(limit=5)
|
||||
rank_text = "🏆 <b>PolyWeather 活跃度排行榜</b>\n"
|
||||
leaderboard = self.db.get_weekly_leaderboard(limit=5)
|
||||
rank_text = f"🏆 <b>PolyWeather 周活跃度排行榜 ({week_key})</b>\n"
|
||||
rank_text += "────────────────────\n"
|
||||
for i, entry in enumerate(leaderboard):
|
||||
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
|
||||
rank_text += f"{medal} {entry['username'][:12]}: <b>{entry['points']}</b> 点\n"
|
||||
username = (entry.get("username") or "unknown")[:12]
|
||||
weekly_points = int(entry.get("weekly_points") or 0)
|
||||
rank_text += f"{medal} {username}: <b>{weekly_points}</b> 点\n"
|
||||
|
||||
if user_info:
|
||||
daily_points = int(user_info.get("daily_points") or 0)
|
||||
if daily_points > MESSAGE_DAILY_CAP:
|
||||
daily_points = MESSAGE_DAILY_CAP
|
||||
weekly_points = int(user_info.get("weekly_points") or 0)
|
||||
weekly_points_week = str(user_info.get("weekly_points_week") or "")
|
||||
if weekly_points_week != week_key:
|
||||
weekly_points = 0
|
||||
rank_text += "────────────────────\n"
|
||||
rank_text += (
|
||||
"👤 <b>我的状态:</b>\n"
|
||||
f"┣ 积分: <code>{user_info['points']}</code>\n"
|
||||
f"┣ 发言: <code>{user_info['message_count']}</code> 次\n"
|
||||
f"┣ 今日发言积分: <code>{user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
f"┣ 本周发言积分: <code>{weekly_points}</code>\n"
|
||||
f"┣ 今日发言积分: <code>{daily_points}/{MESSAGE_DAILY_CAP}</code>\n"
|
||||
f"┗ /city 消耗: <code>{CITY_QUERY_COST}</code> | /deb 消耗: <code>{DEB_QUERY_COST}</code>"
|
||||
)
|
||||
return rank_text
|
||||
@@ -108,7 +123,8 @@ class BotIOLayer:
|
||||
min_text_length=MESSAGE_MIN_LENGTH,
|
||||
)
|
||||
if result.get("awarded"):
|
||||
awarded = int(result.get("points_added") or MESSAGE_POINTS)
|
||||
logger.info(
|
||||
f"message points awarded user={user.id} points=+{MESSAGE_POINTS} "
|
||||
f"message points awarded user={user.id} points=+{awarded} "
|
||||
f"daily_points={result.get('daily_points')}/{MESSAGE_DAILY_CAP}"
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@ from src.bot.io_layer import BotIOLayer
|
||||
from src.bot.runtime_coordinator import StartupCoordinator
|
||||
from src.bot.services.city_command_service import CityCommandService
|
||||
from src.bot.services.deb_command_service import DebCommandService
|
||||
from src.bot.services.entitlement_service import BotEntitlementService
|
||||
|
||||
|
||||
def _project_root() -> str:
|
||||
@@ -61,15 +60,15 @@ def start_bot() -> None:
|
||||
io_layer = BotIOLayer(bot=bot, db=db)
|
||||
city_analysis = CityAnalysisService(weather=weather)
|
||||
deb_analysis = DebAnalysisService(project_root=_project_root())
|
||||
entitlement_service = BotEntitlementService(db=db)
|
||||
guard = CommandGuard(io_layer=io_layer, entitlement_service=entitlement_service)
|
||||
guard = CommandGuard(io_layer=io_layer)
|
||||
city_service = CityCommandService(analysis=city_analysis)
|
||||
deb_service = DebCommandService(analysis=deb_analysis)
|
||||
startup_coordinator = StartupCoordinator(
|
||||
bot=bot,
|
||||
config=config,
|
||||
entitlement_enabled=entitlement_service.enabled,
|
||||
protected_commands=sorted(entitlement_service.protected_commands),
|
||||
command_access_mode="group_member_only",
|
||||
protected_commands=["/city", "/deb"],
|
||||
required_group_chat_id=str(os.getenv("TELEGRAM_CHAT_ID") or "").strip(),
|
||||
)
|
||||
|
||||
_register_handlers(
|
||||
@@ -84,9 +83,7 @@ def start_bot() -> None:
|
||||
started_count = sum(1 for loop in runtime_status.loops if loop.started)
|
||||
|
||||
logger.info(
|
||||
"🤖 Bot 启动中... entitlement_enabled={} protected_commands={} loops_started={}/{}",
|
||||
entitlement_service.enabled,
|
||||
",".join(sorted(entitlement_service.protected_commands)),
|
||||
"🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
|
||||
started_count,
|
||||
len(runtime_status.loops),
|
||||
)
|
||||
|
||||
@@ -44,8 +44,9 @@ class LoopStatus:
|
||||
class RuntimeStatus:
|
||||
started_at: str
|
||||
loops: List[LoopStatus]
|
||||
entitlement_enabled: bool
|
||||
command_access_mode: str
|
||||
protected_commands: List[str]
|
||||
required_group_chat_id: str
|
||||
|
||||
def loop_map(self) -> Dict[str, LoopStatus]:
|
||||
return {loop.key: loop for loop in self.loops}
|
||||
@@ -58,18 +59,21 @@ class StartupCoordinator:
|
||||
self,
|
||||
bot: Any,
|
||||
config: Dict[str, Any],
|
||||
entitlement_enabled: bool,
|
||||
command_access_mode: str,
|
||||
protected_commands: List[str],
|
||||
required_group_chat_id: str,
|
||||
):
|
||||
self.bot = bot
|
||||
self.config = config
|
||||
self.entitlement_enabled = entitlement_enabled
|
||||
self.command_access_mode = command_access_mode
|
||||
self.protected_commands = protected_commands
|
||||
self.required_group_chat_id = required_group_chat_id
|
||||
self._runtime_status = RuntimeStatus(
|
||||
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
loops=[],
|
||||
entitlement_enabled=entitlement_enabled,
|
||||
command_access_mode=command_access_mode,
|
||||
protected_commands=protected_commands,
|
||||
required_group_chat_id=required_group_chat_id,
|
||||
)
|
||||
|
||||
def get_runtime_status(self) -> RuntimeStatus:
|
||||
@@ -84,8 +88,9 @@ class StartupCoordinator:
|
||||
self._runtime_status = RuntimeStatus(
|
||||
started_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC"),
|
||||
loops=loops,
|
||||
entitlement_enabled=self.entitlement_enabled,
|
||||
command_access_mode=self.command_access_mode,
|
||||
protected_commands=self.protected_commands,
|
||||
required_group_chat_id=self.required_group_chat_id,
|
||||
)
|
||||
return self._runtime_status
|
||||
|
||||
@@ -219,8 +224,9 @@ def render_runtime_status_html(status: RuntimeStatus) -> str:
|
||||
"🧭 <b>Bot 启动诊断</b>",
|
||||
f"启动时间: <code>{status.started_at}</code>",
|
||||
"",
|
||||
f"Entitlement: <code>{'ON' if status.entitlement_enabled else 'OFF'}</code>",
|
||||
f"命令准入: <code>{status.command_access_mode}</code>",
|
||||
f"受保护命令: <code>{', '.join(status.protected_commands) or '--'}</code>",
|
||||
f"目标群组: <code>{status.required_group_chat_id or '--'}</code>",
|
||||
"",
|
||||
"后台循环:",
|
||||
]
|
||||
|
||||
+2
-3
@@ -1,7 +1,6 @@
|
||||
MESSAGE_POINTS = 4
|
||||
MESSAGE_DAILY_CAP = 50
|
||||
MESSAGE_MIN_LENGTH = 2
|
||||
MESSAGE_DAILY_CAP = 10
|
||||
MESSAGE_MIN_LENGTH = 3
|
||||
MESSAGE_COOLDOWN_SEC = 30
|
||||
CITY_QUERY_COST = 1
|
||||
DEB_QUERY_COST = 1
|
||||
|
||||
|
||||
+116
-7
@@ -1,5 +1,6 @@
|
||||
import sqlite3
|
||||
import os
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from loguru import logger
|
||||
@@ -43,8 +44,19 @@ class DBManager:
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS activity_fingerprints (
|
||||
telegram_id INTEGER NOT NULL,
|
||||
activity_date TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (telegram_id, activity_date, fingerprint)
|
||||
)
|
||||
""")
|
||||
self._ensure_column(conn, "users", "daily_points", "INTEGER DEFAULT 0")
|
||||
self._ensure_column(conn, "users", "daily_points_date", "TEXT")
|
||||
self._ensure_column(conn, "users", "weekly_points", "INTEGER DEFAULT 0")
|
||||
self._ensure_column(conn, "users", "weekly_points_week", "TEXT")
|
||||
self._ensure_column(conn, "users", "supabase_user_id", "TEXT")
|
||||
self._ensure_column(conn, "users", "supabase_email", "TEXT")
|
||||
conn.commit()
|
||||
@@ -115,16 +127,25 @@ class DBManager:
|
||||
) -> Dict[str, Any]:
|
||||
"""Award points for valid group activity with cooldown and daily cap."""
|
||||
now = datetime.now()
|
||||
normalized = "".join((text or "").split())
|
||||
normalized = "".join((text or "").split()).lower()
|
||||
if len(normalized) < min_text_length:
|
||||
return {"awarded": False, "reason": "too_short"}
|
||||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
today_str = now.strftime("%Y-%m-%d")
|
||||
iso_year, iso_week, _ = now.isocalendar()
|
||||
week_key = f"{iso_year}-W{iso_week:02d}"
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Keep dedupe table bounded.
|
||||
stale_day = (now - timedelta(days=14)).strftime("%Y-%m-%d")
|
||||
conn.execute(
|
||||
"DELETE FROM activity_fingerprints WHERE activity_date < ?",
|
||||
(stale_day,),
|
||||
)
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT points, daily_points, daily_points_date, last_message_at
|
||||
SELECT points, daily_points, daily_points_date, weekly_points, weekly_points_week, last_message_at
|
||||
FROM users WHERE telegram_id = ?
|
||||
""",
|
||||
(telegram_id,),
|
||||
@@ -133,6 +154,18 @@ class DBManager:
|
||||
if not row:
|
||||
return {"awarded": False, "reason": "user_missing"}
|
||||
|
||||
duplicated = conn.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM activity_fingerprints
|
||||
WHERE telegram_id = ? AND activity_date = ? AND fingerprint = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(telegram_id, today_str, fingerprint),
|
||||
).fetchone()
|
||||
if duplicated:
|
||||
return {"awarded": False, "reason": "duplicate_content"}
|
||||
|
||||
last_message_at = row["last_message_at"]
|
||||
if last_message_at:
|
||||
last_at = datetime.fromisoformat(last_message_at)
|
||||
@@ -143,18 +176,49 @@ class DBManager:
|
||||
daily_points_date = row["daily_points_date"] or ""
|
||||
if daily_points_date != today_str:
|
||||
daily_points = 0
|
||||
# Guard against historical overflow values (legacy bug).
|
||||
if daily_points > daily_cap:
|
||||
daily_points = daily_cap
|
||||
|
||||
weekly_points = int(row["weekly_points"] or 0)
|
||||
weekly_points_week = row["weekly_points_week"] or ""
|
||||
if weekly_points_week != week_key:
|
||||
weekly_points = 0
|
||||
|
||||
if daily_points >= daily_cap:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET last_message_at = ?, daily_points = ?, daily_points_date = ?
|
||||
SET last_message_at = ?, daily_points = ?, daily_points_date = ?,
|
||||
weekly_points = ?, weekly_points_week = ?
|
||||
WHERE telegram_id = ?
|
||||
""",
|
||||
(now.isoformat(), daily_points, today_str, telegram_id),
|
||||
(
|
||||
now.isoformat(),
|
||||
daily_points,
|
||||
today_str,
|
||||
weekly_points,
|
||||
week_key,
|
||||
telegram_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return {"awarded": False, "reason": "daily_cap", "daily_points": daily_points}
|
||||
return {
|
||||
"awarded": False,
|
||||
"reason": "daily_cap",
|
||||
"daily_points": daily_points,
|
||||
"weekly_points": weekly_points,
|
||||
}
|
||||
|
||||
remaining = max(0, daily_cap - daily_points)
|
||||
points_added = min(max(0, points_to_add), remaining)
|
||||
if points_added <= 0:
|
||||
return {
|
||||
"awarded": False,
|
||||
"reason": "daily_cap",
|
||||
"daily_points": daily_points,
|
||||
"weekly_points": weekly_points,
|
||||
}
|
||||
|
||||
conn.execute("""
|
||||
UPDATE users
|
||||
@@ -162,14 +226,35 @@ class DBManager:
|
||||
points = points + ?,
|
||||
daily_points = ?,
|
||||
daily_points_date = ?,
|
||||
weekly_points = ?,
|
||||
weekly_points_week = ?,
|
||||
last_message_at = ?
|
||||
WHERE telegram_id = ?
|
||||
""", (points_to_add, daily_points + points_to_add, today_str, now.isoformat(), telegram_id))
|
||||
""", (
|
||||
points_added,
|
||||
daily_points + points_added,
|
||||
today_str,
|
||||
weekly_points + points_added,
|
||||
week_key,
|
||||
now.isoformat(),
|
||||
telegram_id,
|
||||
))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO activity_fingerprints
|
||||
(telegram_id, activity_date, fingerprint)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(telegram_id, today_str, fingerprint),
|
||||
)
|
||||
conn.commit()
|
||||
return {
|
||||
"awarded": True,
|
||||
"reason": "ok",
|
||||
"daily_points": daily_points + points_to_add,
|
||||
"points_added": points_added,
|
||||
"daily_points": daily_points + points_added,
|
||||
"weekly_points": weekly_points + points_added,
|
||||
"weekly_week": week_key,
|
||||
}
|
||||
|
||||
def spend_points(self, telegram_id: int, amount: int) -> Dict[str, Any]:
|
||||
@@ -221,3 +306,27 @@ class DBManager:
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_weekly_leaderboard(self, limit: int = 10):
|
||||
now = datetime.now()
|
||||
iso_year, iso_week, _ = now.isocalendar()
|
||||
week_key = f"{iso_year}-W{iso_week:02d}"
|
||||
with self._get_connection() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
username,
|
||||
points,
|
||||
message_count,
|
||||
CASE
|
||||
WHEN weekly_points_week = ? THEN COALESCE(weekly_points, 0)
|
||||
ELSE 0
|
||||
END AS weekly_points
|
||||
FROM users
|
||||
ORDER BY weekly_points DESC, points DESC, message_count DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(week_key, limit),
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
Reference in New Issue
Block a user