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

rocketbot.py 27KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765
  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. Command handler
  176. """
  177. await message.channel.send(f'Hey there, {message.author.mention}!')
  178. async def command_testwarn(self, context: Context) -> None:
  179. """
  180. 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. f'`{bot.command_prefix}{setwarningchannel.__name__}` in the channel you ' +
  186. 'want warnings to be posted.')
  187. return
  188. await self.__warn('Test warning. This is only a test.')
  189. async def command_setwarningchannel(self, context: Context):
  190. """
  191. Command handler
  192. """
  193. self.__trace(f'Warning channel set to {context.channel.name}')
  194. self.warning_channel = context.channel
  195. self.warning_channel_id = context.channel.id
  196. save_guild_context(self)
  197. await self.__warn('Warning messages will now be sent to ' + self.warning_channel.mention)
  198. async def command_setwarningmention(self, _context: Context, mention: str):
  199. """
  200. Command handler
  201. """
  202. self.__trace('set warning mention')
  203. m = mention if mention is not None and len(mention) > 0 else None
  204. self.warning_mention = m
  205. save_guild_context(self)
  206. if m is None:
  207. await self.__warn('Warning messages will not mention anyone')
  208. else:
  209. await self.__warn('Warning messages will now mention ' + m)
  210. async def command_setraidwarningrate(self, _context: Context, count: int, seconds: int):
  211. """
  212. Command handler
  213. """
  214. self.join_warning_count = count
  215. self.join_warning_seconds = seconds
  216. save_guild_context(self)
  217. await self.__warn(f'Maximum join rate set to {count} joins per {seconds} seconds')
  218. # Events
  219. async def handle_join(self, member: Member) -> None:
  220. """
  221. Event handler for all joins to this guild.
  222. """
  223. print(f'{member.guild.name}: {member.name} joined')
  224. now = member.joined_at
  225. raid = self.current_raid
  226. raid.handle_join(
  227. member,
  228. now=now,
  229. max_age_seconds = self.join_warning_seconds,
  230. max_join_count = self.join_warning_count)
  231. self.__trace(f'raid phase: {raid.phase}')
  232. if raid.phase == RaidPhase.JUST_STARTED:
  233. await self.__on_join_raid_begin(raid)
  234. elif raid.phase == RaidPhase.CONTINUING:
  235. await self.__on_join_raid_updated(raid)
  236. elif raid.phase == RaidPhase.ENDED:
  237. self.__start_new_raid(member)
  238. await self.__on_join_raid_end(raid)
  239. self.__cull_old_raids(now)
  240. def __start_new_raid(self, member: Member = None):
  241. """
  242. Retires self.current_raid and creates a new empty one. If `member` is passed, it will be
  243. added to the new self.current_raid after it is created.
  244. """
  245. self.current_raid = JoinRaid()
  246. self.all_raids.append(self.current_raid)
  247. if member is not None:
  248. self.current_raid.handle_join(
  249. member,
  250. member.joined_at,
  251. max_age_seconds = self.join_warning_seconds,
  252. max_join_count = self.join_warning_count)
  253. async def handle_reaction_add(self, message, member, emoji):
  254. """
  255. Handles all message reaction events to see if they need to be acted on.
  256. """
  257. if member.id == bot.user.id:
  258. # It's-a me, Rocketbot!
  259. return
  260. if message.author.id != bot.user.id:
  261. # The message the user is reacting to wasn't authored by me. Ignore.
  262. return
  263. self.__trace(f'User {member} added emoji {emoji}')
  264. if not member.permissions_in(message.channel).ban_members:
  265. self.__trace('Reactor does not have ban permissions. Ignoring.')
  266. return
  267. if emoji.name == CONFIG['kickEmoji']:
  268. await self.__kick_all_in_raid_message(message)
  269. elif emoji.name == CONFIG['banEmoji']:
  270. await self.__ban_all_in_raid_message(message)
  271. else:
  272. print('Unhandled emoji. Ignoring.')
  273. return
  274. async def __kick_all_in_raid_message(self, message: Message):
  275. """
  276. Kicks all the users mentioned in the given raid warning message. Users who were already
  277. kicked or banned will be skipped.
  278. """
  279. raid = self.__find_raid_for_message(message)
  280. if raid is None:
  281. await message.reply("This is either not a raid warning or it's too old and I don't " +
  282. "have a record for it anymore. Sorry!")
  283. return
  284. self.__trace('Kicking...')
  285. members = await raid.kick_all()
  286. msg = 'Kicked these members:'
  287. for member in members:
  288. msg += f'\n\t{member.name}'
  289. if len(members) == 0:
  290. msg += '\n\t-none-'
  291. self.__trace(msg)
  292. self.__start_new_raid()
  293. await self.__update_join_raid_message(raid)
  294. async def __ban_all_in_raid_message(self, message: Message):
  295. """
  296. Bans all the users mentioned in the given raid warning message. Users who were already
  297. banned will be skipped.
  298. """
  299. raid = self.__find_raid_for_message(message)
  300. if raid is None:
  301. await message.reply("This is either not a raid warning or it's too old and I don't " +
  302. "have a record for it anymore. Sorry!")
  303. return
  304. self.__trace('Banning...')
  305. members = await raid.ban_all()
  306. msg = 'Banned these members:'
  307. for member in members:
  308. msg += f'\n\t{member.name}'
  309. if len(members) == 0:
  310. msg += '\n\t-none-'
  311. self.__trace(msg)
  312. self.__start_new_raid()
  313. await self.__update_join_raid_message(raid)
  314. def __find_raid_for_message(self, message: Message) -> JoinRaid:
  315. """
  316. Retrieves a JoinRaid instance for the given raid warning message. Returns None if not found.
  317. """
  318. for raid in self.all_raids:
  319. if raid.warning_message.id == message.id:
  320. return raid
  321. return None
  322. def __cull_old_raids(self, now: datetime):
  323. """
  324. Gets rid of old JoinRaid records from self.all_raids that are too old to still be useful.
  325. """
  326. i: int = 0
  327. while i < len(self.all_raids):
  328. raid = self.all_raids[i]
  329. if raid == self.current_raid:
  330. i += 1
  331. continue
  332. age_seconds = float((raid.raid_start_time - now).total_seconds())
  333. if age_seconds > 86400.0:
  334. self.__trace('Culling old raid')
  335. self.all_raids.pop(i)
  336. else:
  337. i += 1
  338. def __join_raid_message(self, raid: JoinRaid):
  339. """
  340. Returns a 3-element tuple containing a text message appropriate for posting in
  341. Discord, a flag of whether any of the mentioned users can be kicked, and a flag
  342. of whether any of the mentioned users can be banned.
  343. """
  344. message = ''
  345. if self.warning_mention is not None:
  346. message = self.warning_mention + ' '
  347. message += '**RAID JOIN DETECTED!** It includes these users:\n'
  348. can_kick = False
  349. can_ban = False
  350. for join in raid.joins:
  351. message += '\n• '
  352. if join.is_banned:
  353. message += '~~' + join.member.mention + '~~ - banned'
  354. elif join.is_kicked:
  355. message += '~~' + join.member.mention + '~~ - kicked'
  356. can_ban = True
  357. else:
  358. message += join.member.mention
  359. can_kick = True
  360. can_ban = True
  361. message += '\n'
  362. if can_kick:
  363. message += '\nTo kick all these users, react with :' + CONFIG['kickEmojiName'] + ':'
  364. else:
  365. message += '\nNo kickable users remain'
  366. if can_ban:
  367. message += '\nTo ban all these users, react with :' + CONFIG['banEmojiName'] + ':'
  368. else:
  369. message += '\nNo bannable users remain'
  370. return (message, can_kick, can_ban)
  371. async def __update_join_raid_message(self, raid: JoinRaid):
  372. """
  373. Updates an existing join raid warning message with updated data.
  374. """
  375. if raid.warning_message is None:
  376. self.__trace('No raid warning message to update')
  377. return
  378. (message, can_kick, can_ban) = self.__join_raid_message(raid)
  379. await raid.warning_message.edit(content=message)
  380. if not can_kick:
  381. await raid.warning_message.clear_reaction(CONFIG['kickEmoji'])
  382. if not can_ban:
  383. await raid.warning_message.clear_reaction(CONFIG['banEmoji'])
  384. async def __on_join_raid_begin(self, raid):
  385. """
  386. Event triggered when the first member joins that triggers the raid detection.
  387. """
  388. self.__trace('A join raid has begun!')
  389. if self.warning_channel is None:
  390. self.__trace('NO WARNING CHANNEL SET')
  391. return
  392. (message, can_kick, can_ban) = self.__join_raid_message(raid)
  393. raid.warning_message = await self.warning_channel.send(message)
  394. if can_kick:
  395. await raid.warning_message.add_reaction(CONFIG['kickEmoji'])
  396. if can_ban:
  397. await raid.warning_message.add_reaction(CONFIG['banEmoji'])
  398. async def __on_join_raid_updated(self, raid):
  399. """
  400. Event triggered for each subsequent member join after the first one that triggered the
  401. raid detection.
  402. """
  403. self.__trace('Join raid still occurring')
  404. await self.__update_join_raid_message(raid)
  405. async def __on_join_raid_end(self, _raid):
  406. """
  407. Event triggered when the first member joins who is not part of the most recent raid.
  408. """
  409. self.__trace('Join raid has ended')
  410. async def __warn(self, message):
  411. """
  412. Posts a warning message in the configured warning channel.
  413. """
  414. if self.warning_channel is None:
  415. self.__trace('NO WARNING CHANNEL SET. Warning message not posted.\n' + message)
  416. return None
  417. m = message
  418. if self.warning_mention is not None:
  419. m = self.warning_mention + ' ' + m
  420. return await self.warning_channel.send(m)
  421. def __trace(self, message):
  422. """
  423. Debugging trace.
  424. """
  425. print(f'{self.guild.name}: {message}')
  426. # lookup for int(Guild.guild_id) --> GuildContext
  427. guild_id_to_guild_context = {}
  428. def get_or_create_guild_context(val, save=True):
  429. """
  430. Retrieves a cached GuildContext instance by its Guild id or Guild object
  431. itself. If no GuildContext record exists for the Guild, one is created
  432. and cached (and saved to the database unless `save=False`).
  433. """
  434. gid = None
  435. guild = None
  436. if val is None:
  437. return None
  438. if isinstance(val, int):
  439. gid = val
  440. elif isinstance(val, Guild):
  441. gid = val.id
  442. guild = val
  443. if gid is None:
  444. print('Unhandled datatype', type(val))
  445. return None
  446. looked_up = guild_id_to_guild_context.get(gid)
  447. if looked_up is not None:
  448. return looked_up
  449. gc = GuildContext(gid)
  450. gc.guild = guild or gc.guild
  451. guild_id_to_guild_context[gid] = gc
  452. if save:
  453. save_guild_context(gc)
  454. return gc
  455. # -- Database ---------------------------------------------------------------
  456. def run_sql_batch(batch_function):
  457. """
  458. Performs an SQL transaction. After a connection is opened, the passed
  459. function is invoked with the sqlite3.Connection and sqlite3.Cursor
  460. passed as arguments. Once the passed function finishes, the connection
  461. is closed.
  462. """
  463. db_connection: sqlite3.Connection = sqlite3.connect('rocketbot.db')
  464. db_cursor: sqlite3.Cursor = db_connection.cursor()
  465. batch_function(db_connection, db_cursor)
  466. db_connection.commit()
  467. db_connection.close()
  468. def load_guild_settings():
  469. """
  470. Populates the GuildContext cache with records from the database.
  471. """
  472. def load(_con, cur):
  473. """
  474. SQL
  475. """
  476. for row in cur.execute("""SELECT * FROM guilds"""):
  477. guild_id = row[0]
  478. gc = get_or_create_guild_context(guild_id, save=False)
  479. gc.warning_channel_id = row[1]
  480. gc.warning_mention = row[2]
  481. gc.join_warning_count = row[3] or CONFIG['joinWarningCount']
  482. gc.join_warning_seconds = row[4] or CONFIG['joinWarningSeconds']
  483. print(f'Guild {guild_id} channel id is {gc.warning_channel_id}')
  484. run_sql_batch(load)
  485. def create_tables():
  486. """
  487. Creates all database tables.
  488. """
  489. def make_tables(_con, cur):
  490. """
  491. SQL
  492. """
  493. cur.execute("""CREATE TABLE guilds (
  494. guildId INTEGER,
  495. warningChannelId INTEGER,
  496. warningMention TEXT,
  497. joinWarningCount INTEGER,
  498. joinWarningSeconds INTEGER,
  499. PRIMARY KEY(guildId ASC))""")
  500. run_sql_batch(make_tables)
  501. def save_guild_context(gc: GuildContext):
  502. """
  503. Saves the state of a GuildContext record to the database.
  504. """
  505. def save(_con, cur):
  506. """
  507. SQL
  508. """
  509. print(f'Saving guild context with id {gc.guild_id}')
  510. cur.execute("""
  511. SELECT guildId
  512. FROM guilds
  513. WHERE guildId=?
  514. """, (
  515. gc.guild_id,
  516. ))
  517. channel_id = gc.warning_channel.id if gc.warning_channel is not None \
  518. else gc.warning_channel_id
  519. exists = cur.fetchone() is not None
  520. if exists:
  521. print('Updating existing guild record in db')
  522. cur.execute("""
  523. UPDATE guilds
  524. SET warningChannelId=?,
  525. warningMention=?,
  526. joinWarningCount=?,
  527. joinWarningSeconds=?
  528. WHERE guildId=?
  529. """, (
  530. channel_id,
  531. gc.warning_mention,
  532. gc.join_warning_count,
  533. gc.join_warning_seconds,
  534. gc.guild_id,
  535. ))
  536. else:
  537. print('Creating new guild record in db')
  538. cur.execute("""
  539. INSERT INTO guilds (
  540. guildId,
  541. warningChannelId,
  542. warningMention,
  543. joinWarningCount,
  544. joinWarningSeconds)
  545. VALUES (?, ?, ?, ?, ?)
  546. """, (
  547. gc.guild_id,
  548. channel_id,
  549. gc.warning_mention,
  550. gc.join_warning_count,
  551. gc.join_warning_seconds,
  552. ))
  553. run_sql_batch(save)
  554. # -- Main (1) ---------------------------------------------------------------
  555. load_guild_settings()
  556. intents = Intents.default()
  557. intents.members = True # To get join/leave events
  558. bot = commands.Bot(command_prefix=CONFIG['commandPrefix'], intents=intents)
  559. # -- Bot commands -----------------------------------------------------------
  560. @bot.command(
  561. brief='Simply replies to the invoker with a hello message in the same channel.'
  562. )
  563. async def hello(ctx: Context):
  564. """
  565. Command handler
  566. """
  567. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  568. if gc is None:
  569. return
  570. message = ctx.message
  571. await gc.command_hello(message)
  572. @bot.command(
  573. brief='Posts a test warning message in the configured warning channel.',
  574. help="""If no warning channel is configured, the bot will reply in the channel the command was
  575. issued to notify no warning channel is set. If a warning mention is configured, the test
  576. warning will tag the configured person/role."""
  577. )
  578. @commands.has_permissions(manage_messages=True)
  579. async def testwarn(ctx: Context):
  580. """
  581. Command handler
  582. """
  583. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  584. if gc is None:
  585. return
  586. await gc.command_testwarn(ctx)
  587. @bot.command(
  588. brief='Sets the threshold for detecting a join raid.',
  589. usage='<count> <seconds>',
  590. help="""The raid threshold is expressed as number of joins within a given number of seconds.
  591. Each time a member joins, the number of joins in the previous _x_ seconds is counted, and if
  592. that count, _y_, equals or exceeds the count configured by this command, a raid is detected."""
  593. )
  594. @commands.has_permissions(manage_messages=True)
  595. async def setraidwarningrate(ctx: Context, count: int, seconds: int):
  596. """
  597. Command handler
  598. """
  599. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  600. if gc is None:
  601. return
  602. await gc.command_setraidwarningrate(ctx, count, seconds)
  603. @bot.command(
  604. brief='Sets the current channel as the destination for bot warning messages.'
  605. )
  606. @commands.has_permissions(manage_messages=True)
  607. async def setwarningchannel(ctx: Context):
  608. """
  609. Command handler
  610. """
  611. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  612. if gc is None:
  613. return
  614. await gc.command_setwarningchannel(ctx)
  615. @bot.command(
  616. brief='Sets an optional mention to include in every warning message.',
  617. usage='<mention>',
  618. help="""The argument provided to this command will be included verbatim, so if the intent is
  619. to tag a user or role, the argument must be a tag, not merely the name of the user/role."""
  620. )
  621. @commands.has_permissions(manage_messages=True)
  622. async def setwarningmention(ctx: Context, mention: str):
  623. """
  624. Command handler
  625. """
  626. gc: GuildContext = get_or_create_guild_context(ctx.guild)
  627. if gc is None:
  628. return
  629. await gc.command_setwarningmention(ctx, mention)
  630. # -- Bot events -------------------------------------------------------------
  631. is_connected = False
  632. @bot.listen()
  633. async def on_connect():
  634. """
  635. Discord event handler
  636. """
  637. global is_connected
  638. print('Connected')
  639. is_connected = True
  640. if is_connected and is_ready:
  641. await populate_guilds()
  642. is_ready = False
  643. @bot.listen()
  644. async def on_ready():
  645. """
  646. Discord event handler
  647. """
  648. global is_ready
  649. print('Ready')
  650. is_ready = True
  651. if is_connected and is_ready:
  652. await populate_guilds()
  653. async def populate_guilds():
  654. """
  655. Called after both on_ready and on_connect are done. May be called more than once!
  656. """
  657. for guild in bot.guilds:
  658. gc = guild_id_to_guild_context.get(guild.id)
  659. if gc is None:
  660. print(f'No GuildContext for {guild.id}')
  661. continue
  662. gc.guild = guild
  663. if gc.warning_channel_id is not None:
  664. gc.warning_channel = guild.get_channel(gc.warning_channel_id)
  665. if gc.warning_channel is not None:
  666. print(f'Recovered warning channel {gc.warning_channel}')
  667. else:
  668. print(f'Could not find channel with id {gc.warning_channel_id} in ' +
  669. f'guild {guild.name}')
  670. for channel in await guild.fetch_channels():
  671. print(f'\t{channel.name} ({channel.id})')
  672. @bot.listen()
  673. async def on_member_join(member: Member) -> None:
  674. """
  675. Discord event handler
  676. """
  677. print(f'User {member.name} joined {member.guild.name}')
  678. gc: GuildContext = get_or_create_guild_context(member.guild)
  679. if gc is None:
  680. print(f'No GuildContext for guild {member.guild.name}')
  681. return
  682. await gc.handle_join(member)
  683. @bot.listen()
  684. async def on_member_remove(member: Member) -> None:
  685. """
  686. Discord event handler
  687. """
  688. print(f'User {member.name} left {member.guild.name}')
  689. @bot.listen()
  690. async def on_raw_reaction_add(payload: RawReactionActionEvent) -> None:
  691. """
  692. Discord event handler
  693. """
  694. guild: Guild = bot.get_guild(payload.guild_id)
  695. channel: GuildChannel = guild.get_channel(payload.channel_id)
  696. message: Message = await channel.fetch_message(payload.message_id)
  697. member: Member = payload.member
  698. emoji: PartialEmoji = payload.emoji
  699. gc: GuildContext = get_or_create_guild_context(guild)
  700. await gc.handle_reaction_add(message, member, emoji)
  701. # -- Main -------------------------------------------------------------------
  702. print('Starting bot')
  703. bot.run(CONFIG['clientToken'])
  704. print('Bot done')