diff --git a/.env.example b/.env.example
index e46b957a..7bcaf2c5 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/src/bot/command_guard.py b/src/bot/command_guard.py
index bf291050..f22cd8dc 100644
--- a/src/bot/command_guard.py
+++ b/src/bot/command_guard.py
@@ -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"
- "/bind <supabase_user_id> [email]"
- )
- 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)
diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py
index f0a1a9ae..f0b64c83 100644
--- a/src/bot/io_layer.py
+++ b/src/bot/io_layer.py
@@ -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"
+ "🔐 /city 与 /deb 仅限官方群成员使用。\n\n"
"示例: /city 伦敦\n"
f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分,"
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
@@ -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 = "🏆 PolyWeather 活跃度排行榜\n"
+ leaderboard = self.db.get_weekly_leaderboard(limit=5)
+ rank_text = f"🏆 PolyWeather 周活跃度排行榜 ({week_key})\n"
rank_text += "────────────────────\n"
for i, entry in enumerate(leaderboard):
medal = ["🥇", "🥈", "🥉", " ", " "][i] if i < 5 else " "
- rank_text += f"{medal} {entry['username'][:12]}: {entry['points']} 点\n"
+ username = (entry.get("username") or "unknown")[:12]
+ weekly_points = int(entry.get("weekly_points") or 0)
+ rank_text += f"{medal} {username}: {weekly_points} 点\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 += (
"👤 我的状态:\n"
f"┣ 积分: {user_info['points']}\n"
f"┣ 发言: {user_info['message_count']} 次\n"
- f"┣ 今日发言积分: {user_info.get('daily_points') or 0}/{MESSAGE_DAILY_CAP}\n"
+ f"┣ 本周发言积分: {weekly_points}\n"
+ f"┣ 今日发言积分: {daily_points}/{MESSAGE_DAILY_CAP}\n"
f"┗ /city 消耗: {CITY_QUERY_COST} | /deb 消耗: {DEB_QUERY_COST}"
)
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}"
)
diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py
index 79bbae39..145ab22e 100644
--- a/src/bot/orchestrator.py
+++ b/src/bot/orchestrator.py
@@ -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),
)
diff --git a/src/bot/runtime_coordinator.py b/src/bot/runtime_coordinator.py
index 27438ffb..0d728a66 100644
--- a/src/bot/runtime_coordinator.py
+++ b/src/bot/runtime_coordinator.py
@@ -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:
"🧭 Bot 启动诊断",
f"启动时间: {status.started_at}",
"",
- f"Entitlement: {'ON' if status.entitlement_enabled else 'OFF'}",
+ f"命令准入: {status.command_access_mode}",
f"受保护命令: {', '.join(status.protected_commands) or '--'}",
+ f"目标群组: {status.required_group_chat_id or '--'}",
"",
"后台循环:",
]
diff --git a/src/bot/settings.py b/src/bot/settings.py
index c5bf5f94..4fd95cc4 100644
--- a/src/bot/settings.py
+++ b/src/bot/settings.py
@@ -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
-
diff --git a/src/database/db_manager.py b/src/database/db_manager.py
index 4dce7f49..b890377a 100644
--- a/src/database/db_manager.py
+++ b/src/database/db_manager.py
@@ -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()]