Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

rocketbot.py 23KB

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