Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

rocketbot.py 27KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764
  1. """
  2. Rocketbot Discord bot. Relies on a configured config.py (copy config.py.sample for a template) and
  3. the sqlite database rocketbot.db (copy rocketbot.db.sample for a blank database).
  4. Author: Ian Albert (@rocketsoup)
  5. Date: 2021-11-11
  6. """
  7. from datetime import datetime
  8. import sqlite3
  9. import sys
  10. from discord import Guild, Intents, Member, Message, PartialEmoji, RawReactionActionEvent
  11. from discord.abc import GuildChannel
  12. from discord.ext import commands
  13. from discord.ext.commands.context import Context
  14. from config import CONFIG
  15. if sys.version_info.major < 3:
  16. raise Exception('Requires Python 3+')
  17. # -- Classes ----------------------------------------------------------------
  18. class RaidPhase:
  19. """
  20. Enum of phases in a JoinRaid. Phases progress monotonically.
  21. """
  22. NONE = 0
  23. JUST_STARTED = 1
  24. CONTINUING = 2
  25. ENDED = 3
  26. class JoinRaid:
  27. """
  28. Tracks recent joins to a guild to detect join raids, where a large number of automated users
  29. all join at the same time.
  30. """
  31. def __init__(self):
  32. self.joins = []
  33. self.phase = RaidPhase.NONE
  34. # datetime when the raid started, or None.
  35. self.raid_start_time = None
  36. # Message posted to Discord to warn of the raid. Convenience property managed
  37. # by caller. Ignored by this class.
  38. self.warning_message = None
  39. def handle_join(self,
  40. member: Member,
  41. now: datetime,
  42. max_age_seconds: float,
  43. max_join_count: int) -> None:
  44. """
  45. Processes a new member join to a guild and detects join raids. Updates
  46. self.phase and self.raid_start_time properties.
  47. """
  48. # Check for existing record for this user
  49. print(f'handle_join({member.name}) start')
  50. join: JoinRecord = None
  51. i: int = 0
  52. while i < len(self.joins):
  53. elem = self.joins[i]
  54. if elem.member.id == member.id:
  55. print(f'Member {member.name} already in join list at index {i}. Removing.')
  56. join = self.joins.pop(i)
  57. join.join_time = now
  58. break
  59. i += 1
  60. # Add new record to end
  61. self.joins.append(join or JoinRecord(member))
  62. # Check raid status and do upkeep
  63. self.__process_joins(now, max_age_seconds, max_join_count)
  64. print(f'handle_join({member.name}) end')
  65. def __process_joins(self,
  66. now: datetime,
  67. max_age_seconds: float,
  68. max_join_count: int) -> None:
  69. """
  70. Processes self.joins after each addition, detects raids, updates self.phase,
  71. and throws out unneeded records.
  72. """
  73. print('__process_joins {')
  74. i: int = 0
  75. recent_count: int = 0
  76. should_cull: bool = self.phase == RaidPhase.NONE
  77. while i < len(self.joins):
  78. join: JoinRecord = self.joins[i]
  79. age: float = join.age_seconds(now)
  80. is_old: bool = age > max_age_seconds
  81. if not is_old:
  82. recent_count += 1
  83. print(f'- {i}. {join.member.name} is {age}s old - recent_count={recent_count}')
  84. if is_old and should_cull:
  85. self.joins.pop(i)
  86. print(f'- {i}. {join.member.name} is {age}s old - too old, removing')
  87. else:
  88. print(f'- {i}. {join.member.name} is {age}s old - moving on to next')
  89. i += 1
  90. is_raid = recent_count > max_join_count
  91. print(f'- is_raid {is_raid}')
  92. if is_raid:
  93. if self.phase == RaidPhase.NONE:
  94. self.phase = RaidPhase.JUST_STARTED
  95. self.raid_start_time = now
  96. print('- Phase moved to JUST_STARTED. Recording raid start time.')
  97. elif self.phase == RaidPhase.JUST_STARTED:
  98. self.phase = RaidPhase.CONTINUING
  99. print('- Phase moved to CONTINUING.')
  100. elif self.phase == self.phase in (RaidPhase.JUST_STARTED, RaidPhase.CONTINUING):
  101. self.phase = RaidPhase.ENDED
  102. print('- Phase moved to ENDED.')
  103. # Undo join add if the raid is over
  104. if self.phase == RaidPhase.ENDED and len(self.joins) > 0:
  105. last = self.joins.pop(-1)
  106. print(f'- Popping last join for {last.member.name}')
  107. print('} __process_joins')
  108. async def kick_all(self,
  109. reason: str = "Part of join raid") -> list[Member]:
  110. """
  111. Kicks all users in this join raid. Skips users who have already been
  112. flagged as having been kicked or banned. Returns a List of Members
  113. who were newly kicked.
  114. """
  115. kicks = []
  116. for join in self.joins:
  117. if join.is_kicked or join.is_banned:
  118. continue
  119. await join.member.kick(reason=reason)
  120. join.is_kicked = True
  121. kicks.append(join.member)
  122. self.phase = RaidPhase.ENDED
  123. return kicks
  124. async def ban_all(self,
  125. reason: str = "Part of join raid",
  126. delete_message_days: int = 0) -> list[Member]:
  127. """
  128. Bans all users in this join raid. Skips users who have already been
  129. flagged as having been banned. Users who were previously kicked can
  130. still be banned. Returns a List of Members who were newly banned.
  131. """
  132. bans = []
  133. for join in self.joins:
  134. if join.is_banned:
  135. continue
  136. await join.member.ban(reason=reason, delete_message_days=delete_message_days)
  137. join.is_banned = True
  138. bans.append(join.member)
  139. self.phase = RaidPhase.ENDED
  140. return bans
  141. class JoinRecord:
  142. """
  143. Data object containing details about a guild join event.
  144. """
  145. def __init__(self, member: Member):
  146. self.member = member
  147. self.join_time = member.joined_at or datetime.now()
  148. self.is_kicked = False
  149. self.is_banned = False
  150. def age_seconds(self, now: datetime) -> float:
  151. """
  152. Returns the age of this join in seconds from the given "now" time.
  153. """
  154. a = now - self.join_time
  155. return float(a.total_seconds())
  156. class GuildContext:
  157. """
  158. Logic and state for a single guild serviced by the bot.
  159. """
  160. def __init__(self, guild_id: int):
  161. self.guild_id = guild_id
  162. self.guild = None # Resolved later
  163. # Config populated during load
  164. self.warning_channel_id = None
  165. self.warning_channel = None
  166. self.warning_mention = None
  167. self.join_warning_count = CONFIG['joinWarningCount']
  168. self.join_warning_seconds = CONFIG['joinWarningSeconds']
  169. # Non-persisted runtime state
  170. self.current_raid = JoinRaid()
  171. self.all_raids = [ self.current_raid ] # periodically culled of old ones
  172. # Commands
  173. async def command_hello(self, message: Message) -> None:
  174. """
  175. $hello command handler
  176. """
  177. await message.channel.send(f'Hey there, {message.author.mention}!')
  178. async def command_test_warn(self, context: Context) -> None:
  179. """
  180. $testwarn command handler
  181. """
  182. if self.warning_channel is None:
  183. self.__trace('No warning channel set!')
  184. await context.message.channel.send('No warning channel set on this guild! Type ' +
  185. '`$setwarningchannel` in the channel you want warnings to be posted.')
  186. return
  187. await self.__warn('Test warning. This is only a test.')
  188. async def command_set_warning_channel(self, context: Context):
  189. """
  190. $setwarningchannel command handler
  191. """
  192. self.__trace(f'Warning channel set to {context.channel.name}')
  193. self.warning_channel = context.channel
  194. self.warning_channel_id = context.channel.id
  195. save_guild_context(self)
  196. await self.__warn('Warning messages will now be sent to ' + self.warning_channel.mention)
  197. async def command_set_warning_mention(self, _context: Context, mention: str):
  198. """
  199. $setwarningmention command handler
  200. """
  201. self.__trace('set warning mention')
  202. m = mention if mention is not None and len(mention) > 0 else None
  203. self.warning_mention = m
  204. save_guild_context(self)
  205. if m is None:
  206. await self.__warn('Warning messages will not mention anyone')
  207. else:
  208. await self.__warn('Warning messages will now mention ' + m)
  209. async def command_set_raid_warning_rate(self, _context: Context, count: int, seconds: int):
  210. """
  211. $setraidwarningrate command handler
  212. """
  213. self.join_warning_count = count
  214. self.join_warning_seconds = seconds
  215. save_guild_context(self)
  216. await self.__warn(f'Maximum join rate set to {count} joins per {seconds} seconds')
  217. # Events
  218. async def handle_join(self, member: Member) -> None:
  219. """
  220. Event handler for all joins to this guild.
  221. """
  222. print(f'{member.guild.name}: {member.name} joined')
  223. now = member.joined_at
  224. raid = self.current_raid
  225. raid.handle_join(
  226. member,
  227. now=now,
  228. max_age_seconds = self.join_warning_seconds,
  229. max_join_count = self.join_warning_count)
  230. self.__trace(f'raid phase: {raid.phase}')
  231. if raid.phase == RaidPhase.JUST_STARTED:
  232. await self.__on_join_raid_begin(raid)
  233. elif raid.phase == RaidPhase.CONTINUING:
  234. await self.__on_join_raid_updated(raid)
  235. elif raid.phase == RaidPhase.ENDED:
  236. self.__start_new_raid(member)
  237. await self.__on_join_raid_end(raid)
  238. self.__cull_old_raids(now)
  239. def __start_new_raid(self, member: Member = None):
  240. """
  241. Retires self.current_raid and creates a new empty one. If `member` is passed, it will be
  242. added to the new self.current_raid after it is created.
  243. """
  244. self.current_raid = JoinRaid()
  245. self.all_raids.append(self.current_raid)
  246. if member is not None:
  247. self.current_raid.handle_join(
  248. member,
  249. member.joined_at,
  250. max_age_seconds = self.join_warning_seconds,
  251. max_join_count = self.join_warning_count)
  252. async def handle_reaction_add(self, message, member, emoji):
  253. """
  254. Handles all message reaction events to see if they need to be acted on.
  255. """
  256. if member.id == bot.user.id:
  257. # It's-a me, Rocketbot!
  258. return
  259. if message.author.id != bot.user.id:
  260. # The message the user is reacting to wasn't authored by me. Ignore.
  261. return
  262. self.__trace(f'User {member} added emoji {emoji}')
  263. if not member.permissions_in(message.channel).ban_members:
  264. self.__trace('Reactor does not have ban permissions. Ignoring.')
  265. return
  266. if emoji.name == CONFIG['kickEmoji']:
  267. await self.__kick_all_in_raid_message(message)
  268. elif emoji.name == CONFIG['banEmoji']:
  269. await self.__ban_all_in_raid_message(message)
  270. else:
  271. print('Unhandled emoji. Ignoring.')
  272. return
  273. async def __kick_all_in_raid_message(self, message: Message):
  274. """
  275. Kicks all the users mentioned in the given raid warning message. Users who were already
  276. kicked or banned will be skipped.
  277. """
  278. raid = self.__find_raid_for_message(message)
  279. if raid is None:
  280. await message.reply("This is either not a raid warning or it's too old and I don't " +
  281. "have a record for it anymore. Sorry!")
  282. return
  283. self.__trace('Kicking...')
  284. members = await raid.kick_all()
  285. msg = 'Kicked these members:'
  286. for member in members:
  287. msg += f'\n\t{member.name}'
  288. if len(members) == 0:
  289. msg += '\n\t-none-'
  290. self.__trace(msg)
  291. self.__start_new_raid()
  292. await self.__update_join_raid_message(raid)
  293. async def __ban_all_in_raid_message(self, message: Message):
  294. """
  295. Bans all the users mentioned in the given raid warning message. Users who were already
  296. banned will be skipped.
  297. """
  298. raid = self.__find_raid_for_message(message)
  299. if raid is None:
  300. await message.reply("This is either not a raid warning or it's too old and I don't " +
  301. "have a record for it anymore. Sorry!")
  302. return
  303. self.__trace('Banning...')
  304. members = await raid.ban_all()
  305. msg = 'Banned these members:'
  306. for member in members:
  307. msg += f'\n\t{member.name}'
  308. if len(members) == 0:
  309. msg += '\n\t-none-'
  310. self.__trace(msg)
  311. self.__start_new_raid()
  312. await self.__update_join_raid_message(raid)
  313. def __find_raid_for_message(self, message: Message) -> JoinRaid:
  314. """
  315. Retrieves a JoinRaid instance for the given raid warning message. Returns None if not found.
  316. """
  317. for raid in self.all_raids:
  318. if raid.warning_message.id == message.id:
  319. return raid
  320. return None
  321. def __cull_old_raids(self, now: datetime):
  322. """
  323. Gets rid of old JoinRaid records from self.all_raids that are too old to still be useful.
  324. """
  325. i: int = 0
  326. while i < len(self.all_raids):
  327. raid = self.all_raids[i]
  328. if raid == self.current_raid:
  329. i += 1
  330. continue
  331. age_seconds = float((raid.raid_start_time - now).total_seconds())
  332. if age_seconds > 86400.0:
  333. self.__trace('Culling old raid')
  334. self.all_raids.pop(i)
  335. else:
  336. i += 1
  337. def __join_raid_message(self, raid: JoinRaid):
  338. """
  339. Returns a 3-element tuple containing a text message appropriate for posting in
  340. Discord, a flag of whether any of the mentioned users can be kicked, and a flag
  341. of whether any of the mentioned users can be banned.
  342. """
  343. message = ''
  344. if self.warning_mention is not None:
  345. message = self.warning_mention + ' '
  346. message += '**RAID JOIN DETECTED!** It includes these users:\n'
  347. can_kick = False
  348. can_ban = False
  349. for join in raid.joins:
  350. message += '\n• '
  351. if join.is_banned:
  352. message += '~~' + join.member.mention + '~~ - banned'
  353. elif join.is_kicked:
  354. message += '~~' + join.member.mention + '~~ - kicked'
  355. can_ban = True
  356. else:
  357. message += join.member.mention
  358. can_kick = True
  359. can_ban = True
  360. message += '\n'
  361. if can_kick:
  362. message += '\nTo kick all these users, react with :' + CONFIG['kickEmojiName'] + ':'
  363. else:
  364. message += '\nNo kickable users remain'
  365. if can_ban:
  366. message += '\nTo ban all these users, react with :' + CONFIG['banEmojiName'] + ':'
  367. else:
  368. message += '\nNo bannable users remain'
  369. return (message, can_kick, can_ban)
  370. async def __update_join_raid_message(self, raid: JoinRaid):
  371. """
  372. Updates an existing join raid warning message with updated data.
  373. """
  374. if raid.warning_message is None:
  375. self.__trace('No raid warning message to update')
  376. return
  377. (message, can_kick, can_ban) = self.__join_raid_message(raid)
  378. await raid.warning_message.edit(content=message)
  379. if not can_kick:
  380. await raid.warning_message.clear_reaction(CONFIG['kickEmoji'])
  381. if not can_ban:
  382. await raid.warning_message.clear_reaction(CONFIG['banEmoji'])
  383. async def __on_join_raid_begin(self, raid):
  384. """
  385. Event triggered when the first member joins that triggers the raid detection.
  386. """
  387. self.__trace('A join raid has begun!')
  388. if self.warning_channel is None:
  389. self.__trace('NO WARNING CHANNEL SET')
  390. return
  391. (message, can_kick, can_ban) = self.__join_raid_message(raid)
  392. raid.warning_message = await self.warning_channel.send(message)
  393. if can_kick:
  394. await raid.warning_message.add_reaction(CONFIG['kickEmoji'])
  395. if can_ban:
  396. await raid.warning_message.add_reaction(CONFIG['banEmoji'])
  397. async def __on_join_raid_updated(self, raid):
  398. """
  399. Event triggered for each subsequent member join after the first one that triggered the
  400. raid detection.
  401. """
  402. self.__trace('Join raid still occurring')
  403. await self.__update_join_raid_message(raid)
  404. async def __on_join_raid_end(self, _raid):
  405. """
  406. Event triggered when the first member joins who is not part of the most recent raid.
  407. """
  408. self.__trace('Join raid has ended')
  409. async def __warn(self, message):
  410. """
  411. Posts a warning message in the configured warning channel.
  412. """
  413. if self.warning_channel is None:
  414. self.__trace('NO WARNING CHANNEL SET. Warning message not posted.\n' + message)
  415. return None
  416. m = message
  417. if self.warning_mention is not None:
  418. m = self.warning_mention + ' ' + m
  419. return await self.warning_channel.send(m)
  420. def __trace(self, message):
  421. """
  422. Debugging trace.
  423. """
  424. print(f'{self.guild.name}: {message}')
  425. # lookup for int(Guild.guild_id) --> GuildContext
  426. guild_id_to_guild_context = {}
  427. def get_or_create_guild_context(val, save=True):
  428. """
  429. Retrieves a cached GuildContext instance by its Guild id or Guild object
  430. itself. If no GuildContext record exists for the Guild, one is created
  431. and cached (and saved to the database unless `save=False`).
  432. """
  433. gid = None
  434. guild = None
  435. if val is None:
  436. return None
  437. if isinstance(val, int):
  438. gid = val
  439. elif isinstance(val, Guild):
  440. gid = val.id
  441. guild = val
  442. if gid is None:
  443. print('Unhandled datatype', type(val))
  444. return None
  445. looked_up = guild_id_to_guild_context.get(gid)
  446. if looked_up is not None:
  447. return looked_up
  448. gc = GuildContext(gid)
  449. gc.guild = guild or gc.guild
  450. guild_id_to_guild_context[gid] = gc
  451. if save:
  452. save_guild_context(gc)
  453. return gc
  454. # -- Database ---------------------------------------------------------------
  455. def run_sql_batch(batch_function):
  456. """
  457. Performs an SQL transaction. After a connection is opened, the passed
  458. function is invoked with the sqlite3.Connection and sqlite3.Cursor
  459. passed as arguments. Once the passed function finishes, the connection
  460. is closed.
  461. """
  462. db_connection: sqlite3.Connection = sqlite3.connect('rocketbot.db')
  463. db_cursor: sqlite3.Cursor = db_connection.cursor()
  464. batch_function(db_connection, db_cursor)
  465. db_connection.commit()
  466. db_connection.close()
  467. def load_guild_settings():
  468. """
  469. Populates the GuildContext cache with records from the database.
  470. """
  471. def load(_con, cur):
  472. """
  473. SQL
  474. """
  475. for row in cur.execute("""SELECT * FROM guilds"""):
  476. guild_id = row[0]
  477. gc = get_or_create_guild_context(guild_id, save=False)
  478. gc.warning_channel_id = row[1]
  479. gc.warning_mention = row[2]
  480. gc.join_warning_count = row[3] or CONFIG['joinWarningCount']
  481. gc.join_warning_seconds = row[4] or CONFIG['joinWarningSeconds']
  482. print(f'Guild {guild_id} channel id is {gc.warning_channel_id}')
  483. run_sql_batch(load)
  484. def create_tables():
  485. """
  486. Creates all database tables.
  487. """
  488. def make_tables(_con, cur):
  489. """
  490. SQL
  491. """
  492. cur.execute("""CREATE TABLE guilds (
  493. guildId INTEGER,
  494. warningChannelId INTEGER,
  495. warningMention TEXT,
  496. joinWarningCount INTEGER,
  497. joinWarningSeconds INTEGER,
  498. PRIMARY KEY(guildId ASC))""")
  499. run_sql_batch(make_tables)
  500. def save_guild_context(gc: GuildContext):
  501. """
  502. Saves the state of a GuildContext record to the database.
  503. """
  504. def save(_con, cur):
  505. """
  506. SQL
  507. """
  508. print(f'Saving guild context with id {gc.guild_id}')
  509. cur.execute("""
  510. SELECT guildId
  511. FROM guilds
  512. WHERE guildId=?
  513. """, (
  514. gc.guild_id,
  515. ))
  516. channel_id = gc.warning_channel.id if gc.warning_channel is not None \
  517. else gc.warning_channel_id
  518. exists = cur.fetchone() is not None
  519. if exists:
  520. print('Updating existing guild record in db')
  521. cur.execute("""
  522. UPDATE guilds
  523. SET warningChannelId=?,
  524. warningMention=?,
  525. joinWarningCount=?,
  526. joinWarningSeconds=?
  527. WHERE guildId=?
  528. """, (
  529. channel_id,
  530. gc.warning_mention,
  531. gc.join_warning_count,
  532. gc.join_warning_seconds,
  533. gc.guild_id,
  534. ))
  535. else:
  536. print('Creating new guild record in db')
  537. cur.execute("""
  538. INSERT INTO guilds (
  539. guildId,
  540. warningChannelId,
  541. warningMention,
  542. joinWarningCount,
  543. joinWarningSeconds)
  544. VALUES (?, ?, ?, ?, ?)
  545. """, (
  546. gc.guild_id,
  547. channel_id,
  548. gc.warning_mention,
  549. gc.join_warning_count,
  550. gc.join_warning_seconds,
  551. ))
  552. run_sql_batch(save)
  553. # -- Main (1) ---------------------------------------------------------------
  554. load_guild_settings()
  555. intents = Intents.default()
  556. intents.members = True # To get join/leave events
  557. bot = commands.Bot(command_prefix=CONFIG['commandPrefix'], intents=intents)
  558. # -- Bot commands -----------------------------------------------------------
  559. @bot.command(
  560. brief='Simply replies to the invoker with a hello message in the same channel.'
  561. )
  562. async def hello(ctx: Context):
  563. """
  564. $hello command handler
  565. """
  566. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  567. if gc is None:
  568. return
  569. message = ctx.message
  570. await gc.command_hello(message)
  571. @bot.command(
  572. brief='Posts a test warning message in the configured warning channel.',
  573. help="""If no warning channel is configured, the bot will reply in the channel the command was
  574. issued to notify no warning channel is set. If a warning mention is configured, the test
  575. warning will tag the configured person/role."""
  576. )
  577. @commands.has_permissions(manage_messages=True)
  578. async def testwarn(ctx: Context):
  579. """
  580. $testwarn command handler
  581. """
  582. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  583. if gc is None:
  584. return
  585. await gc.command_test_warn(ctx)
  586. @bot.command(
  587. brief='Sets the threshold for detecting a join raid.',
  588. usage='<count> <seconds>',
  589. help="""The raid threshold is expressed as number of joins within a given number of seconds.
  590. Each time a member joins, the number of joins in the previous _x_ seconds is counted, and if
  591. that count, _y_, equals or exceeds the count configured by this command, a raid is detected."""
  592. )
  593. @commands.has_permissions(manage_messages=True)
  594. async def setraidwarningrate(ctx: Context, count: int, seconds: int):
  595. """
  596. $setraidwarningrate command handler
  597. """
  598. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  599. if gc is None:
  600. return
  601. await gc.command_set_raid_warning_rate(ctx, count, seconds)
  602. @bot.command(
  603. brief='Sets the current channel as the destination for bot warning messages.'
  604. )
  605. @commands.has_permissions(manage_messages=True)
  606. async def setwarningchannel(ctx: Context):
  607. """
  608. $setwarningchannel command handler
  609. """
  610. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  611. if gc is None:
  612. return
  613. await gc.command_set_warning_channel(ctx)
  614. @bot.command(
  615. brief='Sets an optional mention to include in every warning message.',
  616. usage='<mention>',
  617. help="""The argument provided to this command will be included verbatim, so if the intent is
  618. to tag a user or role, the argument must be a tag, not merely the name of the user/role."""
  619. )
  620. @commands.has_permissions(manage_messages=True)
  621. async def setwarningmention(ctx: Context, mention: str):
  622. """
  623. $setwarningmention command handler
  624. """
  625. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  626. if gc is None:
  627. return
  628. await gc.command_set_warning_mention(ctx, mention)
  629. # -- Bot events -------------------------------------------------------------
  630. is_connected = False
  631. @bot.listen()
  632. async def on_connect():
  633. """
  634. Discord event handler
  635. """
  636. global is_connected
  637. print('Connected')
  638. is_connected = True
  639. if is_connected and is_ready:
  640. await populate_guilds()
  641. is_ready = False
  642. @bot.listen()
  643. async def on_ready():
  644. """
  645. Discord event handler
  646. """
  647. global is_ready
  648. print('Ready')
  649. is_ready = True
  650. if is_connected and is_ready:
  651. await populate_guilds()
  652. async def populate_guilds():
  653. """
  654. Called after both on_ready and on_connect are done. May be called more than once!
  655. """
  656. for guild in bot.guilds:
  657. gc = guild_id_to_guild_context.get(guild.id)
  658. if gc is None:
  659. print(f'No GuildContext for {guild.id}')
  660. continue
  661. gc.guild = guild
  662. if gc.warning_channel_id is not None:
  663. gc.warning_channel = guild.get_channel(gc.warning_channel_id)
  664. if gc.warning_channel is not None:
  665. print(f'Recovered warning channel {gc.warning_channel}')
  666. else:
  667. print(f'Could not find channel with id {gc.warning_channel_id} in ' +
  668. f'guild {guild.name}')
  669. for channel in await guild.fetch_channels():
  670. print(f'\t{channel.name} ({channel.id})')
  671. @bot.listen()
  672. async def on_member_join(member: Member) -> None:
  673. """
  674. Discord event handler
  675. """
  676. print(f'User {member.name} joined {member.guild.name}')
  677. gc: GuildContext = get_or_create_guild_context(member.guild)
  678. if gc is None:
  679. print(f'No GuildContext for guild {member.guild.name}')
  680. return
  681. await gc.handle_join(member)
  682. @bot.listen()
  683. async def on_member_remove(member: Member) -> None:
  684. """
  685. Discord event handler
  686. """
  687. print(f'User {member.name} left {member.guild.name}')
  688. @bot.listen()
  689. async def on_raw_reaction_add(payload: RawReactionActionEvent) -> None:
  690. """
  691. Discord event handler
  692. """
  693. guild: Guild = bot.get_guild(payload.guild_id)
  694. channel: GuildChannel = guild.get_channel(payload.channel_id)
  695. message: Message = await channel.fetch_message(payload.message_id)
  696. member: Member = payload.member
  697. emoji: PartialEmoji = payload.emoji
  698. gc: GuildContext = get_or_create_guild_context(guild)
  699. await gc.handle_reaction_add(message, member, emoji)
  700. # -- Main -------------------------------------------------------------------
  701. print('Starting bot')
  702. bot.run(CONFIG['clientToken'])
  703. print('Bot done')