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_UPDATE_MAX_HOLD_SEC=120
|
||||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MIN=0.01
|
||||||
POLYMARKET_WALLET_ACTIVITY_AVG_PRICE_SHOW_MAX=0.99
|
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):
|
def _bind(message):
|
||||||
self.handle_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:
|
def handle_start_help(self, message: Any) -> None:
|
||||||
trace = CommandTrace("/start", message)
|
trace = CommandTrace("/start", message)
|
||||||
try:
|
try:
|
||||||
@@ -104,11 +108,55 @@ class BasicCommandHandler:
|
|||||||
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
|
supabase_email = str(parts[2] or "").strip() if len(parts) >= 3 else ""
|
||||||
user = message.from_user
|
user = message.from_user
|
||||||
self.io_layer.db.upsert_user(user.id, self.io_layer.display_name(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,
|
telegram_id=user.id,
|
||||||
supabase_user_id=supabase_user_id,
|
supabase_user_id=supabase_user_id,
|
||||||
supabase_email=supabase_email,
|
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(
|
self.bot.reply_to(
|
||||||
message,
|
message,
|
||||||
(
|
(
|
||||||
@@ -120,3 +168,24 @@ class BasicCommandHandler:
|
|||||||
trace.set_status("ok")
|
trace.set_status("ok")
|
||||||
finally:
|
finally:
|
||||||
trace.emit()
|
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"
|
"/top - 查看积分排行榜\n"
|
||||||
"/id - 获取当前聊天的 Chat ID\n\n"
|
"/id - 获取当前聊天的 Chat ID\n\n"
|
||||||
"/diag - 查看 Bot 启动诊断\n\n"
|
"/diag - 查看 Bot 启动诊断\n\n"
|
||||||
"/bind - 绑定 Supabase 账号(可选)\n\n"
|
"/bind - 绑定 Supabase 账号(可选)\n"
|
||||||
|
"/unbind - 解除当前 Telegram 与网页账号绑定\n\n"
|
||||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
||||||
"示例: <code>/city 伦敦</code>\n"
|
"示例: <code>/city 伦敦</code>\n"
|
||||||
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
f"💡 <i>提示: 每日签到(有效发言满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||||
|
|||||||
+106
-2
@@ -194,17 +194,121 @@ class DBManager:
|
|||||||
telegram_id: int,
|
telegram_id: int,
|
||||||
supabase_user_id: str,
|
supabase_user_id: str,
|
||||||
supabase_email: 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:
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET supabase_user_id = ?, supabase_email = ?
|
SET supabase_user_id = ?, supabase_email = ?
|
||||||
WHERE telegram_id = ?
|
WHERE telegram_id = ?
|
||||||
""",
|
""",
|
||||||
(supabase_user_id.strip(), supabase_email.strip(), telegram_id),
|
(normalized_uid, normalized_email, telegram_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
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(
|
def add_message_activity(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ def _parse_addresses(raw: Optional[str]) -> List[str]:
|
|||||||
return out
|
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]:
|
def _parse_address_aliases(raw: Optional[str]) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Parse wallet aliases from either:
|
Parse wallet aliases from either:
|
||||||
@@ -512,6 +516,35 @@ def _build_message(
|
|||||||
return "\n".join(lines)
|
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]:
|
def start_polymarket_wallet_activity_loop(bot: Any) -> Optional[threading.Thread]:
|
||||||
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
|
enabled = _env_bool("POLYMARKET_WALLET_ACTIVITY_ENABLED", False)
|
||||||
chat_id = os.getenv("TELEGRAM_CHAT_ID")
|
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")
|
os.getenv("POLYMARKET_WALLET_ACTIVITY_USER_ALIASES")
|
||||||
or os.getenv("POLYMARKET_WALLET_ACTIVITY_USERS_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:
|
if not enabled:
|
||||||
logger.info("polymarket wallet activity watcher disabled")
|
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),
|
_env_int("POLYMARKET_WALLET_ACTIVITY_IMMEDIATE_COOLDOWN_SEC", 20),
|
||||||
)
|
)
|
||||||
max_changes = max(1, _env_int("POLYMARKET_WALLET_ACTIVITY_MAX_CHANGES_PER_MSG", 5))
|
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)
|
notify_closed = _env_bool("POLYMARKET_WALLET_ACTIVITY_NOTIFY_CLOSED", False)
|
||||||
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
|
bootstrap_alert = _env_bool("POLYMARKET_WALLET_ACTIVITY_BOOTSTRAP_ALERT", False)
|
||||||
link_preview = _env_bool("POLYMARKET_WALLET_ACTIVITY_LINK_PREVIEW", True)
|
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(
|
logger.info(
|
||||||
f"polymarket wallet activity watcher started users={len(users)} "
|
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"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"aliases={len(user_aliases)} link_preview={link_preview} "
|
||||||
f"min_avg_price_delta={min_avg_price_delta} "
|
f"min_avg_price_delta={min_avg_price_delta} "
|
||||||
f"immediate_on_size_delta={immediate_on_size_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:
|
if outgoing:
|
||||||
msg = _build_message(
|
msg = _build_message(
|
||||||
user,
|
user,
|
||||||
|
|||||||
Reference in New Issue
Block a user