diff --git a/.env.example b/.env.example index f6994cd7..8f30e177 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/bot/handlers/basic.py b/src/bot/handlers/basic.py index cebe3363..84bc5230 100644 --- a/src/bot/handlers/basic.py +++ b/src/bot/handlers/basic.py @@ -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"当前绑定: {current_uid}\n\n" + "请先执行 /unbind 再绑定新账号。" + ), + 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: {owner}\n\n" + "如需迁移,请先在原 Telegram 账号执行 /unbind。" + ), + 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: {supabase_user_id}" + ), + 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() diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index 2b2f79d9..feb61eb1 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -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" "🔐 /city 与 /deb 仅限官方群成员使用。\n\n" "示例: /city 伦敦\n" f"💡 提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," diff --git a/src/database/db_manager.py b/src/database/db_manager.py index 788414f5..2419b7e5 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -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, diff --git a/src/onchain/polymarket_wallet_activity_watcher.py b/src/onchain/polymarket_wallet_activity_watcher.py index 8bf4bf85..5773c66f 100644 --- a/src/onchain/polymarket_wallet_activity_watcher.py +++ b/src/onchain/polymarket_wallet_activity_watcher.py @@ -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,