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