-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathhambot.py
More file actions
278 lines (243 loc) · 10.3 KB
/
Copy pathhambot.py
File metadata and controls
278 lines (243 loc) · 10.3 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
import os
import time
import json
import logging
from discord.ext import commands
import discord
from reporter import BotReporter
from database.connection import init_pool, close_pool
from database.models import create_schema
from utils.logging_utils import sanitize_exception
# =======================
# Logging Setup
# =======================
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("hambot")
# =======================
# Discord Intents
# =======================
# No privileged intents: nothing here reads message content or member lists.
intents = discord.Intents(
guilds=True,
messages=True,
reactions=True,
)
# =======================
# Bot Setup
# =======================
bot = commands.Bot(
description="Hambot",
command_prefix="/", # Or consider using "/" or configurable
intents=intents,
)
# =======================
# Events
# =======================
@bot.event
async def on_ready():
logger.info(f'Username: {bot.user}')
logger.info(f'Servers: {len(bot.guilds)}')
logger.info('Ready...')
logger.info('WELCOME TO HAMBOT\n-----\n')
# Initialize database if configured
if 'database_url' in config:
try:
# Check if pool exists, if not initialize it
from database.connection import _pool
if _pool is None:
logger.info("Initializing database connection pool...")
await init_pool(config['database_url'])
logger.info("Database connection pool initialized")
# Create schema
await create_schema()
logger.info("Database schema initialized")
else:
logger.info("Database pool already initialized")
except Exception as e:
logger.error(f"Failed to initialize database in on_ready: {sanitize_exception(e)}")
else:
logger.info("Database not configured - alert features will be disabled")
# Start reporter
await reporter.start()
# Ensure healthcheck starts (fallback if cog_load wasn't called)
healthcheck_cog = bot.get_cog('HealthcheckCog')
if healthcheck_cog:
healthcheck_cog.start_heartbeat()
# Ensure metrics autosave starts (fallback if cog_load wasn't called)
metrics_cog = bot.get_cog('MetricsCog')
if metrics_cog:
await metrics_cog.start_autosave()
# Ensure spot monitoring starts after database is ready
spot_monitor_cog = bot.get_cog('SpotMonitorCog')
if spot_monitor_cog and 'database_url' in config:
# Check if task is already running
if hasattr(spot_monitor_cog, 'monitor_task') and not spot_monitor_cog.monitor_task.is_running():
try:
# Re-check database availability now that it's initialized
if spot_monitor_cog._check_database():
# Test provider connections
for source, provider in spot_monitor_cog.providers.items():
try:
connected = await provider.test_connection()
if connected:
logger.info(f"{source} provider connection test passed")
else:
logger.warning(f"{source} provider connection test failed")
except Exception as e:
logger.error(f"Error testing {source} provider connection: {e}")
# Start monitoring task
spot_monitor_cog.monitor_task.change_interval(minutes=spot_monitor_cog.poll_interval)
spot_monitor_cog.monitor_task.start()
logger.info(f"Spot monitoring task started (interval: {spot_monitor_cog.poll_interval} minutes)")
# Start message cleanup task (ensures it runs after redeploys)
if not spot_monitor_cog.message_cleanup_task.is_running():
spot_monitor_cog.message_cleanup_task.change_interval(minutes=10)
spot_monitor_cog.message_cleanup_task.start()
logger.info("Message cleanup task started (runs every 10 minutes)")
except Exception as e:
logger.error(f"Failed to start spot monitoring task: {sanitize_exception(e)}")
@bot.event
async def on_application_command(ctx):
"""Track slash command usage for reporting."""
if ctx.command and ctx.command.name != "metrics":
reporter.record_command(ctx.command.name)
@bot.event
async def on_close():
"""Cleanup when bot shuts down."""
await reporter.stop()
# Close database pool
await close_pool()
logger.info("Bot shutdown complete")
# =======================
# Load Configuration from Environment Variables
# =======================
def load_config_from_env():
"""Load configuration from environment variables with validation."""
config = {}
# Required environment variables
required_vars = {
'DISCORD_TOKEN': 'token',
'DISCORD_OWNER_ID': 'ownerId',
'DISCORD_CLIENT_ID': 'clientID',
'HAMQTH_USERNAME': 'hamqth_username',
'HAMQTH_PASSWORD': 'hamqth_password'
}
# Check for required variables
missing_vars = []
for env_var, config_key in required_vars.items():
value = os.getenv(env_var)
if not value:
missing_vars.append(env_var)
else:
config[config_key] = value
if missing_vars:
logger.error(f"Missing required environment variables: {', '.join(missing_vars)}")
raise SystemExit(1)
# Optional environment variables with defaults
config['embedcolor'] = int(os.getenv('EMBED_COLOR', '0x31a896'), 16)
config['guildId'] = os.getenv('DISCORD_GUILD_ID', '')
# HamQTH configuration
config['hamqth'] = {
'username': config['hamqth_username'],
'password': config['hamqth_password']
}
# Remove the individual keys since they're now in hamqth dict
del config['hamqth_username']
del config['hamqth_password']
# Database configuration (optional - for alert features)
database_url = os.getenv('DATABASE_URL') or os.getenv('POSTGRES_URL')
if database_url and not database_url.startswith('${{'):
config['database_url'] = database_url
else:
logger.info("DATABASE_URL not set - alert features will be disabled")
# Alert configuration (optional)
# PSKReporter recommends querying no more than once every 5 minutes
# Default to 5 minutes to avoid rate limiting (503 errors)
config['poll_interval'] = int(os.getenv('PSKREPORTER_POLL_INTERVAL', '5'))
config['expiration_days'] = int(os.getenv('ALERT_EXPIRATION_DAYS', '30'))
config['alert_cooldown_minutes'] = int(os.getenv('ALERT_COOLDOWN_MINUTES', '5'))
config['max_alerts_per_user_per_hour'] = int(os.getenv('MAX_ALERTS_PER_USER_PER_HOUR', '20'))
# Default modes
default_modes_str = os.getenv('DEFAULT_MODES_PSKREPORTER', 'FT8,FT4,PSK31,CW,RTTY')
config['default_modes_pskreporter'] = [m.strip().upper() for m in default_modes_str.split(',') if m.strip()]
# Enabled data sources
enabled_sources_str = os.getenv('ENABLED_DATA_SOURCES', 'pskreporter,dxcluster')
config['enabled_data_sources'] = [s.strip().lower() for s in enabled_sources_str.split(',') if s.strip()]
# DX Cluster configuration (optional)
# Default: dxmaps.com:7300 (known working public server)
# Note: Some servers may require registration or have access restrictions
config['dxcluster_host'] = os.getenv('DXCLUSTER_HOST', 'dxmaps.com')
config['dxcluster_port'] = int(os.getenv('DXCLUSTER_PORT', '7300'))
config['dxcluster_callsign'] = os.getenv('DXCLUSTER_CALLSIGN') # Optional
logger.info('Configuration loaded from environment variables.')
return config
try:
config = load_config_from_env()
except Exception as ex:
logger.error(f"Failed to load configuration: {ex}")
raise SystemExit(1)
# Apply config properties to bot
bot.owner_id = int(config['ownerId'])
bot.start_time = time.time()
bot.config = config
bot.version = "2.2.0" # Bot version for reporting
# Initialize reporter
reporter = BotReporter(bot)
# =======================
# Extension (Cog) Loading
# =======================
cogs = [
'modules.lookup',
'modules.dxcc',
'modules.utils.embed',
'modules.setstatus',
'modules.misc',
'modules.metrics',
'modules.healthcheck',
'modules.owner',
'modules.alerts',
'modules.spot_monitor',
'modules.expiration_service',
'modules.dxspots',
]
logger.info('Loading extensions...')
for cog in cogs:
try:
bot.load_extension(cog)
logger.info(f'Loaded {cog}')
except Exception as e:
logger.error(f'Failed to load {cog}: {str(e)}')
logger.error(f'Error type: {type(e).__name__}')
import traceback
logger.error(f'Traceback: {traceback.format_exc()}')
logger.info('All extensions attempted.')
# =======================
# Run Bot
# =======================
logger.info('Starting bot...')
try:
bot.run(config['token'])
except discord.LoginFailure as ex:
logger.critical(f"Failed to authenticate: {ex}")
raise SystemExit("Error: Failed to authenticate with Discord")
except discord.ConnectionClosed as ex:
logger.critical(f"Discord gateway connection closed: [Code {ex.code}] {ex.reason}")
raise SystemExit(f"Error: Discord gateway connection closed: [Code {ex.code}] {ex.reason}")
except ConnectionResetError as ex:
logger.critical(f"ConnectionResetError: {ex}")
raise SystemExit(f"ConnectionResetError: {ex}")
except Exception as ex:
logger.critical(f"Unexpected error: {ex}")
raise SystemExit(f"Critical unexpected error: {ex}")
finally:
# Cleanup on exit - but only if we're in an event loop
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Schedule cleanup
asyncio.create_task(close_pool())
else:
asyncio.run(close_pool())
except:
pass