From cd529281f3884a231e159c574fa65ff0222930e4 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Fri, 14 Oct 2022 14:05:27 -0600 Subject: [PATCH 1/7] discord integration --- evennia/accounts/bots.py | 221 ++++++++- evennia/commands/default/cmdset_account.py | 1 + evennia/commands/default/comms.py | 183 ++++++- evennia/server/portal/discord.py | 534 +++++++++++++++++++++ evennia/settings_default.py | 3 + 5 files changed, 915 insertions(+), 27 deletions(-) create mode 100644 evennia/server/portal/discord.py diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index 381960b1af..9899f06d43 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -11,21 +11,19 @@ from django.utils.translation import gettext as _ from evennia.accounts.accounts import DefaultAccount from evennia.scripts.scripts import DefaultScript -from evennia.utils import search, utils +from evennia.utils import logger, search, utils +from evennia.utils.ansi import strip_ansi _IDLE_TIMEOUT = settings.IDLE_TIMEOUT _IRC_ENABLED = settings.IRC_ENABLED _RSS_ENABLED = settings.RSS_ENABLED _GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED - +_DISCORD_ENABLED = settings.DISCORD_ENABLED and hasattr(settings, "DISCORD_BOT_TOKEN") _SESSIONS = None -# Bot helper utilities - - class BotStarter(DefaultScript): """ This non-repeating script has the @@ -42,16 +40,17 @@ class BotStarter(DefaultScript): self.key = "botstarter" self.desc = "bot start/keepalive" self.persistent = True - self.db.started = False + + def at_server_start(self): + self.at_start() def at_start(self): """ Kick bot into gear. """ - if not self.db.started: + if not len(self.account.sessions.all()): self.account.start() - self.db.started = True def at_repeat(self): """ @@ -68,21 +67,6 @@ class BotStarter(DefaultScript): for session in _SESSIONS.sessions_from_account(self.account): session.update_session_counters(idle=True) - def at_server_reload(self): - """ - If server reloads we don't need to reconnect the protocol - again, this is handled by the portal reconnect mechanism. - - """ - self.db.started = True - - def at_server_shutdown(self): - """ - Make sure we are shutdown. - - """ - self.db.started = False - # # Bot base class @@ -110,8 +94,7 @@ class Bot(DefaultAccount): ) self.locks.add(lockstring) # set the basics of being a bot - script_key = str(self.key) - self.scripts.add(BotStarter, key=script_key) + self.scripts.add(BotStarter, key="bot_starter") self.is_bot = True def start(self, **kwargs): @@ -576,3 +559,191 @@ class GrapevineBot(Bot): self.ndb.ev_channel = self.db.ev_channel if self.ndb.ev_channel: self.ndb.ev_channel.msg(text, senders=self) + + +# Discord + + +class DiscordBot(Bot): + """ + Discord bot relay. You will need to set up your own bot (https://discord.com/developers/applications) + and add the bot token as `DISCORD_BOT_TOKEN` to `secret_settings.py` to use + """ + + factory_path = "evennia.server.portal.discord.DiscordWebsocketServerFactory" + + def at_init(self): + """ + Load required channels back into memory + """ + if channel_links := self.db.channels: + # this attribute contains a list of evennia<->discord links in the form of ("evennia_channel", "discord_chan_id") + # grab Evennia channels, cache and connect + channel_set = {evchan for evchan, dcid in channel_links} + self.ndb.ev_channels = {} + for channel_name in list(channel_set): + channel = search.search_channel(channel_name) + if not channel: + raise RuntimeError(f"Evennia Channel {channel_name} not found.") + channel = channel[0] + self.ndb.ev_channels[channel_name] = channel + + def start(self): + """ + Tell the Discord protocol to connect. + + """ + if not _DISCORD_ENABLED: + self.delete() + return + + if self.ndb.ev_channels: + for channel in self.ndb.ev_channels.values(): + channel.connect(self) + + elif channel_links := self.db.channels: + # this attribute contains a list of evennia<->discord links in the form of ("evennia_channel", "discord_chan_id") + # grab Evennia channels, cache and connect + channel_set = {evchan for evchan, dcid in channel_links} + self.ndb.ev_channels = {} + for channel_name in list(channel_set): + channel = search.search_channel(channel_name) + if not channel: + raise RuntimeError(f"Evennia Channel {channel_name} not found.") + channel = channel[0] + self.ndb.ev_channels[channel_name] = channel + channel.connect(self) + + # connect + global _SESSIONS + if not _SESSIONS: + from evennia.server.sessionhandler import SESSIONS as _SESSIONS + # these will be made available as properties on the protocol factory + configdict = {"uid": self.dbid} + logger.log_info("starting discord bot session") + _SESSIONS.start_bot_session(self.factory_path, configdict) + + def at_msg_send(self, **kwargs): + "Skip this to avoid looping, presumably" + pass + + def at_pre_channel_msg(self, message, channel, senders=None, **kwargs): + """ + Called by the Channel just before passing a message into `channel_msg`. + + We overload this to force off the channel tag prefix. + """ + kwargs["no_prefix"] = not self.db.tag_channel + return super().at_pre_channel_msg(message, channel, senders=senders, **kwargs) + + def channel_msg(self, message, channel, senders=None, **kwargs): + """ + Passes channel messages received on to discord + + Args: + message (str): Incoming text from channel. + channel (Channel): The channel the message is being received from + + Keyword Args: + senders (list or None): Object(s) sending the message + + """ + if kwargs.get("relayed"): + # don't relay our own relayed messages + return + if channel_list := self.db.channels: + # get all the discord channels connected to this evennia channel + channel_name = channel.name + for dc_chan in [dcid for evchan, dcid in channel_list if evchan == channel_name]: + # send outputfunc channel(msg, discord channel) + super().msg(channel=(strip_ansi(message.strip()), dc_chan)) + + def direct_msg(self, message, sender, **kwargs): + """ + Called when the Discord bot receives a direct message on Discord. + + Args: + message (str) - Incoming text from Discord. + sender (tuple) - The Discord info for the sender in the form (id, nickname) + + """ + pass + + def relay_to_channel( + self, message, to_channel, sender=None, from_channel=None, from_server=None, **kwargs + ): + """ + Formats and sends a Discord -> Evennia message. Called when the Discord bot receives a channel message on Discord. + + Args: + message (str) - Incoming text from Discord. + to_channel (Channel) - The Evennia channel receiving the message + + Keyword args: + sender (tuple) - The Discord info for the sender in the form (id, nickname) + from_channel (str) - The Discord channel name + from_server (str) - The Discord server name + + """ + + tag_str = "" + if from_channel and self.db.tag_channel: + tag_str = f"#{from_channel}" + if from_server and self.db.tag_guild: + if tag_str: + tag_str += f"@{from_server}" + else: + tag_str = from_server + + if tag_str: + tag_str = f"[{tag_str}] " + + if sender: + sender_name = f"|c{sender[1]}|n: " + + message = f"{tag_str}{sender_name}{message}" + to_channel.msg(message, senders=None, relayed=True) + + def execute_cmd( + self, + txt=None, + session=None, + type=None, + sender=None, + **kwargs, + ): + """ + Take incoming data from protocol and send it to connected channel. This is + triggered by the bot_data_in Inputfunc. + """ + # normal channel message + if type == "channel": + channel_id = kwargs.get("channel_id") + channel_name = self.db.discord_channels.get(channel_id, {}).get("name", channel_id) + guild_id = kwargs.get("guild_id") + guild = self.db.guilds.get(guild_id) + + if channel_links := self.db.channels: + for ev_channel in [ + ev_chan for ev_chan, dc_id in channel_links if dc_id == channel_id + ]: + channel = search.channel_search(ev_channel) + if not channel: + continue + channel = channel[0] + self.relay_to_channel(txt, channel, sender, channel_name, guild) + + # direct message + elif type == "direct": + # pass on to the DM hook + self.direct_msg(txt, sender, **kwargs) + + # guild info update + elif type == "guild": + if guild_id := kwargs.get("guild_id"): + if not self.db.guilds: + self.db.guilds = {} + self.db.guilds[guild_id] = kwargs.get("guild_name", "Unidentified") + if not self.db.discord_channels: + self.db.discord_channels = {} + self.db.discord_channels.update(kwargs.get("channels", {})) diff --git a/evennia/commands/default/cmdset_account.py b/evennia/commands/default/cmdset_account.py index 45aa027301..edd5844a5c 100644 --- a/evennia/commands/default/cmdset_account.py +++ b/evennia/commands/default/cmdset_account.py @@ -72,6 +72,7 @@ class AccountCmdSet(CmdSet): self.add(comms.CmdIRCStatus()) self.add(comms.CmdRSS2Chan()) self.add(comms.CmdGrapevine2Chan()) + self.add(comms.CmdDiscord2Chan()) # self.add(comms.CmdChannels()) # self.add(comms.CmdAddCom()) # self.add(comms.CmdDelCom()) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 4c780840b3..37c373afe1 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -3,7 +3,7 @@ Communication commands: - channel - page -- irc/rss/grapevine linking +- irc/rss/grapevine/discord linking """ @@ -14,7 +14,7 @@ from evennia.accounts.models import AccountDB from evennia.comms.comms import DefaultChannel from evennia.comms.models import Msg from evennia.locks.lockhandler import LockException -from evennia.utils import create, logger, utils +from evennia.utils import create, logger, search, utils from evennia.utils.evmenu import ask_yes_no from evennia.utils.logger import tail_log_file from evennia.utils.utils import class_from_module, strip_unsafe_input @@ -34,6 +34,7 @@ __all__ = ( "CmdIRCStatus", "CmdRSS2Chan", "CmdGrapevine2Chan", + "CmdDiscord2Chan", ) _DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH @@ -1908,3 +1909,181 @@ class CmdGrapevine2Chan(COMMAND_DEFAULT_CLASS): bot.start(ev_channel=channel, grapevine_channel=grapevine_channel) self.msg(f"Grapevine connection created {channel} <-> {grapevine_channel}.") + + +class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): + """ + Link an Evennia channel to an external Discord channel + + Usage: + discord2chan[/switches] + discord2chan[/switches] [= ] + discord2chan/name + + Switches: + /name - Assign a name for the Discord bot to use on Evennia channels + /list - (or no switch) show existing Evennia <-> Discord links + /remove - remove an existing link by link ID + /delete - alias to remove + /guild - toggle the Discord server tag on/off + /channel - toggle the Evennia/Discord channel tags on/off + + Example: + discord2chan mydiscord = 555555555555555 + + This creates a link between an in-game Evennia channel and an external + Discord channel. You must have a valid Discord bot application + (https://discord.com/developers/applications)) and your DISCORD_BOT_TOKEN + must be added to settings. (Please put it in secret_settings !) + """ + + key = "discord2chan" + aliases = ("discord",) + switch_options = ( + "available", + "channel", + "delete", + "guild", + "list", + "name", + "remove", + ) + locks = "cmd:serversetting(DISCORD_ENABLED) and pperm(Developer)" + help_category = "Comms" + + def func(self): + """Manage the Evennia<->Discord channel links""" + + if not settings.DISCORD_BOT_TOKEN: + self.msg( + "You must add your Discord bot application token to settings as DISCORD_BOT_TOKEN" + ) + return + + discord_bot = bots.DiscordBot.objects.filter_family() + if not discord_bot: + if "name" in self.switches: + # create a new discord bot + # TODO: reference settings for custom typeclass + discord_bot = create.create_account(self.lhs, None, None, typeclass=bots.DiscordBot) + discord_bot.start() + else: + self.msg("Please set up your Discord bot first: discord2chan/name ") + return + + else: + discord_bot = discord_bot[0] + + if "name" in self.switches: + new_name = self.args.strip() + if bots.DiscordBot.validate_username(new_name): + discord_bot.name = new_name + self.msg(f"The Discord relay account is now named {new_name} in-game.") + return + + if "guild" in self.switches: + discord_bot.db.tag_guild = not discord_bot.db.tag_guild + self.msg( + f"Messages to Evennia |wwill {'' if discord_bot.db.tag_guild else 'not '}|ninclude the Discord server." + ) + return + if "channel" in self.switches: + discord_bot.db.tag_channel = not discord_bot.db.tag_channel + self.msg( + f"Relayed messages |wwill {'' if discord_bot.db.tag_channel else 'not '}|ninclude the originating channel." + ) + return + + if "list" in self.switches or not self.args: + # show all connections + if channel_list := discord_bot.db.channels: + table = self.styled_table( + "|wLink ID|n", + "|wEvennia|n", + "|wDiscord|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + # iterate through the channel links + # load in the pretty names for the discord channels from cache + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + for i, (evchan, dcchan) in enumerate(channel_list): + dc_info = dc_chan_names.get(dcchan, {"name": "unknown", "guild": "unknown"}) + table.add_row( + i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}" + ) + self.msg(table) + else: + self.msg("No Discord connections found.") + return + + if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches: + if channel_list := discord_bot.db.channels: + try: + lid = int(self.args.strip()) + except ValueError: + self.msg("Usage: discord2chan/remove ") + return + if lid < len(channel_list): + ev_chan, dc_chan = discord_bot.db.channels.pop(lid) + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + dc_info = dc_chan_names.get(dc_chan, {"name": "unknown", "guild": "unknown"}) + self.msg( + f"Removed link between {ev_chan} and #{dc_info.get('name','?')}@{dc_info.get('guild','?')}" + ) + return + else: + self.msg("There are no active connections to Discord.") + return + + ev_channel = self.lhs + dc_channel = self.rhs + + if ev_channel and not dc_channel: + # show all discord channels linked to self.lhs + if channel_list := discord_bot.db.channels: + table = self.styled_table( + "|wLink ID|n", + "|wEvennia|n", + "|wDiscord|n", + border="cells", + maxwidth=_DEFAULT_WIDTH, + ) + # iterate through the channel links + # load in the pretty names for the discord channels from cache + dc_chan_names = discord_bot.attributes.get("discord_channels", {}) + results = False + for i, (evchan, dcchan) in enumerate(channel_list): + if evchan.lower() == ev_channel.lower(): + dc_info = dc_chan_names.get(dcchan, {"name": "unknown", "guild": "unknown"}) + table.add_row( + i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}" + ) + results = True + if results: + self.msg(table) + else: + self.msg(f"There are no Discord channels connected to {ev_channel}.") + else: + self.msg("There are no active connections to Discord.") + return + + # check if link already exists + if channel_list := discord_bot.db.channels: + if (ev_channel, dc_channel) in channel_list: + self.msg(f"Those channels are already linked.") + return + else: + discord_bot.db.channels = [] + # create the new link + channel_obj = search.search_channel(ev_channel) + if not channel_obj: + self.msg(f"There is no channel '{ev_channel}'") + return + channel_obj = channel_obj[0] + discord_bot.db.channels.append((channel_obj.name, dc_channel)) + if dc_chans := discord_bot.db.discord_channels: + dc_channel_name = dc_chans.get(dc_channel, {}).get("name", dc_channel) + else: + dc_channel_name = dc_channel + self.msg(f"Discord connection created: {channel_obj.name} <-> #{dc_channel_name}.") diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py new file mode 100644 index 0000000000..2aee460bee --- /dev/null +++ b/evennia/server/portal/discord.py @@ -0,0 +1,534 @@ +""" +Implements Discord chat channel integration. + +The Discord API uses a mix of websockets and REST API endpoints. + +In order for this integration to work, you need to have your own +discord bot set up via https://discord.com/developers/applications +with the MESSAGE CONTENT toggle switched on, and your bot token +added to `server/conf/secret_settings.py` as your DISCORD_BOT_TOKEN +""" +import json +import os +from random import random +from io import BytesIO + +from autobahn.twisted.websocket import ( + WebSocketClientFactory, + WebSocketClientProtocol, + connectWS, +) +from django.conf import settings +from twisted.internet import protocol, reactor, ssl, task +from twisted.web.client import Agent, FileBodyProducer, readBody +from twisted.web.http_headers import Headers + +from evennia.server.session import Session +from evennia.utils import class_from_module, get_evennia_version, logger + +_AGENT = Agent(reactor) + +_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS) + +DISCORD_API_VERSION = 10 +# include version number to prevent automatically updating to breaking changes +DISCORD_API_BASE_URL = f"https://discord.com/api/v{DISCORD_API_VERSION}" + +DISCORD_USER_AGENT = f"Evennia (https://www.evennia.com, {get_evennia_version(mode='short')})" +DISCORD_BOT_TOKEN = getattr(settings, "DISCORD_BOT_TOKEN", None) +DISCORD_BOT_INTENTS = getattr(settings, "DISCORD_BOT_INTENTS", 105985) + +# Discord OP codes, alphabetic +OP_DISPATCH = 0 +OP_HEARTBEAT = 1 +OP_HEARTBEAT_ACK = 11 +OP_HELLO = 10 +OP_IDENTIFY = 2 +OP_INVALID_SESSION = 9 +OP_RECONNECT = 7 +OP_RESUME = 6 + + +class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory): + """ + A variant of the websocket-factory that auto-reconnects. + + """ + + initialDelay = 1 + factor = 1.5 + maxDelay = 60 + gateway = None + resume_url = None + + def __init__(self, sessionhandler, *args, **kwargs): + self.uid = kwargs.pop("uid") + self.sessionhandler = sessionhandler + self.port = None + self.bot = None + + def get_gateway_url(self, *args, **kwargs): + # get the websocket gateway URL from Discord + d = _AGENT.request( + b"GET", + f"{DISCORD_API_BASE_URL}/gateway".encode("utf-8"), + Headers( + { + "User-Agent": [DISCORD_USER_AGENT], + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + } + ), + None, + ) + + def cbResponse(response): + # check status code here to verify it was a successful connection first + # then schedule a retry if not + d = readBody(response) + d.addCallback(self.websocket_init, *args, **kwargs) + return d + + d.addCallback(cbResponse) + + def websocket_init(self, payload, *args, **kwargs): + """ + callback for when the URL is gotten + """ + data = json.loads(str(payload, "utf-8")) + logger.log_info(f"payload: {data}") + if url := data.get("url"): + self.gateway = f"{url}/?v={DISCORD_API_VERSION}&encoding=json".encode("utf-8") + useragent = kwargs.pop("useragent", DISCORD_USER_AGENT) + headers = kwargs.pop( + "headers", + { + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + }, + ) + + logger.log_info("Connecting to Discord Gateway...") + WebSocketClientFactory.__init__( + self, url, *args, headers=headers, useragent=useragent, **kwargs + ) + self.start() + else: + # TODO: set this up to schedule a retry instead + logger.log_err("Discord did not return a websocket URL; connection cancelled.") + + def buildProtocol(self, addr): + """ + Build new instance of protocol + + Args: + addr (str): Not used, using factory/settings data + + """ + if hasattr(settings, "DISCORD_SESSION_CLASS"): + protocol_class = class_from_module( + settings.DISCORD_SESSION_CLASS, fallback=DiscordClient + ) + protocol = protocol_class() + else: + protocol = DiscordClient() + + protocol.factory = self + protocol.sessionhandler = self.sessionhandler + return protocol + + def startedConnecting(self, connector): + """ + Tracks reconnections for debugging. + + Args: + connector (Connector): Represents the connection. + + """ + logger.log_info("Attempting connection to Discord...") + + def clientConnectionFailed(self, connector, reason): + """ + Called when Client failed to connect. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason) + + def clientConnectionLost(self, connector, reason): + """ + Called when Client loses connection. + + Args: + connector (Connection): Represents the connection. + reason (str): The reason for the failure. + + """ + # what is the purpose of this check??? do i need it + if not self.bot: + self.retry(connector) + + def reconnect(self): + """ + Force a reconnection of the bot protocol. This requires + de-registering the session and then reattaching a new one. + + """ + self.bot.stopping = True + self.bot.transport.loseConnection() + self.sessionhandler.server_disconnect(self.bot) + if self.resume_url: + self.url = self.resume_url + elif self.gateway: + self.url = self.gateway + else: + # we don't know where to reconnect to! start from the beginning + self.get_gateway_url() + return + self.start() + + def start(self): + "Connect protocol to remote server" + + if not self.gateway: + # we can't actually start yet + # get the gateway URL from Discord + self.get_gateway_url() + else: + connectWS(self) + + +class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): + """ + Implements the grapevine client + """ + + nextHeartbeatCall = None + pending_heartbeat = False + heartbeat_interval = None + last_sequence = 0 + session_id = None + + def __init__(self): + WebSocketClientProtocol.__init__(self) + _BASE_SESSION_CLASS.__init__(self) + self.restart_downtime = None + + # self.discord_id + + def at_login(self): + pass + + def onOpen(self): + """ + Called when connection is established. + + """ + self.restart_downtime = None + self.restart_task = None + + self.stopping = False + self.factory.bot = self + + self.init_session("discord", "discord.gg", self.factory.sessionhandler) + self.uid = int(self.factory.uid) + self.logged_in = True + self.sessionhandler.connect(self) + + def onMessage(self, payload, isBinary): + """ + Callback fired when a complete WebSocket message was received. + + Args: + payload (bytes): The WebSocket message received. + isBinary (bool): Flag indicating whether payload is binary or + UTF-8 encoded text. + + """ + if isBinary: + logger.log_info("DISCORD: got a binary payload for some reason") + return + data = json.loads(str(payload, "utf-8")) + if seqid := data.get("s"): + self.last_sequence = seqid + + # not sure if that error json format is for websockets + # check for it just in case + if "errors" in data: + self.handle_error(data) + return + + # check for discord gateway API op codes first + if data["op"] == OP_HELLO: + self.interval = data["d"]["heartbeat_interval"] / 1000 # convert millisec to seconds + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + self.nextHeartbeatCall = self.factory._batched_timer.call_later( + self.interval * random(), + self.doHeartbeat, + ) + if self.session_id: + # we already have a session; try to resume instead + self.resume() + else: + self.identify() + elif data["op"] == OP_HEARTBEAT_ACK: + self.pending_heartbeat = False + elif data["op"] == OP_HEARTBEAT: + self.doHeartbeat(force=True) + elif data["op"] == OP_INVALID_SESSION: + # reconnect + logger.log_msg("Discord: received 'Invalid Session' opcode. Reconnecting.") + if data["d"] == False: + # can't resume, clear existing resume data + self.session_id = None + self.factory.resume_url = None + self.factory.reconnect() + elif data["op"] == OP_RECONNECT: + logger.log_msg("Discord: received 'Reconnect' opcode. Reconnecting.") + self.factory.reconnect() + elif data["op"] == OP_DISPATCH: + if data["t"] == "READY": + self.connection_ready(data["d"]) + else: + # general message, pass on to data_in + self.data_in(data=data) + + def onClose(self, wasClean, code=None, reason=None): + """ + This is executed when the connection is lost for whatever + reason. it can also be called directly, from the disconnect + method. + + Args: + wasClean (bool): ``True`` if the WebSocket was closed cleanly. + code (int or None): Close status as sent by the WebSocket peer. + reason (str or None): Close reason as sent by the WebSocket peer. + + """ + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + self.disconnect(reason) + if code >= 4000: + logger.log_err(f"Discord connection closed: {reason}") + else: + logger.log_info(f"Discord disconnected: {reason}") + + def _send_json(self, data): + """ + Post JSON data to the websocket + + Args: + data (dict): content to send. + + """ + return self.sendMessage(json.dumps(data).encode("utf-8")) + + def _post_json(self, url, data, **kwargs): + """ + Post JSON data to a REST API endpoint + + Args: + url (str) - + data (dict) - Content to be sent + """ + url = f"{DISCORD_API_BASE_URL}/{url}" + body = FileBodyProducer(BytesIO(json.dumps(data).encode("utf-8"))) + d = _AGENT.request( + b"POST", + url.encode("utf-8"), + Headers( + { + "User-Agent": [DISCORD_USER_AGENT], + "Authorization": [f"Bot {DISCORD_BOT_TOKEN}"], + "Content-Type": ["application/json"], + } + ), + body, + ) + + def cbResponse(response): + # check status code here to verify it was a successful connection first + # then schedule a retry if not + d = readBody(response) + d.addCallback(self.post_response) + return d + + d.addCallback(cbResponse) + + def post_response(self, body, **kwargs): + """ + Process the response from sending a POST request + + Args: + body (bytes) - The post response body + """ + data = json.loads(body) + if "errors" in data: + self.handle_error(data) + + def handle_error(self, data, **kwargs): + """ + General hook for processing errors. + + Args: + data (dict) - The received error data + + """ + logger.log_err(str(data)) + + def resume(self): + """ + Called after a reconnection to re-identify and replay missed events + + """ + if not self.last_sequence or not self.session_id: + # we have no known state to resume from, identify normally + self.identify() + + data = { + "op": OP_RESUME, + "d": { + "token": DISCORD_BOT_TOKEN, + "session_id": self.session_id, + "s": self.sequence_id, + }, + } + self._send_json(data) + + def disconnect(self, reason=None): + """ + Generic hook for the engine to call in order to + disconnect this protocol. + + Args: + reason (str or None): Motivation for the disconnection. + + """ + self.sessionhandler.disconnect(self) + self.sendClose(self.CLOSE_STATUS_CODE_NORMAL, reason) + + def identify(self, *args, **kwargs): + """ + Send Discord authentication. This should be sent once heartbeats begin. + + """ + data = { + "op": 2, + "d": { + "token": DISCORD_BOT_TOKEN, + "intents": DISCORD_BOT_INTENTS, + "properties": { + "os": os.name, + "browser": DISCORD_USER_AGENT, + "device": DISCORD_USER_AGENT, + }, + }, + } + self._send_json(data) + + def connection_ready(self, data): + """ + Process READY data for relevant bot info. + """ + self.factory.resume_url = data["resume_gateway_url"] + self.session_id = data["session_id"] + self.discord_id = data["user"]["id"] + + def doHeartbeat(self, *args, **kwargs): + """ + Send heartbeat to Discord. + + """ + if not self.pending_heartbeat or kwargs.get("force"): + if self.nextHeartbeatCall: + self.nextHeartbeatCall.cancel() + data = {"op": 1, "d": self.last_sequence} + self._send_json(data) + self.pending_heartbeat = True + self.nextHeartbeatCall = self.factory._batched_timer.call_later( + self.interval, + self.doHeartbeat, + ) + else: + # we didn't get a response since the last heartbeat; reconnect + self.factory.reconnect() + + def send_channel(self, text, channel_id, **kwargs): + """ + Send a message from an Evennia channel to a Discord channel. + + Use with session.msg(channel=(message, channel, sender)) + + """ + + data = {"content": text} + data.update(kwargs) + self._post_json(f"channels/{channel_id}/messages", data) + + def send_default(self, *args, **kwargs): + """ + Ignore other outputfuncs + + """ + pass + + def data_in(self, data, **kwargs): + """ + + Send data grapevine -> Evennia + Keyword Args: + data (dict): Converted json data. + + """ + action_type = data.get("t", "UNKNOWN") + logger.log_msg(f"DISCORD: Received an action of type {action_type}.") + + if action_type == "MESSAGE_CREATE": + logger.log_msg(str(data)) + data = data["d"] + if data["author"]["id"] == self.discord_id: + return + message = data["content"] + channel_id = data["channel_id"] + keywords = {"channel_id": channel_id} + if "guild_id" in data: + # channel message + keywords["type"] = "channel" + author = data["member"]["nick"] or data["author"]["username"] + author_id = data["author"]["id"] + keywords["sender"] = (author_id, author) + keywords["guild_id"] = data["guild_id"] + + else: + # direct message + keywords["type"] = "direct" + author = data["author"]["username"] + author_id = data["author"]["id"] + keywords["sender"] = (author_id, author) + + self.sessionhandler.data_in(self, bot_data_in=(message, keywords)) + + elif action_type in ("GUILD_CREATE", "GUILD_UPDATE"): + data = data["d"] + keywords = {"type": "guild", "guild_id": data["id"], "guild_name": data["name"]} + keywords["channels"] = { + chan["id"]: {"name": chan["name"], "guild": data["name"]} + for chan in data["channels"] + if chan["type"] == 0 + } + self.sessionhandler.data_in(self, bot_data_in=("", keywords)) + + elif "DELETE" in action_type: + # deletes should probably be handled separately to check for channel removal + # for now, just ignore + pass + + else: + # send all the data on to the bot as-is for optional bot-side handling + keywords = {"type": action_type} + keywords.update(data["d"]) + self.sessionhandler.data_in(self, bot_data_in=("", keywords)) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 16ffc86570..6bb8b255ce 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -874,6 +874,9 @@ GRAPEVINE_CHANNELS = ["gossip", "testing"] # them. These are secret and should thus be overridden in secret_settings file GRAPEVINE_CLIENT_ID = "" GRAPEVINE_CLIENT_SECRET = "" +# Discord integration +# TODO: add doc comments here +DISCORD_ENABLED = False ###################################################################### # Django web features From a2eb049fc908e0fd8a776b80d45a7a127b33ec08 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Sat, 26 Nov 2022 18:36:53 -0700 Subject: [PATCH 2/7] cleanup and docs --- docs/source/Setup/Channels-to-Discord.md | 169 +++++++++++++++++++++++ evennia/accounts/bots.py | 5 - evennia/server/portal/discord.py | 3 - 3 files changed, 169 insertions(+), 8 deletions(-) create mode 100644 docs/source/Setup/Channels-to-Discord.md diff --git a/docs/source/Setup/Channels-to-Discord.md b/docs/source/Setup/Channels-to-Discord.md new file mode 100644 index 0000000000..4bae6ff08f --- /dev/null +++ b/docs/source/Setup/Channels-to-Discord.md @@ -0,0 +1,169 @@ +# Connect Evennia channels to Discord + +[Discord](https://discord.com) is a popular chat service, especially for game +communities. If you have a discord server for your game, you can connect it +to your in-game channels to communicate between in-game and out. + +## Configuring Discord + +The first thing you'll need is to set up a Discord bot to connect to your game. +Go to the [bot applications](https://discord.com/developers/applications) page page and make a new application. You'll need the +"MESSAGE CONTENT" toggle flipped On, and to add your bot token to your settings. + +```python +# mygame/server/conf/secret_settings.py +DISCORD_BOT_TOKEN = +``` + +You will also need the `pyopenssl` module, if it isn't already installed. +Install it into your Evennia python environment with + + pip install pyopenssl + +Lastly, enable Discord in your settings + +```python +DISCORD_ENABLED = True +``` + +Start/reload Evennia and log in as a privileged user. You should now have a new +command available: `@discord2chan`. Enter `help discord2chan` for an explanation +of its options. + +Adding a new channel link is done with the following command: + + @discord2chan = + +The `evennia_channel` argument must be the name of an existing Evennia channel, +and `discord_channel_id` is the full numeric ID of the Discord channel. + +> Your bot needs to be added to the correct server with access to the channel +> in order to send or receive messages. This command does NOT verify that your +> bot has access! + +## Step-By-Step Discord Setup + +This section will walk through the entire process of setting up a Discord +connection to your Evennia game, step by step. If you've completed any of the +steps already, feel free to skip to the next. + +### Creating a Discord Bot Application + +> You will need an active Discord account and admin access to a Discord server +> in order to connect Evennia to it. This assumes you already do. + +Make sure you're logged in on the Discord website, then visit +[https://discord.com/developers/applications]. Click the "New Application" +button in the upper right corner, then enter the name for your new app - the +name of your Evennia game is a good option. + +You'll next be brought to the settings page for the new application. Click "Bot" +on the sidebar menu, then "Build-a-Bot" to create your bot account. + +**Save the displayed token!** This will be the ONLY time that Discord will allow +you to see that token - if you lose it, you will have to reset it. This token is +how your bot confirms its identity, so it's very important. + +Next, add this token to your _secret_ settings. + +```python +# file: mygame/server/conf/secret_settings.py + +DISCORD_BOT_TOKEN = +``` + +Once that is saved, scroll down the Bot page a little more and find the toggle for +"Message Content Intent". You'll need this to be toggled to ON, or you bot won't +be able to read anyone's messages. + +Finally, you can add any additional settings to your new bot account: a display image, +display nickname, bio, etc. You can come back and change these at any time, so +don't worry about it too much now. + +### Adding your bot to your server + +While still in your new application, click "OAuth2" on the side menu, then "URL +Generator". On this page, you'll generate an invite URL for your app, then visit +that URL to add it to your server. + +In the top box, find the checkbox for `bot` and check it: this will make a second +permissions box appear. In that box, you'll want to check off at least the +following boxes: + +- Read Messages/View Channels (in "General Permissions") +- Send Messages (in "Text Permissions") + +Lastly, scroll down to the bottom of the page and copy the resulting URL. It should +look something like this: + + https://discord.com/api/oauth2/authorize?client_id=55555555555555555&permissions=3072&scope=bot + +Visit that link, select the server for your Evennia connection, and confirm. + +After the bot is added to your server, you can fine-tune the permissions further +through the usual Discord server administration. + +### Activating Discord in Evennia + +You'll need to do two additional things with your Evennia game before it can connect +to Discord. + +First, install `pyopenssl` to your virtual environment, if you haven't already. + + pip install pyopenssl + +Second, enable the Discord integration in your settings file. + +```python +# file: server/conf/settings.py +DISCORD_ENABLED = True +``` + +Start or reload your game to apply the changed settings, then log in as an account +with at least `Developer` permissions and initialize the bot account on Evennia: + + @discord2chan/name + +The name you assign it can be anything; it will show up in the `who` list for your +game and your game's channels, but is otherwise unused. + +Lastly, confirm that it's fully enabled by entering `@discord2chan` on its own. +You should receive a message that there are no active connections to Discord. + +### Connecting an Evennia channel to a Discord channel + +You will need the name of your Evennia channel, and the channel ID for your Discord +channel. The channel ID is the last part of the URL when you visit a channel. + +e.g. if the url is `https://discord.com/channels/55555555555555555/12345678901234567890` +then your channel ID is `12345678901234567890` + +Link the two channels with the following command: + + @discord2chan = + +The two channels should now relay to each other. Confirm this works by posting a +message on the evennia channel, and another on the Discord channel - they should +both show up on the other end. + +> If you don't see any messages coming to or from Discord, make sure that your bot +> has permission to read and send messages and that your application has the +> "Message Content Intents" flag set. + +### Further Customization + +The help file for `discord2chan` has more information on how to use the command to +customize your relayed messages. + +For anything more complex, however, you can create your own child class of +`DiscordBot` and add it to your settings. + +```python +# file: mygame/server/conf/settings.py +# EXAMPLE +DISCORD_BOT_CLASS = 'accounts.bots.DiscordBot' +``` + +> If you had already set up a Discord relay and are changing this, make sure you +> either delete the old bot account in Evennia or change its typeclass or it won't +> take effect. \ No newline at end of file diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index 9899f06d43..f26464e34e 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -620,13 +620,8 @@ class DiscordBot(Bot): from evennia.server.sessionhandler import SESSIONS as _SESSIONS # these will be made available as properties on the protocol factory configdict = {"uid": self.dbid} - logger.log_info("starting discord bot session") _SESSIONS.start_bot_session(self.factory_path, configdict) - def at_msg_send(self, **kwargs): - "Skip this to avoid looping, presumably" - pass - def at_pre_channel_msg(self, message, channel, senders=None, **kwargs): """ Called by the Channel just before passing a message into `channel_msg`. diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py index 2aee460bee..4f900b9062 100644 --- a/evennia/server/portal/discord.py +++ b/evennia/server/portal/discord.py @@ -96,7 +96,6 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin callback for when the URL is gotten """ data = json.loads(str(payload, "utf-8")) - logger.log_info(f"payload: {data}") if url := data.get("url"): self.gateway = f"{url}/?v={DISCORD_API_VERSION}&encoding=json".encode("utf-8") useragent = kwargs.pop("useragent", DISCORD_USER_AGENT) @@ -485,10 +484,8 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): """ action_type = data.get("t", "UNKNOWN") - logger.log_msg(f"DISCORD: Received an action of type {action_type}.") if action_type == "MESSAGE_CREATE": - logger.log_msg(str(data)) data = data["d"] if data["author"]["id"] == self.discord_id: return From 4e7222ea7f17c0cbfddc852d5a9f1d8036164c34 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Sun, 27 Nov 2022 22:56:13 -0700 Subject: [PATCH 3/7] cleanup and comments --- docs/source/Setup/Channels-to-Discord.md | 26 ++++++++++----- evennia/commands/default/comms.py | 4 +-- evennia/server/portal/discord.py | 40 +++++++++++++++--------- evennia/settings_default.py | 16 ++++++++-- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/docs/source/Setup/Channels-to-Discord.md b/docs/source/Setup/Channels-to-Discord.md index 4bae6ff08f..87ff92aec3 100644 --- a/docs/source/Setup/Channels-to-Discord.md +++ b/docs/source/Setup/Channels-to-Discord.md @@ -7,12 +7,12 @@ to your in-game channels to communicate between in-game and out. ## Configuring Discord The first thing you'll need is to set up a Discord bot to connect to your game. -Go to the [bot applications](https://discord.com/developers/applications) page page and make a new application. You'll need the +Go to the [bot applications](https://discord.com/developers/applications) page and make a new application. You'll need the "MESSAGE CONTENT" toggle flipped On, and to add your bot token to your settings. ```python # mygame/server/conf/secret_settings.py -DISCORD_BOT_TOKEN = +DISCORD_BOT_TOKEN = '' ``` You will also need the `pyopenssl` module, if it isn't already installed. @@ -37,9 +37,9 @@ Adding a new channel link is done with the following command: The `evennia_channel` argument must be the name of an existing Evennia channel, and `discord_channel_id` is the full numeric ID of the Discord channel. -> Your bot needs to be added to the correct server with access to the channel -> in order to send or receive messages. This command does NOT verify that your -> bot has access! +> Your bot needs to be added to the correct Discord server with access to the +> channel in order to send or receive messages. This command does NOT verify that +> your bot has Discord permissions! ## Step-By-Step Discord Setup @@ -53,7 +53,7 @@ steps already, feel free to skip to the next. > in order to connect Evennia to it. This assumes you already do. Make sure you're logged in on the Discord website, then visit -[https://discord.com/developers/applications]. Click the "New Application" +https://discord.com/developers/applications. Click the "New Application" button in the upper right corner, then enter the name for your new app - the name of your Evennia game is a good option. @@ -69,7 +69,7 @@ Next, add this token to your _secret_ settings. ```python # file: mygame/server/conf/secret_settings.py -DISCORD_BOT_TOKEN = +DISCORD_BOT_TOKEN = '' ``` Once that is saved, scroll down the Bot page a little more and find the toggle for @@ -166,4 +166,14 @@ DISCORD_BOT_CLASS = 'accounts.bots.DiscordBot' > If you had already set up a Discord relay and are changing this, make sure you > either delete the old bot account in Evennia or change its typeclass or it won't -> take effect. \ No newline at end of file +> take effect. + +The core DiscordBot account class has several useful hooks already set up for +processing and relaying channel messages between Discord and Evennia channels, +along with the (unused by default) `direct_msg` hook for processing DMs sent to +the bot on Discord. + +Only messages and server updates are processed by default, but the Discord custom +protocol passes all other unprocessed dispatch data on to the Evennia bot account +so you can add additional handling yourself. However, **this integration is not a full library** +and does not document the full range of possible Discord events. \ No newline at end of file diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 37c373afe1..df848c86eb 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1964,8 +1964,8 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): if not discord_bot: if "name" in self.switches: # create a new discord bot - # TODO: reference settings for custom typeclass - discord_bot = create.create_account(self.lhs, None, None, typeclass=bots.DiscordBot) + bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) + discord_bot = create.create_account(self.lhs, None, None, typeclass=bot_class) discord_bot.start() else: self.msg("Please set up your Discord bot first: discord2chan/name ") diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py index 4f900b9062..e0b8b3bc53 100644 --- a/evennia/server/portal/discord.py +++ b/evennia/server/portal/discord.py @@ -35,8 +35,8 @@ DISCORD_API_VERSION = 10 DISCORD_API_BASE_URL = f"https://discord.com/api/v{DISCORD_API_VERSION}" DISCORD_USER_AGENT = f"Evennia (https://www.evennia.com, {get_evennia_version(mode='short')})" -DISCORD_BOT_TOKEN = getattr(settings, "DISCORD_BOT_TOKEN", None) -DISCORD_BOT_INTENTS = getattr(settings, "DISCORD_BOT_INTENTS", 105985) +DISCORD_BOT_TOKEN = settings.DISCORD_BOT_TOKEN +DISCORD_BOT_INTENTS = settings.DISCORD_BOT_INTENTS # Discord OP codes, alphabetic OP_DISPATCH = 0 @@ -83,7 +83,7 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin ) def cbResponse(response): - # check status code here to verify it was a successful connection first + # TODO: check status code here to verify it was a successful connection first # then schedule a retry if not d = readBody(response) d.addCallback(self.websocket_init, *args, **kwargs) @@ -176,7 +176,6 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin de-registering the session and then reattaching a new one. """ - self.bot.stopping = True self.bot.transport.loseConnection() self.sessionhandler.server_disconnect(self.bot) if self.resume_url: @@ -228,8 +227,6 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): """ self.restart_downtime = None self.restart_task = None - - self.stopping = False self.factory.bot = self self.init_session("discord", "discord.gg", self.factory.sessionhandler) @@ -275,11 +272,13 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): else: self.identify() elif data["op"] == OP_HEARTBEAT_ACK: + # our last heartbeat was acknowledged, so reset the "pending" flag self.pending_heartbeat = False elif data["op"] == OP_HEARTBEAT: + # Discord wants us to send a heartbeat immediately self.doHeartbeat(force=True) elif data["op"] == OP_INVALID_SESSION: - # reconnect + # Discord doesn't like our current session; reconnect for a new one logger.log_msg("Discord: received 'Invalid Session' opcode. Reconnecting.") if data["d"] == False: # can't resume, clear existing resume data @@ -287,10 +286,13 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): self.factory.resume_url = None self.factory.reconnect() elif data["op"] == OP_RECONNECT: + # reconnect as requested; Discord does this regularly for server load balancing logger.log_msg("Discord: received 'Reconnect' opcode. Reconnecting.") self.factory.reconnect() elif data["op"] == OP_DISPATCH: + # handle the general dispatch opcode events by type if data["t"] == "READY": + # our recent identification is valid; process new session info self.connection_ready(data["d"]) else: # general message, pass on to data_in @@ -331,7 +333,7 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): Post JSON data to a REST API endpoint Args: - url (str) - + url (str) - The API path which is being posted to data (dict) - Content to be sent """ url = f"{DISCORD_API_BASE_URL}/{url}" @@ -350,7 +352,7 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): ) def cbResponse(response): - # check status code here to verify it was a successful connection first + # TODO: check status code here to verify it was a successful connection first # then schedule a retry if not d = readBody(response) d.addCallback(self.post_response) @@ -388,6 +390,7 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): # we have no known state to resume from, identify normally self.identify() + # build a RESUME request for Discord and send it data = { "op": OP_RESUME, "d": { @@ -445,8 +448,10 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): if not self.pending_heartbeat or kwargs.get("force"): if self.nextHeartbeatCall: self.nextHeartbeatCall.cancel() + # send the heartbeat data = {"op": 1, "d": self.last_sequence} self._send_json(data) + # track that we sent a heartbeat, in case we don't receive an ACK self.pending_heartbeat = True self.nextHeartbeatCall = self.factory._batched_timer.call_later( self.interval, @@ -477,23 +482,25 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): def data_in(self, data, **kwargs): """ + Process incoming data from Discord and sent to the Evennia server - Send data grapevine -> Evennia - Keyword Args: + Args: data (dict): Converted json data. """ action_type = data.get("t", "UNKNOWN") if action_type == "MESSAGE_CREATE": + # someone posted a message on Discord that the bot can see data = data["d"] if data["author"]["id"] == self.discord_id: + # it's by the bot itself! disregard return message = data["content"] channel_id = data["channel_id"] keywords = {"channel_id": channel_id} if "guild_id" in data: - # channel message + # message received to a Discord channel keywords["type"] = "channel" author = data["member"]["nick"] or data["author"]["username"] author_id = data["author"]["id"] @@ -501,15 +508,17 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): keywords["guild_id"] = data["guild_id"] else: - # direct message + # message sent directly to the bot account via DM keywords["type"] = "direct" author = data["author"]["username"] author_id = data["author"]["id"] keywords["sender"] = (author_id, author) + # pass the processed data to the server self.sessionhandler.data_in(self, bot_data_in=(message, keywords)) elif action_type in ("GUILD_CREATE", "GUILD_UPDATE"): + # we received the current status of a guild the bot is on; process relevant info data = data["d"] keywords = {"type": "guild", "guild_id": data["id"], "guild_name": data["name"]} keywords["channels"] = { @@ -517,15 +526,16 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): for chan in data["channels"] if chan["type"] == 0 } + # send the possibly-updated guild and channel data to the server self.sessionhandler.data_in(self, bot_data_in=("", keywords)) elif "DELETE" in action_type: - # deletes should probably be handled separately to check for channel removal + # deletes should possibly be handled separately to check for channel removal # for now, just ignore pass else: - # send all the data on to the bot as-is for optional bot-side handling + # send the data for any other action types on to the bot as-is for optional server-side handling keywords = {"type": action_type} keywords.update(data["d"]) self.sessionhandler.data_in(self, bot_data_in=("", keywords)) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 6bb8b255ce..38f372255f 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -874,9 +874,21 @@ GRAPEVINE_CHANNELS = ["gossip", "testing"] # them. These are secret and should thus be overridden in secret_settings file GRAPEVINE_CLIENT_ID = "" GRAPEVINE_CLIENT_SECRET = "" -# Discord integration -# TODO: add doc comments here +# Discord (discord.com) is a popular communication service for many, especially +# for game communities. Evennia's channels can be connected to Discord channels +# and relay messages between Evennia and Discord. To use, you will need to create +# your own Discord application and bot. +# Discord also requires installing the pyopenssl library. +# Full step-by-step instructions are available in the official Evennia documentation. DISCORD_ENABLED = False +# The Intents bitmask required by Discord bots to request particular API permissions. +# By default, this includes the basic guild status and message read/write flags. +DISCORD_BOT_INTENTS = 105985 +# The authentication token for the Discord bot. This should be kept secret and +# put in your secret_settings file. +DISCORD_BOT_TOKEN = None +# The account typeclass which the Evennia-side Discord relay bot will use. +DISCORD_BOT_CLASS = "evennia.accounts.bots.DiscordBot" ###################################################################### # Django web features From 9a225c7ad4d05b4bf11678980131f84256f44d48 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Mon, 28 Nov 2022 18:29:58 -0700 Subject: [PATCH 4/7] add tests for new command --- evennia/commands/default/comms.py | 26 ++++++++------- evennia/commands/default/tests.py | 54 +++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index df848c86eb..9a28e258c2 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1962,7 +1962,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): discord_bot = bots.DiscordBot.objects.filter_family() if not discord_bot: - if "name" in self.switches: + if "name" in self.switches and self.args: # create a new discord bot bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) discord_bot = create.create_account(self.lhs, None, None, typeclass=bot_class) @@ -1975,11 +1975,14 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): discord_bot = discord_bot[0] if "name" in self.switches: - new_name = self.args.strip() - if bots.DiscordBot.validate_username(new_name): - discord_bot.name = new_name - self.msg(f"The Discord relay account is now named {new_name} in-game.") - return + if self.args: + new_name = self.args.strip() + if bots.DiscordBot.validate_username(new_name): + discord_bot.name = new_name + self.msg(f"The Discord relay bot is now named {new_name} in-game.") + else: + self.msg("Please enter a name for your Discord relay bot.") + return if "guild" in self.switches: discord_bot.db.tag_guild = not discord_bot.db.tag_guild @@ -2008,7 +2011,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): # load in the pretty names for the discord channels from cache dc_chan_names = discord_bot.attributes.get("discord_channels", {}) for i, (evchan, dcchan) in enumerate(channel_list): - dc_info = dc_chan_names.get(dcchan, {"name": "unknown", "guild": "unknown"}) + dc_info = dc_chan_names.get(dcchan, {"name": dcchan, "guild": "unknown"}) table.add_row( i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}" ) @@ -2055,10 +2058,8 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): results = False for i, (evchan, dcchan) in enumerate(channel_list): if evchan.lower() == ev_channel.lower(): - dc_info = dc_chan_names.get(dcchan, {"name": "unknown", "guild": "unknown"}) - table.add_row( - i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}" - ) + dc_info = dc_chan_names.get(dcchan, {"name": dcchan, "guild": "unknown"}) + table.add_row(i, evchan, f"#{dc_info['name']}@{dc_info['guild']}") results = True if results: self.msg(table) @@ -2071,7 +2072,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): # check if link already exists if channel_list := discord_bot.db.channels: if (ev_channel, dc_channel) in channel_list: - self.msg(f"Those channels are already linked.") + self.msg("Those channels are already linked.") return else: discord_bot.db.channels = [] @@ -2082,6 +2083,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): return channel_obj = channel_obj[0] discord_bot.db.channels.append((channel_obj.name, dc_channel)) + channel_obj.connect(discord_bot) if dc_chans := discord_bot.db.discord_channels: dc_channel_name = dc_chans.get(dc_channel, {}).get("name", dc_channel) else: diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 195d2fde25..45dcfb9fd3 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -2002,6 +2002,60 @@ class TestComms(BaseEvenniaCommandTest): ) +@override_settings(DISCORD_BOT_TOKEN="notarealtoken", DISCORD_ENABLED=True) +class TestDiscord(BaseEvenniaCommandTest): + def setUp(self): + super().setUp() + self.channel = create.create_channel(key="testchannel", desc="A test channel") + self.cmddiscord = cmd_comms.CmdDiscord2Chan + self.cmddiscord.account_caller = False + # create bot manually so it doesn't get started + self.discordbot = create.create_account( + "DiscordTest", None, None, typeclass="evennia.accounts.bots.DiscordBot" + ) + + def tearDown(self): + if self.channel.pk: + self.channel.delete() + + @parameterized.expand( + [ + ("", "No Discord connections found."), + ("/list", "No Discord connections found."), + ("/name", "Please enter a name for your Discord relay bot."), + ("/name DiscordBot", "The Discord relay bot is now named DiscordBot in-game."), + ("/guild", "Messages to Evennia will include the Discord server."), + ("/channel", "Relayed messages will include the originating channel."), + ] + ) + def test_discord__switches(self, cmd_args, expected): + self.call(self.cmddiscord(), cmd_args, expected) + + def test_discord__linking(self): + self.call( + self.cmddiscord(), "nosuchchannel = 5555555", "There is no channel 'nosuchchannel'" + ) + self.call( + self.cmddiscord(), + "testchannel = 5555555", + "Discord connection created: testchannel <-> #5555555", + ) + self.assertTrue(self.discordbot in self.channel.subscriptions.all()) + self.assertTrue(("testchannel", "5555555") in self.discordbot.db.channels) + self.call(self.cmddiscord(), "testchannel = 5555555", "Those channels are already linked.") + + def test_discord__list(self): + self.discordbot.db.channels = [("testchannel", "5555555")] + cmdobj = self.cmddiscord() + cmdobj.msg = lambda text, **kwargs: setattr(self, "out", str(text)) + self.call(cmdobj, "", None) + self.assertIn("testchannel", self.out) + self.assertIn("5555555", self.out) + self.call(cmdobj, "testchannel", None) + self.assertIn("testchannel", self.out) + self.assertIn("5555555", self.out) + + class TestBatchProcess(BaseEvenniaCommandTest): """ Test the batch processor. From 87867509ccc01a137f72e795e06afd4f4735c52a Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Mon, 28 Nov 2022 23:05:26 -0700 Subject: [PATCH 5/7] add retry for discord server error codes --- evennia/server/portal/discord.py | 49 ++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py index e0b8b3bc53..3605450851 100644 --- a/evennia/server/portal/discord.py +++ b/evennia/server/portal/discord.py @@ -25,6 +25,7 @@ from twisted.web.http_headers import Headers from evennia.server.session import Session from evennia.utils import class_from_module, get_evennia_version, logger +from evennia.utils.utils import delay _AGENT = Agent(reactor) @@ -49,6 +50,25 @@ OP_RECONNECT = 7 OP_RESUME = 6 +def should_retry(status_code): + """ + Helper function to check if the request should be retried later. + + Args: + status_code (int) - The HTTP status code + + Returns: + retry (bool) - True if request should be retried False otherwise + """ + if status_code >= 500 and status_code <= 504: + # these are common server error codes when the server is temporarily malfunctioning + # in these cases, we should retry + return True + else: + # handle all other cases; this can be expanded later if needed for special cases + return False + + class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory): """ A variant of the websocket-factory that auto-reconnects. @@ -83,11 +103,12 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin ) def cbResponse(response): - # TODO: check status code here to verify it was a successful connection first - # then schedule a retry if not - d = readBody(response) - d.addCallback(self.websocket_init, *args, **kwargs) - return d + if response.code == 200: + d = readBody(response) + d.addCallback(self.websocket_init, *args, **kwargs) + return d + elif should_retry(response.code): + delay(300, self.get_gateway_url, *args, **kwargs) d.addCallback(cbResponse) @@ -113,7 +134,6 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin ) self.start() else: - # TODO: set this up to schedule a retry instead logger.log_err("Discord did not return a websocket URL; connection cancelled.") def buildProtocol(self, addr): @@ -166,8 +186,7 @@ class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.Reconnectin reason (str): The reason for the failure. """ - # what is the purpose of this check??? do i need it - if not self.bot: + if self.do_retry and not self.bot: self.retry(connector) def reconnect(self): @@ -209,14 +228,13 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): heartbeat_interval = None last_sequence = 0 session_id = None + discord_id = None def __init__(self): WebSocketClientProtocol.__init__(self) _BASE_SESSION_CLASS.__init__(self) self.restart_downtime = None - # self.discord_id - def at_login(self): pass @@ -352,11 +370,12 @@ class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS): ) def cbResponse(response): - # TODO: check status code here to verify it was a successful connection first - # then schedule a retry if not - d = readBody(response) - d.addCallback(self.post_response) - return d + if response.code == 200: + d = readBody(response) + d.addCallback(self.post_response) + return d + elif should_retry(response.code): + delay(300, self._post_json, url, data, **kwargs) d.addCallback(cbResponse) From e8d0754d7db0e286c54905f0f66a8149e5bab0d0 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Tue, 29 Nov 2022 16:33:12 -0700 Subject: [PATCH 6/7] addressing requested changes --- docs/source/Setup/Channels-to-Discord.md | 10 +++---- evennia/accounts/bots.py | 36 +++++++++++++++++------- evennia/commands/default/comms.py | 32 ++++++++------------- evennia/commands/default/tests.py | 2 -- 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/docs/source/Setup/Channels-to-Discord.md b/docs/source/Setup/Channels-to-Discord.md index 87ff92aec3..bbc80c2676 100644 --- a/docs/source/Setup/Channels-to-Discord.md +++ b/docs/source/Setup/Channels-to-Discord.md @@ -27,12 +27,12 @@ DISCORD_ENABLED = True ``` Start/reload Evennia and log in as a privileged user. You should now have a new -command available: `@discord2chan`. Enter `help discord2chan` for an explanation +command available: `discord2chan`. Enter `help discord2chan` for an explanation of its options. Adding a new channel link is done with the following command: - @discord2chan = + discord2chan = The `evennia_channel` argument must be the name of an existing Evennia channel, and `discord_channel_id` is the full numeric ID of the Discord channel. @@ -122,12 +122,12 @@ DISCORD_ENABLED = True Start or reload your game to apply the changed settings, then log in as an account with at least `Developer` permissions and initialize the bot account on Evennia: - @discord2chan/name + discord2chan/name The name you assign it can be anything; it will show up in the `who` list for your game and your game's channels, but is otherwise unused. -Lastly, confirm that it's fully enabled by entering `@discord2chan` on its own. +Lastly, confirm that it's fully enabled by entering `discord2chan` on its own. You should receive a message that there are no active connections to Discord. ### Connecting an Evennia channel to a Discord channel @@ -140,7 +140,7 @@ then your channel ID is `12345678901234567890` Link the two channels with the following command: - @discord2chan = + discord2chan = The two channels should now relay to each other. Confirm this works by posting a message on the evennia channel, and another on the Discord channel - they should diff --git a/evennia/accounts/bots.py b/evennia/accounts/bots.py index f26464e34e..8f157c3953 100644 --- a/evennia/accounts/bots.py +++ b/evennia/accounts/bots.py @@ -49,7 +49,7 @@ class BotStarter(DefaultScript): Kick bot into gear. """ - if not len(self.account.sessions.all()): + if not self.account.sessions.all(): self.account.start() def at_repeat(self): @@ -626,21 +626,22 @@ class DiscordBot(Bot): """ Called by the Channel just before passing a message into `channel_msg`. - We overload this to force off the channel tag prefix. + We overload this to set the channel tag prefix. """ kwargs["no_prefix"] = not self.db.tag_channel return super().at_pre_channel_msg(message, channel, senders=senders, **kwargs) - def channel_msg(self, message, channel, senders=None, **kwargs): + def channel_msg(self, message, channel, senders=None, relayed=False, **kwargs): """ Passes channel messages received on to discord Args: - message (str): Incoming text from channel. - channel (Channel): The channel the message is being received from + message (str) - Incoming text from channel. + channel (Channel) - The channel the message is being received from Keyword Args: - senders (list or None): Object(s) sending the message + senders (list or None) - Object(s) sending the message + relayed (bool) - A flag identifying whether the message was relayed by the bot. """ if kwargs.get("relayed"): @@ -661,6 +662,9 @@ class DiscordBot(Bot): message (str) - Incoming text from Discord. sender (tuple) - The Discord info for the sender in the form (id, nickname) + Keyword args: + kwargs (optional) - Unused by default, but can carry additional data from the protocol. + """ pass @@ -678,7 +682,7 @@ class DiscordBot(Bot): sender (tuple) - The Discord info for the sender in the form (id, nickname) from_channel (str) - The Discord channel name from_server (str) - The Discord server name - + kwargs - Any additional keywords. Unused by default, but available for adding additional flags or parameters. """ tag_str = "" @@ -701,7 +705,7 @@ class DiscordBot(Bot): def execute_cmd( self, - txt=None, + content=None, session=None, type=None, sender=None, @@ -710,6 +714,18 @@ class DiscordBot(Bot): """ Take incoming data from protocol and send it to connected channel. This is triggered by the bot_data_in Inputfunc. + + Keyword args: + content (str) - The content of the message from Discord. + session (Session) - The protocol session this command came from. + type (str, optional) - Indicates the type of activity from Discord, if + the protocol pre-processed it. + sender (tuple) - Identifies the author of the Discord activity in a tuple of two + strings, in the form of (id, nickname) + + kwargs - Any additional data specific to a particular type of actions. The data for + any Discord actions not pre-processed by the protocol will also be passed via kwargs. + """ # normal channel message if type == "channel": @@ -726,12 +742,12 @@ class DiscordBot(Bot): if not channel: continue channel = channel[0] - self.relay_to_channel(txt, channel, sender, channel_name, guild) + self.relay_to_channel(content, channel, sender, channel_name, guild) # direct message elif type == "direct": # pass on to the DM hook - self.direct_msg(txt, sender, **kwargs) + self.direct_msg(content, sender, **kwargs) # guild info update elif type == "guild": diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 9a28e258c2..8b865e107e 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1921,7 +1921,6 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): discord2chan/name Switches: - /name - Assign a name for the Discord bot to use on Evennia channels /list - (or no switch) show existing Evennia <-> Discord links /remove - remove an existing link by link ID /delete - alias to remove @@ -1940,12 +1939,10 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): key = "discord2chan" aliases = ("discord",) switch_options = ( - "available", "channel", "delete", "guild", "list", - "name", "remove", ) locks = "cmd:serversetting(DISCORD_ENABLED) and pperm(Developer)" @@ -1960,29 +1957,22 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): ) return - discord_bot = bots.DiscordBot.objects.filter_family() + discord_bot = [ + bot for bot in AccountDB.objects.filter(db_is_bot=True, username="DiscordBot") + ] if not discord_bot: - if "name" in self.switches and self.args: - # create a new discord bot - bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) - discord_bot = create.create_account(self.lhs, None, None, typeclass=bot_class) - discord_bot.start() - else: - self.msg("Please set up your Discord bot first: discord2chan/name ") - return + # create a new discord bot + bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) + discord_bot = create.create_account("DiscordBot", None, None, typeclass=bot_class) + discord_bot.start() else: discord_bot = discord_bot[0] - if "name" in self.switches: - if self.args: - new_name = self.args.strip() - if bots.DiscordBot.validate_username(new_name): - discord_bot.name = new_name - self.msg(f"The Discord relay bot is now named {new_name} in-game.") - else: - self.msg("Please enter a name for your Discord relay bot.") - return + if not discord_bot.is_typeclass(settings.DISCORD_BOT_CLASS, exact=True): + self.msg( + f"WARNING: The Discord bot's typeclass is '{discord_bot.typeclass_path}'. This does not match {settings.DISCORD_BOT_CLASS} in settings!" + ) if "guild" in self.switches: discord_bot.db.tag_guild = not discord_bot.db.tag_guild diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 45dcfb9fd3..3926edc214 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -2022,8 +2022,6 @@ class TestDiscord(BaseEvenniaCommandTest): [ ("", "No Discord connections found."), ("/list", "No Discord connections found."), - ("/name", "Please enter a name for your Discord relay bot."), - ("/name DiscordBot", "The Discord relay bot is now named DiscordBot in-game."), ("/guild", "Messages to Evennia will include the Discord server."), ("/channel", "Relayed messages will include the originating channel."), ] From 049e40910bcf636b9620301c651d0632b9df8d42 Mon Sep 17 00:00:00 2001 From: InspectorCaracal Date: Wed, 30 Nov 2022 15:39:22 -0700 Subject: [PATCH 7/7] update for removal of /name --- docs/source/Setup/Channels-to-Discord.md | 12 +++--------- evennia/commands/default/comms.py | 2 +- evennia/commands/default/tests.py | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/docs/source/Setup/Channels-to-Discord.md b/docs/source/Setup/Channels-to-Discord.md index bbc80c2676..e4aef95dd8 100644 --- a/docs/source/Setup/Channels-to-Discord.md +++ b/docs/source/Setup/Channels-to-Discord.md @@ -120,15 +120,9 @@ DISCORD_ENABLED = True ``` Start or reload your game to apply the changed settings, then log in as an account -with at least `Developer` permissions and initialize the bot account on Evennia: - - discord2chan/name - -The name you assign it can be anything; it will show up in the `who` list for your -game and your game's channels, but is otherwise unused. - -Lastly, confirm that it's fully enabled by entering `discord2chan` on its own. -You should receive a message that there are no active connections to Discord. +with at least `Developer` permissions and initialize the bot account on Evennia with +the `discord2chan` command. You should receive a message that the bot was created, and +that there are no active connections to Discord. ### Connecting an Evennia channel to a Discord channel diff --git a/evennia/commands/default/comms.py b/evennia/commands/default/comms.py index 8b865e107e..dfbc699f91 100644 --- a/evennia/commands/default/comms.py +++ b/evennia/commands/default/comms.py @@ -1965,7 +1965,7 @@ class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS): bot_class = class_from_module(settings.DISCORD_BOT_CLASS, fallback=bots.DiscordBot) discord_bot = create.create_account("DiscordBot", None, None, typeclass=bot_class) discord_bot.start() - + self.msg("Created and initialized a new Discord relay bot.") else: discord_bot = discord_bot[0] diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 3926edc214..8d0e299d66 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -2011,7 +2011,7 @@ class TestDiscord(BaseEvenniaCommandTest): self.cmddiscord.account_caller = False # create bot manually so it doesn't get started self.discordbot = create.create_account( - "DiscordTest", None, None, typeclass="evennia.accounts.bots.DiscordBot" + "DiscordBot", None, None, typeclass="evennia.accounts.bots.DiscordBot" ) def tearDown(self):