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,