Repository navigation
Expand file tree
/
Copy pathbot.py
More file actions
276 lines (229 loc) · 9.51 KB
/
Copy pathbot.py
File metadata and controls
276 lines (229 loc) · 9.51 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
"""
CodeClaimer — private, fair code drops for Discord communities.
Entry point. Wires up logging, the database, slash commands, the component
router, and the background loops. Feature code lives in:
db.py storage, migrations, boot backup, atomic claim
parsing.py input cleaning, link blocking, expirations, bulk format
challenges.py claim challenges
cards.py what members see (cards, reveal, DM receipt)
claims.py posting drops and the claim flow
raffle.py raffle drops, entries and the draw
refresh.py one time redraw of pre v2 cards
resurface.py roundups, last call, raffle reminders, 60 day cleanup
welcome.py welcome card and test drop for newly added servers
botlists.py server counts for Top.gg and the other bot lists
commands.py slash commands and panels
"""
import asyncio
import logging
import os
import sys
import aiohttp
import discord
from discord.ext import commands, tasks
import botlists
import cards
import claims
import db
import raffle
import refresh
import resurface
import welcome
from commands import ALL_COMMANDS
from parsing import is_expired
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
stream=sys.stdout,
)
logging.getLogger("discord").setLevel(logging.WARNING)
log = logging.getLogger("codeclaimer")
BOT_TOKEN = os.getenv("DISCORD_TOKEN")
if not BOT_TOKEN:
raise RuntimeError("Missing DISCORD_TOKEN environment variable.")
class CodeBot(commands.Bot):
def __init__(self):
super().__init__(command_prefix=commands.when_mentioned, intents=discord.Intents.default(),
help_command=None)
self.http_session: aiohttp.ClientSession | None = None
async def setup_hook(self):
db.backup_db()
db.init_db()
raffle.init_tables()
raffle.recover_stuck()
refresh.init_table()
resurface.init_tables()
welcome.init_table()
claims.bind(self)
self.http_session = aiohttp.ClientSession()
for cmd in ALL_COMMANDS:
self.tree.add_command(cmd)
try:
synced = await self.tree.sync()
log.info("Slash commands synced: %d", len(synced))
except discord.HTTPException as e:
log.warning("Command sync failed: %s", e)
async def close(self):
if self.http_session:
await self.http_session.close()
await super().close()
bot = CodeBot()
# ---------------------------------------------------------------------------
# Component router
# ---------------------------------------------------------------------------
# Claim and reveal buttons are routed here by custom_id instead of registered
# persistent views. One handler then serves every card ever posted, whatever
# its layout, and survives restarts.
ROUTES = {
cards.CLAIM_CUSTOM_ID: claims.handle_claim_click,
cards.MYCODE_CUSTOM_ID: claims.handle_show_my_code,
raffle.ENTER_CUSTOM_ID: raffle.handle_enter,
raffle.HOST_CUSTOM_ID: raffle.handle_host_leftovers,
resurface.PICK_ID: resurface.handle_pick,
}
# custom_id prefix -> handler(interaction, suffix). The suffix carries an id.
PREFIX_ROUTES = [
("cc:raffle:enter:", lambda i, x: raffle.handle_enter(i, int(x))),
(resurface.CLAIMREF_PREFIX, resurface.handle_claim_ref),
(resurface.BROWSE_PREFIX, resurface.handle_browse),
(resurface.APPROVE_PREFIX, lambda i, x: resurface.handle_approval(i, x, True)),
(resurface.SKIP_PREFIX, lambda i, x: resurface.handle_approval(i, x, False)),
]
ROUTES.update({
welcome.TEST_ID: welcome.handle_test,
welcome.SETTINGS_ID: welcome.handle_settings,
welcome.HELP_ID: welcome.handle_help,
})
@bot.event
async def on_interaction(interaction: discord.Interaction):
if interaction.type is not discord.InteractionType.component:
return
custom_id = (interaction.data or {}).get("custom_id", "")
handler = ROUTES.get(custom_id)
if handler is None:
for prefix, fn in PREFIX_ROUTES:
if custom_id.startswith(prefix):
suffix = custom_id[len(prefix):]
handler = lambda i, fn=fn, suffix=suffix: fn(i, suffix) # noqa: E731
break
if handler is None:
return
try:
await handler(interaction)
except Exception:
log.exception("Component failed custom_id=%s guild=%s user=%s",
custom_id, interaction.guild_id, interaction.user.id)
try:
text = "Something went wrong. Try again in a moment."
if interaction.response.is_done():
await interaction.followup.send(text, ephemeral=True)
else:
await interaction.response.send_message(text, ephemeral=True)
except discord.HTTPException:
pass
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: Exception):
log.error("Command /%s failed guild=%s: %r",
interaction.command.name if interaction.command else "?", interaction.guild_id, error,
exc_info=error)
try:
text = "Something went wrong. Try again in a moment."
if interaction.response.is_done():
await interaction.followup.send(text, ephemeral=True)
else:
await interaction.response.send_message(text, ephemeral=True)
except discord.HTTPException:
pass
# ---------------------------------------------------------------------------
# Background loops
# ---------------------------------------------------------------------------
@tasks.loop(minutes=5)
async def expire_unclaimed_codes():
try:
claims.prune_memory()
expired = [r for r in db.codes_with_expiry() if is_expired(r["expires_at"])]
if expired:
log.info("Expiring %d code(s)", len(expired))
for row in expired:
await claims.mark_expired(row)
await asyncio.sleep(0.4)
except Exception:
log.exception("Expiration loop failed")
@tasks.loop(minutes=30)
async def post_topgg_stats():
"""Server count to Top.gg and every other bot list with a token set."""
await botlists.post_all(bot.http_session, bot.user.id, len(bot.guilds))
@tasks.loop(minutes=30)
async def update_presence():
"""Status next to the avatar and on the profile: "42 servers · 1,134 codes dropped" (HotDrop style)."""
try:
servers, dropped = len(bot.guilds), db.codes_dropped_total()
text = f"{servers:,} servers · {dropped:,} codes dropped"
if text != getattr(bot, "_presence_text", None):
await bot.change_presence(activity=discord.CustomActivity(name=text))
bot._presence_text = text
log.info("Presence updated: %s", text)
except Exception:
log.exception("Presence update failed")
@tasks.loop(hours=24)
async def daily_stats():
try:
s = db.usage_stats()
log.info("Stats guilds=%d %s", len(bot.guilds), " ".join(f"{k}={v}" for k, v in s.items()))
for r in db.live_code_breakdown():
g = bot.get_guild(r["guild_id"] or 0)
log.info("Live codes guild=%s name=%r members=%s codes=%d channels=%d with_expiry=%d oldest=%s newest=%s",
r["guild_id"], g.name if g else "(left)", g.member_count if g else "-", r["n"],
r["channels"], r["with_expiry"], (r["oldest"] or "")[:10], (r["newest"] or "")[:10])
except Exception:
log.exception("Stats failed")
for loop in (expire_unclaimed_codes, post_topgg_stats, daily_stats, update_presence):
@loop.before_loop
async def _wait_ready():
await bot.wait_until_ready()
# ---------------------------------------------------------------------------
# Events
# ---------------------------------------------------------------------------
@bot.event
async def on_ready():
db.seed_guild_settings([g.id for g in bot.guilds])
welcome.mark_existing([g.id for g in bot.guilds])
members = sum(g.member_count or 0 for g in bot.guilds)
log.info("Logged in as %s (%d) guilds=%d members=%d db=%s",
bot.user, bot.user.id, len(bot.guilds), members, db.DB_PATH)
for loop in (expire_unclaimed_codes, post_topgg_stats, daily_stats, update_presence):
if not loop.is_running():
loop.start()
raffle.start(bot)
resurface.start(bot)
asyncio.create_task(refresh.run(bot))
@bot.event
async def on_guild_join(guild: discord.Guild):
db.seed_guild_settings([guild.id])
log.info("Joined guild=%d name=%r members=%s total_guilds=%d",
guild.id, guild.name, guild.member_count, len(bot.guilds))
await welcome.on_guild_join(guild)
await update_presence()
@bot.event
async def on_guild_remove(guild: discord.Guild):
log.info("Left guild=%d name=%r members=%s live_codes=%d total_guilds=%d",
guild.id, guild.name, guild.member_count, db.live_codes_in_guild(guild.id), len(bot.guilds))
await update_presence()
@bot.event
async def on_message(message: discord.Message):
resurface.on_message(message)
@bot.event
async def on_raw_message_delete(payload: discord.RawMessageDeleteEvent):
await claims.on_card_deleted(payload.message_id)
@bot.event
async def on_raw_bulk_message_delete(payload: discord.RawBulkMessageDeleteEvent):
for message_id in payload.message_ids:
await claims.on_card_deleted(message_id)
async def main():
async with bot:
await bot.start(BOT_TOKEN)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
log.info("Bot stopped.")