-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbot_handler.py
More file actions
435 lines (352 loc) · 15.9 KB
/
bot_handler.py
File metadata and controls
435 lines (352 loc) · 15.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
"""
AddressTrackerBot v3 - Telegram bot command handlers.
Multi-chain support, SQLite persistence, inline keyboards.
"""
import re
from decimal import Decimal, InvalidOperation
import telebot
from telebot import types
from config import CHAINS, RATE_LIMIT_COMMANDS, RATE_LIMIT_WINDOW, HISTORY_PAGE_SIZE
from database import (
add_user, remove_user, get_user, is_user_authorized, is_user_admin,
get_all_users, add_address, remove_address, get_address,
get_user_addresses, get_transactions, get_known_tokens,
set_threshold, check_rate_limit,
)
from utils import logger
from web3_handler import (
ChainManager, is_valid_address, get_native_balance,
get_block_number, format_transaction, get_token_balance,
get_token_info, get_price_usd,
)
def config_bot(token: str) -> telebot.TeleBot:
if not token:
raise ValueError("TELEGRAM_BOT_TOKEN is not set")
return telebot.TeleBot(token)
def register_handlers(bot: telebot.TeleBot, chain_manager: ChainManager):
def _check_auth(message) -> bool:
"""Check if user is authorized, send denial if not."""
if not is_user_authorized(message.chat.id):
bot.reply_to(message, "Unauthorized. Ask an admin to add you with /adduser.")
return False
return True
def _check_rate(message) -> bool:
"""Check rate limit."""
if not check_rate_limit(message.chat.id, RATE_LIMIT_COMMANDS, RATE_LIMIT_WINDOW):
bot.reply_to(message, "Rate limit exceeded. Please wait a moment.")
return False
return True
def _chain_keyboard(callback_prefix: str) -> types.InlineKeyboardMarkup:
"""Build inline keyboard with available chains."""
markup = types.InlineKeyboardMarkup(row_width=2)
connected = chain_manager.get_connected_chains()
buttons = []
for cid in sorted(connected):
info = CHAINS.get(cid, {})
label = f"{info.get('icon', '')} {info.get('name', f'Chain {cid}')}".strip()
buttons.append(types.InlineKeyboardButton(label, callback_data=f"{callback_prefix}|{cid}"))
markup.add(*buttons)
return markup
# ---- /start, /help ----
@bot.message_handler(commands=['start', 'help'])
def handle_start(message):
if not _check_auth(message):
return
is_admin = is_user_admin(message.chat.id)
admin_cmds = (
"\n\nAdmin commands:\n"
"/adduser <chat_id> <username> - Authorize a user\n"
"/rmuser <chat_id> - Remove a user\n"
"/users - List all users"
) if is_admin else ""
bot.send_message(message.chat.id, (
"AddressTrackerBot v3 - Multi-chain monitor\n\n"
"Commands:\n"
"/add - Add an address to monitor\n"
"/remove - Remove a monitored address\n"
"/list - List all monitored addresses\n"
"/details <name> - Show address details and balance\n"
"/history <name> - Show recent transactions\n"
"/threshold <name> <ETH> - Set minimum alert value\n"
"/chains - Show supported chains\n"
"/help - Show this message"
f"{admin_cmds}"
))
# ---- /chains ----
@bot.message_handler(commands=['chains'])
def handle_chains(message):
if not _check_auth(message) or not _check_rate(message):
return
connected = chain_manager.get_connected_chains()
lines = []
for cid in sorted(connected):
info = CHAINS.get(cid, {})
icon = info.get('icon', '')
name = info.get('name', f'Chain {cid}')
symbol = info.get('native_symbol', '?')
lines.append(f"{icon} {name} (chain {cid}) - {symbol}")
bot.send_message(message.chat.id, "Supported chains:\n\n" + "\n".join(lines))
# ---- /add (multi-step with chain selection) ----
@bot.message_handler(commands=['add', 'addAddress'])
def handle_add(message):
if not _check_auth(message) or not _check_rate(message):
return
markup = _chain_keyboard('addchain')
bot.send_message(message.chat.id, "Select the chain:", reply_markup=markup)
@bot.callback_query_handler(func=lambda call: call.data.startswith('addchain|'))
def handle_add_chain_selected(call):
try:
chain_id = int(call.data.split('|', 1)[1])
except (IndexError, ValueError):
bot.answer_callback_query(call.id, text="Invalid selection.")
return
chain_info = CHAINS.get(chain_id, {})
bot.answer_callback_query(call.id)
bot.send_message(
call.message.chat.id,
f"Chain: {chain_info.get('name', chain_id)}\nEnter the address to monitor:"
)
bot.register_next_step_handler(call.message, step_add_address, chain_id)
def step_add_address(message, chain_id):
chat_id = message.chat.id
address = message.text.strip()
if not is_valid_address(address):
bot.send_message(chat_id, "Invalid address. Must be a valid EVM address.")
return
bot.send_message(chat_id, "Enter a name for this address (no spaces, use _ instead):")
bot.register_next_step_handler(message, step_add_name, chain_id, address)
def step_add_name(message, chain_id, address):
chat_id = message.chat.id
name = message.text.strip()
if not name or len(name) > 32 or not re.match(r'^[a-zA-Z0-9_]+$', name):
bot.send_message(chat_id, "Invalid name. Use letters, numbers, and underscores only (max 32 chars).")
return
current_block = get_block_number(chain_manager, chain_id)
if current_block is None:
bot.send_message(chat_id, "Failed to connect to chain. Try again later.")
return
if add_address(chat_id, chain_id, address, name, current_block):
chain_name = CHAINS.get(chain_id, {}).get('name', f'Chain {chain_id}')
bot.send_message(
chat_id,
f"Added '{name}' on {chain_name}:\n{address}\nMonitoring from block {current_block}."
)
logger.info(f"User {chat_id} added address '{name}' on chain {chain_id}")
else:
bot.send_message(chat_id, f"Name '{name}' already exists. Choose a different name.")
# ---- /remove ----
@bot.message_handler(commands=['remove', 'rmAddress'])
def handle_remove(message):
if not _check_auth(message) or not _check_rate(message):
return
chat_id = message.chat.id
addresses = get_user_addresses(chat_id)
if not addresses:
bot.send_message(chat_id, "No addresses to remove.")
return
markup = types.InlineKeyboardMarkup()
for addr in addresses:
icon = addr.get('icon', '')
label = f"{icon} {addr['name']} ({addr['chain_name']})".strip()
markup.add(types.InlineKeyboardButton(label, callback_data=f"rm|{addr['name']}"))
bot.send_message(chat_id, "Select address to remove:", reply_markup=markup)
@bot.callback_query_handler(func=lambda call: call.data.startswith('rm|'))
def handle_removal_callback(call):
chat_id = call.message.chat.id
name = call.data.split('|', 1)[1]
if remove_address(chat_id, name):
bot.send_message(chat_id, f"Removed '{name}' and its transaction history.")
logger.info(f"User {chat_id} removed address '{name}'")
else:
bot.send_message(chat_id, f"Address '{name}' not found.")
bot.answer_callback_query(call.id)
# ---- /list ----
@bot.message_handler(commands=['list', 'show'])
def handle_list(message):
if not _check_auth(message) or not _check_rate(message):
return
chat_id = message.chat.id
addresses = get_user_addresses(chat_id)
if not addresses:
bot.send_message(chat_id, "No addresses being monitored.\nUse /add to start.")
return
lines = []
current_chain = None
for addr in addresses:
if addr['chain_name'] != current_chain:
current_chain = addr['chain_name']
icon = addr.get('icon', '')
lines.append(f"\n{icon} {current_chain}:")
lines.append(f" {addr['name']}: {addr['address'][:10]}...{addr['address'][-6:]}")
bot.send_message(chat_id, "Monitored addresses:" + "\n".join(lines))
# ---- /details ----
@bot.message_handler(commands=['details'])
def handle_details(message):
if not _check_auth(message) or not _check_rate(message):
return
chat_id = message.chat.id
parts = message.text.strip().split(maxsplit=1)
if len(parts) < 2:
bot.send_message(chat_id, "Usage: /details <name>")
return
name = parts[1].strip()
addr = get_address(chat_id, name)
if not addr:
bot.send_message(chat_id, f"Address '{name}' not found.")
return
chain_id = addr['chain_id']
address = addr['address']
balance = get_native_balance(chain_manager, chain_id, address)
symbol = addr['native_symbol']
balance_str = f"{balance:.6f} {symbol}" if balance is not None else "Error fetching balance"
# USD value for native balance
usd_price = get_price_usd(chain_id)
usd_str = ""
if balance is not None and usd_price is not None:
usd_str = f" (${balance * usd_price:,.2f})"
explorer = addr['explorer_url']
lines = [
f"{addr.get('icon', '')} {addr['chain_name']} - {name}",
f"Address: {address}",
f"Balance: {balance_str}{usd_str}",
f"Last Block: {addr['last_seen_block']}",
]
# Show known ERC-20 token balances
known_tokens = get_known_tokens(addr['id'])
if known_tokens:
lines.append("\nToken balances:")
for tok in known_tokens:
tok_balance = get_token_balance(
chain_manager, chain_id, tok['token_address'], address
)
if tok_balance is not None and tok_balance > 0:
lines.append(f" {tok['token_symbol']}: {tok_balance:.6f}")
lines.append(f"\nExplorer: {explorer}/address/{address}")
bot.send_message(chat_id, "\n".join(lines))
# ---- /history ----
@bot.message_handler(commands=['history'])
def handle_history(message):
if not _check_auth(message) or not _check_rate(message):
return
chat_id = message.chat.id
parts = message.text.strip().split(maxsplit=1)
if len(parts) < 2:
bot.send_message(chat_id, "Usage: /history <name>")
return
name = parts[1].strip()
addr = get_address(chat_id, name)
if not addr:
bot.send_message(chat_id, f"Address '{name}' not found.")
return
txs, total = get_transactions(addr['id'], limit=HISTORY_PAGE_SIZE, offset=0)
if not txs:
bot.send_message(chat_id, f"No transactions recorded for '{name}'.")
return
lines = []
for tx in txs:
lines.append(format_transaction(
chain_id=tx['chain_id'], tx_hash=tx['tx_hash'],
from_addr=tx['from_addr'], to_addr=tx['to_addr'],
value_wei=tx['value_wei'], name=name, address=addr['address'],
tx_type=tx['tx_type'], token_symbol=tx.get('token_symbol'),
token_decimals=tx.get('token_decimals'),
timestamp=tx.get('timestamp'), func_name=tx.get('func_name'),
))
footer = f"\nShowing {len(txs)} of {total} transactions." if total > HISTORY_PAGE_SIZE else ""
full_msg = f"Transaction history for '{name}':\n\n" + "\n\n---\n\n".join(lines) + footer
# Telegram message limit is 4096 chars; split if needed
if len(full_msg) <= 4096:
bot.send_message(chat_id, full_msg)
else:
# Send in chunks that respect the limit
chunk = f"Transaction history for '{name}':\n\n"
for line in lines:
entry = line + "\n\n---\n\n"
if len(chunk) + len(entry) > 4000:
bot.send_message(chat_id, chunk.rstrip("\n-"))
chunk = ""
chunk += entry
if chunk.strip():
bot.send_message(chat_id, chunk.rstrip("\n-") + footer)
# ---- /threshold ----
@bot.message_handler(commands=['threshold'])
def handle_threshold(message):
if not _check_auth(message) or not _check_rate(message):
return
chat_id = message.chat.id
parts = message.text.strip().split()
if len(parts) < 3:
bot.send_message(chat_id, "Usage: /threshold <name> <ETH_amount>\nExample: /threshold my_wallet 0.1")
return
name = parts[1]
try:
eth_value = Decimal(parts[2])
if eth_value < 0:
raise InvalidOperation
threshold_wei = str(int(eth_value * 10**18))
except (InvalidOperation, ValueError):
bot.send_message(chat_id, "Invalid amount. Use a non-negative number like 0.1")
return
if set_threshold(chat_id, name, threshold_wei):
bot.send_message(chat_id, f"Threshold for '{name}' set to {eth_value} ETH.\nOnly transactions above this value will trigger alerts.")
else:
bot.send_message(chat_id, f"Address '{name}' not found.")
# ---- Admin: /adduser ----
@bot.message_handler(commands=['adduser'])
def handle_adduser(message):
if not is_user_admin(message.chat.id):
bot.reply_to(message, "Admin only.")
return
parts = message.text.strip().split()
if len(parts) < 3:
bot.send_message(message.chat.id, "Usage: /adduser <chat_id> <username>")
return
try:
new_chat_id = int(parts[1])
except ValueError:
bot.send_message(message.chat.id, "Invalid chat_id.")
return
username = parts[2]
if add_user(new_chat_id, username):
bot.send_message(message.chat.id, f"User '{username}' (chat_id={new_chat_id}) authorized.")
logger.info(f"Admin {message.chat.id} added user {new_chat_id}")
else:
bot.send_message(message.chat.id, "Failed to add user.")
# ---- Admin: /rmuser ----
@bot.message_handler(commands=['rmuser'])
def handle_rmuser(message):
if not is_user_admin(message.chat.id):
bot.reply_to(message, "Admin only.")
return
parts = message.text.strip().split()
if len(parts) < 2:
bot.send_message(message.chat.id, "Usage: /rmuser <chat_id>")
return
try:
target_id = int(parts[1])
except ValueError:
bot.send_message(message.chat.id, "Invalid chat_id.")
return
if target_id == message.chat.id:
bot.send_message(message.chat.id, "Cannot remove yourself.")
return
if remove_user(target_id):
bot.send_message(message.chat.id, f"User {target_id} removed (including all their data).")
logger.info(f"Admin {message.chat.id} removed user {target_id}")
else:
bot.send_message(message.chat.id, "Failed to remove user or user not found.")
# ---- Admin: /users ----
@bot.message_handler(commands=['users'])
def handle_users(message):
if not is_user_admin(message.chat.id):
bot.reply_to(message, "Admin only.")
return
users = get_all_users()
if not users:
bot.send_message(message.chat.id, "No users.")
return
lines = []
for u in users:
role = "admin" if u['is_admin'] else "user"
lines.append(f"{u['username']} (chat_id={u['chat_id']}) [{role}]")
bot.send_message(message.chat.id, "Authorized users:\n\n" + "\n".join(lines))