├── .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 |
--------------------------------------------------------------------------------