feat: Implement core Telegram bot features including command handling, user points, and identity management, and add Polymarket wallet activity watcher.

This commit is contained in:
2569718930@qq.com
2026-03-13 18:39:39 +08:00
parent 7bde49bd5e
commit edf2a37976
5 changed files with 232 additions and 4 deletions
+6
View File
@@ -161,4 +161,10 @@ POLYMARKET_WALLET_ACTIVITY_UPDATE_DEBOUNCE_SEC=30
POLYMARKET_WALLET_ACTIVITY_UPDATE_MAX_HOLD_SEC=120
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
# Skip wallet activity pushes when position value is below this floor (USD).
# Set 0 to disable.
POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD=0
# Comma-separated wallet addresses exempt from min value floor.
# Example: 0x849d9a4dd64829b8b0141ea53e7caca7e99529ec
POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS=
+70 -1
View File
@@ -40,6 +40,10 @@ class BasicCommandHandler:
def _bind(message):
self.handle_bind(message)
@self.bot.message_handler(commands=["unbind"])
def _unbind(message):
self.handle_unbind(message)
def handle_start_help(self, message: Any) -> None:
trace = CommandTrace("/start", message)
try:
@@ -104,11 +108,55 @@ class BasicCommandHandler:
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
user = message.from_user
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
self.io_layer.db.bind_supabase_identity(
result = self.io_layer.db.bind_supabase_identity(
telegram_id=user.id,
supabase_user_id=supabase_user_id,
supabase_email=supabase_email,
)
if not bool(result.get("ok")):
reason = str(result.get("reason") or "bind_failed")
if reason == "telegram_already_bound_other":
current_uid = str(result.get("current_supabase_user_id") or "")
self.bot.reply_to(
message,
(
"❌ 当前 Telegram 已绑定其他网页账号。\n"
f"当前绑定: <code>{current_uid}</code>\n\n"
"请先执行 <code>/unbind</code> 再绑定新账号。"
),
parse_mode="HTML",
)
trace.set_status("conflict", "telegram_already_bound_other")
return
if reason == "supabase_already_bound_other":
owner = str(result.get("owner_telegram_id") or "")
self.bot.reply_to(
message,
(
"❌ 该网页账号已绑定到其他 Telegram。\n"
f"绑定中的 Telegram ID: <code>{owner}</code>\n\n"
"如需迁移,请先在原 Telegram 账号执行 <code>/unbind</code>。"
),
parse_mode="HTML",
)
trace.set_status("conflict", "supabase_already_bound_other")
return
self.bot.reply_to(message, "❌ 绑定失败,请稍后重试。")
trace.set_status("error", reason)
return
if str(result.get("reason") or "") == "already_bound_same":
self.bot.reply_to(
message,
(
"✅ 已是当前绑定账号,无需重复绑定。\n"
f"supabase_user_id: <code>{supabase_user_id}</code>"
),
parse_mode="HTML",
)
trace.set_status("ok", "already_bound_same")
return
self.bot.reply_to(
message,
(
@@ -120,3 +168,24 @@ class BasicCommandHandler:
trace.set_status("ok")
finally:
trace.emit()
def handle_unbind(self, message: Any) -> None:
trace = CommandTrace("/unbind", message)
try:
user = message.from_user
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(user))
result = self.io_layer.db.unbind_supabase_identity(user.id)
if str(result.get("reason") or "") == "not_bound":
self.bot.reply_to(
message,
"ℹ️ 当前 Telegram 尚未绑定网页账号。",
)
trace.set_status("ok", "not_bound")
return
self.bot.reply_to(
message,
"✅ 已解除当前 Telegram 与网页账号的绑定。",
)
trace.set_status("ok", "unbound")
finally:
trace.emit()
+2 -1
View File
@@ -60,7 +60,8 @@ class BotIOLayer:
"/top - 查看积分排行榜\n"
"/id - 获取当前聊天的 Chat ID\n\n"
"/diag - 查看 Bot 启动诊断\n\n"
"/bind - 绑定 Supabase 账号(可选)\n\n"
"/bind - 绑定 Supabase 账号(可选)\n"
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
"示例: <code>/city 伦敦</code>\n"
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
+106 -2
View File
@@ -194,17 +194,121 @@ class DBManager:
telegram_id: int,
supabase_user_id: str,
supabase_email: str = "",
) -> None:
) -> Dict[str, Any]:
"""
Strict one-to-one binding between Telegram account and Supabase account.
Rules:
- One telegram_id can only bind one supabase_user_id (no overwrite via /bind).
- One supabase_user_id can only bind one telegram_id.
- Rebind requires explicit unbind first.
"""
normalized_uid = str(supabase_user_id or "").strip().lower()
normalized_email = str(supabase_email or "").strip()
if not normalized_uid:
return {"ok": False, "reason": "invalid_supabase_user_id"}
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
# Ensure current telegram user row exists.
conn.execute(
"""
INSERT INTO users (telegram_id, username)
VALUES (?, COALESCE((SELECT username FROM users WHERE telegram_id = ?), ''))
ON CONFLICT(telegram_id) DO NOTHING
""",
(telegram_id, telegram_id),
)
current_row = conn.execute(
"""
SELECT telegram_id, supabase_user_id, supabase_email
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
current_uid = str(
(current_row["supabase_user_id"] if current_row else "") or ""
).strip().lower()
owner_row = conn.execute(
"""
SELECT telegram_id
FROM users
WHERE lower(trim(COALESCE(supabase_user_id, ''))) = ?
LIMIT 1
""",
(normalized_uid,),
).fetchone()
owner_telegram_id = int(owner_row["telegram_id"]) if owner_row else None
if current_uid and current_uid != normalized_uid:
return {
"ok": False,
"reason": "telegram_already_bound_other",
"current_supabase_user_id": current_uid,
}
if owner_telegram_id is not None and owner_telegram_id != int(telegram_id):
return {
"ok": False,
"reason": "supabase_already_bound_other",
"owner_telegram_id": owner_telegram_id,
}
if current_uid == normalized_uid:
# Keep idempotent bind behavior while allowing email refresh.
conn.execute(
"""
UPDATE users
SET supabase_email = ?
WHERE telegram_id = ?
""",
(normalized_email, telegram_id),
)
conn.commit()
return {"ok": True, "reason": "already_bound_same"}
conn.execute(
"""
UPDATE users
SET supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
(supabase_user_id.strip(), supabase_email.strip(), telegram_id),
(normalized_uid, normalized_email, telegram_id),
)
conn.commit()
return {"ok": True, "reason": "bound"}
def unbind_supabase_identity(self, telegram_id: int) -> Dict[str, Any]:
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
current = conn.execute(
"""
SELECT supabase_user_id
FROM users
WHERE telegram_id = ?
LIMIT 1
""",
(telegram_id,),
).fetchone()
current_uid = str((current["supabase_user_id"] if current else "") or "").strip()
if not current_uid:
return {"ok": True, "reason": "not_bound"}
conn.execute(
"""
UPDATE users
SET supabase_user_id = '', supabase_email = ''
WHERE telegram_id = ?
""",
(telegram_id,),
)
conn.commit()
return {"ok": True, "reason": "unbound", "previous_supabase_user_id": current_uid}
def add_message_activity(
self,
@@ -76,6 +76,10 @@ def _parse_addresses(raw: Optional[str]) -> List[str]:
return out
def _parse_address_set(raw: Optional[str]) -> set[str]:
return set(_parse_addresses(raw))
def _parse_address_aliases(raw: Optional[str]) -> Dict[str, str]:
"""
Parse wallet aliases from either:
@@ -512,6 +516,35 @@ def _build_message(
return "\n".join(lines)
def _filter_changes_by_position_value(
*,
wallet: str,
changes: List[Tuple[str, Dict[str, Any]]],
min_position_value_usd: float,
exempt_wallets: set[str],
) -> List[Tuple[str, Dict[str, Any]]]:
if min_position_value_usd <= 0:
return changes
if wallet in exempt_wallets:
return changes
out: List[Tuple[str, Dict[str, Any]]] = []
for change_type, pos in changes:
value = _safe_float(pos.get("position_value"))
if value >= min_position_value_usd:
out.append((change_type, pos))
else:
logger.info(
"wallet activity skipped by position value floor user={} change={} value={:.2f} floor={:.2f} market={}",
wallet,
change_type,
value,
min_position_value_usd,
str(pos.get("title") or ""),
)
return out
def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]:
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
chat_id = os.getenv("TELEGRAM_CHAT_ID")
@@ -520,6 +553,9 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES")
or os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS_ALIASES")
)
exempt_wallets = _parse_address_set(
os.getenv("POLYMARKET_WALLET_ACTIVITY_MIN_VALUE_EXEMPT_USERS")
)
if not enabled:
logger.info("polymarket wallet activity watcher disabled")
@@ -558,6 +594,9 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
_env_int("POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC", 20),
)
max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5))
min_position_value_usd = max(
0.0, _env_float("POLYMARKET_WALLET_ACTIVITY_MIN_POSITION_VALUE_USD", 0.0)
)
notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False)
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
link_preview = _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True)
@@ -586,6 +625,8 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
logger.info(
f"polymarket wallet activity watcher started users={len(users)} "
f"poll={poll_sec}s data_api={data_api_url} price_filter={min_price}-{max_price} "
f"min_position_value_usd={min_position_value_usd} "
f"min_value_exempt_users={len(exempt_wallets)} "
f"aliases={len(user_aliases)} link_preview={link_preview} "
f"min_avg_price_delta={min_avg_price_delta} "
f"immediate_on_size_delta={immediate_on_size_delta} "
@@ -715,6 +756,13 @@ def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread
)
)
outgoing = _filter_changes_by_position_value(
wallet=user,
changes=outgoing,
min_position_value_usd=min_position_value_usd,
exempt_wallets=exempt_wallets,
)
if outgoing:
msg = _build_message(
user,