├── .gitignore ├── DoomReference.sh ├── README.md ├── commands ├── main.py ├── moderation.py └── owner.py ├── games.sample.json ├── modules ├── command_sys.py ├── game.py ├── game_channel.py ├── posts.py ├── process_helpers.py └── quetzal_parser.py ├── options.sample.cfg ├── plugh.png ├── xyzzy.png └── xyzzy.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Random stuff 2 | .vscode/ 3 | __pycache__/ 4 | .tool-versions 5 | venv 6 | 7 | # Config files 8 | options.cfg 9 | games.json 10 | 11 | # Data directories 12 | bot-data/ 13 | games/ 14 | stories/ 15 | saves/ 16 | save-cache/ 17 | tests/ -------------------------------------------------------------------------------- /DoomReference.sh: -------------------------------------------------------------------------------- 1 | while true; do 2 | python3 xyzzy.py 3 | sleep 5 4 | done; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | ## >xyzzy 5 | ### `A hollow voice says "cretin".` 6 |
-------------------------------------------------------------------------------- /commands/main.py: -------------------------------------------------------------------------------- 1 | from modules.command_sys import command, Command 2 | from modules.game_channel import GameChannel, InputMode 3 | from modules.game import Game 4 | from io import BytesIO 5 | from math import floor 6 | from xyzzy import Xyzzy 7 | 8 | import os 9 | import re 10 | import typing 11 | import asyncio 12 | import random 13 | import modules.quetzal_parser as qzl 14 | 15 | 16 | class Main: 17 | def __init__(self, xyzzy: Xyzzy): 18 | self.xyzzy = xyzzy 19 | 20 | @command(usage="[ command ]", has_site_help=False) 21 | async def help(self, ctx): 22 | """Show help for commands.""" 23 | if not ctx.args: 24 | return await ctx.send( 25 | "```inform\n" 26 | "Detailed help can be found at the link below.\n" 27 | 'For quick information on a command, type "@{0.name}#{0.discriminator} help (command)"\n' 28 | "```\n" 29 | "http://xyzzy.roadcrosser.xyz/help/".format(self.xyzzy.user) 30 | ) 31 | 32 | cmd = self.xyzzy.commands.get_command(ctx.args[0].lower()) 33 | 34 | if cmd and not cmd.hidden: 35 | msg = '```inform7\n"{}{}{}{}"\n```'.format( 36 | self.xyzzy.user.mention, 37 | cmd.name, 38 | " " + cmd.usage + " " if cmd.usage else "", 39 | cmd.description, 40 | ) 41 | 42 | if cmd.has_site_help: 43 | msg += ( 44 | "\nMore information: http://xyzzy.roadcrosser.xyz/help/#{}".format( 45 | cmd.name 46 | ) 47 | ) 48 | 49 | return await ctx.send(msg) 50 | else: 51 | return await ctx.send( 52 | '```diff\n-No information found on "{}".\n```'.format( 53 | ctx.args[0].lower() 54 | ) 55 | ) 56 | 57 | @command(has_site_help=False) 58 | async def ping(self, ctx): 59 | msg = await ctx.send("Pong!") 60 | 61 | await msg.edit( 62 | content="Pong! `{}ms`".format( 63 | floor( 64 | msg.created_at.timestamp() * 1000 65 | - ctx.msg.created_at.timestamp() * 1000 66 | ) 67 | ) 68 | ) 69 | 70 | @command(has_site_help=False) 71 | async def about(self, ctx): 72 | """Sends information about xyzzy.""" 73 | await ctx.send( 74 | "Information about xyzzy can be found here: http://roadcrosser.xyz/zy" 75 | ) 76 | 77 | @command(aliases=["join"], has_site_help=False) 78 | async def invite(self, ctx): 79 | """Gives the bot"s invite link.""" 80 | await ctx.send( 81 | "This bot can be invited through the following URL: " 82 | ) 83 | 84 | @command(has_site_help=False) 85 | async def list(self, ctx): 86 | """Sends you a direct message containing all games in xyzzy's library.""" 87 | msg = """```md 88 | # Here are all of the games I have available: # 89 | {} 90 | ``` 91 | Alternatively, an up-to-date list can be found here: http://xyzzy.roadcrosser.xyz/list""".format( 92 | "\n".join(sorted(self.xyzzy.games)) 93 | ) 94 | 95 | if ctx.args and ctx.args[0] == "here": 96 | await ctx.send(msg) 97 | else: 98 | try: 99 | await ctx.send(msg, dest="author") 100 | except: 101 | await ctx.send( 102 | "I cannot PM you, as you seem to have private messages disabled. However, an up-to-date list is available at: http://xyzzy.roadcrosser.xyz/list" 103 | ) 104 | 105 | @command(has_site_help=False, hidden=True) 106 | async def backticks(self, ctx): 107 | await ctx.send( 108 | "Unfortunately the backtick rule is no longer able to be used, due to using a mention prefix now." 109 | ) 110 | 111 | @command(has_site_help=False, hidden=True) 112 | async def unprefixed(self, ctx): 113 | await ctx.send( 114 | "Unfortunately the unprefixed option is no longner possible due to restrictions from Discord on getting message content." 115 | ) 116 | 117 | @command(usage="[ game ]") 118 | async def play(self, ctx): 119 | """ 120 | Tells xyzzy to start playing the [game] in the current channel. 121 | If no game is found with that name, xyzzy will show you all games with the [game] in their name. 122 | If only one game is found with that text in it's name, it will start that game. 123 | If you run the command with a save file attached, xyzzy will try to load a game from it. 124 | """ 125 | # Don't do DMs kids. 126 | if ctx.is_dm(): 127 | return await ctx.send( 128 | "```accesslog\nSorry, but games cannot be played in DMs. Please try again in a server.```" 129 | ) 130 | 131 | if ctx.msg.channel.id in self.xyzzy.channels: 132 | return await ctx.send( 133 | '```accesslog\nSorry, but #{} is currently playing "{}". Please try again after the game has finished.\n```'.format( 134 | ctx.msg.channel.name, 135 | self.xyzzy.channels[ctx.msg.channel.id].game.name, 136 | ) 137 | ) 138 | 139 | if not ctx.msg.attachments: 140 | if not ctx.args: 141 | return await ctx.send("```diff\n-Please provide a game to play.\n```") 142 | 143 | print("Searching for " + ctx.raw) 144 | 145 | games = { 146 | x: y 147 | for x, y in self.xyzzy.games.items() 148 | if ctx.raw.lower() in x.lower() 149 | or [z for z in y.aliases if ctx.raw.lower() in z.lower()] 150 | } 151 | perfect_match = None 152 | 153 | if games: 154 | perfect_match = { 155 | x: y 156 | for x, y in games.items() 157 | if ctx.raw.lower() == x.lower() 158 | or [z for z in y.aliases if ctx.raw.lower() == z.lower()] 159 | } 160 | 161 | if not games: 162 | return await ctx.send( 163 | '```diff\n-I couldn\'t find any games matching "{}"\n```'.format( 164 | ctx.raw 165 | ) 166 | ) 167 | elif len(games) > 1 and not perfect_match: 168 | return await ctx.send( 169 | "```accesslog\n" 170 | 'I couldn\'t find any games with that name, but I found "{}" in {} other games. Did you mean one of these?\n' 171 | '"{}"\n' 172 | "```".format(ctx.raw, len(games), '"\n"'.join(sorted(games))) 173 | ) 174 | 175 | if perfect_match: 176 | game = list(perfect_match.items())[0][1] 177 | else: 178 | game = list(games.items())[0][1] 179 | else: 180 | # Attempt to load a game from a possible save file. 181 | attach = ctx.msg.attachments[0] 182 | 183 | if attach.width or attach.height: 184 | return await ctx.send("```diff\n-Images are not save files.\n```") 185 | 186 | async with ctx.typing(): 187 | async with self.xyzzy.session.get(attach.url) as r: 188 | res = await r.read() 189 | 190 | try: 191 | qzl_headers = qzl.parse_quetzal(BytesIO(res)) 192 | except Exception as e: 193 | if str(e) == "Invalid file format.": 194 | return await ctx.send("```diff\n-Invalid file format.\n```") 195 | else: 196 | return await ctx.send("```diff\n-{}\n```".format(str(e))) 197 | 198 | for name, stuff in self.xyzzy.games.items(): 199 | comp_res = qzl.compare_quetzal(qzl_headers, stuff.path) 200 | 201 | if comp_res: 202 | game = stuff 203 | break 204 | 205 | if not comp_res: 206 | return await ctx.send( 207 | "```diff\n-No games matching your save file could be found.\n```" 208 | ) 209 | 210 | if not os.path.exists("./saves/{}".format(ctx.msg.channel.id)): 211 | os.makedirs("./saves/{}".format(ctx.msg.channel.id)) 212 | 213 | with open( 214 | "./saves/{}/__UPLOADED__.qzl".format(ctx.msg.channel.id), "wb" 215 | ) as save: 216 | save.write(res) 217 | 218 | if str(ctx.msg.guild.id) in self.xyzzy.server_settings: 219 | if ( 220 | game.name 221 | in self.xyzzy.server_settings[str(ctx.msg.guild.id)]["blocked_games"] 222 | ): 223 | return await ctx.send( 224 | '```diff\n- "{}" has been blocked on this server.\n```'.format( 225 | game.name 226 | ) 227 | ) 228 | 229 | print( 230 | "Now loading {} for #{} (Server: {})".format( 231 | game.name, ctx.msg.channel.name, ctx.msg.guild.name 232 | ) 233 | ) 234 | 235 | chan = GameChannel(ctx.msg, game) 236 | self.xyzzy.channels[ctx.msg.channel.id] = chan 237 | 238 | if ctx.msg.attachments: 239 | chan.save = "./saves/{}/__UPLOADED__.qzl".format(ctx.msg.channel.id) 240 | 241 | await ctx.send( 242 | '```py\nLoaded "{}"{}\n```'.format( 243 | game.name, " by " + game.author if game.author else "" 244 | ) 245 | ) 246 | await chan.init_process() 247 | await self.xyzzy.update_game() 248 | await chan.game_loop() 249 | await self.xyzzy.update_game() 250 | 251 | if ctx.msg.channel.id in self.xyzzy.channels: 252 | del self.xyzzy.channels[ctx.msg.channel.id] 253 | 254 | @command(usage="[ filename ]") 255 | async def debugload(self, ctx): 256 | """ 257 | Tells xyzzy to load a [filename] from the test folder in the current channel. 258 | The game will not count towards any statistics. 259 | """ 260 | # Don't do DMs kids. 261 | if ctx.is_dm(): 262 | return await ctx.send( 263 | "```accesslog\nSorry, but games cannot be played in DMs. Please try again in a server.```" 264 | ) 265 | 266 | if ctx.msg.channel.id in self.xyzzy.channels: 267 | return await ctx.send( 268 | '```accesslog\nSorry, but #{} is currently playing "{}". Please try again after the game has finished.\n```'.format( 269 | ctx.msg.channel.name, 270 | self.xyzzy.channels[ctx.msg.channel.id].game.name, 271 | ) 272 | ) 273 | 274 | if not ctx.args: 275 | return await ctx.send("```diff\n-Please provide a game to play.\n```") 276 | 277 | file_dir = "./tests/" + ctx.raw 278 | 279 | if not os.path.isfile(file_dir): 280 | return await ctx.send("```diff\n-File not found.\n```") 281 | 282 | print( 283 | "Now loading test file {} for #{} (Server: {})".format( 284 | ctx.raw, ctx.msg.channel.name, ctx.msg.guild.name 285 | ) 286 | ) 287 | 288 | chan = GameChannel(ctx.msg, Game(ctx.raw, {"path": file_dir, "debug": True})) 289 | self.xyzzy.channels[ctx.msg.channel.id] = chan 290 | 291 | await ctx.send('```py\nLoaded "{}"\n```'.format(ctx.raw)) 292 | await chan.init_process() 293 | await chan.game_loop() 294 | 295 | if ctx.msg.channel.id in self.xyzzy.channels: 296 | del self.xyzzy.channels[ctx.msg.channel.id] 297 | 298 | @command() 299 | async def output(self, ctx): 300 | """ 301 | Toggles whether the text being sent to this channel from a currently playing game also should be printed to the terminal. 302 | This is functionally useless in most cases. 303 | """ 304 | if ctx.msg.channel.id not in self.xyzzy.channels: 305 | return await ctx.send( 306 | "```diff\n-Nothing is being played in this channel.\n```" 307 | ) 308 | 309 | chan = self.xyzzy.channels[ctx.msg.channel.id] 310 | 311 | if chan.output: 312 | chan.output = False 313 | await ctx.send('```basic\n"Terminal Output" is now OFF.\n```') 314 | else: 315 | chan.output = True 316 | await ctx.send('```basic\n"Terminal Output" is now ON\n```') 317 | 318 | @command(usage="[ indent level ]") 319 | async def indent(self, ctx): 320 | """ 321 | Will make xyzzy scrap the first nth characters for each line in his output. 322 | If you're noticing random spaces after each line break, use this command. 323 | [Indent level] must be an integer between 0 and the total console width. (Usually 80.) 324 | """ 325 | if ctx.msg.channel.id not in self.xyzzy.channels: 326 | return await ctx.send( 327 | "```diff\n-Nothing is being played in this channel.\n```" 328 | ) 329 | 330 | if not ctx.args: 331 | return await ctx.send("```diff\n-You need to supply a number.\n```") 332 | 333 | chan = self.xyzzy.channels[ctx.msg.channel.id] 334 | 335 | try: 336 | chan.indent = int(ctx.args[0]) 337 | await ctx.send( 338 | '```basic\n"Indent Level" is now {}.\n```'.format(chan.indent) 339 | ) 340 | except ValueError: 341 | await ctx.send("```diff\n!ERROR: Valid number not supplied.\n```") 342 | 343 | @command(aliases=["mortim"]) 344 | async def forcequit(self, ctx): 345 | """ 346 | After confirmation, terminates the process running the xyzzy game you are playing. 347 | [It is recommended to try to exit the game using an in-game method before using this command.] >quit usually works. 348 | This command has an alias in '@xyzzy mortim' 349 | """ 350 | if ctx.msg.channel.id not in self.xyzzy.channels: 351 | return await ctx.send( 352 | "```diff\n-Nothing is being played in this channel.\n```" 353 | ) 354 | 355 | channel = self.xyzzy.channels[ctx.msg.channel.id] 356 | 357 | if ( 358 | not ctx.has_permission("manage_guild", "author") 359 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 360 | and ctx.msg.author != channel.owner 361 | and ( 362 | channel.mode == InputMode.DEMOCRACY or channel.mode == InputMode.DRIVER 363 | ) 364 | ): 365 | return await ctx.send( 366 | '```diff\n-Only people who can manage the server, or the "owner" of the current game may force quit.\n```' 367 | ) 368 | 369 | await ctx.send( 370 | "```diff\n" 371 | "Are you sure you want to quit?\n" 372 | "-Say Y or Yes to close the program.\n" 373 | "!NOTE: You will lose all unsaved progress!\n" 374 | "+Send any other message to continue playing.\n" 375 | "```" 376 | ) 377 | 378 | try: 379 | check = ( 380 | lambda x: x.channel == ctx.msg.channel and x.author == ctx.msg.author 381 | ) 382 | msg = await self.xyzzy.wait_for("message", check=check, timeout=30) 383 | 384 | if re.match(r"^`?({})?y(es)?`?$", msg.content.lower()): 385 | chan = self.xyzzy.channels[ctx.msg.channel.id] 386 | 387 | await chan.force_quit() 388 | chan.cleanup() 389 | 390 | if ctx.msg.channel.id in self.xyzzy.channels: 391 | del self.xyzzy.channels[ctx.msg.channel.id] 392 | else: 393 | await ctx.send("```diff\n+Continuing game.\n```") 394 | except asyncio.TimeoutError: 395 | await ctx.send("```diff\n+Message timeout expired. Continuing game.\n```") 396 | except ProcessLookupError: 397 | chan = self.xyzzy.channels[ctx.msg.channel.id] 398 | saves = chan.check_saves() 399 | 400 | if saves: 401 | await self.channel.send( 402 | "```diff\n-The game has ended.\n+Here are your saves from the game.\n```", 403 | files=saves, 404 | ) 405 | else: 406 | await ctx.send("```diff\n-The game has ended.\n```") 407 | 408 | chan.cleanup() 409 | 410 | if ctx.msg.channel.id in self.xyzzy.channels: 411 | del self.xyzzy.channels[ctx.msg.channel.id] 412 | 413 | @command(aliases=["upload"], usage="[ Save as Attachment ]") 414 | async def uploadsave(self, ctx): 415 | """Uploads a save to be played from during a game.""" 416 | if not ctx.msg.attachments: 417 | return await ctx.send("Please send a save file as an attachment.") 418 | 419 | attach = ctx.msg.attachments[0] 420 | 421 | if attach.height or attach.width: 422 | return await ctx.send("```diff\n-Images are not save files.\n```") 423 | 424 | async with ctx.typing(): 425 | async with self.xyzzy.session.get(attach.url) as r: 426 | res = await r.read() 427 | 428 | try: 429 | qzl_headers = qzl.parse_quetzal(BytesIO(res)) 430 | except Exception as e: 431 | if str(e) == "Invalid file format.": 432 | return await ctx.send("```diff\n-Invalid file format.\n```") 433 | else: 434 | return await ctx.send("```diff\n-{}\n```".format(str(e))) 435 | 436 | if not os.path.exists("./saves/{}".format(ctx.msg.channel.id)): 437 | os.makedirs("./saves/{}".format(ctx.msg.channel.id)) 438 | 439 | with open( 440 | "./saves/{}/{}.qzl".format( 441 | ctx.msg.channel.id, attach.filename.rsplit(".")[0] 442 | ), 443 | "wb", 444 | ) as save: 445 | save.write(res) 446 | 447 | await ctx.send( 448 | "```diff\n" 449 | "+Saved file as '{}.qzl'.\n" 450 | "+You can load it by playing the relevant game and using the RESTORE command.\n" 451 | "-Note that this will get removed during the next game played if it is not loaded, or after the next reboot.\n" 452 | "```".format(attach.filename.split(".")[0]) 453 | ) 454 | 455 | @command() 456 | async def modes(self, ctx): 457 | """ 458 | Shows a list of all the different game input modes currently supported by xyzzy. 459 | """ 460 | await ctx.send( 461 | "```asciidoc\n" 462 | ".Game Input Modes.\n" 463 | "A list of all the different input modes available for xyzzy.\n\n" 464 | "* Anarchy *default*\n" 465 | " Anyone can run any command with no restrictions.\n\n" 466 | "* Democracy\n" 467 | " Commands are voted on by players. After 15 seconds, the highest voted command is run.\n" 468 | " More info: http://helixpedia.wikia.com/wiki/Democracy\n\n" 469 | "* Driver\n" 470 | " Only one person can control the game at a time, but can transfer ownership at any time.\n" 471 | "```" 472 | ) 473 | 474 | @command(usage="[ mode ] or list") 475 | async def mode(self, ctx): 476 | """ 477 | Changes the input mode for the currently running session to [mode]. 478 | If "list" is specified as the mode, a list of the supported input modes will be shown (this is also aliased to '@xyzzy modes'). 479 | [Only users who can manage the server, or the "owner" of the current game can change the mode.] 480 | """ 481 | if ctx.msg.channel.id not in self.xyzzy.channels: 482 | return await ctx.send( 483 | "```diff\n-Nothing is being played in this channel.\n```" 484 | ) 485 | 486 | if ( 487 | not ctx.has_permission("manage_guild", "author") 488 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 489 | and ctx.msg.author != self.xyzzy.channels[ctx.msg.channel.id].owner 490 | ): 491 | return await ctx.send( 492 | '```diff\n-Only people who can manage the server, or the "owner" of the current game can change the mode.\n```' 493 | ) 494 | 495 | if not ctx.args: 496 | return await ctx.send("```diff\n-Please tell me a mode to switch to.\n```") 497 | 498 | if ctx.args[0].lower() == "list": 499 | return await self.modes.run(ctx) 500 | 501 | if ctx.args[0].lower() not in ("democracy", "anarchy", "driver"): 502 | return await ctx.send( 503 | "```diff\n" 504 | "Please select a valid mode.\n" 505 | "You can run '@xyzzy modes' to view all the currently available modes.\n" 506 | "```" 507 | ) 508 | 509 | res = [x for x in InputMode if ctx.args[0].lower() == x.name.lower()][0] 510 | channel = self.xyzzy.channels[ctx.msg.channel.id] 511 | 512 | if res == channel.mode: 513 | return await ctx.send( 514 | '```diff\n-The current mode is already "{}".\n```'.format( 515 | ctx.args[0].lower() 516 | ) 517 | ) 518 | 519 | channel.mode = res 520 | 521 | if res == InputMode.ANARCHY: 522 | await ctx.send( 523 | "```glsl\n" 524 | "#Anarchy mode is now on.\n" 525 | "Any player can now submit any command with no restriction.\n" 526 | "```" 527 | ) 528 | elif res == InputMode.DEMOCRACY: 529 | await ctx.send( 530 | "```diff\n" 531 | "+Democracy mode is now on.\n" 532 | "Players will now vote on commands. After 15 seconds, the top voted command will be input.\n" 533 | "On ties, the command will be scrapped and no input will be sent.\n" 534 | "More info: http://helixpedia.wikia.com/wiki/Democracy\n" 535 | "```" 536 | ) 537 | else: 538 | await ctx.send( 539 | "```diff\n" 540 | "-Driver mode is now on.\n" 541 | "Only {} will be able to submit commands.\n" 542 | "You can transfer the \"wheel\" with '@xyzzy transfer [user]'\n" 543 | "```".format(channel.owner) 544 | ) 545 | 546 | @command(usage="[ @User Mentions#1234 ]") 547 | async def transfer(self, ctx): 548 | """ 549 | Passes the "wheel" to another user, letting them take control of the current game. 550 | NOTE: this only works in driver or democracy mode. 551 | [This command can only be used by the "owner" of the game.] 552 | """ 553 | if ctx.msg.channel.id not in self.xyzzy.channels: 554 | return await ctx.send( 555 | "```diff\n-Nothing is being played in this channel.\n```" 556 | ) 557 | 558 | if self.xyzzy.channels[ctx.msg.channel.id].mode == InputMode.ANARCHY: 559 | return await ctx.send( 560 | "```diff\n-'transfer' may only be used in driver or anarchy mode.\n```" 561 | ) 562 | 563 | if ( 564 | str(ctx.msg.author.id) not in self.xyzzy.owner_ids 565 | and ctx.msg.author != self.xyzzy.channels[ctx.msg.channel.id].owner 566 | ): 567 | return await ctx.send( 568 | "```diff\n-Only the current owner of the game can use this command.\n```" 569 | ) 570 | 571 | if not ctx.msg.mentions: 572 | return await ctx.send( 573 | '```diff\n-Please give me a user to pass the "wheel" to.\n```' 574 | ) 575 | 576 | self.xyzzy.channels[ctx.msg.channel.id].owner = ctx.msg.mentions[0] 577 | 578 | await ctx.send( 579 | '```diff\n+Transferred the "wheel" to {}.\n```'.format(ctx.msg.mentions[0]) 580 | ) 581 | 582 | @command(has_site_help=False) 583 | async def jump(self, ctx): 584 | """Wheeeeeeeeee!!!!!""" 585 | await ctx.send( 586 | random.choice( 587 | [ 588 | "Wheeeeeeeeee!!!!!", 589 | "Are you enjoying yourself?", 590 | "Do you expect me to applaud?", 591 | "Very good. Now you can go to the second grade.", 592 | "Have you tried hopping around the dungeon, too?", 593 | "You jump on the spot.", 594 | "You jump on the spot, fruitlessly.", 595 | ] 596 | ) 597 | ) 598 | 599 | 600 | def setup(xyzzy): 601 | return Main(xyzzy) 602 | -------------------------------------------------------------------------------- /commands/moderation.py: -------------------------------------------------------------------------------- 1 | from modules.command_sys import command 2 | import json 3 | 4 | 5 | class Moderation: 6 | def __init__(self, xyzzy): 7 | self.xyzzy = xyzzy 8 | 9 | @command(aliases=["plugh"], usage="[ @User Mention#1234 ]") 10 | async def block(self, ctx): 11 | """ 12 | For each user mentioned, disables their ability to send commands to the bot in the server this command was invoked. 13 | Blocked users will be sent a Direct Message stating that they are blocked when they try to send a command. 14 | This command has an alias in '@xyzzy plugh' 15 | [A user may only use this command if they have permission to kick other users.] 16 | """ 17 | if ( 18 | not ctx.has_permission("kick_members", "author") 19 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 20 | ): 21 | return await ctx.send( 22 | "```diff\n!Only users with the permission to kick other users can use this command.\n```" 23 | ) 24 | 25 | if not ctx.msg.mentions: 26 | return await ctx.send("```diff\n-Please mention some people to block.\n```") 27 | 28 | if str(ctx.msg.guild.id) not in self.xyzzy.blocked_users: 29 | self.xyzzy.blocked_users[str(ctx.msg.guild.id)] = [] 30 | 31 | for men in ctx.msg.mentions: 32 | self.xyzzy.blocked_users[str(ctx.msg.guild.id)].append(str(men.id)) 33 | await ctx.send( 34 | '```diff\n+ "{}" has been restricted from entering commands in this server.\n```'.format( 35 | men.display_name 36 | ) 37 | ) 38 | 39 | with open("./bot-data/blocked_users.json", "w") as blck: 40 | json.dump(self.xyzzy.blocked_users, blck) 41 | 42 | @command(usage="[ @User Mention#1234s ]") 43 | async def unblock(self, ctx): 44 | """ 45 | For each user mentioned, re-enables their ability to send commands to the bot in the server this command was invoked. 46 | If the user was never blocked, this command fails silently. 47 | [A user may only use this command if they have permission to kick other users.] 48 | """ 49 | if ( 50 | not ctx.has_permission("kick_members", "author") 51 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 52 | ): 53 | return await ctx.send( 54 | "```diff\n!Only users with the permission to kick other users can use this command.\n```" 55 | ) 56 | 57 | if not ctx.msg.mentions: 58 | return await ctx.send( 59 | "```diff\n-Please mention some people to unblock.\n```" 60 | ) 61 | 62 | if str(ctx.msg.guild.id) not in self.xyzzy.blocked_users: 63 | self.xyzzy.blocked_users[str(ctx.msg.guild.id)] = [] 64 | return 65 | 66 | for men in ctx.msg.mentions: 67 | if str(men.id) in self.xyzzy.blocked_users[str(ctx.msg.guild.id)]: 68 | self.xyzzy.blocked_users[str(ctx.msg.guild.id)].remove(str(men.id)) 69 | await ctx.send( 70 | '```diff\n+ "{}" is now allowed to submit commands.\n```'.format( 71 | men.display_name 72 | ) 73 | ) 74 | 75 | with open("./bot-data/blocked_users.json", "w") as blck: 76 | json.dump(self.xyzzy.blocked_users, blck) 77 | 78 | @command(usage="[ Game name ] or list") 79 | async def blockgame(self, ctx): 80 | """ 81 | Stops the game specified in [game name] being played on the server. 82 | If "list" is specified as the game name, then a list of all the currently blocked games will be shown. 83 | [A user may only use this command if they can manage the server.] 84 | """ 85 | if ( 86 | not ctx.has_permission("manage_guild", "author") 87 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 88 | ): 89 | return await ctx.send( 90 | "```diff\n!Only users who can manage the server can use this command.\n```" 91 | ) 92 | 93 | if not ctx.args: 94 | return await ctx.send( 95 | "```diff\n-Please specify a game to block for the server.\n```" 96 | ) 97 | 98 | if ctx.args[0].lower() == "list": 99 | if ( 100 | str(ctx.msg.guild.id) not in self.xyzzy.server_settings 101 | or not self.xyzzy.server_settings[str(ctx.msg.guild.id)][ 102 | "blocked_games" 103 | ] 104 | ): 105 | return await ctx.send("```diff\n+No blocked games.\n```") 106 | else: 107 | games = self.xyzzy.server_settings[str(ctx.msg.guild.id)][ 108 | "blocked_games" 109 | ] 110 | 111 | return await ctx.send( 112 | "```asciidoc\n.Blocked Games.\n{}\n```".format( 113 | "\n".join("* '{}'".format(x) for x in sorted(games)) 114 | ) 115 | ) 116 | 117 | games = { 118 | x: y 119 | for x, y in self.xyzzy.games.items() 120 | if ctx.raw.lower() in x.lower() 121 | or [z for z in y.aliases if ctx.raw.lower() in z.lower()] 122 | } 123 | perfect_match = None 124 | 125 | if games: 126 | perfect_match = { 127 | x: y 128 | for x, y in games.items() 129 | if ctx.raw.lower() == x.lower() 130 | or [z for z in y.aliases if ctx.raw.lower() == z.lower()] 131 | } 132 | 133 | if not games: 134 | return await ctx.send( 135 | '```diff\n-I couldn\'t find any games matching "{}"\n```'.format( 136 | ctx.raw 137 | ) 138 | ) 139 | elif len(games) > 1 and not perfect_match: 140 | return await ctx.send( 141 | "```accesslog\n" 142 | 'I couldn\'t find any games with that name, but I found "{}" in {} other games. Did you mean one of these?\n' 143 | '"{}"\n' 144 | "```".format(ctx.raw, len(games), '"\n"'.join(sorted(games))) 145 | ) 146 | 147 | if perfect_match: 148 | game = list(perfect_match.items())[0][0] 149 | else: 150 | game = list(games[0].items())[0][0] 151 | 152 | if str(ctx.msg.guild.id) not in self.xyzzy.server_settings: 153 | self.xyzzy.server_settings[str(ctx.msg.guild.id)] = { 154 | "blocked_games": [game] 155 | } 156 | else: 157 | if ( 158 | game 159 | not in self.xyzzy.server_settings[str(ctx.msg.guild.id)][ 160 | "blocked_games" 161 | ] 162 | ): 163 | self.xyzzy.server_settings[str(ctx.msg.guild.id)][ 164 | "blocked_games" 165 | ].append(game) 166 | else: 167 | return await ctx.send( 168 | '```diff\n- "{}" has already been blocked on this server.\n```'.format( 169 | game 170 | ) 171 | ) 172 | 173 | await ctx.send( 174 | '```diff\n+ "{}" has been blocked and will no longer be able to be played on this server.\n```'.format( 175 | game 176 | ) 177 | ) 178 | 179 | with open("./bot-data/server_settings.json", "w") as srv: 180 | json.dump(self.xyzzy.server_settings, srv) 181 | 182 | @command(usage="[ Game name ]") 183 | async def unblockgame(self, ctx): 184 | """ 185 | Re-allows a previously blocked game to be played again. 186 | [A user may only use this command if can manage the server.] 187 | """ 188 | if ( 189 | not ctx.has_permission("manage_guild", "author") 190 | and str(ctx.msg.author.id) not in self.xyzzy.owner_ids 191 | ): 192 | return await ctx.send( 193 | "```diff\n!Only users who can manage the server can use this command.\n```" 194 | ) 195 | 196 | if not ctx.args: 197 | return await ctx.send("```diff\n-Please specify a game to unblock.\n```") 198 | 199 | games = { 200 | x: y 201 | for x, y in self.xyzzy.games.items() 202 | if ctx.raw.lower() in x.lower() 203 | or [z for z in y.aliases if ctx.raw.lower() in z.lower()] 204 | } 205 | perfect_match = None 206 | 207 | if games: 208 | perfect_match = { 209 | x: y 210 | for x, y in games.items() 211 | if ctx.raw.lower() == x.lower() 212 | or [z for z in y.aliases if ctx.raw.lower() == z.lower()] 213 | } 214 | 215 | if not games: 216 | return await ctx.send( 217 | '```diff\n-I couldn\'t find any games matching "{}"\n```'.format( 218 | ctx.raw 219 | ) 220 | ) 221 | elif len(games) > 1 and not perfect_match: 222 | return await ctx.send( 223 | "```accesslog\n" 224 | 'I couldn\'t find any games with that name, but I found "{}" in {} other games. Did you mean one of these?\n' 225 | '"{}"\n' 226 | "```".format(ctx.raw, len(games), "\n".join(sorted(games))) 227 | ) 228 | 229 | if perfect_match: 230 | game = list(perfect_match.items())[0][0] 231 | else: 232 | game = list(games[0].items())[0][0] 233 | 234 | if str(ctx.msg.guild.id) not in self.xyzzy.server_settings: 235 | self.xyzzy.server_settings[str(ctx.msg.guild.id)] = { 236 | "blocked_games": [game] 237 | } 238 | return await ctx.send( 239 | "```diff\n-No games have been blocked on this server.\n```" 240 | ) 241 | 242 | if game in self.xyzzy.server_settings[str(ctx.msg.guild.id)]["blocked_games"]: 243 | self.xyzzy.server_settings[str(ctx.msg.guild.id)]["blocked_games"].remove( 244 | game 245 | ) 246 | else: 247 | return await ctx.send( 248 | '```diff\n- "{}" has not been blocked on this server.\n```'.format(game) 249 | ) 250 | 251 | await ctx.send( 252 | '```diff\n+ "{}" has been unblocked and can be played again on this server.\n```'.format( 253 | game 254 | ) 255 | ) 256 | 257 | with open("./bot-data/server_settings.json", "w") as srv: 258 | json.dump(self.xyzzy.server_settings, srv) 259 | 260 | 261 | def setup(xyzzy): 262 | return Moderation(xyzzy) 263 | -------------------------------------------------------------------------------- /commands/owner.py: -------------------------------------------------------------------------------- 1 | from modules.command_sys import command 2 | from subprocess import PIPE 3 | 4 | import traceback as tb 5 | import inspect 6 | import asyncio 7 | import disnake as discord 8 | import re 9 | import sys 10 | import importlib 11 | import os.path 12 | 13 | from xyzzy import Xyzzy 14 | 15 | 16 | class Owner: 17 | def __init__(self, xyzzy: Xyzzy): 18 | self.xyzzy = xyzzy 19 | 20 | @command(aliases=["eval"], usage="[ python ]", owner=True) 21 | async def evaluate(self, ctx): 22 | """ 23 | Executes arbitrary Python code. 24 | [This command may only be used by trusted individuals.] 25 | """ 26 | env = {"bot": self.xyzzy, "ctx": ctx, "message": ctx.msg} 27 | 28 | try: 29 | out = eval(ctx.raw, env) 30 | 31 | if inspect.isawaitable(out): 32 | out = await out 33 | except Exception as e: 34 | out = str(e) 35 | 36 | await ctx.send("```py\n{}\n```".format(out)) 37 | 38 | @command(owner=True) 39 | async def shutdown(self, ctx): 40 | """ 41 | After confirmation, shuts down the bot and all running games. 42 | [This command may only be used by trusted individuals.] 43 | """ 44 | if self.xyzzy.channels: 45 | await ctx.send( 46 | "```diff\n" 47 | "!There are currently {} games running on my system.\n" 48 | "-If you shut me down now, all unsaved data regarding these games could be lost!\n" 49 | "(Use `{}nowplaying` for a list of currently running games.)\n```".format( 50 | len(self.xyzzy.channels), self.xyzzy.user.mention 51 | ) 52 | ) 53 | 54 | await ctx.send( 55 | "```md\n## Are you sure you want to shut down the bot? ##\n[y/n]:\n```" 56 | ) 57 | 58 | while True: 59 | try: 60 | check = ( 61 | lambda x: x.channel == ctx.msg.channel 62 | and x.author == ctx.msg.author 63 | ) 64 | msg = await self.xyzzy.wait_for("message", check=check, timeout=30) 65 | 66 | if re.match( 67 | r"^`?({} ?)?y(es)?`?$".format(self.xyzzy.user.mention), 68 | msg.content.lower(), 69 | ): 70 | await ctx.send("```asciidoc\n.Xyzzy.\n// Now shutting down...\n```") 71 | await self.xyzzy.logout() 72 | elif re.match( 73 | r"^`?({} ?)?no?`?$".format(self.xyzzy.user.mention), 74 | msg.content.lower(), 75 | ): 76 | return await ctx.send("```css\nShutdown aborted.\n```") 77 | else: 78 | await ctx.send("```md\n# Invalid response. #\n```") 79 | except asyncio.TimeoutError: 80 | return await ctx.send("```css\nMessage timeout: Shutdown aborted.\n```") 81 | 82 | @command(owner=True, usage="[ announcement ]") 83 | async def announce(self, ctx): 84 | """ 85 | For each channel currently playing a game, sends the text in [announcement]. 86 | [This command may only be used by trusted individuals.] 87 | """ 88 | if not ctx.args: 89 | return await ctx.send("```diff\n-Nothing to announce.\n```") 90 | 91 | count = 0 92 | with ctx.msg.channel.typing(): 93 | for chan in self.xyzzy.channels.values(): 94 | try: 95 | await chan.channel.send("```{}```".format(ctx.raw)) 96 | count += 1 97 | except: 98 | pass 99 | 100 | return await ctx.send( 101 | f"```diff\n+ Announcement as been sent to {count} channels. (Failed in {len(self.xyzzy.channels) - count})\n```" 102 | ) 103 | 104 | @command(usage="[ module ]", owner=True, has_site_help=False) 105 | async def reload(self, ctx): 106 | """ 107 | Reloads the module specified in [module]. 108 | [This command may only be used by trusted individuals.] 109 | """ 110 | 111 | if not ctx.args: 112 | return await ctx.send("```diff\n-No module to reload.\n```") 113 | 114 | if os.path.exists("commands/{}.py".format(ctx.args[0].lower())): 115 | self.xyzzy.commands.reload_module("commands." + ctx.args[0].lower()) 116 | elif ( 117 | os.path.exists("modules/{}.py".format(ctx.args[0].lower())) 118 | and "modules.{}".format(ctx.args[0].lower()) in sys.modules 119 | ): 120 | del sys.modules["modules.{}".format(ctx.args[0].lower())] 121 | importlib.import_module("modules.{}".format(ctx.args[0].lower())) 122 | elif os.path.exists("modules/{}.py".format(ctx.args[0].lower())): 123 | return await ctx.send("```diff\n-Module is not loaded.\n```") 124 | else: 125 | return await ctx.send("```diff\n-Unknown thing to reload.\n```") 126 | 127 | await ctx.send( 128 | '```diff\n+Reloaded module "{}".\n```'.format(ctx.args[0].lower()) 129 | ) 130 | 131 | @command(owner=True) 132 | async def nowplaying(self, ctx): 133 | """ 134 | Sends you a direct message containing all currently running xyzzy instances across Discord. 135 | [This command may only be used by trusted individuals.] 136 | """ 137 | if not self.xyzzy.channels: 138 | return await ctx.send( 139 | "```md\n## Nothing is currently being played. ##\n```", dest="author" 140 | ) 141 | 142 | msg = "```md\n## Currently playing games: ##\n" 143 | 144 | for chan in self.xyzzy.channels.values(): 145 | msg += "[{0.channel.guild.name}]({0.channel.name}) {0.game.name} {{{1} minutes ago}}\n".format( 146 | chan, (ctx.msg.created_at - chan.last).total_seconds() // 60 147 | ) 148 | 149 | msg += "```" 150 | 151 | await ctx.send(msg, dest="author") 152 | 153 | @command(owner=True, has_site_help=False) 154 | async def repl(self, ctx): 155 | """Repl in Discord. Because debugging using eval is a PiTA.""" 156 | check = ( 157 | lambda m: m.content.startswith("`") 158 | and m.author == ctx.msg.author 159 | and m.channel == ctx.msg.channel 160 | ) 161 | locals = {} 162 | globals = {"discord": discord, "xyzzy": self.xyzzy, "ctx": ctx, "last": None} 163 | 164 | await ctx.send( 165 | "```You sit down at the terminal and press a key.\nA message appears.```" 166 | "```\n" 167 | " **** XYZZY PYTHON V3.5 REPL ****\n" 168 | " 4GB RAM SYSTEM A LOT OF PYTHON BYTES FREE\n\n" 169 | "READY.\n" 170 | "```" 171 | ) 172 | 173 | while True: 174 | resp = await self.xyzzy.wait_for("message", check=check) 175 | clean = resp.content.strip("` \n") 176 | 177 | if clean.lower() in ("quit", "exit", "exit()", "quit()"): 178 | return await ctx.send("```You stand up from the terminal.```") 179 | 180 | runner = exec 181 | 182 | if clean.count("\n") == 0: 183 | try: 184 | res = compile(clean, "", "eval") 185 | runner = eval 186 | except SyntaxError: 187 | pass 188 | 189 | if runner is exec: 190 | try: 191 | res = compile(clean, "", "exec") 192 | except SyntaxError as e: 193 | if e.text is None: 194 | await ctx.send( 195 | "```py\n{0.__class__.__name__}: {0}\n```".format(e) 196 | ) 197 | else: 198 | await ctx.send( 199 | "```py\n{0.text}{1:>{0.offset}}\n{0.__class__.__name__}: {0}\n```".format( 200 | e, "^" 201 | ) 202 | ) 203 | 204 | continue 205 | 206 | globals["last"] = resp 207 | fmt = None 208 | 209 | try: 210 | res = runner(res, globals, locals) 211 | 212 | if inspect.isawaitable(res): 213 | res = await res 214 | 215 | if res: 216 | msg = "```py\n{}\n```".format(res) 217 | else: 218 | msg = "```Nothing happens.```" 219 | except Exception as e: 220 | msg = "```py\n{}\n```".format(tb.format_exc()) 221 | 222 | if msg: 223 | try: 224 | await ctx.send(msg) 225 | except discord.Forbidden: 226 | pass 227 | except discord.HTTPException as e: 228 | await ctx.send("Unexpected error: `{}`".format(e)) 229 | 230 | @command(owner=True, has_site_help=False) 231 | async def git(self, ctx): 232 | """Runs some git commands in Discord.""" 233 | if not ctx.args or ctx.args[0] not in ("status", "pull", "gud", "rekt"): 234 | return await ctx.send( 235 | "```\n" 236 | "usage: git []\n\n" 237 | " pull Fetches latest updates from a remote repository\n" 238 | " status Show the working tree status\n" 239 | " gud Gits gud\n" 240 | " rekt Gits rekt\n" 241 | "```" 242 | ) 243 | 244 | if ctx.args[0] == "status": 245 | process = await asyncio.create_subprocess_shell("git status", stdout=PIPE) 246 | res = await process.stdout.read() 247 | 248 | return await ctx.send("```{}```".format(res.decode("utf8"))) 249 | 250 | if ctx.args[0] == "pull": 251 | async with ctx.typing(): 252 | process = await asyncio.create_subprocess_shell("git pull", stdout=PIPE) 253 | res = await process.stdout.read() 254 | 255 | await ctx.send("```{}```".format(res.decode("utf8"))) 256 | 257 | if ctx.args[0] == "gud": 258 | if not ctx.args[1:]: 259 | return await ctx.send("```You are now so gud!```") 260 | else: 261 | return await ctx.send( 262 | "```{} is now so gud!```".format(ctx.raw.split(" ", 1)[1]) 263 | ) 264 | 265 | if ctx.args[0] == "rekt": 266 | if not ctx.args[1:]: 267 | return await ctx.send("```You got #rekt!```") 268 | else: 269 | return await ctx.send( 270 | "```{} got #rekt!```".format(ctx.raw.split(" ", 1)[1]) 271 | ) 272 | 273 | 274 | def setup(xyzzy): 275 | return Owner(xyzzy) 276 | -------------------------------------------------------------------------------- /games.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name of game": { 3 | "path": "/path/to/game.ext", 4 | "url": "Url to display.", 5 | "aliases": ["alternate lookup names"], 6 | "author": "Optional author" 7 | } 8 | } -------------------------------------------------------------------------------- /modules/command_sys.py: -------------------------------------------------------------------------------- 1 | """ 2 | Totally not "stolen" from Amethyst and stripped down. 3 | """ 4 | 5 | from typing import Callable, List, Union, Tuple 6 | from random import randint 7 | import disnake as discord 8 | import inspect 9 | import re 10 | import sys 11 | import importlib 12 | import shlex 13 | import typing 14 | 15 | if typing.TYPE_CHECKING: 16 | from xyzzy import Xyzzy 17 | 18 | 19 | PERMS = [ 20 | x 21 | for x in dir(discord.Permissions) 22 | if not x.startswith(("_", "is")) 23 | and x 24 | not in ("value", "all", "all_channel", "none", "general", "text", "voice", "update") 25 | ] 26 | 27 | 28 | class Context: 29 | """ 30 | Custom object that gets passed to commands. 31 | Not intended to be created manually. 32 | """ 33 | 34 | msg: discord.Message 35 | client: "Xyzzy" 36 | clean: str 37 | cmd: str 38 | args: typing.List[str] 39 | raw: str 40 | 41 | def __init__(self, msg: discord.Message, xyzzy: "Xyzzy"): 42 | is_reply_to_me = ( 43 | msg.reference 44 | and isinstance(msg.reference.resolved, discord.Message) 45 | and msg.reference.resolved.author.id == xyzzy.user.id 46 | ) 47 | 48 | self.msg = msg 49 | self.client = xyzzy 50 | 51 | self.clean = ( 52 | msg.content 53 | if is_reply_to_me 54 | else typing.cast(re.Match, xyzzy.prefix.match(msg.content))[1].strip() 55 | ) 56 | 57 | self.cmd = self.clean.split(" ")[0] 58 | self.args = shlex.split( 59 | self.clean.replace(r"\"", "\u009E").replace("'", "\u009F") 60 | ) # Shlex doesn't obey escaped quotes, so lets do it ourselves. 61 | self.args = [ 62 | x.replace("\u009E", '"').replace("\u009F", "'") for x in self.args 63 | ][1:] 64 | self.raw = self.clean.split(" ", 1)[1] if len(self.clean.split(" ")) > 1 else "" 65 | 66 | async def _send(self, content, dest, *, embed=None, file=None, files=None): 67 | """Internal send function, not actually meant to be used by anyone.""" 68 | if dest == "channel": 69 | return await self.msg.channel.send( 70 | content, embed=embed, file=file, files=files 71 | ) 72 | elif dest == "author": 73 | return await self.msg.author.send( 74 | content, embed=embed, file=file, files=files 75 | ) 76 | else: 77 | raise ValueError("Destination is not `channel` or `author`.") 78 | 79 | async def send( 80 | self, 81 | content: str = None, 82 | *, 83 | dest: str = "channel", 84 | embed: discord.Embed = None, 85 | file: discord.File = None, 86 | files: List[discord.File] = None, 87 | ): 88 | """Sends a message to the context origin, can either be the channel or author.""" 89 | if content is None and not embed and not file and not files: 90 | content = "```ERROR at memory location {}\n No content.\n```".format( 91 | hex(randint(2**4, 2**32)) 92 | ) 93 | elif content: 94 | # Escape bad mentions 95 | content = ( 96 | str(content) 97 | .replace("@everyone", "@\u200Beveryone") 98 | .replace("@here", "@\u200Bhere") 99 | ) 100 | 101 | msg = None 102 | 103 | if content and len(content) > 2000: 104 | if ( 105 | content.find("```") == -1 106 | or content.find("```", content.find("```") + 3) == -1 107 | ): 108 | await self._send( 109 | content[:2000], dest, embed=embed, file=file, files=files 110 | ) 111 | msg = await self.send(content[2000:], dest=dest) 112 | elif content.find("```", content.find("```") + 3) + 2 < 2000: 113 | await self._send( 114 | content[: content.find("```", content.find("```") + 3) + 3], 115 | dest, 116 | embed=embed, 117 | file=file, 118 | files=files, 119 | ) 120 | msg = await self.send( 121 | content[content.find("```", content.find("```") + 3) + 3 :], 122 | dest=dest, 123 | ) 124 | else: 125 | start_block = content[ 126 | content.find("```") : content.find("\n", content.find("```")) + 1 127 | ] 128 | 129 | if content.find("\n", content.find("```")) == content.rfind( 130 | "\n", 0, 2000 131 | ): 132 | split_cont = content[:1996] + "\n```" 133 | content = start_block + content[1996:] 134 | else: 135 | split_cont = ( 136 | content[ 137 | : content.rfind("\n", 0, content.rfind("\n", 0, 2000) + 1) 138 | ][:1996] 139 | + "\n```" 140 | ) 141 | content = start_block + content[len(split_cont) - 4 :] 142 | 143 | msg = await self.send( 144 | split_cont + content, dest=dest, embed=embed, file=file, files=files 145 | ) 146 | else: 147 | msg = await self._send(content, dest, embed=embed, file=file, files=files) 148 | 149 | return msg 150 | 151 | def is_dm(self): 152 | """Check if the channel for the context is a DM or not.""" 153 | return isinstance(self.msg.channel, discord.DMChannel) 154 | 155 | def has_permission(self, permission, who="self"): 156 | """Check if someone in context has a permission.""" 157 | if who not in ["self", "author"]: 158 | raise ValueError("Invalid value for `who` (must be `self` or `author`).") 159 | 160 | if permission not in PERMS: 161 | return False 162 | 163 | if who == "self": 164 | return getattr( 165 | self.msg.channel.permissions_for(self.msg.guild.me), permission 166 | ) 167 | elif who == "author": 168 | return getattr( 169 | self.msg.channel.permissions_for(self.msg.author), permission 170 | ) 171 | 172 | def typing(self): 173 | """d.py `async with` shortcut for sending typing to a channel.""" 174 | return self.msg.channel.typing() 175 | 176 | 177 | class Command: 178 | """Represents a command.""" 179 | 180 | def __init__( 181 | self, 182 | func: Callable[..., None], 183 | *, 184 | name: str = None, 185 | description: str = "", 186 | aliases: list = [], 187 | usage: str = "", 188 | owner: bool = False, 189 | has_site_help: bool = True, 190 | hidden: bool = False, 191 | ): 192 | self.func = func 193 | self.name = name or func.__name__ 194 | self.description = description or inspect.cleandoc(func.__doc__ or "") 195 | self.aliases = aliases or [] 196 | self.cls = None 197 | self.usage = usage 198 | self.owner = owner 199 | self.has_site_help = has_site_help 200 | self.hidden = hidden 201 | 202 | def __repr__(self) -> str: 203 | return self.name 204 | 205 | async def run(self, ctx: Context) -> None: 206 | if self.owner and str(ctx.msg.author.id) not in ctx.client.owner_ids: 207 | return 208 | else: 209 | await self.func(self.cls, ctx) 210 | 211 | 212 | class Holder: 213 | """Object that holds commands and aliases, as well as managing the loading and unloading of modules.""" 214 | 215 | def __init__(self, xyzzy): 216 | self.commands = {} 217 | self.aliases = {} 218 | self.modules = {} 219 | self.xyzzy = xyzzy 220 | 221 | def __len__(self): 222 | return len(self.commands) 223 | 224 | def __contains__(self, x: str) -> bool: 225 | return x in self.commands 226 | 227 | def load_module(self, module_name: str) -> None: 228 | """Loads a module by name, and registers all its commands.""" 229 | if module_name in self.modules: 230 | raise Exception("Module `{}` is already loaded.".format(module_name)) 231 | 232 | module = importlib.import_module(module_name) 233 | 234 | # Check if module has needed function 235 | try: 236 | module.setup 237 | except AttributeError: 238 | del sys.modules[module_name] 239 | raise Exception("Module does not have a `setup` function.") 240 | 241 | # Get class returned from setup. 242 | module = module.setup(self.xyzzy) 243 | # Filter all class methods to only commands and those that do not have a parent (subcommands). 244 | cmds = [ 245 | x 246 | for x in dir(module) 247 | if not re.match("__?.*(?:__)?", x) 248 | and isinstance(getattr(module, x), Command) 249 | ] 250 | loaded_cmds = [] 251 | loaded_aliases = [] 252 | 253 | if not cmds: 254 | del sys.modules[module_name] 255 | raise ValueError("Module is empty.") 256 | 257 | for cmd in cmds: 258 | # Get command from name 259 | cmd = getattr(module, cmd) 260 | 261 | # Ingore any non-commands if they got through, and subcommands 262 | if not isinstance(cmd, Command): 263 | continue 264 | 265 | # Give the command its parent class because it got ripped out. 266 | cmd.cls = module 267 | self.commands[cmd.name] = cmd 268 | 269 | # Load aliases for the command 270 | for alias in cmd.aliases: 271 | self.aliases[alias] = self.commands[cmd.name] 272 | loaded_aliases.append(alias) 273 | 274 | loaded_cmds.append(cmd.name) 275 | 276 | self.modules[module_name] = loaded_cmds + loaded_aliases 277 | 278 | def reload_module(self, module_name: str) -> None: 279 | """Reloads a module by name, and all its commands.""" 280 | if module_name not in self.modules: 281 | self.load_module(module_name) 282 | return 283 | 284 | self.unload_module(module_name) 285 | self.load_module(module_name) 286 | 287 | def unload_module(self, module_name: str) -> None: 288 | """Unloads a module by name, and unregisters all its commands.""" 289 | if module_name not in self.modules: 290 | raise Exception("Module `{}` is not loaded.".format(module_name)) 291 | 292 | # Walk through the commands and remove them from the command and aliases dicts 293 | for cmd in self.modules[module_name]: 294 | if cmd in self.aliases: 295 | del self.aliases[cmd] 296 | elif cmd in self.commands: 297 | del self.commands[cmd] 298 | 299 | # Remove from self module array, and delete cache. 300 | del self.modules[module_name] 301 | del sys.modules[module_name] 302 | 303 | def get_command(self, cmd_name: str) -> Union[Command, None]: 304 | """Easily get a command via its name or alias""" 305 | return ( 306 | self.aliases[cmd_name] 307 | if cmd_name in self.aliases 308 | else self.commands[cmd_name] 309 | if cmd_name in self.commands 310 | else None 311 | ) 312 | 313 | async def run(self, ctx: Context) -> None: 314 | cmd = self.get_command(ctx.cmd) 315 | 316 | if not cmd: 317 | return 318 | 319 | await cmd.run(ctx) 320 | 321 | @property 322 | def all_commands(self) -> List[str]: 323 | return sorted(self.commands.keys()) 324 | 325 | @property 326 | def all_aliases(self) -> List[str]: 327 | return sorted(self.aliases.keys()) 328 | 329 | @property 330 | def all_modules(self) -> List[str]: 331 | return sorted(self.modules.keys()) 332 | 333 | 334 | # Command conversion decorator 335 | def command(**attrs): 336 | """Decorator which converts a function into a command.""" 337 | 338 | def decorator(func): 339 | if isinstance(func, Command): 340 | raise TypeError("Function is already a command.") 341 | 342 | if not inspect.iscoroutinefunction(func): 343 | raise TypeError("Command function isn't a coroutine.") 344 | 345 | return Command(func, **attrs) 346 | 347 | return decorator 348 | -------------------------------------------------------------------------------- /modules/game.py: -------------------------------------------------------------------------------- 1 | class Game: 2 | def __init__(self, name, data): 3 | self.name = name 4 | self.path = data["path"] 5 | self.url = data.get("url") 6 | self.aliases = data.get("aliases", []) 7 | self.author = data.get("author") 8 | self.debug = data.get("debug", False) 9 | -------------------------------------------------------------------------------- /modules/game_channel.py: -------------------------------------------------------------------------------- 1 | from subprocess import PIPE 2 | from enum import Enum 3 | from modules.process_helpers import handle_process_output 4 | 5 | import re 6 | import shutil 7 | import os 8 | import asyncio 9 | import disnake as discord 10 | 11 | SCRIPT_OR_RECORD = re.compile(r"(?i).*(?:\.rec|\.scr)$") 12 | 13 | 14 | def parse_action(action): 15 | """Parses an action string to easily clump similar actions""" 16 | if action.lower() in ("n", "north", "go n", "go north", "walk north", "run north"): 17 | return "n" 18 | elif action.lower() in ( 19 | "s", 20 | "south", 21 | "go s", 22 | "go south", 23 | "walk south", 24 | "run south", 25 | ): 26 | return "s" 27 | elif action.lower() in ("e", "east", "go e", "go east", "walk east", "run east"): 28 | return "e" 29 | elif action.lower() in ("w", "west", "go w", "go west", "walk west", "run west"): 30 | return "w" 31 | elif ( 32 | action.lower() in ("[enter]", "(enter)", "{enter}", "") 33 | or action == "ENTER" 34 | ): 35 | return "ENTER" 36 | elif action.lower() in ("space", "[space]", "(space)", "{space}", ""): 37 | return "SPACE" 38 | elif re.match(r"^(?:x|examine) +(?:.+)", action.lower()): 39 | return "examine " + action.lower().split(" ", 1)[1].strip() 40 | elif action.lower() in ("z", "wait"): 41 | return "wait" 42 | elif action.lower() in ("i", "inventory", "inv"): 43 | return "inventory" 44 | if re.match(r"l(?:ook)(?: .*)?", action.lower()): 45 | return ( 46 | "look " + action.lower().split(" ", 1)[1].strip() 47 | if len(action.lower().split(" ")) > 1 48 | else "look" 49 | ) 50 | else: 51 | return action.lower() 52 | 53 | 54 | class InputMode(Enum): 55 | ANARCHY = 1 56 | DEMOCRACY = 2 57 | DRIVER = 3 58 | 59 | 60 | class GameChannel: 61 | """Represents a channel that is prepped for playing a game through Xyzzy.""" 62 | 63 | def __init__(self, msg, game): 64 | self.loop = asyncio.get_event_loop() 65 | self.indent = 0 66 | self.output = False 67 | self.last = msg.created_at 68 | self.owner = msg.author 69 | self.channel = msg.channel 70 | self.game = game 71 | self.process = None 72 | self.playing = False 73 | self.save_path = "./saves/" + str(self.channel.id) 74 | self.last_save = None 75 | self.save = None 76 | self.mode = InputMode.ANARCHY 77 | self.votes = {} 78 | self.timer = None 79 | self.voting = True 80 | 81 | async def _democracy_loop(self): 82 | try: 83 | await asyncio.sleep(10) 84 | await self.channel.send("```py\n@ 5 seconds of voting remaining. @\n```") 85 | await asyncio.sleep(5) 86 | 87 | self.voting = False 88 | vote_sort = sorted( 89 | self.votes.items(), key=lambda x: len(x[1]), reverse=True 90 | ) 91 | highest = sorted(x for x in vote_sort if len(x[1]) == len(vote_sort[0][1])) 92 | 93 | # Discard draws 94 | if len(highest) > 1: 95 | highest = [x[0] for x in highest] 96 | draw_join = '"{}" and "{}"'.format(", ".join(highest[:-1]), highest[-1]) 97 | 98 | await self.channel.send( 99 | "```py\n@ VOTING DRAW @\nDraw between {}\nDitching all current votes and starting fresh.```".format( 100 | draw_join 101 | ) 102 | ) 103 | else: 104 | cmd = highest[0][0] 105 | amt = len(highest[0][1]) 106 | 107 | await self.channel.send( 108 | '```py\n@ VOTING RESULTS @\nRunning command "{}" with {} vote(s).\n```'.format( 109 | cmd, amt 110 | ) 111 | ) 112 | self._send_input(cmd) 113 | 114 | self.votes = {} 115 | self.voting = True 116 | self.timer = None 117 | except Exception as e: 118 | print(e) 119 | raise e 120 | 121 | def _send_input(self, input): 122 | """Send's text input to the game process.""" 123 | if not self.process: 124 | raise Exception("Channel does not have an attached process.") 125 | 126 | if input == "ENTER": 127 | input = "" 128 | elif input == "SPACE": 129 | input = " " 130 | 131 | self.process.stdin.write((input + "\n").encode("latin-1", "replace")) 132 | 133 | async def parse_output(self, buffer): 134 | if buffer != b"": 135 | out = buffer.decode("latin-1", "replace") 136 | msg = "" 137 | 138 | for i, line in enumerate(out.splitlines()): 139 | if line.strip() == ".": 140 | line = "" 141 | 142 | line = line.replace("*", "\*").replace("_", "\_").replace("~", "\~") 143 | 144 | if len(msg + line[self.indent :] + "\n") < 2000: 145 | msg += line[self.indent :] + "\n" 146 | else: 147 | await self.send_game_output(msg) 148 | 149 | msg = line[self.indent :] 150 | 151 | if not msg.strip(): 152 | return 153 | 154 | msg = msg.strip() 155 | saves = self.check_saves() 156 | 157 | if self.first_time: 158 | saves = None 159 | self.first_time = False 160 | 161 | await self.send_game_output(msg, saves) 162 | 163 | async def game_loop(self): 164 | """Enters into the channel's game process loop.""" 165 | if not self.process: 166 | await self.init_process() 167 | 168 | self.first_time = True 169 | self.playing = True 170 | 171 | async def looper(buffer): 172 | await self.parse_output(buffer) 173 | 174 | if os.path.exists(self.save_path): 175 | files = os.listdir(self.save_path) 176 | latest = 0 177 | 178 | for file in os.listdir(self.save_path): 179 | mod_time = os.stat("{}/{}".format(self.save_path, file)).st_mtime_ns 180 | 181 | if ( 182 | mod_time < latest 183 | or SCRIPT_OR_RECORD.match(file) 184 | or file == "__UPLOADED__.qzl" 185 | ): 186 | os.unlink("{}/{}".format(self.save_path, file)) 187 | elif mod_time > latest and not SCRIPT_OR_RECORD.match(file): 188 | latest = mod_time 189 | 190 | await handle_process_output(self.process, looper, self.parse_output) 191 | 192 | self.playing = False 193 | end_msg = "```diff\n-The game has ended.\n" 194 | end_kwargs = {} 195 | 196 | if self.last_save: 197 | file_dir = "{}/{}".format(self.save_path, self.last_save) 198 | 199 | if os.path.isfile(file_dir): 200 | end_kwargs = {"file": discord.File(file_dir, self.last_save)} 201 | end_msg += "+Here is your most recent save from the game.\n" 202 | 203 | end_msg += "```" 204 | 205 | await self.channel.send(end_msg, **end_kwargs) 206 | 207 | self.cleanup() 208 | 209 | async def force_quit(self): 210 | """Forces the channel's game process to end.""" 211 | if self.process is not None: 212 | self.process.terminate() 213 | 214 | self.playing = False 215 | 216 | if self.timer: 217 | self.timer.cancel_task() 218 | 219 | async def handle_input(self, msg, input): 220 | """Easily handles the various input types for the game.""" 221 | 222 | if self.mode == InputMode.ANARCHY: 223 | # Default mode, anyone can send any command at any time. 224 | self._send_input(input) 225 | elif self.mode == InputMode.DEMOCRACY: 226 | # Players vote on commands. After 15 seconds of input, the top command is picked. 227 | # On ties, all commands are scrapped and we start again. 228 | if not self.voting: 229 | return 230 | 231 | voters = [] 232 | [voters.extend(x) for x in self.votes.values()] 233 | 234 | if msg.author.id in voters: 235 | return 236 | 237 | action = parse_action(input) 238 | 239 | if action in self.votes: 240 | self.votes[action] += [msg.author.id] 241 | else: 242 | self.votes[action] = [msg.author.id] 243 | 244 | await self.channel.send( 245 | "{} has voted for `{}`".format(msg.author.mention, action) 246 | ) 247 | 248 | if not self.timer: 249 | self.timer = self.loop.create_task(self._democracy_loop()) 250 | 251 | elif self.mode == InputMode.DRIVER: 252 | # Only the "driver" can send input. They can pass the "wheel" to other people. 253 | if msg.author.id == self.owner.id: 254 | self._send_input(input) 255 | else: 256 | raise ValueError("Currently in unknown input state: {}".format(self.mode)) 257 | 258 | async def init_process(self): 259 | """Sets up the channel's game process.""" 260 | if self.process: 261 | raise Exception("Game already has a process.") 262 | 263 | # Make directory for saving 264 | if not os.path.exists(self.save_path): 265 | os.makedirs(self.save_path) 266 | 267 | if self.save: 268 | self.process = await asyncio.create_subprocess_shell( 269 | "dfrotz -h 80 -w 5000 -m -R {} -L {} '{}'".format( 270 | self.save_path, self.save, self.game.path 271 | ), 272 | stdout=PIPE, 273 | stdin=PIPE, 274 | ) 275 | else: 276 | self.process = await asyncio.create_subprocess_shell( 277 | "dfrotz -h 80 -w 5000 -m -R {} '{}'".format( 278 | self.save_path, self.game.path 279 | ), 280 | stdout=PIPE, 281 | stdin=PIPE, 282 | ) 283 | 284 | async def send_game_output(self, msg, save=None): 285 | """Sends the game output to the game's channel, handling permissions.""" 286 | if self.output: 287 | print(msg) 288 | 289 | can_attach = self.channel.permissions_for(self.channel.guild.me).attach_files 290 | opts = {} 291 | 292 | if self.channel.permissions_for(self.channel.guild.me).embed_links: 293 | opts["embed"] = discord.Embed( 294 | description=msg, colour=self.channel.guild.me.top_role.colour 295 | ) 296 | else: 297 | opts["content"] = ">>> {}".format(msg) 298 | 299 | if save and can_attach: 300 | opts["file"] = save 301 | elif save and not can_attach: 302 | if "content" in opts: 303 | opts["content"] += ( 304 | "\nI was unable to attach the save game due to not having permission to attach files.\n" 305 | "If you wish to have saves available, please give me the `Attach Files` permission." 306 | ) 307 | else: 308 | opts["embed"].add_field( 309 | name="\u200b", 310 | value="I was unable to attach the save game due to not having permission to attach files.\n" 311 | "If you wish to have saves available, pleease give me the `Attach Files` permission.", 312 | ) 313 | 314 | await self.channel.send(**opts) 315 | 316 | def check_saves(self): 317 | """Checks if the user saved the game.""" 318 | if os.path.exists(self.save_path): 319 | files = [ 320 | x 321 | for x in os.listdir(self.save_path) 322 | if not SCRIPT_OR_RECORD.match(x) and x != "__UPLOADED__.qzl" 323 | ] 324 | latest = [0, None] 325 | 326 | for file in files: 327 | mod_time = os.stat("{}/{}".format(self.save_path, file)).st_mtime_ns 328 | 329 | if mod_time > latest[0]: 330 | latest = [mod_time, file] 331 | 332 | if latest[1] and latest[1] != self.last_save: 333 | self.last_save = latest[1] 334 | return discord.File( 335 | "{}/{}".format(self.save_path, latest[1]), latest[1] 336 | ) 337 | return None 338 | 339 | def cleanup(self): 340 | """Cleans up after the game.""" 341 | 342 | # Check if cleanup has already been done. 343 | if os.path.isdir(self.save_path): 344 | shutil.rmtree(self.save_path) 345 | -------------------------------------------------------------------------------- /modules/posts.py: -------------------------------------------------------------------------------- 1 | import json 2 | import asyncio 3 | 4 | 5 | async def post_carbon(xyzzy): 6 | if xyzzy.carbon_key: 7 | url = "https://www.carbonitex.net/discord/data/botdata.php" 8 | data = {"key": xyzzy.carbon_key, "servercount": len(xyzzy.guilds)} 9 | 10 | print("\nPosting to Carbonitex...") 11 | 12 | async with xyzzy.session.post(url, data=data) as r: 13 | text = await r.text() 14 | 15 | print("[{}] {}".format(r.status, text)) 16 | 17 | 18 | async def post_dbots(xyzzy): 19 | if xyzzy.dbots_key: 20 | url = "https://bots.discord.pw/api/bots/{}/stats".format(xyzzy.user.id) 21 | data = json.dumps({"server_count": len(xyzzy.guilds)}) 22 | headers = {"Authorization": xyzzy.dbots_key, "content-type": "application/json"} 23 | 24 | print("\nPosting to DBots...") 25 | 26 | async with xyzzy.session.post(url, data=data, headers=headers) as r: 27 | text = await r.text() 28 | 29 | print("[{}] {}".format(r.status, text)) 30 | 31 | 32 | async def post_gist(xyzzy): 33 | if xyzzy.gist_key and xyzzy.gist_id: 34 | url = "https://api.github.com/gists/" + xyzzy.gist_id 35 | data = { 36 | "server_count": len(xyzzy.guilds), 37 | "session_count": xyzzy.game_count(), 38 | "token": "MTcxMjg4MjM4NjU5NjAwMzg0.Bqwo2M.YJGwHHKzHqRcqCI2oGRl-tlRpn", 39 | } 40 | 41 | if xyzzy.gist_data_cache != data: 42 | xyzzy.gist_data_cache = data 43 | data = json.dumps( 44 | {"files": {"xyzzy_data.json": {"content": json.dumps(data)}}} 45 | ) 46 | headers = { 47 | "Accept": "application/vnd.github.v3+json", 48 | "Authorization": "token " + xyzzy.gist_key, 49 | } 50 | 51 | print("\nPosting to GitHub...") 52 | 53 | async with xyzzy.session.patch(url, data=data, headers=headers) as r: 54 | print("[{}]".format(r.status)) 55 | else: 56 | print("\nGitHub posting skipped.") 57 | 58 | 59 | async def post_all(xyzzy): 60 | await post_carbon(xyzzy) 61 | await post_dbots(xyzzy) 62 | await post_gist(xyzzy) 63 | 64 | 65 | def task_loop(xyzzy): 66 | # 10/10 would make dumb function names again 67 | async def loopy_doodle(): 68 | while True: 69 | await post_all(xyzzy) 70 | await asyncio.sleep(3600) 71 | 72 | return xyzzy.loop.create_task(loopy_doodle()) 73 | -------------------------------------------------------------------------------- /modules/process_helpers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | async def handle_process_output(process, looper, after): 5 | buffer = b"" 6 | 7 | while process.returncode is None: 8 | try: 9 | output = await asyncio.wait_for(process.stdout.read(1), 0.5) 10 | buffer += output 11 | except asyncio.TimeoutError: 12 | await looper(buffer) 13 | 14 | buffer = b"" 15 | 16 | last = await process.stdout.read() 17 | 18 | await after(last) 19 | -------------------------------------------------------------------------------- /modules/quetzal_parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Quetzal file format parser. 3 | Only parses the IFhd header, because that's all we need. 4 | Loosely based off of the code here: https://github.com/sussman/zvm/blob/master/zvm/quetzal.py 5 | 6 | Quetzal file format standard: http://inform-fiction.org/zmachine/standards/quetzal/index.html 7 | """ 8 | 9 | from typing import List, Union 10 | from chunk import Chunk 11 | 12 | import os 13 | 14 | 15 | class HeaderData: 16 | def __init__(self, release, serial, checksum): 17 | self.release = release 18 | self.serial = serial 19 | self.checksum = checksum 20 | 21 | def __str__(self): 22 | return "HeaderData(release={}, serial={}, checksum={})".format( 23 | self.release, self.serial, self.checksum 24 | ) 25 | 26 | def __repr__(self): 27 | return self.__str__() 28 | 29 | 30 | def read_word(address: int, mem: List[int]) -> int: 31 | """Read's a 16-bit value at the specified address.""" 32 | return (mem[address] << 8) + mem[address + 1] 33 | 34 | 35 | def parse_quetzal(fp) -> HeaderData: 36 | """Reads a Quetzal save file, and returns information about the associated game.""" 37 | if type(fp) != str and not hasattr(fp, "read"): 38 | raise TypeError("file is not a string or a bytes-like object.") 39 | 40 | if type(fp) == str: 41 | if not os.path.isfile(fp): 42 | raise Exception("File provided isn't a file, or doesn't exist.") 43 | 44 | # Open file as bytes 45 | qzl = open(fp, "rb") 46 | else: 47 | qzl = fp 48 | 49 | header = qzl.read(4) 50 | 51 | # Header checking yay. 52 | if header != b"FORM": 53 | raise Exception("Invalid file format.") 54 | 55 | qzl.read(4) # Skip some bytes we don't care about. 56 | 57 | ftype = qzl.read(4) 58 | 59 | # More header checking yay. 60 | if ftype != b"IFZS": 61 | raise Exception("Invalid file format.") 62 | 63 | chunk = Chunk(qzl) 64 | name = chunk.getname() 65 | size = chunk.getsize() 66 | data = chunk.read(size).decode("latin_1") 67 | 68 | # Make sure first chunk is IFhd. 69 | if name != b"IFhd": 70 | raise Exception("File does not start with an IFhd chunk.") 71 | elif size != 13: 72 | raise Exception("Invalid size for IFhd chunk: " + str(size)) 73 | 74 | try: 75 | # Bitwise magic to get data. 76 | release = (ord(data[0]) << 8) + ord(data[1]) 77 | serial = int(data[2:8]) 78 | checksum = (ord(data[8]) << 8) + ord(data[9]) 79 | # pc = (ord(data[10]) << 16) + (ord(data[11]) << 8) + ord(data[12]) # This isn't needed rn, but it's commented just in case. 80 | except ValueError as e: 81 | print(data) 82 | print(release) 83 | print(data[2:8]) 84 | print((ord(data[8]) << 8) + ord(data[9])) 85 | raise e 86 | 87 | return HeaderData(release, serial, checksum) 88 | 89 | 90 | def parse_zcode(path: str) -> HeaderData: 91 | """Parses the header of a z-code game, and returns some information.""" 92 | if type(path) != str: 93 | raise TypeError("path is not a string.") 94 | 95 | if not os.path.isfile(path): 96 | raise Exception("File provided isn't a file, or doesn't exist.") 97 | 98 | with open(path, encoding="latin_1") as zcode: 99 | mem = zcode.read() 100 | mem = [ord(x) for x in mem] 101 | 102 | # Byte magic 103 | release = read_word(2, mem) 104 | serial = int("".join(chr(x) for x in mem[0x12:0x18])) 105 | checksum = read_word(0x1C, mem) 106 | 107 | return HeaderData(release, serial, checksum) 108 | 109 | 110 | def compare_quetzal( 111 | quetzal: Union[str, HeaderData], game: Union[str, HeaderData] 112 | ) -> bool: 113 | """Reads a Quetzal file and a game file, and determines if they match.""" 114 | if not isinstance(quetzal, (HeaderData, str)): 115 | raise Exception("`quetzal` is not a HeaderData instance, or a string.") 116 | elif not isinstance(game, (HeaderData, str)): 117 | raise Exception("game_path is not a HeaderData instance, or a string.") 118 | 119 | if type(quetzal) == str: 120 | quetzal = parse_quetzal(quetzal) 121 | 122 | if type(game) == str: 123 | game = parse_zcode(game) 124 | 125 | if quetzal.release != game.release: 126 | return False 127 | elif quetzal.serial != game.serial: 128 | return False 129 | elif game.checksum != 0 and quetzal.checksum != game.checksum: 130 | return False 131 | 132 | return True 133 | -------------------------------------------------------------------------------- /options.sample.cfg: -------------------------------------------------------------------------------- 1 | [Config] 2 | 3 | # REQUIRED 4 | # Your bot token goes here. 5 | # token = 123458239472808950342 6 | 7 | # OPTIONAL 8 | # Provide a channel ID here to set a "home channel", where Xyzzy will report 9 | # information. like server joins. 10 | # home_channel_id = 12345676789018 11 | 12 | # Any IDs found in this comma-seperated list will have access to debug and 13 | # shutdown commands on the bot. This is a huge responsibility and is not to be 14 | # taken lightly: a sneaky user can use a quick and simple debug command to get 15 | # your bot token. 16 | # The default IDs are for Roadcrosser#3657 and Orangestar#1432 17 | owner_ids = 88401933936640000, 116138050710536192 18 | 19 | # Key for the Carbotinex Bot API 20 | # carbon_key = gyaaaaaaaaaaa 21 | 22 | # Key for the Discord Bots API 23 | # dbots_key = abalabahaha 24 | 25 | # Key and Gist ID for GitHub 26 | # gist_key = bepis 27 | # gist_id = 133742069 28 | -------------------------------------------------------------------------------- /plugh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Roadcrosser/xyzzy/49963ac025a561bfa2279d38e98d8d8445c359f9/plugh.png -------------------------------------------------------------------------------- /xyzzy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Roadcrosser/xyzzy/49963ac025a561bfa2279d38e98d8d8445c359f9/xyzzy.png -------------------------------------------------------------------------------- /xyzzy.py: -------------------------------------------------------------------------------- 1 | import sys # Checking if host platform is Windows 2 | 3 | if sys.platform == "win32": 4 | raise Exception( 5 | "Xyzzy cannot run on Windows as it requires asyncios's subproccess." 6 | ) 7 | 8 | import shutil # Check if dfrotz is in PATH 9 | 10 | if not shutil.which("dfrotz"): 11 | raise Exception( 12 | 'dfrotz not detected to be in PATH. If you do not have frotz in dumb mode, refer to "https://github.com/DavidGriffith/frotz/blob/master/INSTALL#L78", and then move the dfrotz executable to somewhere that is in PATH, for example /usr/bin.' 13 | ) 14 | 15 | from modules.command_sys import Context, Holder 16 | from modules.game import Game 17 | from datetime import datetime 18 | from glob import glob 19 | from random import randint 20 | from configparser import ConfigParser 21 | 22 | import os 23 | import json 24 | import re 25 | import asyncio 26 | import traceback 27 | import typing 28 | import aiohttp 29 | import disnake as discord 30 | import modules.posts as posts 31 | 32 | OPTIONAL_CONFIG_OPTIONS = ( 33 | "home_channel_id", 34 | "owner_ids", 35 | "carbon_key", 36 | "dbots_key", 37 | "gist_key", 38 | "gist_id", 39 | ) 40 | REQUIRED_CONFIG_OPTIONS = { 41 | "token": '"token" option required in configuration.\nThis is needed to connect to Discord and actually run.\nMake sure there is a line that is something like "token = hTtPSwWwyOutUBECOMW_AtcH-vdQW4W9WgXc_q".', 42 | } 43 | 44 | CAH_REGEX = re.compile( 45 | r"(?:can|does|is) this bot (?:play |do )?(?:cah|cards against humanity|pretend you'?re xyzzy)\??" 46 | ) 47 | 48 | 49 | class ConsoleColours: 50 | HEADER = "\033[95m" 51 | OK_BLUE = "\033[94m" 52 | OK_GREEN = "\033[92m" 53 | WARNING = "\033[93m" 54 | FAIL = "\033[91m" 55 | END = "\033[0m" 56 | BOLD = "\033[1m" 57 | UNDERLINE = "\033[4m" 58 | 59 | 60 | class Xyzzy(discord.Client): 61 | home_channel: discord.TextChannel 62 | prefix: re.Pattern 63 | 64 | def __init__(self): 65 | print(ConsoleColours.HEADER + "Welcome to Xyzzy, v2.0." + ConsoleColours.END) 66 | 67 | if not os.path.exists("./saves/"): 68 | print('Creating saves directory at "./saves/"') 69 | os.makedirs("./saves/") 70 | 71 | self.config = {} 72 | self.timestamp = 0 73 | 74 | print('Reading "options.cfg".') 75 | 76 | parser = ConfigParser() 77 | 78 | with open("./options.cfg") as cfg_data: 79 | parser.read_string(cfg_data.read()) 80 | self.config = dict(parser._sections["Config"]) 81 | 82 | for opt, msg in REQUIRED_CONFIG_OPTIONS.items(): 83 | if opt not in self.config: 84 | raise Exception(msg) 85 | else: 86 | self.__setattr__(opt, self.config[opt]) 87 | 88 | for opt in OPTIONAL_CONFIG_OPTIONS: 89 | if opt in self.config: 90 | self.__setattr__(opt, self.config[opt]) 91 | else: 92 | self.__setattr__(opt, None) 93 | 94 | if self.home_channel_id: 95 | self.home_channel_id = int(self.home_channel_id) 96 | 97 | self.owner_ids = ( 98 | [] if not self.owner_ids else [x.strip() for x in self.owner_ids.split(",")] 99 | ) 100 | self.gist_data_cache = None 101 | self.gist_game_cache = None 102 | 103 | print("Reading game database...") 104 | 105 | with open("./games.json") as games: 106 | games = json.load(games) 107 | self.games = {} 108 | 109 | for name, data in games.items(): 110 | if not os.path.exists(data["path"]): 111 | print("Path for {} is invalid. Delisting.".format(name)) 112 | else: 113 | self.games[name] = Game(name, data) 114 | 115 | if not os.path.exists("./bot-data/"): 116 | print('Creating bot data directory at "./bot-data/"') 117 | os.makedirs("./bot-data/") 118 | 119 | if not os.path.exists("./save-cache/"): 120 | print('Creating save cache directory at "./save-cache/"') 121 | os.makedirs("./save-cache/") 122 | 123 | try: 124 | print("Loading blocked user list...") 125 | 126 | with open("./bot-data/blocked_users.json") as blk: 127 | self.blocked_users = json.load(blk) 128 | except FileNotFoundError: 129 | print( 130 | ConsoleColours.WARNING 131 | + "Blocked user list not found. Creating new blocked user list..." 132 | + ConsoleColours.END 133 | ) 134 | 135 | with open("./bot-data/blocked_users.json", "w") as blk: 136 | blk.write("{}") 137 | self.blocked_users = {} 138 | 139 | try: 140 | print("Loading server settings...") 141 | 142 | with open("./bot-data/server_settings.json") as srv: 143 | self.server_settings = json.load(srv) 144 | except FileNotFoundError: 145 | print( 146 | ConsoleColours.WARNING 147 | + "Server settings not found. Creating new server settings file.." 148 | + ConsoleColours.END 149 | ) 150 | 151 | with open("./bot-data/server_settings.json", "w") as srv: 152 | srv.write("{}") 153 | self.server_settings = {} 154 | 155 | self.process = None 156 | self.thread = None 157 | self.queue = None 158 | self.channels = {} 159 | 160 | self.session = aiohttp.ClientSession() 161 | self.commands = Holder(self) 162 | 163 | if os.listdir("./saves"): 164 | print("Cleaning out saves directory after reboot.") 165 | 166 | for s in os.listdir("./saves"): 167 | shutil.rmtree("./saves/" + s) 168 | 169 | print( 170 | ConsoleColours.OK_GREEN 171 | + "Initialisation complete! Connecting to Discord..." 172 | + ConsoleColours.END 173 | ) 174 | 175 | super().__init__() 176 | 177 | def game_count(self): 178 | return sum(1 for i in self.channels.values() if i.game and not i.game.debug) 179 | 180 | async def update_game(self): 181 | game = "nothing yet!" 182 | 183 | if self.game_count(): 184 | game = "{} game{}.".format( 185 | self.game_count(), "s" if len(self.channels) > 1 else "" 186 | ) 187 | 188 | game += " | @xyzzy help" 189 | 190 | await self.change_presence(activity=discord.Game(name=game)) 191 | 192 | async def handle_error(self, ctx, exc): 193 | trace = "".join(traceback.format_tb(exc.__traceback__)) 194 | err = "Traceback (most recent call last):\n{}{}: {}".format( 195 | trace, type(exc).__name__, exc 196 | ) 197 | 198 | print("\n" + ConsoleColours.FAIL + "An error has occured!") 199 | print(err + ConsoleColours.END) 200 | 201 | if ctx.is_dm(): 202 | print('This was caused by a DM with "{}".\n'.format(ctx.msg.author.name)) 203 | else: 204 | print( 205 | 'This was caused by a message.\nServer: "{}"\nChannel: #{}'.format( 206 | ctx.msg.guild.name, ctx.msg.channel.name 207 | ) 208 | ) 209 | 210 | if self.home_channel: 211 | await self.home_channel.send( 212 | "User: `{}`\nInput: `{}`\n```py\n{}\n```".format( 213 | ctx.msg.author.name, ctx.clean, err 214 | ) 215 | ) 216 | 217 | await ctx.send( 218 | '```py\nERROR at memory location {}\n {}: {}\n\nInput: "{}"\n```'.format( 219 | hex(randint(2**4, 2**32)), type(exc).__name__, exc, ctx.clean 220 | ) 221 | ) 222 | 223 | async def on_ready(self): 224 | print( 225 | "======================\n" 226 | "{0.user.name} is online.\n" 227 | "Connected with ID {0.user.id}\n" 228 | "Accepting commands with the syntax `@{0.user.name}#{0.user.discriminator} command`".format( 229 | self 230 | ) 231 | ) 232 | 233 | self.prefix = re.compile(rf"^<@!?{self.user.id}>(.*)") 234 | self.home_channel = typing.cast( 235 | discord.TextChannel, self.get_channel(self.home_channel_id) 236 | ) 237 | 238 | for mod in glob("commands/*.py"): 239 | mod = mod.replace("/", ".").replace("\\", ".")[:-3] 240 | 241 | try: 242 | self.commands.load_module(mod) 243 | except Exception as e: 244 | print( 245 | ConsoleColours.FAIL 246 | + 'Error loading module "{}"\n{}'.format(mod, e) 247 | + ConsoleColours.END 248 | ) 249 | 250 | await self.update_game() 251 | 252 | if not self.timestamp: 253 | self.timestamp = datetime.utcnow().timestamp() 254 | 255 | if self.gist_key and self.gist_id: 256 | url = "https://api.github.com/gists/" + self.gist_id 257 | headers = { 258 | "Accept": "application/vnd.github.v3+json", 259 | "Authorization": "token " + self.gist_key, 260 | } 261 | 262 | print("\nFetching cached GitHub data...") 263 | 264 | async with self.session.get(url, headers=headers) as r: 265 | res = await r.json() 266 | 267 | print("[{}]".format(r.status)) 268 | 269 | self.gist_data_cache = json.loads( 270 | res["files"]["xyzzy_data.json"]["content"] 271 | ) 272 | self.gist_game_cache = json.loads( 273 | res["files"]["xyzzy_games.json"]["content"] 274 | ) 275 | gist_game = sorted( 276 | [[k, v.url] for k, v in self.games.items()], key=lambda x: x[0] 277 | ) 278 | 279 | if self.gist_game_cache != gist_game: 280 | gist_game = json.dumps( 281 | { 282 | "files": { 283 | "xyzzy_games.json": {"content": json.dumps(gist_game)} 284 | } 285 | } 286 | ) 287 | 288 | async with self.session.patch( 289 | url, data=gist_game, headers=headers 290 | ) as r: 291 | print("[{}]".format(r.status)) 292 | 293 | self.post_loop = await posts.task_loop(self) 294 | 295 | async def on_guild_join(self, guild: discord.Guild): 296 | print('I have been added to "{}".'.format(guild.name)) 297 | 298 | if self.home_channel: 299 | await self.home_channel.send( 300 | 'I have been added to "{0.name}" (ID: {0.id}).'.format(guild) 301 | ) 302 | 303 | async def on_guild_remove(self, guild: discord.Guild): 304 | print('I have been removed from "{}".'.format(guild.name)) 305 | 306 | if self.home_channel: 307 | await self.home_channel.send( 308 | 'I have been removed from "{0.name}" (ID: {0.id}).'.format(guild) 309 | ) 310 | 311 | async def on_message(self, msg: discord.Message): 312 | # don't check message if no prefix 313 | if 'prefix' not in dir(self): return 314 | 315 | is_reply_to_me = ( 316 | msg.reference 317 | and isinstance(msg.reference.resolved, discord.Message) 318 | and msg.reference.resolved.author.id == self.user.id 319 | ) 320 | 321 | if ( 322 | msg.author.bot 323 | or msg.author.id == self.user.id 324 | or ( 325 | msg.guild 326 | and not msg.channel.permissions_for(msg.guild.me).send_messages 327 | ) 328 | or (not self.prefix.match(msg.content) and not is_reply_to_me) 329 | ): 330 | return 331 | 332 | if msg.guild and ( 333 | ( 334 | str(msg.guild.id) in self.blocked_users 335 | and str(msg.author.id) in self.blocked_users[str(msg.guild.id)] 336 | ) 337 | or ( 338 | "global" in self.blocked_users 339 | and str(msg.author.id) in self.blocked_users["global"] 340 | ) 341 | ): 342 | return await msg.author.send( 343 | "```diff\n" 344 | '!An administrator has disabled your ability to submit commands in "{}"\n' 345 | "```".format(msg.guild.name) 346 | ) 347 | 348 | # Take plain message content if it's a reply to us, otherwise force prefix match 349 | clean = ( 350 | msg.content 351 | if is_reply_to_me 352 | else typing.cast(re.Match, self.prefix.match(msg.content))[1].strip() 353 | ) 354 | is_game_cmd = clean.startswith(">") 355 | 356 | # Without this, an error is thrown below due to only one character. 357 | if len(clean) == 0: 358 | return 359 | 360 | # Send game input if a game is running. 361 | if ( 362 | is_game_cmd 363 | and msg.channel.id in self.channels 364 | and self.channels[msg.channel.id].playing 365 | ): 366 | channel = self.channels[msg.channel.id] 367 | channel.last = msg.created_at 368 | 369 | return await channel.handle_input(msg, clean[1:].strip()) 370 | 371 | if clean == "get ye flask": 372 | return await msg.channel.send("You can't get ye flask!") 373 | 374 | if CAH_REGEX.match(clean): 375 | return await msg.channel.send("no") 376 | 377 | if not self.commands.get_command(clean.split(" ")[0]): 378 | return 379 | 380 | try: 381 | ctx = Context(msg, self) 382 | except ValueError: 383 | return await msg.channel.send("Shlex error.") 384 | 385 | try: 386 | await self.commands.run(ctx) 387 | except Exception as e: 388 | await self.handle_error(ctx, e) 389 | 390 | 391 | if __name__ == "__main__": 392 | # Only start the bot if it is being run directly 393 | bot = Xyzzy() 394 | bot.run(bot.token) 395 | --------------------------------------------------------------------------------