feat: revamp points/reward system for inclusive engagement
- /city /deb now free, capped at 10/day each (was 2 pts cost) - Welcome bonus +20 pts on first-ever valid message - First-message-of-day bonus +2 pts - Weekly winner point bonuses reduced (500→200, 300→100, 150→50) - Weekly participation rewards for all active users (+5 base, +15 for ≥20 pts) - Pro-day rewards for top 3 unchanged Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,24 @@ class CommandGuard:
|
||||
self._reply_group_required(message)
|
||||
return False
|
||||
|
||||
def check_daily_query_limit(self, message: Any, query_type: str, limit: int) -> bool:
|
||||
user = message.from_user
|
||||
result = self.io_layer.db.track_query_usage(user.id, query_type)
|
||||
if result.get("allowed"):
|
||||
return True
|
||||
if result.get("reason") == "user_missing":
|
||||
self.io_layer.bot.reply_to(message, "⚠️ 用户未注册,请先在群内发言。")
|
||||
return False
|
||||
if result.get("reason") == "daily_limit":
|
||||
used = int(result.get("used") or 0)
|
||||
self.io_layer.send_query_message(
|
||||
message,
|
||||
f"❌ 今日 <b>/{query_type}</b> 免费次数已用完 ({used}/{limit})\n请明天再来,或通过群内发言赚取积分。",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def ensure_access_and_points(self, message: Any, cost: int, command_label: str) -> bool:
|
||||
if not self.ensure_group_member(message, command_label):
|
||||
return False
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.bot.command_guard import CommandGuard
|
||||
from src.bot.io_layer import BotIOLayer
|
||||
from src.bot.observability import CommandTrace
|
||||
from src.bot.services.city_command_service import CityCommandService
|
||||
from src.bot.settings import CITY_QUERY_COST
|
||||
from src.bot.settings import CITY_DAILY_FREE_LIMIT, CITY_QUERY_COST
|
||||
|
||||
def _is_city_command(message: Any) -> bool:
|
||||
command = extract_command_name(
|
||||
@@ -77,6 +77,9 @@ class CityCommandHandler:
|
||||
return
|
||||
|
||||
city_name = str(resolved.city_name)
|
||||
if not self.guard.check_daily_query_limit(message, "city", CITY_DAILY_FREE_LIMIT):
|
||||
trace.set_status("blocked", "daily_query_limit")
|
||||
return
|
||||
if not self.guard.ensure_access_and_points(message, CITY_QUERY_COST, "/city"):
|
||||
trace.set_status("blocked", "guard_rejected")
|
||||
return
|
||||
|
||||
@@ -10,7 +10,7 @@ from src.bot.command_guard import CommandGuard
|
||||
from src.bot.io_layer import BotIOLayer
|
||||
from src.bot.observability import CommandTrace
|
||||
from src.bot.services.deb_command_service import DebCommandService
|
||||
from src.bot.settings import DEB_QUERY_COST
|
||||
from src.bot.settings import DEB_DAILY_FREE_LIMIT, DEB_QUERY_COST
|
||||
|
||||
def _is_deb_command(message: Any) -> bool:
|
||||
command = extract_command_name(
|
||||
@@ -75,6 +75,9 @@ class DebCommandHandler:
|
||||
)
|
||||
return
|
||||
|
||||
if not self.guard.check_daily_query_limit(message, "deb", DEB_DAILY_FREE_LIMIT):
|
||||
trace.set_status("blocked", "daily_query_limit")
|
||||
return
|
||||
if not self.guard.ensure_access_and_points(message, DEB_QUERY_COST, "/deb"):
|
||||
trace.set_status("blocked", "guard_rejected")
|
||||
return
|
||||
|
||||
+12
-5
@@ -7,7 +7,9 @@ from typing import Any, Dict, Optional, Tuple
|
||||
from loguru import logger
|
||||
|
||||
from src.bot.settings import (
|
||||
CITY_DAILY_FREE_LIMIT,
|
||||
CITY_QUERY_COST,
|
||||
DEB_DAILY_FREE_LIMIT,
|
||||
DEB_QUERY_COST,
|
||||
MESSAGE_COOLDOWN_SEC,
|
||||
MESSAGE_DAILY_CAP,
|
||||
@@ -176,8 +178,8 @@ class BotIOLayer:
|
||||
return (
|
||||
"🚀 <b>PolyWeather 天气查询机器人</b>\n\n"
|
||||
"可用指令:\n"
|
||||
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (消耗 {CITY_QUERY_COST} 积分)\n"
|
||||
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (消耗 {DEB_QUERY_COST} 积分)\n"
|
||||
f"/city [城市名] 或 /pwcity [城市名] - 查询城市天气预测与实测 (免费, 每日 {CITY_DAILY_FREE_LIMIT} 次)\n"
|
||||
f"/deb [城市名] 或 /pwdeb [城市名] - 查看 DEB 融合预测准确率 (免费, 每日 {DEB_DAILY_FREE_LIMIT} 次)\n"
|
||||
"/markets - 私聊机器人查看当前市场监控摘要\n"
|
||||
"/top - 查看积分排行榜\n"
|
||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||
@@ -189,8 +191,9 @@ class BotIOLayer:
|
||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。</i>"
|
||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
f"首次发言额外奖励 <b>20</b> 积分,每日首条消息 +<b>2</b> 积分。</i>"
|
||||
)
|
||||
|
||||
def build_points_rank_text(self, user: Any) -> str:
|
||||
@@ -225,6 +228,10 @@ class BotIOLayer:
|
||||
f"{weekly_rank}/{ranked_count}" if weekly_rank and ranked_count > 0 else "未上榜"
|
||||
)
|
||||
|
||||
daily_queries_date = str(user_info.get("daily_queries_date") or "")
|
||||
city_used = int(user_info.get("daily_city_queries") or 0) if daily_queries_date == today_str else 0
|
||||
deb_used = int(user_info.get("daily_deb_queries") or 0) if daily_queries_date == today_str else 0
|
||||
|
||||
rank_text += "────────────────────\n"
|
||||
rank_text += (
|
||||
"👤 <b>我的状态:</b>\n"
|
||||
@@ -233,7 +240,7 @@ class BotIOLayer:
|
||||
f"┣ 本周排名: <code>{weekly_rank_text}</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>"
|
||||
f"┗ /city 免费 ({city_used}/{CITY_DAILY_FREE_LIMIT}) | /deb 免费 ({deb_used}/{DEB_DAILY_FREE_LIMIT})"
|
||||
)
|
||||
return rank_text
|
||||
|
||||
|
||||
+11
-2
@@ -20,5 +20,14 @@ MESSAGE_MIN_LENGTH = _env_int("POLYWEATHER_BOT_MESSAGE_MIN_LENGTH", 3, min_value
|
||||
MESSAGE_COOLDOWN_SEC = _env_int("POLYWEATHER_BOT_MESSAGE_COOLDOWN_SEC", 30, min_value=0)
|
||||
# Optional per-chat override map, parsed in BotIOLayer:
|
||||
# POLYWEATHER_BOT_MESSAGE_COOLDOWN_BY_CHAT="-1003586303099:10,-1003539418691:20"
|
||||
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 2, min_value=0)
|
||||
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 2, min_value=0)
|
||||
CITY_QUERY_COST = _env_int("POLYWEATHER_BOT_CITY_QUERY_COST", 0, min_value=0)
|
||||
DEB_QUERY_COST = _env_int("POLYWEATHER_BOT_DEB_QUERY_COST", 0, min_value=0)
|
||||
CITY_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_CITY_DAILY_FREE_LIMIT", 10, min_value=1)
|
||||
DEB_DAILY_FREE_LIMIT = _env_int("POLYWEATHER_BOT_DEB_DAILY_FREE_LIMIT", 10, min_value=1)
|
||||
|
||||
FIRST_MESSAGE_BONUS = _env_int("POLYWEATHER_BOT_FIRST_MESSAGE_BONUS", 2, min_value=0)
|
||||
WELCOME_BONUS = _env_int("POLYWEATHER_BOT_WELCOME_BONUS", 20, min_value=0)
|
||||
|
||||
WEEKLY_PARTICIPATION_BONUS = _env_int("POLYWEATHER_BOT_WEEKLY_PARTICIPATION_BONUS", 5, min_value=0)
|
||||
WEEKLY_ACTIVE_BONUS = _env_int("POLYWEATHER_BOT_WEEKLY_ACTIVE_BONUS", 15, min_value=0)
|
||||
WEEKLY_ACTIVE_THRESHOLD = _env_int("POLYWEATHER_BOT_WEEKLY_ACTIVE_THRESHOLD", 20, min_value=1)
|
||||
|
||||
@@ -14,6 +14,11 @@ except Exception: # pragma: no cover - Python < 3.9 fallback
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from src.bot.settings import (
|
||||
WEEKLY_ACTIVE_BONUS,
|
||||
WEEKLY_ACTIVE_THRESHOLD,
|
||||
WEEKLY_PARTICIPATION_BONUS,
|
||||
)
|
||||
from src.database.db_manager import DBManager
|
||||
from src.utils.telegram_chat_ids import get_telegram_chat_ids_from_env
|
||||
|
||||
@@ -69,11 +74,11 @@ def _compute_target_week_key(
|
||||
|
||||
def _reward_rule_for_rank(rank: int) -> Optional[Tuple[int, int]]:
|
||||
if rank == 1:
|
||||
return 500, 7
|
||||
return 200, 7
|
||||
if rank in (2, 3):
|
||||
return 300, 3
|
||||
return 100, 3
|
||||
if 4 <= rank <= 10:
|
||||
return 150, 0
|
||||
return 50, 0
|
||||
return None
|
||||
|
||||
|
||||
@@ -170,10 +175,12 @@ def _render_settle_report(
|
||||
week_key: str,
|
||||
winners: List[Dict[str, Any]],
|
||||
skipped: int,
|
||||
participation_count: int = 0,
|
||||
active_count: int = 0,
|
||||
) -> str:
|
||||
lines = [f"🏆 <b>PolyWeather 周榜奖励已结算 ({week_key})</b>", "────────────────────"]
|
||||
if not winners:
|
||||
lines.append("本周无有效活跃用户,未发放奖励。")
|
||||
lines.append("本周无上榜用户。")
|
||||
else:
|
||||
medals = {1: "🥇", 2: "🥈", 3: "🥉"}
|
||||
for row in winners:
|
||||
@@ -186,6 +193,11 @@ def _render_settle_report(
|
||||
lines.append(f"{medal} {name}: +{points_bonus} 积分{pro_text}")
|
||||
if skipped > 0:
|
||||
lines.append(f"(重复发放保护已生效,跳过 {skipped} 条)")
|
||||
if participation_count > 0 or active_count > 0:
|
||||
lines.append("────────────────────")
|
||||
lines.append(f"🎁 参与奖 +{WEEKLY_PARTICIPATION_BONUS} 积分: {participation_count} 人")
|
||||
if active_count > 0:
|
||||
lines.append(f"🔥 活跃奖 +{WEEKLY_ACTIVE_BONUS} 积分 (周积分≥{WEEKLY_ACTIVE_THRESHOLD}): {active_count} 人")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -302,8 +314,39 @@ def _runner(bot: Any) -> None:
|
||||
"winner_count": len(winners),
|
||||
"skipped_count": skipped,
|
||||
"winners": winners,
|
||||
"participation_count": 0,
|
||||
"active_count": 0,
|
||||
"settled_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
winner_ids = {int(w.get("telegram_id") or 0) for w in winners}
|
||||
participants = db.get_weekly_participation_candidates(week_key, winner_ids)
|
||||
participation_count = 0
|
||||
active_count = 0
|
||||
for p in participants:
|
||||
tid = int(p.get("telegram_id") or 0)
|
||||
if tid <= 0:
|
||||
continue
|
||||
weekly_pts = int(p.get("weekly_points") or 0)
|
||||
is_active = weekly_pts >= WEEKLY_ACTIVE_THRESHOLD
|
||||
bonus = WEEKLY_PARTICIPATION_BONUS + (WEEKLY_ACTIVE_BONUS if is_active else 0)
|
||||
if bonus <= 0:
|
||||
continue
|
||||
applied = db.apply_weekly_reward_payout(
|
||||
week_key=week_key,
|
||||
telegram_id=tid,
|
||||
rank=0,
|
||||
username=str(p.get("username") or f"user_{tid}"),
|
||||
points_bonus=bonus,
|
||||
pro_days=0,
|
||||
)
|
||||
if applied:
|
||||
participation_count += 1
|
||||
if is_active:
|
||||
active_count += 1
|
||||
|
||||
summary["participation_count"] = participation_count
|
||||
summary["active_count"] = active_count
|
||||
db.mark_weekly_reward_settled(
|
||||
week_key=week_key,
|
||||
winners_count=len(winners),
|
||||
@@ -316,7 +359,13 @@ def _runner(bot: Any) -> None:
|
||||
skipped,
|
||||
)
|
||||
if announce and chat_ids:
|
||||
message = _render_settle_report(week_key=week_key, winners=winners, skipped=skipped)
|
||||
message = _render_settle_report(
|
||||
week_key=week_key,
|
||||
winners=winners,
|
||||
skipped=skipped,
|
||||
participation_count=participation_count,
|
||||
active_count=active_count,
|
||||
)
|
||||
for chat_id in chat_ids:
|
||||
try:
|
||||
bot.send_message(
|
||||
|
||||
Reference in New Issue
Block a user