├── requirements.txt ├── icon ├── icon.png └── icon.svg ├── .gitignore ├── pyproject.toml ├── sample.config ├── discoggin ├── util.py ├── __main__.py ├── attlist.py ├── clifunc.py ├── markup.py ├── games.py ├── sessions.py ├── glk.py └── client.py ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | discord.py~=2.5 2 | aiohttp>=3.9 3 | -------------------------------------------------------------------------------- /icon/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iftechfoundation/discoggin/main/icon/icon.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | games 3 | terps 4 | autosaves 5 | savefiles 6 | sql 7 | venv 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "discoggin" 3 | version = "0.1" 4 | dependencies = [ 5 | "discord.py~=2.5", 6 | "aiohttp>=3.9", 7 | ] 8 | requires-python = ">=3.8" 9 | authors = [ 10 | { name = "Andrew Plotkin", email = "erkyrath@eblong.com" } 11 | ] 12 | description = "A Discord bot for playing parser IF games" 13 | license = "MIT" 14 | 15 | [project.urls] 16 | Repository = "https://github.com/iftechfoundation/discoggin.git" 17 | -------------------------------------------------------------------------------- /sample.config: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | 3 | # The "Token" from the "Bot" tab of your Discord application. 4 | BotToken = XXXXXX 5 | 6 | # Path for the Sqlite database. 7 | DBFile = ./sql/discoggin.db 8 | 9 | # Path for logging. 10 | LogFile = ./log/bot.log 11 | 12 | # Directory to find interpreters in. 13 | InterpretersDir = ./terps 14 | 15 | # Directory to store game files in. 16 | GamesDir = ./games 17 | 18 | # Directory to store game state. 19 | AutoSaveDir = ./autosaves 20 | 21 | # Directory to store player-created save files and game data files. 22 | SaveFileDir = ./savefiles 23 | -------------------------------------------------------------------------------- /discoggin/util.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import json 3 | 4 | def delete_flat_dir(path): 5 | """Delete a directory and all the files it contains. This is *not* 6 | recursive; if the directory contains subdirs, it will raise an 7 | exception (before deleting anything). 8 | """ 9 | if not os.path.exists(path): 10 | return 11 | if os.path.isfile(path): 12 | raise Exception('not a directory: %s' % (path,)) 13 | files = list(os.scandir(path)) 14 | for ent in files: 15 | if ent.is_dir(follow_symlinks=False): 16 | raise Exception('contains a directory: %s' % (ent.path,)) 17 | for ent in files: 18 | os.remove(ent.path) 19 | os.rmdir(path) 20 | 21 | def load_json(path): 22 | """ 23 | Read and parse a JSON file. Allow for the possibility of JSONP 24 | ("var foo = {...};"). 25 | """ 26 | with open(path) as fl: 27 | dat = fl.read() 28 | startpos = dat.index('{') 29 | endpos = dat.rindex('}') 30 | dat = dat[ startpos : endpos+1 ] 31 | return json.loads(dat) 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2025, Andrew Plotkin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /discoggin/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging, logging.handlers 3 | import argparse 4 | import configparser 5 | 6 | from .client import DiscogClient 7 | from .clifunc import cmd_createdb, cmd_addchannel, cmd_delchannel, cmd_delsession, cmd_delgame, cmd_cmdinstall 8 | 9 | popt = argparse.ArgumentParser(prog='python -m discoggin') 10 | subopt = popt.add_subparsers(dest='cmd', title='commands') 11 | 12 | popt.add_argument('--logstream', 13 | action='store_true', dest='logstream', 14 | help='log to stdout rather than the configured file') 15 | 16 | pcmd = subopt.add_parser('createdb', help='create database tables') 17 | pcmd.set_defaults(cmdfunc=cmd_createdb) 18 | 19 | pcmd = subopt.add_parser('cmdinstall', help='upload slash commands to Discord') 20 | pcmd.set_defaults(cmdfunc=cmd_cmdinstall) 21 | 22 | pcmd = subopt.add_parser('addchannel', help='add a playing channel') 23 | pcmd.add_argument('channelurl') 24 | pcmd.set_defaults(cmdfunc=cmd_addchannel) 25 | 26 | pcmd = subopt.add_parser('delchannel', help='delete a playing channel') 27 | pcmd.add_argument('channelurl') 28 | pcmd.set_defaults(cmdfunc=cmd_delchannel) 29 | 30 | pcmd = subopt.add_parser('delsession', help='delete a session') 31 | pcmd.add_argument('sessionid') 32 | pcmd.set_defaults(cmdfunc=cmd_delsession) 33 | 34 | pcmd = subopt.add_parser('delgame', help='delete a game') 35 | pcmd.add_argument('game') 36 | pcmd.set_defaults(cmdfunc=cmd_delgame) 37 | 38 | args = popt.parse_args() 39 | 40 | config = configparser.ConfigParser() 41 | config.read('app.config') 42 | 43 | bottoken = config['DEFAULT']['BotToken'] 44 | logfilepath = config['DEFAULT']['LogFile'] 45 | 46 | if args.logstream: 47 | loghandler = logging.StreamHandler(sys.stdout) 48 | else: 49 | loghandler = logging.handlers.WatchedFileHandler(logfilepath) 50 | logformatter = logging.Formatter('[%(levelname).1s %(asctime)s] (%(name)s) %(message)s', datefmt='%b-%d %H:%M:%S') 51 | loghandler.setFormatter(logformatter) 52 | 53 | rootlogger = logging.getLogger() 54 | rootlogger.addHandler(loghandler) 55 | rootlogger.setLevel(logging.INFO) 56 | 57 | client = DiscogClient(config) 58 | 59 | if args.cmd: 60 | args.cmdfunc(args, client) 61 | else: 62 | client.run(bottoken, log_handler=loghandler, log_formatter=logformatter) 63 | 64 | -------------------------------------------------------------------------------- /discoggin/attlist.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | class AttachList: 4 | def __init__(self): 5 | # maps channels to lists of Attachments 6 | self.map = {} 7 | 8 | def tryadd(self, obj, chan): 9 | """Add an attachment to the list associated with a channel, if 10 | it looks like a game file. 11 | The arguments are a Discord attachment and a Discord channel. 12 | """ 13 | chanid = chan.id 14 | try: 15 | att = Attachment(obj) 16 | except: 17 | return 18 | if not detect_format(att.filename): 19 | # Doesn't look like a game file. 20 | return 21 | 22 | if chanid not in self.map: 23 | self.map[chanid] = [] 24 | for oatt in self.map[chanid]: 25 | if oatt.url == att.url: 26 | # Already got this one. Bump the timestamp. 27 | # (This doesn't really help; Discord doesn't check for duplicate files. Let's pretend it will someday.) 28 | oatt.timestamp = att.timestamp 29 | return 30 | self.map[chanid].append(att) 31 | 32 | def getlist(self, chan): 33 | """Get the list of attachments associated with a channel. 34 | The argument is a Discord channel; the result is a list of 35 | our Attachment objects. 36 | """ 37 | chanid = chan.id 38 | if chanid not in self.map: 39 | return [] 40 | return list(self.map[chanid]) 41 | 42 | def findbyname(self, filename, chan): 43 | """Look through the list of attachments and find one with a given 44 | (exact) filename. If there are several, return the most recent. 45 | """ 46 | ls = self.getlist(chan) 47 | ls.sort(key=lambda att:-att.timestamp) 48 | for att in ls: 49 | if filename == att.filename: 50 | return att 51 | return None 52 | 53 | class Attachment: 54 | def __init__(self, obj): 55 | """The argument is a Discord Attachment object. 56 | We'll pull the interesting info from it. 57 | """ 58 | if not obj.filename or not obj.url: 59 | raise Exception('missing fields') 60 | self.filename = obj.filename 61 | self.url = obj.url 62 | self.timestamp = time.time() 63 | 64 | def __repr__(self): 65 | return '' % (self.filename,) 66 | 67 | 68 | # Late imports 69 | from .games import detect_format 70 | -------------------------------------------------------------------------------- /icon/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 39 | 55 | 56 | 58 | 62 | 68 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /discoggin/clifunc.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import re 3 | import logging 4 | 5 | from .sessions import get_session_by_id, get_sessions_for_hash, delete_session 6 | from .games import get_game_by_name, delete_game 7 | 8 | def cmd_createdb(args, app): 9 | curs = app.db.cursor() 10 | res = curs.execute('SELECT name FROM sqlite_master') 11 | tables = [ tup[0] for tup in res.fetchall() ] 12 | 13 | if 'games' in tables: 14 | print('"games" table exists') 15 | else: 16 | print('creating "games" table...') 17 | curs.execute('CREATE TABLE games(hash unique, filename, url, format)') 18 | 19 | if 'sessions' in tables: 20 | print('"sessions" table exists') 21 | else: 22 | print('creating "sessions" table...') 23 | curs.execute('CREATE TABLE sessions(sessid unique, gid, hash, movecount, lastupdate)') 24 | 25 | if 'channels' in tables: 26 | print('"channels" table exists') 27 | else: 28 | print('creating "channels" table...') 29 | curs.execute('CREATE TABLE channels(gckey unique, gid, chanid, sessid)') 30 | 31 | def cmd_cmdinstall(args, app): 32 | app.cmdsync = True 33 | bottoken = app.config['DEFAULT']['BotToken'] 34 | app.run(bottoken) 35 | print('slash commands installed') 36 | 37 | # We accept a full channel URL or a gckey. 38 | pat_channel = re.compile('^(?:https://discord.com/channels/)?([0-9]+)[/-]([0-9]+)$') 39 | 40 | def cmd_addchannel(args, app): 41 | match = pat_channel.match(args.channelurl) 42 | if not match: 43 | print('argument must be a channel URL: https://discord.com/channels/X/Y') 44 | return 45 | gid = match.group(1) 46 | chanid = match.group(2) 47 | gckey = gid+'-'+chanid 48 | 49 | curs = app.db.cursor() 50 | res = curs.execute('SELECT * FROM channels WHERE gckey = ?', (gckey,)) 51 | if res.fetchone(): 52 | print('channel is already enabled') 53 | return 54 | 55 | tup = (gckey, gid, chanid, None) 56 | curs.execute('INSERT INTO channels (gckey, gid, chanid, sessid) VALUES (?, ?, ?, ?)', tup) 57 | print('enabled channel') 58 | 59 | def cmd_delchannel(args, app): 60 | match = pat_channel.match(args.channelurl) 61 | if not match: 62 | print('argument must be a channel URL: https://discord.com/channels/X/Y') 63 | return 64 | gid = match.group(1) 65 | chanid = match.group(2) 66 | gckey = gid+'-'+chanid 67 | 68 | curs = app.db.cursor() 69 | res = curs.execute('SELECT * FROM channels WHERE gckey = ?', (gckey,)) 70 | if not res.fetchone(): 71 | print('channel is not enabled') 72 | return 73 | 74 | curs.execute('DELETE FROM channels WHERE gckey = ?', (gckey,)) 75 | print('deleted channel') 76 | 77 | def cmd_delsession(args, app): 78 | session = get_session_by_id(app, args.sessionid) 79 | if session is None: 80 | print('no such session:', args.sessionid) 81 | return 82 | 83 | # TODO: There's a tiny race condition here if someone makes a move in the session while we're deleting. Hand this off to the bot and rely on its inflight locking? 84 | delete_session(app, session.sessid) 85 | print('deleted session', args.sessionid) 86 | 87 | def cmd_delgame(args, app): 88 | game = get_game_by_name(app, args.game) 89 | if game is None: 90 | print('no such game:', args.game) 91 | return 92 | ls = get_sessions_for_hash(app, game.hash) 93 | if ls: 94 | sessls = [ str(obj.sessid) for obj in ls ] 95 | print('game has active sessions:', ', '.join(sessls)) 96 | return 97 | 98 | # TODO: There's a tiny race condition here if someone starts a session while we're deleting. 99 | delete_game(app, game.hash) 100 | print('deleted game', game.filename) 101 | 102 | -------------------------------------------------------------------------------- /discoggin/markup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | pat_cmd = re.compile('^[\\s]*>(.*)$') 4 | pat_linkinput = re.compile('^#([0-9]+)$') 5 | 6 | def extract_command(msg): 7 | """See whether a Discord message is meant as a command input. 8 | A command input is one line starting with ">" (perhaps whitespace too). 9 | Multi-line inputs, or inputs containing a command plus commentary, 10 | do not count. 11 | """ 12 | match = pat_cmd.match(msg.strip()) 13 | if match is None: 14 | return None 15 | val = match.group(1) 16 | return val.strip() 17 | 18 | def command_is_hyperlink(cmd): 19 | """See whether a command looks like a hyperlink reference: "#12", 20 | etc. Returns a number or None. 21 | This is theoretically ambiguous, but it's only a problem if a game 22 | expects both hyperlinks and line input of that form. 23 | ### TODO: accept a bare number if there is no line/char input pending. 24 | """ 25 | match = pat_linkinput.match(cmd) 26 | if match: 27 | return int(match.group(1)) 28 | return None 29 | 30 | def content_to_markup(dat, hyperlinklabels=None): 31 | """Convert a ContentLine object into a Discord message, using 32 | Discord markup as much as possible. 33 | Hyperlinks are rendered as "[#1][text]" -- that isn't Discord markup, 34 | mind you. The user gets to figure it out. 35 | """ 36 | ### has some bugs to do with whitespace. E.g. "_This _not_ that._" 37 | res = [] 38 | curlink = None 39 | 40 | uniformlink = None 41 | if hyperlinklabels: 42 | uniformlink = dat.uniformlink() 43 | if uniformlink: 44 | label = hyperlinklabels.get(uniformlink, '???') 45 | res.append('[#%s] ' % (label,)) 46 | curlink = uniformlink 47 | 48 | for tup in dat.arr: 49 | text = tup[0] 50 | style = tup[1] if len(tup) > 1 else 'normal' 51 | link = tup[2] if (len(tup) > 2 and hyperlinklabels) else None 52 | if link != curlink: 53 | if curlink is not None: 54 | res.append(']') 55 | curlink = link 56 | if curlink is not None: 57 | label = hyperlinklabels.get(curlink, '???') 58 | res.append('[#%s][' % (label,)) 59 | val = escape(text) 60 | if style == 'header' or style == 'subheader' or style == 'input': 61 | sval = '**'+val+'**' 62 | elif style == 'emphasized': 63 | sval = '_'+val+'_' 64 | elif style == 'preformatted': 65 | # use the unescaped text here 66 | sval = '`'+text+'`' 67 | else: 68 | sval = val 69 | res.append(sval) 70 | 71 | if not uniformlink: 72 | if curlink is not None: 73 | res.append(']') 74 | 75 | return ''.join(res) 76 | 77 | pat_singlechar = re.compile('[`*_<>\\[\\]\\\\]') 78 | 79 | def escape(val): 80 | """Escape a string so that Discord will interpret it as plain text. 81 | """ 82 | val = pat_singlechar.sub(lambda match:'\\'+match.group(0), val) 83 | ### more? 84 | return val 85 | 86 | # Maximum size of a Discord message (minus a safety margin). 87 | MSG_LIMIT = 1990 88 | 89 | def rebalance_output(ls): 90 | """Given a list of lines (paragraphs) to be sent as a Discord message, 91 | combine them as much as possible while staying under the Discord 92 | size limit. If there are any lines longer than the limit, break 93 | them up (preferably at word boundaries). 94 | 95 | This will introduce paragraph breaks into very long paragraphs; 96 | no help for that. 97 | """ 98 | res = [] 99 | cur = None 100 | for val in ls: 101 | if cur is not None: 102 | if len(cur)+1+len(val) < MSG_LIMIT: 103 | cur += '\n' 104 | cur += val 105 | continue 106 | if cur: 107 | res.append(cur) 108 | cur = None 109 | if len(val) < MSG_LIMIT: 110 | cur = val 111 | continue 112 | # This paragraph is over MSG_LIMIT by itself. Try to split it 113 | # up at word boundaries. If we can't do that, just split it. 114 | # BUG: This can split a markup span like "_italics_", which will 115 | # break the markup. 116 | while len(val) > MSG_LIMIT: 117 | pos = val[ : MSG_LIMIT ].rfind(' ') 118 | if pos >= 0: 119 | res.append(val[ : pos ]) 120 | val = val[ pos+1 : ] 121 | continue 122 | res.append(val[ : MSG_LIMIT ]) 123 | val = val[ MSG_LIMIT : ] 124 | if val: 125 | res.append(val) 126 | 127 | if cur: 128 | res.append(cur) 129 | cur = None 130 | 131 | # Make sure no completely blank or whitespace lines remain. 132 | # (Discord doesn't like to print those.) 133 | res = [ val.rstrip() for val in res ] 134 | res = [ val for val in res if val ] 135 | return res 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discoggin -- a Discord bot for playing parser IF games 2 | 3 | - Copyright 2025 by Andrew Plotkin 4 | - Distributed under the MIT license 5 | - Implemented with the [discord.py][] library 6 | 7 | [discord.py]: https://github.com/Rapptz/discord.py/ 8 | 9 | Discoggin (the name doesn't mean anything) allows players to run old-style interactive fiction games in a Discord channel. You play by typing commands like `>GET LAMP` as regular Discord chat messages. The initial `>` indicates a game command. The bot will carry out the command and respond with the game's output. 10 | 11 | It can also play newer-style choice-based games. For these, it will show numbered choices; you type a command like `>#2` to select one. 12 | 13 | Discoggin is configured to run on specific Discord channels, which are assumed to be dedicated to playing IF. (Non-players can mute the those channels.) It can only play one game at a time per channel, but it can keep any number of game sessions paused in the background. A "session" is a particular game along with its current state and any save files you've created. 14 | 15 | (The idea is that one group of players might log in on Tuesdays to play game X, while another group is playing game Y on Thursdays. You just have to **/select** your game session when you arrive in the channel. You will be back in your game, exactly where you left off.) 16 | 17 | (Yes, you can have two sessions playing the same game. In case the Tuesday and Thursday crowds have similar tastes. Each session is its own "save slot".) 18 | 19 | Sessions and games not played for thirty days will be discarded. 20 | 21 | ## Limitations 22 | 23 | There are many. Discoggin is a work in progress. 24 | 25 | At present, Discoggin can only play: 26 | 27 | - Z-code games (file suffix `.z1` through `.z8` plus `.zblorb`) 28 | - Glulx games (file suffix `.ulx` and `.gblorb`) 29 | - Ink games (file suffix `.json` or `.js`) 30 | - YarnSpinner games (file suffix `.json` or `.js`) 31 | 32 | It does not support extended display features like graphics or sound. (So `.z6` is not actually going to work.) 33 | 34 | There is currently no way to download save files. Similarly, you can create a transcript, but there is no way to view it. 35 | 36 | ## Slash commands 37 | 38 | Discoggin is controlled with the usual sort of Discord slash commands. 39 | 40 | - **/install** _URL_ : Download a game from a web site and install it for play. 41 | - **/install** _FILENAME_ : Install a game recently uploaded to the Discord channel. (Upload the game file in the usual Discord way, then give its filename.) 42 | - **/games** : List games available for play. 43 | - **/channels** : List channels available to play on. 44 | - **/sessions** : List game sessions in progress. 45 | - **/select** _GAME_ : Select a game to play in this channel. (This looks for an existing session of that game, or starts a new session.) 46 | - **/select** _SESSION_ : Select an existing session. 47 | - **/newsession** _GAME_ : Explicitly start a new session for the named game. 48 | - **/start** : Begin the selected game in this channel. 49 | - **/status** : Display the current status line of a game. 50 | - **/recap** _COUNT_ : Recap the last few commands (max of 10). 51 | - **/files** : List save files (and other data files) recorded in this session. 52 | - **/forcequit** : Shut down a game if it's gotten stuck for some reason. (You will then need to **/start** it again.) 53 | 54 | Sessions are referred to by number. Games are referred to by filename, or part of the filename. (**/select scroll** will suffice to find `Scroll_Thief.gblorb`, if it's installed.) 55 | 56 | ## Under the hood 57 | 58 | Discoggin uses traditional IF interpreters installed on the bot server. When a player enters a command, the bot fires up the interpreter, loads the last-saved position, passes in the command, saves the position again, and reports the command results on the Discord channel. 59 | 60 | The interpreters use an autosave feature, so you never have to explicitly save a game. But if you do, the save file is kept as part of the session data. The same is true of transcripts or other game data files. 61 | 62 | Because the interpreter only runs a single move at a time, there is no RAM or CPU cost associated with a session -- whether the session is active or background. Nothing is cached in memory; it's all files on disk. On the down side, the server incurs the CPU cost of launching an interpreter every time a command is processed. 63 | 64 | Getting even deeper: the interpreters all use the [RemGlk][] library, which translates the standard IF interface (story and status windows) into a stream of JSON updates. The Discoggin bot therefore just has to launch the interpreter as a subprocess (with the `--singleturn` option), and pass JSON in and out. 65 | 66 | [RemGlk]: https://github.com/erkyrath/remglk 67 | 68 | ## Setting up Discoggin 69 | 70 | To run your own installation of Discoggin, you must create a Discord application. 71 | 72 | ## On Discord's web site 73 | 74 | Go to the [Discord developer portal][discorddev] and hit "New Application". 75 | 76 | [discorddev]: https://discord.com/developers/applications 77 | 78 | On the General Information tab, give your bot an appropriate name. For this example, we will call it "MyDiscoggin". 79 | 80 | On the Installation tab, turn *off* the "User Install" checkbox. Then, under "Guild Install", add "bot" to the Scopes line. A new line will appear for Permissions; add "Manage Channels", "Manage Messages", "Send Messages", "Use Slash Commands". 81 | 82 | On the Bot tab, turn *on* the "Message Content Intent" switch. Hit the "Reset Token" button to create a bot token; record its value. 83 | 84 | Back on the Installation tab, use the URL under "Install Link" to install the application on your Discord instance. Give it the permissions it asks for. 85 | 86 | The MyDiscoggin bot will appear on your server, but it will appear as offline. That's because the bot process isn't running yet. 87 | 88 | Create a channel for Discoggin to play games in. For the sake of example, we will call it `#game`. 89 | 90 | ### On your machine 91 | 92 | Set up a directory to hold the bot's files. Create subdirectories: 93 | 94 | mkdir sql games terps autosaves savefiles log 95 | 96 | Copy the [`sample.config`](./sample.config) file into your directory and rename it `app.config`. 97 | 98 | In `app.config`, change the `BotToken` entry to the token value you recorded above. (If you didn't write down the value, you'll have to reset it again -- Discord only shows you the token once.) 99 | 100 | Create a venv for the application. (The [discord.py][] library doesn't play well with some other modules, so I prefer to use a venv.) 101 | 102 | python3 -m venv venv 103 | ./venv/bin/pip3 install -r requirements.txt 104 | 105 | Make sure the `discoggin` module is in your `$PYTHON_PATH` (or in the venv). 106 | 107 | Compile [Glulxe][] and [Bocfel][] with the [RemGlk][] library. I'm afraid I don't have detailed instructions for this. It's a pain in the butt. Once you've figured it out, put the `glulxe` and `bocfel` binaries in the `terps` directory. 108 | 109 | [Glulxe]: https://github.com/erkyrath/glulxe 110 | [Bocfel]: https://github.com/erkyrath/bocfel 111 | 112 | Install [inkrun.js][] in the `terps` directory. (Requires Node.js.) 113 | 114 | [inkrun.js]: https://github.com/erkyrath/inkrun-single 115 | 116 | Compile and install [ysrun][] in the `terps` directory. (Requires .NET.) 117 | 118 | [ysrun]: https://github.com/erkyrath/YSRun-Single 119 | 120 | Create the bot's SQLite database: 121 | 122 | ./venv/bin/python3 -m discoggin createdb 123 | 124 | Activate the bot in your `#game` channel. Use this command, replacing the URL with the channel URL from your Discord server: 125 | 126 | ./venv/bin/python3 -m discoggin addchannel https://discord.com/channels/12345678/87654321 127 | 128 | Install the bot's slash commands on your server: 129 | 130 | ./venv/bin/python3 -m discoggin --logstream cmdinstall 131 | 132 | Hit slash in your Discord window to see a list of the commands. You may have to reload your Discord window to make them appear. The commands won't work yet, though, because the bot isn't running. 133 | 134 | Time to do that! This command will start the bot: 135 | 136 | ./venv/bin/python3 -m discoggin 137 | 138 | Log output will be written to `log/bot.log` (or wherever you configured it in `app.config`). If you want to log to stdout, use the `--logstream` option: 139 | 140 | ./venv/bin/python3 -m discoggin --logstream 141 | 142 | -------------------------------------------------------------------------------- /discoggin/games.py: -------------------------------------------------------------------------------- 1 | import re 2 | import os, os.path 3 | import urllib.parse 4 | import time 5 | import logging 6 | import hashlib 7 | 8 | class GameFile: 9 | def __init__(self, hash, filename, url, format): 10 | self.hash = hash 11 | self.filename = filename 12 | self.url = url 13 | self.format = format 14 | 15 | def __repr__(self): 16 | return '' % (self.filename, self.format, self.hash,) 17 | 18 | def get_gamelist(app): 19 | curs = app.db.cursor() 20 | res = curs.execute('SELECT * FROM games') 21 | gamels = [ GameFile(*tup) for tup in res.fetchall() ] 22 | return gamels 23 | 24 | def get_gamemap(app): 25 | ls = get_gamelist(app) 26 | res = {} 27 | for game in ls: 28 | res[game.hash] = game 29 | return res 30 | 31 | def get_game_by_hash(app, hash): 32 | curs = app.db.cursor() 33 | res = curs.execute('SELECT * FROM games WHERE hash = ?', (hash,)) 34 | tup = res.fetchone() 35 | if not tup: 36 | return None 37 | return GameFile(*tup) 38 | 39 | def get_game_by_name(app, val): 40 | ls = get_gamelist(app) 41 | for game in ls: 42 | if game.hash == val: 43 | return game 44 | lval = val.lower() 45 | for game in ls: 46 | if lval in game.filename.lower(): 47 | return game 48 | return None 49 | 50 | def get_game_by_session(app, sessid): 51 | session = get_session_by_id(app, sessid) 52 | if not session: 53 | return None 54 | return get_game_by_hash(app, session.hash) 55 | 56 | def get_game_by_channel(app, gckey): 57 | playchan = get_playchannel(app, gckey) 58 | if not playchan: 59 | return None 60 | if not playchan.sessid: 61 | return None 62 | return get_game_by_session(app, playchan.sessid) 63 | 64 | def delete_game(app, hash): 65 | """Delete a game and all its files. 66 | This is called from the command-line. 67 | """ 68 | game = get_game_by_hash(app, hash) 69 | if game is None: 70 | return 71 | 72 | gamefiledir = os.path.join(app.gamesdir, hash) 73 | delete_flat_dir(gamefiledir) 74 | 75 | curs = app.db.cursor() 76 | curs.execute('DELETE FROM games WHERE hash = ?', (hash,)) 77 | 78 | # Matches empty string, ".", "..", and so on. 79 | pat_alldots = re.compile('^[.]*$') 80 | 81 | download_nonce = 1 82 | 83 | async def download_game_url(app, url, filename=None): 84 | """Download a game and install it in gamesdir. 85 | If filename is not provided, slice it off the URL. 86 | On success, return a GameFile. On error, return a string describing 87 | the error. (Sorry, that's messy. Pretend it's a Result sort of thing.) 88 | """ 89 | global download_nonce 90 | 91 | app.logger.info('Requested download: %s', url) 92 | 93 | if not (url.lower().startswith('http://') or url.lower().startswith('https://')): 94 | return 'Download URL must start with `http://` or `https://`' 95 | 96 | ### reject .zip here too? 97 | 98 | if not filename: 99 | _, _, filename = url.rpartition('/') 100 | filename = urllib.parse.unquote(filename) 101 | if pat_alldots.match(filename) or '/' in filename: 102 | return 'URL does not appear to be a file: %s' % (url,) 103 | 104 | download_nonce += 1 105 | tmpfile = '_tmp_%d_%d_%s' % (time.time(), download_nonce, filename,) 106 | tmppath = os.path.join(app.gamesdir, tmpfile) 107 | 108 | async with app.httpsession.get(url) as resp: 109 | if resp.status != 200: 110 | return 'Download error: %s %s: %s' % (resp.status, resp.reason, url,) 111 | 112 | totallen = 0 113 | md5 = hashlib.md5() 114 | with open(tmppath, 'wb') as outfl: 115 | async for dat in resp.content.iter_chunked(4096): 116 | totallen += len(dat) 117 | outfl.write(dat) 118 | md5.update(dat) 119 | dat = None 120 | hash = md5.hexdigest() 121 | 122 | curs = app.db.cursor() 123 | res = curs.execute('SELECT * FROM games WHERE hash = ?', (hash,)) 124 | tup = res.fetchone() 125 | if tup: 126 | os.remove(tmppath) 127 | return 'Game is already installed (try **/select %s**)' % (filename,) 128 | 129 | format = detect_format(filename, tmppath) 130 | if not format: 131 | os.remove(tmppath) 132 | return 'Format not recognized: %s' % (url,) 133 | 134 | ### this would be a great place to pull ifiction from blorbs 135 | ### and unpack resources, too 136 | app.logger.info('Downloaded %s (hash %s, format %s)', url, hash, format) 137 | 138 | finaldir = os.path.join(app.gamesdir, hash) 139 | finalpath = os.path.join(app.gamesdir, hash, filename) 140 | 141 | tup = (hash, filename, url, format) 142 | game = GameFile(*tup) 143 | curs.execute('INSERT INTO games (hash, filename, url, format) VALUES (?, ?, ?, ?)', tup) 144 | 145 | if not os.path.exists(finaldir): 146 | os.mkdir(finaldir) 147 | os.rename(tmppath, finalpath) 148 | 149 | return game 150 | 151 | def detect_format(filename, path=None): 152 | """Figure out the type of a file given its bare filename and, optionally, 153 | its full path. 154 | In the path=None case, we really only care whether the filename 155 | *might* be an IF game, so it's okay that we can't distinguish the 156 | type completely. We may return '?' there. 157 | """ 158 | _, ext = os.path.splitext(filename) 159 | ext = ext.lower() 160 | 161 | if ext in ('.ulx', '.gblorb'): 162 | ### verify first word, if possible 163 | return 'glulx' 164 | if ext in ('.z1', '.z2', '.z3', '.z4', '.z5', '.z6', '.z7', '.z8', '.zblorb'): 165 | ### verify first byte, if possible 166 | return 'zcode' 167 | if ext in ('.json', '.js'): 168 | # We could check for the full '.ink.json' suffix, but that's not 169 | # reliable; Ink files may be found with just '.js' (and perhaps 170 | # JSONP at that). 171 | # Instead, we'll check the contents, if possible. 172 | if not path: 173 | return '?' 174 | try: 175 | obj = load_json(path) 176 | except: 177 | return None 178 | if 'inkVersion' in obj: 179 | return 'ink' 180 | if 'program' in obj and 'strings' in obj: 181 | return 'ys' 182 | return None 183 | return None 184 | 185 | def format_interpreter_args(format, firstrun, *, gamefile, terpsdir, savefiledir, autosavedir): 186 | """Return an argument list and environment variables for the interpreter 187 | to run the given format. 188 | """ 189 | if format == 'glulx': 190 | terp = os.path.join(terpsdir, 'glulxe') 191 | if firstrun: 192 | args = [ terp, '-singleturn', '-filedir', savefiledir, '-onlyfiledir', '--autosave', '--autodir', autosavedir, gamefile ] 193 | else: 194 | args = [ terp, '-singleturn', '-filedir', savefiledir, '-onlyfiledir', '-autometrics', '--autosave', '--autorestore', '--autodir', autosavedir, gamefile ] 195 | return (args, {}) 196 | 197 | if format == 'zcode': 198 | terp = os.path.join(terpsdir, 'bocfel') 199 | env = { 200 | 'BOCFEL_AUTOSAVE': '1', 201 | 'BOCFEL_AUTOSAVE_DIRECTORY': autosavedir, 202 | 'BOCFEL_AUTOSAVE_LIBRARYSTATE': '1', 203 | } 204 | # -C is BOCFEL_DISABLE_CONFIG 205 | # -H is BOCFEL_DISABLE_HISTORY_PLAYBACK 206 | # -m is BOCFEL_DISABLE_META_COMMANDS 207 | # -T is BOCFEL_TRANSCRIPT_NAME 208 | if firstrun: 209 | env['BOCFEL_SKIP_AUTORESTORE'] = '1' 210 | args = [ terp, '-C', '-H', '-m', '-T', 'transcript.txt', '-singleturn', '-filedir', savefiledir, '-onlyfiledir', gamefile ] 211 | else: 212 | args = [ terp, '-C', '-H', '-m', '-T', 'transcript.txt', '-singleturn', '-filedir', savefiledir, '-onlyfiledir', '-autometrics', gamefile ] 213 | return (args, env) 214 | 215 | if format == 'ink': 216 | terp = os.path.join(terpsdir, 'inkrun.js') 217 | if firstrun: 218 | args = [ terp, '--start', '--autodir', autosavedir, gamefile ] 219 | else: 220 | args = [ terp, '--autodir', autosavedir, gamefile ] 221 | return (args, {}) 222 | 223 | if format == 'ys': 224 | terp = os.path.join(terpsdir, 'ysrun') 225 | if firstrun: 226 | args = [ terp, '--start', '--autodir', autosavedir, gamefile ] 227 | else: 228 | args = [ terp, '--autodir', autosavedir, gamefile ] 229 | return (args, {}) 230 | 231 | return (None, None) 232 | 233 | 234 | # Late imports 235 | from .sessions import get_playchannel, get_session_by_id 236 | from .util import delete_flat_dir, load_json 237 | 238 | 239 | -------------------------------------------------------------------------------- /discoggin/sessions.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os, os.path 3 | import logging 4 | 5 | class Session: 6 | def __init__(self, sessid, gid, hash, movecount=0, lastupdate=None): 7 | if lastupdate is None: 8 | lastupdate = int(time.time()) 9 | self.sessid = sessid 10 | self.gid = gid 11 | self.hash = hash 12 | self.movecount = movecount 13 | self.lastupdate = lastupdate 14 | 15 | self.sessdir = 's%d' % (self.sessid,) 16 | 17 | def __repr__(self): 18 | timestr = time.ctime(self.lastupdate) 19 | return '' % (self.sessid, self.hash, self.movecount, timestr,) 20 | 21 | def logger(self): 22 | return logging.getLogger('cli.s%d' % (self.sessid,)) 23 | 24 | class PlayChannel: 25 | def __init__(self, gckey, gid, chanid, sessid=None): 26 | self.gckey = gckey 27 | self.gid = gid 28 | self.chanid = chanid 29 | self.sessid = sessid 30 | 31 | # Replaced more sensibly by get_valid_playchannel() 32 | self.channame = str(self.chanid) 33 | 34 | # Filled in by accessors that offer the withgame option 35 | self.game = None 36 | self.session = None 37 | 38 | def __repr__(self): 39 | val = '' 40 | if self.sessid: 41 | val = ' session %s' % (self.sessid,) 42 | return '' % (self.gckey, val,) 43 | 44 | def logger(self): 45 | if self.sessid: 46 | return logging.getLogger('cli.s%d' % (self.sessid,)) 47 | else: 48 | return logging.getLogger('cli.s-') 49 | 50 | def get_sessions(app): 51 | """Get all sessions (for all servers) 52 | """ 53 | curs = app.db.cursor() 54 | res = curs.execute('SELECT * FROM sessions') 55 | sessls = [ Session(*tup) for tup in res.fetchall() ] 56 | return sessls 57 | 58 | def get_session_by_id(app, sessid): 59 | """Get one session by ID (or None). 60 | """ 61 | try: 62 | sessid = int(sessid) 63 | except: 64 | return None 65 | curs = app.db.cursor() 66 | res = curs.execute('SELECT * FROM sessions WHERE sessid = ?', (sessid,)) 67 | tup = res.fetchone() 68 | if not tup: 69 | return None 70 | return Session(*tup) 71 | 72 | def get_sessions_for_server(app, gid): 73 | """Get all sessions for a given server. 74 | """ 75 | curs = app.db.cursor() 76 | res = curs.execute('SELECT * FROM sessions WHERE gid = ?', (gid,)) 77 | sessls = [ Session(*tup) for tup in res.fetchall() ] 78 | return sessls 79 | 80 | def get_sessions_for_hash(app, hash, gid=None): 81 | """Get all sessions for a given hash. If a server is provided, limit 82 | to that. 83 | """ 84 | curs = app.db.cursor() 85 | if gid is None: 86 | res = curs.execute('SELECT * FROM sessions WHERE hash = ?', (hash,)) 87 | else: 88 | res = curs.execute('SELECT * FROM sessions WHERE hash = ? AND gid = ?', (hash, gid,)) 89 | sessls = [ Session(*tup) for tup in res.fetchall() ] 90 | return sessls 91 | 92 | def get_available_session_for_hash(app, hash, gid): 93 | """Get an *unused* session for a given game, on a given server. 94 | The game is identified by hash. If all sessions for game are in 95 | use by some channel, or if there are no sessions, this returns None. 96 | If there are several available sessions, this returns the most 97 | recently-used one. 98 | """ 99 | curs = app.db.cursor() 100 | res = curs.execute('SELECT * FROM sessions WHERE hash = ? AND gid = ?', (hash, gid,)) 101 | sessls = [ Session(*tup) for tup in res.fetchall() ] 102 | 103 | chanls = get_playchannels(app) 104 | chanmap = {} 105 | for playchan in chanls: 106 | if playchan.sessid: 107 | chanmap[playchan.sessid] = playchan 108 | 109 | availls = [ sess for sess in sessls if sess.sessid not in chanmap ] 110 | if not availls: 111 | return None 112 | availls.sort(key=lambda sess: -sess.lastupdate) 113 | return availls[0] 114 | 115 | def create_session(app, game, gid): 116 | """Create a new session for a game on a server. 117 | """ 118 | curs = app.db.cursor() 119 | res = curs.execute('SELECT sessid FROM sessions') 120 | idlist = [ tup[0] for tup in res.fetchall() ] 121 | if not idlist: 122 | sessid = 1 123 | else: 124 | sessid = 1 + max(idlist) 125 | tup = (sessid, gid, game.hash, 0, int(time.time())) 126 | curs.execute('INSERT INTO sessions (sessid, gid, hash, movecount, lastupdate) VALUES (?, ?, ?, ?, ?)', tup) 127 | return Session(*tup) 128 | 129 | def delete_session(app, sessid): 130 | """Delete a session and all its files (autosave and save files). 131 | This is called from the command-line. 132 | """ 133 | session = get_session_by_id(app, sessid) 134 | if session is None: 135 | return 136 | 137 | autosavedir = os.path.join(app.autosavedir, session.sessdir) 138 | delete_flat_dir(autosavedir) 139 | savefiledir = os.path.join(app.savefiledir, session.sessdir) 140 | delete_flat_dir(savefiledir) 141 | 142 | curs = app.db.cursor() 143 | curs.execute('UPDATE channels SET sessid = ? WHERE sessid = ?', (None, sessid,)) 144 | curs.execute('DELETE FROM sessions WHERE sessid = ?', (sessid,)) 145 | 146 | def get_playchannels(app): 147 | """Get all channels (for all servers). 148 | """ 149 | curs = app.db.cursor() 150 | res = curs.execute('SELECT * FROM channels') 151 | chanls = [ PlayChannel(*tup) for tup in res.fetchall() ] 152 | return chanls 153 | 154 | def get_playchannels_for_server(app, gid, withgame=False): 155 | """Get all channels for a given server. 156 | If withgame is true, this gets the session and game info for 157 | each channel as well. 158 | """ 159 | curs = app.db.cursor() 160 | res = curs.execute('SELECT * FROM channels WHERE gid = ?', (str(gid),)) 161 | chanls = [ PlayChannel(*tup) for tup in res.fetchall() ] 162 | if withgame: 163 | for playchan in chanls: 164 | if playchan.sessid: 165 | playchan.session = get_session_by_id(app, playchan.sessid) 166 | if playchan.session.gid != gid: 167 | raise Exception('session gid mismatch') 168 | if playchan.session and playchan.session.hash: 169 | playchan.game = get_game_by_hash(app, playchan.session.hash) 170 | return chanls 171 | 172 | def get_playchannel(app, gckey): 173 | """Get one channel by ID (or None). 174 | """ 175 | curs = app.db.cursor() 176 | res = curs.execute('SELECT * FROM channels WHERE gckey = ?', (gckey,)) 177 | tup = res.fetchone() 178 | if not tup: 179 | return None 180 | return PlayChannel(*tup) 181 | 182 | def get_playchannel_for_session(app, sessid): 183 | """Get the channel playing a given session, by session ID. 184 | If no channel is playing that session, return None. 185 | """ 186 | curs = app.db.cursor() 187 | res = curs.execute('SELECT * FROM channels where sessid = ?', (sessid,)) 188 | # There shouldn't be more than one. 189 | tup = res.fetchone() 190 | if not tup: 191 | return None 192 | return PlayChannel(*tup) 193 | 194 | def get_valid_playchannel(app, interaction=None, message=None, withgame=False): 195 | """Get the channel for a given interaction or Discord message. 196 | You must provide one or the other. 197 | If withgame is true, this gets the session and game info for 198 | the channel as well. 199 | This is called very frequently (for every message on the server!) so 200 | it relies on a cache of known play-channels for fast rejection. 201 | """ 202 | channame = None 203 | if interaction: 204 | gid = interaction.guild_id 205 | if not gid: 206 | return None 207 | if not interaction.channel: 208 | return None 209 | chanid = interaction.channel.id 210 | if not chanid: 211 | return None 212 | channame = interaction.channel.name 213 | elif message: 214 | if not message.guild: 215 | return None 216 | gid = message.guild.id 217 | if not gid: 218 | return None 219 | if not message.channel: 220 | return None 221 | chanid = message.channel.id 222 | if not chanid: 223 | return None 224 | channame = message.channel.name 225 | else: 226 | return None 227 | 228 | gckey = '%s-%s' % (gid, chanid,) 229 | if gckey not in app.playchannels: 230 | # Fast check 231 | return None 232 | 233 | curs = app.db.cursor() 234 | res = curs.execute('SELECT * FROM channels WHERE gckey = ?', (gckey,)) 235 | tup = res.fetchone() 236 | if not tup: 237 | return None 238 | playchan = PlayChannel(*tup) 239 | 240 | if channame: 241 | playchan.channame = channame 242 | 243 | if withgame: 244 | if playchan.sessid: 245 | playchan.session = get_session_by_id(app, playchan.sessid) 246 | if playchan.session and playchan.session.hash: 247 | playchan.game = get_game_by_hash(app, playchan.session.hash) 248 | return playchan 249 | 250 | def set_channel_session(app, playchan, session): 251 | """Set the current session for a given channel. 252 | """ 253 | curs = app.db.cursor() 254 | curs.execute('UPDATE channels SET sessid = ? WHERE gckey = ?', (session.sessid, playchan.gckey,)) 255 | 256 | def update_session_movecount(app, session, movecount=None): 257 | """Update the movecount and current time for a session. 258 | """ 259 | if movecount is None: 260 | movecount = session.movecount + 1 261 | curs = app.db.cursor() 262 | lastupdate = int(time.time()) 263 | curs.execute('UPDATE sessions SET movecount = ?, lastupdate = ? WHERE sessid = ?', (movecount, lastupdate, session.sessid,)) 264 | 265 | 266 | 267 | # Late imports 268 | from .games import get_game_by_hash 269 | from .util import delete_flat_dir 270 | 271 | -------------------------------------------------------------------------------- /discoggin/glk.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import json 3 | import logging 4 | 5 | def get_glkstate_for_session(app, session): 6 | """Load the GlkState for a session. An exited session will return a 7 | GlkState with exit=True. If the game has never run at all (or has 8 | been force-quit), this returns None. 9 | """ 10 | path = os.path.join(app.autosavedir, session.sessdir, 'glkstate.json') 11 | if not os.path.exists(path): 12 | return None 13 | try: 14 | with open(path) as fl: 15 | obj = json.load(fl) 16 | return GlkState.from_jsonable(obj) 17 | except Exception as ex: 18 | session.logger().error('get_glkstate: %s', ex, exc_info=ex) 19 | return None 20 | 21 | def put_glkstate_for_session(app, session, state): 22 | """Store the GlkState for a session, or delete it if state is None. 23 | This assumes the session directory exists. (Unless state is None, 24 | in which case it's okay if there is nothing to delete!) 25 | """ 26 | path = os.path.join(app.autosavedir, session.sessdir, 'glkstate.json') 27 | if not state: 28 | if os.path.exists(path): 29 | os.remove(path) 30 | else: 31 | obj = state.to_jsonable() 32 | with open(path, 'w') as fl: 33 | json.dump(obj, fl) 34 | 35 | class GlkState: 36 | _singleton_keys = [ 'generation', 'exited', 'lineinputwin', 'charinputwin', 'specialinput', 'hyperlinkinputwin' ] 37 | _contentlist_keys = [ 'statuswindat', 'storywindat', 'graphicswindat' ] 38 | 39 | def __init__(self): 40 | # Lists of ContentLines 41 | self.statuswindat = [] 42 | self.storywindat = [] 43 | self.graphicswindat = [] 44 | # Gotta keep track of where each status window begins in the 45 | # (vertically) agglomerated statuswin[] array 46 | self.statuslinestarts = {} 47 | self.windows = {} 48 | # This doesn't track multiple-window input the way it should, 49 | # nor distinguish hyperlink input state across multiple windows. 50 | ### following should be an array? gotta get the window in there too 51 | self.hyperlinklabels = {} # link key to label 52 | self.hyperlinkkeys = {} # link label to key 53 | self.lineinputwin = None 54 | self.charinputwin = None 55 | self.specialinput = None 56 | self.hyperlinkinputwin = None 57 | self.exited = False 58 | self.generation = 0 59 | 60 | def to_jsonable(self): 61 | """Turn a GlkState into a jsonable dict. We use this for 62 | serialization. 63 | Note that the contents of a GlkState are not immutable. So 64 | you shouldn't hold onto the object returned by this call; 65 | serialize it immediately and discard it. 66 | """ 67 | obj = {} 68 | for key in GlkState._singleton_keys: 69 | obj[key] = getattr(self, key) 70 | for key in GlkState._contentlist_keys: 71 | arr = getattr(self, key) 72 | obj[key] = [ dat.to_jsonable() for dat in arr ] 73 | obj['statuslinestarts'] = strkeydict(self.statuslinestarts) 74 | obj['windows'] = strkeydict(self.windows) 75 | if self.hyperlinkkeys: 76 | obj['hyperlinkkeys'] = strkeydict(self.hyperlinkkeys) 77 | # self.hyperlinklabels is a back-cache 78 | return obj 79 | 80 | def islive(self): 81 | return not self.exited 82 | 83 | @staticmethod 84 | def from_jsonable(obj): 85 | """Create a GlkState from a jsonable object (from to_jsonable). 86 | """ 87 | state = GlkState() 88 | for key in GlkState._singleton_keys: 89 | setattr(state, key, obj[key]) 90 | for key in GlkState._contentlist_keys: 91 | ls = [ ContentLine.from_jsonable(val) for val in obj[key] ] 92 | setattr(state, key, ls) 93 | state.statuslinestarts = intkeydict(obj['statuslinestarts']) 94 | state.windows = intkeydict(obj['windows']) 95 | if 'hyperlinkkeys' in obj: 96 | state.hyperlinkkeys = intkeydict(obj['hyperlinkkeys']) 97 | for (label, key) in state.hyperlinkkeys.items(): 98 | state.hyperlinklabels[key] = label 99 | return state 100 | 101 | def accept_update(self, update, extrainput=None): 102 | """Parse the GlkOte update object and update the state 103 | accordingly. 104 | This is complicated. For the format, see 105 | http://eblong.com/zarf/glk/glkote/docs.html 106 | """ 107 | self.generation = update.get('gen') 108 | self.exited = update.get('exit', False) 109 | 110 | windows = update.get('windows') 111 | if windows is not None: 112 | self.windows = {} 113 | for win in windows: 114 | id = win.get('id') 115 | self.windows[id] = win 116 | 117 | grids = [ win for win in self.windows.values() if win.get('type') == 'grid' ] 118 | totalheight = 0 119 | # This doesn't work if just one status window resizes. 120 | # We should be keeping track of them separately and merging 121 | # the lists on every update. 122 | self.statuslinestarts.clear() 123 | for win in grids: 124 | self.statuslinestarts[win.get('id')] = totalheight 125 | totalheight += win.get('gridheight', 0) 126 | if totalheight < len(self.statuswindat): 127 | self.statuswindat = self.statuswindat[0:totalheight] 128 | while totalheight > len(self.statuswindat): 129 | self.statuswindat.append(ContentLine()) 130 | 131 | contents = update.get('content') 132 | if contents is not None: 133 | for content in contents: 134 | id = content.get('id') 135 | win = self.windows.get(id) 136 | if not win: 137 | raise Exception('No such window') 138 | if win.get('type') == 'buffer': 139 | text = content.get('text') 140 | # Clear the buffer. But if the content starts with 141 | # append, preserve the last line. 142 | willappend = ((text and text[0].get('append')) 143 | or (extrainput is not None)) 144 | if willappend and self.storywindat: 145 | self.storywindat = [ self.storywindat[-1] ] 146 | else: 147 | self.storywindat = [] 148 | if extrainput is not None: 149 | dat = ContentLine(extrainput, 'input') 150 | if len(self.storywindat): 151 | self.storywindat[-1].extend(dat) 152 | else: 153 | self.storywindat.append(dat) 154 | self.storywindat.append(ContentLine()) 155 | if text: 156 | for line in text: 157 | dat = extract_raw(line) 158 | if line.get('append') and len(self.storywindat): 159 | self.storywindat[-1].extend(dat) 160 | else: 161 | self.storywindat.append(dat) 162 | elif win.get('type') == 'grid': 163 | lines = content.get('lines') 164 | for line in lines: 165 | linenum = self.statuslinestarts[id] + line.get('line') 166 | dat = extract_raw(line) 167 | if linenum >= 0 and linenum < len(self.statuswindat): 168 | self.statuswindat[linenum] = dat 169 | elif win.get('type') == 'graphics': 170 | self.graphicswin = [] 171 | self.graphicswindat = [] 172 | draw = content.get('draw') 173 | if draw: 174 | self.graphicswindat.append([draw]) 175 | 176 | inputs = update.get('input') 177 | specialinputs = update.get('specialinput') 178 | if specialinputs is not None: 179 | self.specialinput = specialinputs.get('type') 180 | if self.specialinput == 'fileref_prompt': 181 | inptype = specialinputs.get('filetype', '???') 182 | inpmode = specialinputs.get('filemode', '???') 183 | val = 'Enter %s filename to %s:' % (inptype, inpmode,) 184 | self.storywindat.append(ContentLine(val)) 185 | self.storywindat.append(ContentLine('>>')) 186 | self.lineinputwin = None 187 | self.charinputwin = None 188 | self.hyperlinkinputwin = None 189 | #self.mouseinputwin = None 190 | elif inputs is not None: 191 | self.specialinput = None 192 | self.lineinputwin = None 193 | self.charinputwin = None 194 | self.hyperlinkinputwin = None 195 | #self.mouseinputwin = None 196 | for input in inputs: 197 | if input.get('type') == 'line': 198 | if self.lineinputwin: 199 | raise Exception('Multiple windows accepting line input') 200 | self.lineinputwin = input.get('id') 201 | if input.get('type') == 'char': 202 | if self.charinputwin: 203 | raise Exception('Multiple windows accepting char input') 204 | self.charinputwin = input.get('id') 205 | if input.get('hyperlink'): 206 | self.hyperlinkinputwin = input.get('id') 207 | #if input.get('mouse'): 208 | # self.mouseinputwin = input.get('id') 209 | 210 | self.hyperlinklabels.clear() 211 | self.hyperlinkkeys.clear() 212 | counter = 1 213 | # We do the status window first, on the somewhat shaky theory 214 | # that its links will be stable as the player takes turns 215 | # (and looks only at the story window). 216 | # Remember that the link (key) value isn't necessarily an integer. 217 | # The counter is though. 218 | for dat in self.statuswindat: 219 | for tup in dat.arr: 220 | if len(tup) > 2: 221 | link = tup[2] 222 | if link in self.hyperlinklabels: 223 | continue 224 | self.hyperlinkkeys[counter] = link 225 | self.hyperlinklabels[link] = counter 226 | counter += 1 227 | for dat in self.storywindat: 228 | for tup in dat.arr: 229 | if len(tup) > 2: 230 | link = tup[2] 231 | if link in self.hyperlinklabels: 232 | continue 233 | self.hyperlinkkeys[counter] = link 234 | self.hyperlinklabels[link] = counter 235 | counter += 1 236 | 237 | def construct_input(self, cmd): 238 | """Given a player command string, construct a GlkOte input 239 | appropriate to what the game is expecting. 240 | On error, raise an exception -- this will be displayed as a 241 | warning line. 242 | TODO: If the game is expecting more than one kind of input 243 | (e.g. line+hypertext or line+timer), allow the player to 244 | specify both. 245 | """ 246 | if self.hyperlinkinputwin: 247 | linklabel = command_is_hyperlink(cmd) 248 | if linklabel is not None: 249 | if linklabel not in self.hyperlinkkeys: 250 | raise Exception('invalid hyperlink value') 251 | linkkey = self.hyperlinkkeys[linklabel] 252 | return { 253 | 'type':'hyperlink', 'gen':self.generation, 254 | 'window':self.hyperlinkinputwin, 'value':linkkey 255 | } 256 | if self.lineinputwin: 257 | return { 258 | 'type':'line', 'gen':self.generation, 259 | 'window':self.lineinputwin, 'value':cmd 260 | } 261 | if self.charinputwin: 262 | if cmd == 'space': 263 | cmd = ' ' 264 | return { 265 | 'type':'char', 'gen':self.generation, 266 | 'window':self.charinputwin, 'value':cmd 267 | } 268 | if self.specialinput == 'fileref_prompt': 269 | return { 270 | 'type':'specialresponse', 'gen':self.generation, 271 | 'response':'fileref_prompt', 'value':cmd 272 | } 273 | 274 | if self.hyperlinkinputwin: 275 | # We didn't get a link-style command 276 | raise Exception('game is expecting hyperlink input') 277 | 278 | raise Exception('game is not expecting input') 279 | 280 | def strkeydict(map): 281 | return dict([ (str(key), val) for (key, val) in map.items() ]) 282 | 283 | def intkeydict(map): 284 | return dict([ (int(key), val) for (key, val) in map.items() ]) 285 | 286 | def stanza_is_transcript(sta): 287 | """Returns true if the stanza is part of a transcript (as opposed 288 | to a comment or metadata). 289 | """ 290 | return sta.get('format') == 'glkote' 291 | 292 | def storywindat_from_stanza(stanza, storywindat=None): 293 | """Look at a stanza (as from stanza_reader()), extract the story 294 | window output, and return it as a list of ContentLines. 295 | If storywindat is provided, append to it. 296 | 297 | This is similar to what accept_update() does, but it's simpler 298 | and standalone. (That is, it doesn't operate as part of a GlkState.) 299 | It is used for displaying transcripts. 300 | """ 301 | if storywindat is None: 302 | storywindat = [] 303 | 304 | output = stanza.get('output') 305 | if not output: 306 | return [] 307 | contents = output.get('content') 308 | if not contents: 309 | return [] 310 | for content in contents: 311 | text = content.get('text') 312 | if text: 313 | for line in text: 314 | dat = extract_raw(line) 315 | if line.get('append') and len(storywindat): 316 | storywindat[-1].extend(dat) 317 | else: 318 | storywindat.append(dat) 319 | 320 | return storywindat 321 | 322 | class ContentLine: 323 | def __init__(self, text=None, style='normal'): 324 | self.arr = [] 325 | if text is not None: 326 | self.add(text, style) 327 | 328 | def __repr__(self): 329 | return 'C:'+repr(self.arr) 330 | 331 | def to_jsonable(self): 332 | return self.arr 333 | 334 | @staticmethod 335 | def from_jsonable(arr): 336 | dat = ContentLine() 337 | dat.arr = arr 338 | return dat 339 | 340 | def uniformlink(self): 341 | if not self.arr: 342 | return None 343 | dat = self.arr[0] 344 | if len(dat) < 3: 345 | return None 346 | link = dat[2] 347 | for dat in self.arr: 348 | if len(dat) < 3: 349 | return None 350 | if link != dat[2]: 351 | return None 352 | return link 353 | 354 | def add(self, text='', style='normal', link=None): 355 | if link: 356 | self.arr.append( (text, style, link) ) 357 | elif style and style != 'normal': 358 | self.arr.append( (text, style) ) 359 | else: 360 | self.arr.append( (text,) ) 361 | 362 | def extend(self, dat): 363 | self.arr.extend(dat.arr) 364 | 365 | def extract_raw(line): 366 | # Extract the content array from a GlkOte line object. 367 | res = ContentLine() 368 | con = line.get('content') 369 | if not con: 370 | return res 371 | for val in con: 372 | if type(val) == str: 373 | res.add(val) 374 | else: 375 | res.add(val.get('text', ''), val.get('style', 'normal'), val.get('hyperlink', None)) 376 | return res 377 | 378 | def stanza_reader(path): 379 | """ Read a file as a sequence of newline-separated JSON stanzas. 380 | 381 | A partial stanza at the end will be silently ignored. 382 | 383 | It's okay if the JSON has more whitespace or newlines. You just need 384 | at least one newline between stanzas. 385 | 386 | If non-JSON occurs at the start or between stanzas, this will throw 387 | an exception. Bad formatting inside a stanza will silently end the 388 | parsing (after reading in the entire rest of the file). No, that's not 389 | ideal. 390 | """ 391 | with open(path, 'r') as fl: 392 | buf = '' 393 | while True: 394 | ln = fl.readline() 395 | if not ln: 396 | # End of file. 397 | # We may have an incomplete stanza in the buffer, but we 398 | # ignore that. 399 | break 400 | if not buf: 401 | buf = ln.lstrip() 402 | if buf and not buf.startswith('{'): 403 | raise Exception('non-JSON encountered') 404 | else: 405 | buf = buf + ln 406 | try: 407 | obj = json.loads(buf) 408 | except: 409 | continue 410 | yield obj 411 | buf = '' 412 | 413 | def parse_json(val): 414 | """Normally an interpreter returns a single JSON update stanza. 415 | However, errors aren't always tidy. We might get one or more error 416 | stanzas (type="error") in addition to the result. This function 417 | deals with that situation; it returns (update, errorlist). 418 | The errorlist is a list of strings (the "message" part of the error 419 | stanza). 420 | If there is no non-error stanza, update will be None. 421 | Can raise JSONDecodeError for genuinely malformed JSON. 422 | Note that the empty string (or whitespace) will return (None, []), 423 | rather than raising JSONDecodeError. 424 | """ 425 | try: 426 | # The simple case: correct JSON. 427 | obj = json.loads(val) 428 | if obj.get('type') == 'error': 429 | msg = obj.get('message', '???') 430 | return (None, [ msg ]) 431 | return (obj, []) 432 | except: 433 | pass 434 | 435 | # Assume this is a sequence of newline-separated JSON stanzas; try 436 | # to decode that. 437 | objls = [] 438 | errls = [] 439 | val = val.strip() 440 | while val: 441 | start = 0 442 | while True: 443 | pos = val.find(b'\n', start) 444 | if pos < 0: 445 | obj = json.loads(val) 446 | pos = len(val) 447 | break 448 | try: 449 | obj = json.loads(val[ : pos ]) 450 | break 451 | except: 452 | start = pos+1 453 | continue 454 | val = val[ pos : ].strip() 455 | if obj.get('type') == 'error': 456 | msg = obj.get('message', '???') 457 | errls.append(msg) 458 | else: 459 | objls.append(obj) 460 | if not objls: 461 | return (None, errls) 462 | if len(objls) == 1: 463 | return (objls[0], errls) 464 | errls.append('parse_json: more than one non-error result; discarding extras') 465 | return (objls[0], errls) 466 | 467 | def create_metrics(width=None, height=None): 468 | if not width: 469 | width = 800 470 | if not height: 471 | height = 480 472 | res = { 473 | 'width':width, 'height':height, 474 | 'gridcharwidth':10, 'gridcharheight':12, 475 | 'buffercharwidth':10, 'buffercharheight':12, 476 | } 477 | return res 478 | 479 | 480 | # Late imports 481 | from .markup import command_is_hyperlink 482 | -------------------------------------------------------------------------------- /discoggin/client.py: -------------------------------------------------------------------------------- 1 | import os, os.path 2 | import time 3 | import json 4 | import collections 5 | import logging 6 | import sqlite3 7 | import asyncio 8 | import asyncio.subprocess 9 | import aiohttp 10 | 11 | import discord 12 | import discord.app_commands 13 | 14 | from .markup import extract_command, content_to_markup, rebalance_output, escape 15 | from .games import GameFile 16 | from .games import get_gamelist, get_gamemap, get_game_by_name, get_game_by_hash, get_game_by_channel 17 | from .games import download_game_url 18 | from .games import format_interpreter_args 19 | from .sessions import get_sessions, get_session_by_id, get_sessions_for_server, get_available_session_for_hash, create_session, set_channel_session, update_session_movecount 20 | from .sessions import get_playchannels, get_playchannels_for_server, get_valid_playchannel, get_playchannel_for_session 21 | from .glk import create_metrics 22 | from .glk import parse_json 23 | from .glk import ContentLine 24 | from .glk import GlkState, get_glkstate_for_session, put_glkstate_for_session 25 | from .glk import stanza_reader, stanza_is_transcript, storywindat_from_stanza 26 | from .attlist import AttachList 27 | 28 | _appcmds = [] 29 | 30 | def appcmd(name, description, argdesc=None): 31 | """A decorator for slash commands. 32 | The discord module provides such a decorator but I don't like it. 33 | I wrote this one instead. 34 | """ 35 | def decorator(func): 36 | _appcmds.append( (func.__name__, name, description, argdesc) ) 37 | return func 38 | return decorator 39 | 40 | class DiscogClient(discord.Client): 41 | """Our Discord client class. 42 | """ 43 | def __init__(self, config): 44 | self.config = config 45 | self.cmdsync = False 46 | 47 | self.logger = logging.getLogger('cli') 48 | 49 | self.dbfile = config['DEFAULT']['DBFile'] 50 | 51 | # These are absolutized because we will pass them to the interpreter, 52 | # which runs in a subdirectory. 53 | self.autosavedir = os.path.abspath(config['DEFAULT']['AutoSaveDir']) 54 | self.savefiledir = os.path.abspath(config['DEFAULT']['SaveFileDir']) 55 | self.gamesdir = os.path.abspath(config['DEFAULT']['GamesDir']) 56 | self.terpsdir = os.path.abspath(config['DEFAULT']['InterpretersDir']) 57 | 58 | intents = discord.Intents(guilds=True, messages=True, guild_messages=True, dm_messages=True, message_content=True) 59 | 60 | super().__init__(intents=intents) 61 | 62 | self.playchannels = set() # of gckeys 63 | self.inflight = set() # of session ids 64 | self.attachments = AttachList() 65 | 66 | # Container for slash commands. 67 | self.tree = discord.app_commands.CommandTree(self) 68 | 69 | # Add all the slash commands noted by the @appcmd decorator. 70 | for (key, name, description, argdesc) in _appcmds: 71 | callback = getattr(self, key) 72 | cmd = discord.app_commands.Command(name=name, callback=callback, description=description) 73 | if argdesc: 74 | for akey, adesc in argdesc.items(): 75 | cmd._params[akey].description = adesc 76 | self.tree.add_command(cmd) 77 | 78 | # Our async HTTP client session. 79 | # We will set this up in setup_hook. 80 | self.httpsession = None 81 | 82 | # Open the sqlite database. 83 | self.db = sqlite3.connect(self.dbfile) 84 | self.db.isolation_level = None # autocommit 85 | 86 | async def print_lines(self, outls, chan, prefix=None): 87 | """Print a bunch of lines (paragraphs) to the Discord channel. 88 | They should already be formatted in Discord markup. 89 | Add prefix if provided. 90 | ### the prefix must be shorter than the safety margin 91 | """ 92 | # Optimize to fit in the fewest number of Discord messages. 93 | outls = rebalance_output(outls) 94 | 95 | first = True 96 | for out in outls: 97 | if not first: 98 | # Short sleep to avoid rate-limiting. 99 | await asyncio.sleep(0.25) 100 | if prefix: 101 | out = prefix+out 102 | await chan.send(out) 103 | first = False 104 | 105 | async def setup_hook(self): 106 | """Called when the client is starting up. We have not yet connected 107 | to Discord, but we have entered the async regime. 108 | """ 109 | # Create the HTTP session, which must happen inside the async 110 | # event loop. 111 | headers = { 'user-agent': 'Discoggin-IF-Terp' } 112 | self.httpsession = aiohttp.ClientSession(headers=headers) 113 | 114 | self.cache_playchannels() 115 | 116 | if self.cmdsync: 117 | # Push our slash commands to Discord. We only need to do 118 | # this once after adding or modifying a slash command. 119 | # (No, we're not connected to Discord yet. This uses web 120 | # RPC calls rather than the websocket.) 121 | self.logger.info('Syncing %d slash commands...', len(_appcmds)) 122 | await self.tree.sync() 123 | # The cmdsync option was set by a command-line command. 124 | # Shut down now that it's done. 125 | await self.close() 126 | 127 | async def close(self): 128 | """Called when the client is shutting down. We override this to 129 | finalize resources. 130 | """ 131 | self.logger.warning('Shutting down...') 132 | 133 | if self.httpsession: 134 | await self.httpsession.close() 135 | self.httpsession = None 136 | 137 | if self.db: 138 | self.db.close() 139 | self.db = None 140 | 141 | await super().close() 142 | 143 | async def on_ready(self): 144 | """We have finished connecting to Discord. 145 | (Docs recommend against doing Discord API calls from here.) 146 | """ 147 | self.logger.info('Logged in as %s', self.user) 148 | 149 | def cache_playchannels(self): 150 | """Grab the list of valid playchannels and store it in memory. 151 | We will use this for fast channel-checking. 152 | """ 153 | ls = get_playchannels(self) 154 | self.playchannels.clear() 155 | for chan in ls: 156 | self.playchannels.add(chan.gckey) 157 | 158 | # Slash command implementations. 159 | 160 | @appcmd('start', description='Start the current game') 161 | async def on_cmd_start(self, interaction): 162 | """/start 163 | """ 164 | playchan = get_valid_playchannel(self, interaction=interaction, withgame=True) 165 | if not playchan: 166 | await interaction.response.send_message('Discoggin does not play games in this channel.') 167 | return 168 | if not playchan.game: 169 | await interaction.response.send_message('No game is being played in this channel.') 170 | return 171 | glkstate = get_glkstate_for_session(self, playchan.session) 172 | if glkstate and glkstate.islive(): 173 | await interaction.response.send_message('The game is already running.') 174 | return 175 | await interaction.response.send_message('Game is starting...') 176 | 177 | if playchan.sessid in self.inflight: 178 | playchan.logger().warning('run_turn wrapper (s%s): command in flight', playchan.sessid) 179 | return 180 | self.inflight.add(playchan.sessid) 181 | try: 182 | await self.run_turn(None, interaction.channel, playchan, None) 183 | finally: 184 | self.inflight.discard(playchan.sessid) 185 | 186 | @appcmd('forcequit', description='Force the current game to end') 187 | async def on_cmd_stop(self, interaction): 188 | """/forcequit 189 | """ 190 | playchan = get_valid_playchannel(self, interaction=interaction, withgame=True) 191 | if not playchan: 192 | await interaction.response.send_message('Discoggin does not play games in this channel.') 193 | return 194 | if not playchan.game: 195 | await interaction.response.send_message('No game is being played in this channel.') 196 | return 197 | glkstate = get_glkstate_for_session(self, playchan.session) 198 | if glkstate is None or not glkstate.islive(): 199 | await interaction.response.send_message('The game is not running.') 200 | return 201 | # We delete the GlkState entirely, rather than messing with the 202 | # exit flag. This lets us recover from a corrupted GlkState or 203 | # autosave entry. 204 | playchan.logger().info('game force-quit') 205 | put_glkstate_for_session(self, playchan.session, None) 206 | await interaction.response.send_message('Game has been stopped. (**/start** to restart it.)') 207 | 208 | @appcmd('files', description='List the save files for the current session') 209 | async def on_cmd_listfiles(self, interaction): 210 | """/files 211 | """ 212 | playchan = get_valid_playchannel(self, interaction=interaction, withgame=True) 213 | if not playchan: 214 | await interaction.response.send_message('Discoggin does not play games in this channel.') 215 | return 216 | savefiledir = os.path.join(self.savefiledir, playchan.session.sessdir) 217 | if not os.path.exists(savefiledir): 218 | files = [] 219 | else: 220 | files = [ ent for ent in os.scandir(savefiledir) if ent.is_file() ] 221 | if not files: 222 | await interaction.response.send_message('No files for the current session ("%s")' % (playchan.game.filename,)) 223 | return 224 | files.sort(key=lambda ent: ent.name) 225 | ls = [ 'Files for the current session ("%s"):' % (playchan.game.filename,) ] 226 | for ent in files: 227 | timeval = int(ent.stat().st_mtime) 228 | ls.append('- %s ' % (escape(ent.name), timeval,)) 229 | await interaction.response.send_message('\n'.join(ls)) 230 | 231 | @appcmd('status', description='Display the status window') 232 | async def on_cmd_status(self, interaction): 233 | """/status 234 | """ 235 | playchan = get_valid_playchannel(self, interaction=interaction, withgame=True) 236 | if not playchan: 237 | await interaction.response.send_message('Discoggin does not play games in this channel.') 238 | return 239 | if not playchan.game: 240 | await interaction.response.send_message('No game is being played in this channel.') 241 | return 242 | glkstate = get_glkstate_for_session(self, playchan.session) 243 | if glkstate is None: 244 | # Actually we can view the status line of an exited game. 245 | await interaction.response.send_message('The game is not running.') 246 | return 247 | chan = interaction.channel 248 | await interaction.response.send_message('Status line displayed.', ephemeral=True) 249 | outls = [ content_to_markup(val, glkstate.hyperlinklabels) for val in glkstate.statuswindat ] 250 | await self.print_lines(outls, chan, '|\n') 251 | 252 | @appcmd('recap', description='Recap the last few commands', 253 | argdesc={ 'count':'Number of commands to recap' }) 254 | async def on_cmd_recap(self, interaction, count:int=3): 255 | """/recap NUM 256 | """ 257 | playchan = get_valid_playchannel(self, interaction=interaction, withgame=True) 258 | if not playchan: 259 | await interaction.response.send_message('Discoggin does not play games in this channel.') 260 | return 261 | if not playchan.game: 262 | await interaction.response.send_message('No game is being played in this channel.') 263 | return 264 | 265 | # At least one, but no more than ten, please 266 | count = min(10, count) 267 | count = max(1, count) 268 | 269 | playchan.logger().info('recap %d', count) 270 | 271 | autosavedir = os.path.join(self.autosavedir, playchan.session.sessdir) 272 | trapath = os.path.join(autosavedir, 'transcript.glktra') 273 | 274 | if not os.path.exists(trapath): 275 | await interaction.response.send_message('No transcript is available.') 276 | return 277 | 278 | # Fake in the initial prompt... 279 | storywindat = [ ContentLine('>') ] 280 | try: 281 | reader = stanza_reader(trapath) 282 | # skip comments and other non-glkote stanzas 283 | trareader = filter(stanza_is_transcript, reader) 284 | # tail recipe from itertools docs 285 | tailreader = iter(collections.deque(trareader, maxlen=count)) 286 | for stanza in tailreader: 287 | storywindat_from_stanza(stanza, storywindat=storywindat) 288 | except Exception as ex: 289 | self.logger.error('Transcript: %s', ex, exc_info=ex) 290 | await interaction.response.send_message('Transcript error: %s' % (ex,)) 291 | return 292 | 293 | await interaction.response.send_message('Recapping last %d commands.' % (count,)) 294 | 295 | # Display the output. (No hyperlink labels.) 296 | outls = [ content_to_markup(val) for val in storywindat ] 297 | await self.print_lines(outls, interaction.channel, 'RECAP\n') 298 | 299 | @appcmd('install', description='Download and install a game file for play', 300 | argdesc={ 'url':'Game file URL' }) 301 | async def on_cmd_install(self, interaction, url:str): 302 | """/install URL 303 | """ 304 | playchan = get_valid_playchannel(self, interaction=interaction) 305 | if not playchan: 306 | await interaction.response.send_message('Discoggin does not play games in this channel.') 307 | return 308 | 309 | filename = None 310 | 311 | if '/' not in url: 312 | # Special case 313 | if url == '?': 314 | attls = self.attachments.getlist(interaction.channel) 315 | if not attls: 316 | await interaction.response.send_message('No recent file uploads to this channel.') 317 | return 318 | attls.sort(key=lambda att:-att.timestamp) 319 | ls = [ 'Recently uploaded files:' ] 320 | for att in attls: 321 | ls.append('- %s, ' % (att.filename, int(att.timestamp),)) 322 | val = '\n'.join(ls) 323 | ### is there a message size limit here? 324 | await interaction.response.send_message(val) 325 | return 326 | 327 | att = self.attachments.findbyname(url, interaction.channel) 328 | if not att: 329 | await interaction.response.send_message('You must name a URL or a recently uploaded file.') 330 | return 331 | url = att.url 332 | filename = att.filename 333 | # continue... 334 | 335 | try: 336 | res = await download_game_url(self, url, filename) 337 | except Exception as ex: 338 | self.logger.error('Download: %s', ex, exc_info=ex) 339 | await interaction.response.send_message('Download error: %s' % (ex,)) 340 | if isinstance(res, str): 341 | await interaction.response.send_message(res) 342 | return 343 | 344 | game = res 345 | if not isinstance(res, GameFile): 346 | await interaction.response.send_message('download_game_url: not a game') 347 | return 348 | 349 | session = create_session(self, game, interaction.guild_id) 350 | set_channel_session(self, playchan, session) 351 | session.logger().info('installed "%s" in #%s', game.filename, playchan.channame) 352 | await interaction.response.send_message('Downloaded "%s" and began a new session. (**/start** to start the game.)' % (game.filename,)) 353 | 354 | @appcmd('games', description='List downloaded games') 355 | async def on_cmd_gamelist(self, interaction): 356 | """/games 357 | """ 358 | gamels = get_gamelist(self) 359 | if not gamels: 360 | await interaction.response.send_message('No games are installed. (**/install URL** to install one.)') 361 | return 362 | ls = [ 'Downloaded games available for play: (**/select** one)' ] 363 | for game in gamels: 364 | ls.append('- %s (%s)' % (game.filename, game.format,)) 365 | val = '\n'.join(ls) 366 | ### is there a message size limit here? 367 | await interaction.response.send_message(val) 368 | 369 | @appcmd('sessions', description='List game sessions') 370 | async def on_cmd_sessionlist(self, interaction): 371 | """/sessions 372 | """ 373 | sessls = get_sessions_for_server(self, interaction.guild_id) 374 | if not sessls: 375 | await interaction.response.send_message('No game sessions are in progress.') 376 | return 377 | sessls.sort(key=lambda sess: -sess.lastupdate) 378 | gamemap = get_gamemap(self) 379 | chanls = get_playchannels_for_server(self, interaction.guild_id) 380 | chanmap = {} 381 | for playchan in chanls: 382 | if playchan.sessid: 383 | chanmap[playchan.sessid] = playchan 384 | ls = [] 385 | for sess in sessls: 386 | game = gamemap.get(sess.hash) 387 | gamestr = game.filename if game else '???' 388 | playchan = chanmap.get(sess.sessid) 389 | chanstr = '' 390 | if playchan: 391 | chanstr = ' (playing in channel <#%s>)' % (playchan.chanid,) 392 | ls.append('- session %s: %s%s, %d moves, ' % (sess.sessid, gamestr, chanstr, sess.movecount, sess.lastupdate,)) 393 | val = '\n'.join(ls) 394 | ### is there a message size limit here? 395 | await interaction.response.send_message(val) 396 | 397 | @appcmd('channels', description='List channels that we can play on') 398 | async def on_cmd_channellist(self, interaction): 399 | """/channels 400 | This also re-checks the valid channel list. This is a hack. 401 | (We should have a way for the command-line API to tell the 402 | running instance to recache.) 403 | """ 404 | self.cache_playchannels() 405 | chanls = get_playchannels_for_server(self, interaction.guild_id, withgame=True) 406 | if not chanls: 407 | await interaction.response.send_message('Discoggin is not available on this Discord server.') 408 | return 409 | ls = [] 410 | for playchan in chanls: 411 | gamestr = '' 412 | if playchan.game: 413 | gamestr = ': %s, %d moves, ' % (playchan.game.filename, playchan.session.movecount, playchan.session.lastupdate,) 414 | ls.append('- <#%s>%s' % (playchan.chanid, gamestr)) 415 | val = '\n'.join(ls) 416 | ### is there a message size limit here? 417 | await interaction.response.send_message(val) 418 | 419 | @appcmd('newsession', description='Start a new game session in this channel', 420 | argdesc={ 'game':'Game name' }) 421 | async def on_cmd_newsession(self, interaction, game:str): 422 | """/newsession GAME 423 | """ 424 | gamearg = game 425 | playchan = get_valid_playchannel(self, interaction=interaction) 426 | if not playchan: 427 | await interaction.response.send_message('Discoggin does not play games in this channel.') 428 | return 429 | game = get_game_by_name(self, gamearg) 430 | if not game: 431 | await interaction.response.send_message('Game not found: "%s"' % (gamearg,)) 432 | return 433 | session = create_session(self, game, interaction.guild_id) 434 | set_channel_session(self, playchan, session) 435 | session.logger().info('new session for "%s" in #%s', game.filename, playchan.channame) 436 | await interaction.response.send_message('Began a new session for "%s" (**/start** to start the game.)' % (game.filename,)) 437 | # No status line, game hasn't started yet 438 | 439 | @appcmd('select', description='Select a game or session to play in this channel', 440 | argdesc={ 'game':'Game name or session number' }) 441 | async def on_cmd_select(self, interaction, game:str): 442 | """/select [ GAME | SESSION ] 443 | """ 444 | gamearg = game 445 | playchan = get_valid_playchannel(self, interaction=interaction) 446 | if not playchan: 447 | await interaction.response.send_message('Discoggin does not play games in this channel.') 448 | return 449 | 450 | try: 451 | gameargint = int(gamearg) 452 | except: 453 | gameargint = None 454 | if gameargint is not None: 455 | await self.on_cmd_select_session(interaction, playchan, gameargint) 456 | else: 457 | await self.on_cmd_select_game(interaction, playchan, gamearg) 458 | 459 | async def on_cmd_select_game(self, interaction, playchan, gamearg): 460 | """/select GAME (a game name) 461 | This is called by on_cmd_select(). It is responsible for responding 462 | to the interaction. 463 | """ 464 | game = get_game_by_name(self, gamearg) 465 | if not game: 466 | await interaction.response.send_message('Game not found: "%s"' % (gamearg,)) 467 | return 468 | curgame = get_game_by_channel(self, playchan.gckey) 469 | if curgame and game.hash == curgame.hash: 470 | await interaction.response.send_message('This channel is already playing "%s".' % (curgame.filename,)) 471 | return 472 | 473 | session = get_available_session_for_hash(self, game.hash, interaction.guild_id) 474 | if session: 475 | set_channel_session(self, playchan, session) 476 | session.logger().info('selected "%s" in #%s', game.filename, playchan.channame) 477 | await interaction.response.send_message('Activated session %d for "%s"' % (session.sessid, game.filename,)) 478 | # Display the status line of this session 479 | glkstate = get_glkstate_for_session(self, session) 480 | if glkstate: 481 | chan = interaction.channel 482 | outls = [ content_to_markup(val, glkstate.hyperlinklabels) for val in glkstate.statuswindat ] 483 | await self.print_lines(outls, chan, '|\n') 484 | return 485 | session = create_session(self, game, interaction.guild_id) 486 | set_channel_session(self, playchan, session) 487 | session.logger().info('new session for "%s" in #%s', game.filename, playchan.channame) 488 | await interaction.response.send_message('Began a new session for "%s" (**/start** to start the game.)' % (game.filename,)) 489 | # No status line, game hasn't started yet 490 | 491 | async def on_cmd_select_session(self, interaction, playchan, sessid): 492 | """/select SESSION (a session number) 493 | This is called by on_cmd_select(). It is responsible for responding 494 | to the interaction. 495 | """ 496 | session = get_session_by_id(self, sessid) 497 | if not session or session.gid != interaction.guild_id: 498 | await interaction.response.send_message('No session %d.' % (sessid,)) 499 | return 500 | prevchan = get_playchannel_for_session(self, session.sessid) 501 | if prevchan: 502 | if prevchan.chanid == playchan.chanid: 503 | await interaction.response.send_message('This channel is already using session %d.' % (session.sessid,)) 504 | else: 505 | await interaction.response.send_message('Session %d is already being used in channel <#%s>.' % (session.sessid, prevchan.chanid,)) 506 | return 507 | set_channel_session(self, playchan, session) 508 | game = get_game_by_hash(self, session.hash) 509 | if not game: 510 | playchan.logger().warning('activated session, but could not find game') 511 | await interaction.response.send_message('Activated session %s, but cannot find associated game' % (session.sessid,)) 512 | return 513 | session.logger().info('selected "%s" in #%s', game.filename, playchan.channame) 514 | await interaction.response.send_message('Activated session %d for "%s"' % (session.sessid, game.filename,)) 515 | # Display the status line of this session 516 | glkstate = get_glkstate_for_session(self, session) 517 | if glkstate: 518 | chan = interaction.channel 519 | outls = [ content_to_markup(val, glkstate.hyperlinklabels) for val in glkstate.statuswindat ] 520 | await self.print_lines(outls, chan, '|\n') 521 | 522 | async def on_message(self, message): 523 | """Event handler for regular Discord chat messages. 524 | We will respond to messages that look like ">GET LAMP". 525 | """ 526 | if message.author == self.user: 527 | # silently ignore messages we sent 528 | return 529 | 530 | playchan = get_valid_playchannel(self, message=message, withgame=True) 531 | if not playchan: 532 | # silently ignore messages in non-play channels 533 | return 534 | 535 | if message.attachments: 536 | # keep track of file attachments 537 | for obj in message.attachments: 538 | self.attachments.tryadd(obj, message.channel) 539 | 540 | cmd = extract_command(message.content) 541 | if not cmd: 542 | # silently ignore messages that don't look like commands 543 | # but record the message as a comment! 544 | self.record_comment(message, playchan) 545 | return 546 | 547 | if not playchan.game: 548 | await message.channel.send('No game is being played in this channel.') 549 | return 550 | 551 | glkstate = get_glkstate_for_session(self, playchan.session) 552 | if glkstate is None or not glkstate.islive(): 553 | await message.channel.send('The game is not running. (**/start** to start it.)') 554 | return 555 | 556 | if playchan.sessid in self.inflight: 557 | playchan.logger().warning('run_turn wrapper (s%s): command in flight', playchan.sessid) 558 | return 559 | self.inflight.add(playchan.sessid) 560 | try: 561 | await self.run_turn(cmd, message.channel, playchan, glkstate) 562 | finally: 563 | self.inflight.discard(playchan.sessid) 564 | 565 | def record_comment(self, message, playchan): 566 | if not playchan.sessid: 567 | return 568 | if not message.author: 569 | return 570 | val = message.content.strip() 571 | if not val: 572 | return 573 | text = '%s: %s' % (message.author.name, val,) 574 | 575 | outputtime = int(time.time() * 1000) 576 | tradat = { 577 | "format": "comment", 578 | "sessionId": str(playchan.sessid), 579 | "text": text, 580 | "timestamp": outputtime 581 | } 582 | try: 583 | autosavedir = os.path.join(self.autosavedir, playchan.session.sessdir) 584 | if not os.path.exists(autosavedir): 585 | os.mkdir(autosavedir) 586 | 587 | trapath = os.path.join(autosavedir, 'transcript.glktra') 588 | with open(trapath, 'a') as outfl: 589 | json.dump(tradat, outfl) 590 | outfl.write('\n') 591 | except Exception as ex: 592 | logger.warning('Failed to write comment: %s', ex, exc_info=ex) 593 | 594 | 595 | async def run_turn(self, cmd, chan, playchan, glkstate): 596 | """Execute a turn by invoking an interpreter. 597 | The cmd and glkstate arguments should be None for the initial turn 598 | (starting the game). 599 | We always set sessid in the inflight set before calling this, 600 | and clear it after this completes. This lets us avoid invoking 601 | two turns on the same session at the same time. 602 | """ 603 | logger = playchan.logger() 604 | 605 | if not chan: 606 | logger.warning('run_turn: channel not set') 607 | return 608 | 609 | inputtime = int(time.time() * 1000) 610 | firsttime = (glkstate is None or not glkstate.islive()) 611 | 612 | gamefile = os.path.join(self.gamesdir, playchan.game.hash, playchan.game.filename) 613 | if not os.path.exists(gamefile): 614 | logger.error('run_turn: game file not found: %s', gamefile) 615 | await chan.send('Error: The game file seems to be missing.') 616 | return 617 | 618 | autosavedir = os.path.join(self.autosavedir, playchan.session.sessdir) 619 | if not os.path.exists(autosavedir): 620 | os.mkdir(autosavedir) 621 | 622 | savefiledir = os.path.join(self.savefiledir, playchan.session.sessdir) 623 | if not os.path.exists(savefiledir): 624 | os.mkdir(savefiledir) 625 | 626 | iargs, ienv = format_interpreter_args(playchan.game.format, firsttime, terpsdir=self.terpsdir, gamefile=gamefile, savefiledir=savefiledir, autosavedir=autosavedir) 627 | if iargs is None: 628 | logger.warning('run_turn: unknown format: %s', playchan.game.format) 629 | await chan.send('Error: No known interpreter for this format (%s)' % (playchan.game.format,)) 630 | return 631 | 632 | # Inherit env vars 633 | allenv = os.environ.copy() 634 | if ienv: 635 | allenv.update(ienv) 636 | 637 | input = None 638 | extrainput = None 639 | 640 | if firsttime: 641 | # Game-start case. 642 | if cmd is not None: 643 | logger.warning('run_turn: tried to send command when game was not running: %s', cmd) 644 | return 645 | 646 | playchan.logger().info('game started') 647 | 648 | # Fresh state. 649 | glkstate = GlkState() 650 | 651 | update = { 652 | 'type':'init', 'gen':0, 653 | 'metrics': create_metrics(), 654 | 'support': [ 'timer', 'hyperlinks' ], 655 | } 656 | indat = json.dumps(update) 657 | else: 658 | # Regular turn case. 659 | if cmd is None: 660 | logger.warning('run_turn: tried to send no command when game was running') 661 | return 662 | 663 | playchan.logger().info('>%s', cmd) 664 | 665 | try: 666 | input = glkstate.construct_input(cmd) 667 | indat = json.dumps(input) 668 | except Exception as ex: 669 | await chan.send('Input: %s' % (ex,)) 670 | return 671 | 672 | if input.get('type') == 'specialresponse' and input.get('response') == 'fileref_prompt': 673 | extrainput = cmd 674 | 675 | # Launch the interpreter, push an input event into it, and then pull 676 | # an update out. 677 | try: 678 | async def func(): 679 | proc = await asyncio.create_subprocess_exec( 680 | *iargs, 681 | env=allenv, 682 | stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE) 683 | return await proc.communicate((indat+'\n').encode()) 684 | (outdat, errdat) = await asyncio.wait_for(func(), 5) 685 | except TimeoutError: 686 | logger.error('Interpreter error: Command timed out') 687 | await chan.send('Interpreter error: Command timed out.') 688 | return 689 | except Exception as ex: 690 | logger.error('Interpreter exception: %s', ex, exc_info=ex) 691 | await chan.send('Interpreter exception: %s' % (ex,)) 692 | return 693 | 694 | if errdat: 695 | await chan.send('Interpreter stderr: %s' % (errdat,)) 696 | # but try to continue 697 | 698 | try: 699 | (update, errorls) = parse_json(outdat) 700 | except json.JSONDecodeError: 701 | try: 702 | outstr = outdat.decode() 703 | except: 704 | outstr = str(outdat) 705 | logger.error('Invalid JSON output: %r', outstr) 706 | await chan.send('Invalid JSON output: %s' % (outstr[:160],)) 707 | return 708 | except Exception as ex: 709 | logger.error('JSON decode exception: %s', ex, exc_info=ex) 710 | await chan.send('JSON decode exception: %s' % (ex,)) 711 | return 712 | 713 | # Display errorls, which contains the contents of JSON-encoded 714 | # error stanza(s). But don't exit just because we got errors. 715 | for msg in errorls: 716 | logger.error('Interpreter error message: %s', msg) 717 | outls = [ 'Interpreter error: %s' % (msg,) for msg in errorls ] 718 | await self.print_lines(outls, chan) 719 | 720 | if update is None: 721 | # If we didn't get any *non*-errors, that's a reason to exit. 722 | # But make sure we report at least one error. 723 | if not errorls: 724 | logger.error('Interpreter error: no update') 725 | await chan.send('Interpreter error: no update') 726 | return 727 | 728 | # Update glkstate with the output. 729 | try: 730 | glkstate.accept_update(update, extrainput) 731 | except Exception as ex: 732 | logger.error('Update error: %s', ex, exc_info=ex) 733 | await chan.send('Update error: %s' % (ex,)) 734 | return 735 | 736 | put_glkstate_for_session(self, playchan.session, glkstate) 737 | 738 | update_session_movecount(self, playchan.session) 739 | 740 | outputtime = int(time.time() * 1000) 741 | tradat = { 742 | "format": "glkote", 743 | "input": input, 744 | "output": update, 745 | "sessionId": str(playchan.sessid), 746 | "label": playchan.game.filename, 747 | "timestamp": inputtime, 748 | "outtimestamp": outputtime 749 | } 750 | try: 751 | trapath = os.path.join(autosavedir, 'transcript.glktra') 752 | with open(trapath, 'a') as outfl: 753 | json.dump(tradat, outfl) 754 | outfl.write('\n') 755 | except Exception as ex: 756 | logger.warning('Failed to write transcript: %s', ex, exc_info=ex) 757 | 758 | # Display the output. 759 | outls = [ content_to_markup(val, glkstate.hyperlinklabels) for val in glkstate.storywindat ] 760 | printcount = sum([ len(out) for out in outls ]) 761 | await self.print_lines(outls, chan, '>\n') 762 | 763 | if printcount <= 4: 764 | # No story output, or not much. Try showing the status line. 765 | outls = [ content_to_markup(val, glkstate.hyperlinklabels) for val in glkstate.statuswindat ] 766 | printcount = sum([ len(out) for out in outls ]) 767 | await self.print_lines(outls, chan, '|\n') 768 | 769 | if printcount <= 4: 770 | await chan.send('(no game output)') 771 | 772 | if glkstate.exited: 773 | await chan.send('The game has exited. (**/start** to restart it.)') 774 | 775 | --------------------------------------------------------------------------------