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:
@@ -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=
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user