diff --git a/docs/source/Setup/Channels-to-Discord.md b/docs/source/Setup/Channels-to-Discord.md new file mode 100644 index 0000000000..e4aef95dd8 --- /dev/null +++ b/docs/source/Setup/Channels-to-Discord.md @@ -0,0 +1,173 @@ +# 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 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 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 + +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 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 + +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. + +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/accounts/bots.py b/evennia/accounts/bots.py index 381960b1af..8f157c3953 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 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,202 @@ 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} + _SESSIONS.start_bot_session(self.factory_path, configdict) + + 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 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, 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 + + Keyword Args: + 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"): + # 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) + + Keyword args: + kwargs (optional) - Unused by default, but can carry additional data from the protocol. + + """ + 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 + kwargs - Any additional keywords. Unused by default, but available for adding additional flags or parameters. + """ + + 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, + content=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. + + 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": + 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(content, channel, sender, channel_name, guild) + + # direct message + elif type == "direct": + # pass on to the DM hook + self.direct_msg(content, 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..dfbc699f91 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,173 @@ 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: + /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 = ( + "channel", + "delete", + "guild", + "list", + "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 = [ + bot for bot in AccountDB.objects.filter(db_is_bot=True, username="DiscordBot") + ] + if not discord_bot: + # 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() + self.msg("Created and initialized a new Discord relay bot.") + else: + discord_bot = discord_bot[0] + + 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 + 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": dcchan, "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": dcchan, "guild": "unknown"}) + table.add_row(i, evchan, f"#{dc_info['name']}@{dc_info['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("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)) + 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: + dc_channel_name = dc_channel + self.msg(f"Discord connection created: {channel_obj.name} <-> #{dc_channel_name}.") diff --git a/evennia/commands/default/tests.py b/evennia/commands/default/tests.py index 195d2fde25..8d0e299d66 100644 --- a/evennia/commands/default/tests.py +++ b/evennia/commands/default/tests.py @@ -2002,6 +2002,58 @@ 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( + "DiscordBot", 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."), + ("/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. diff --git a/evennia/server/portal/discord.py b/evennia/server/portal/discord.py new file mode 100644 index 0000000000..3605450851 --- /dev/null +++ b/evennia/server/portal/discord.py @@ -0,0 +1,560 @@ +""" +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 +from evennia.utils.utils import delay + +_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 = settings.DISCORD_BOT_TOKEN +DISCORD_BOT_INTENTS = settings.DISCORD_BOT_INTENTS + +# 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 + + +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. + + """ + + 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): + 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) + + def websocket_init(self, payload, *args, **kwargs): + """ + callback for when the URL is gotten + """ + data = json.loads(str(payload, "utf-8")) + 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: + 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. + + """ + if self.do_retry and 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.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 + discord_id = None + + def __init__(self): + WebSocketClientProtocol.__init__(self) + _BASE_SESSION_CLASS.__init__(self) + self.restart_downtime = None + + def at_login(self): + pass + + def onOpen(self): + """ + Called when connection is established. + + """ + self.restart_downtime = None + self.restart_task = None + 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: + # 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: + # 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 + self.session_id = None + 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 + 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) - The API path which is being posted to + 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): + 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) + + 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() + + # build a RESUME request for Discord and send it + 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() + # 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, + 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): + """ + Process incoming data from Discord and sent to the Evennia server + + 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: + # message received to a Discord channel + 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: + # 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"] = { + chan["id"]: {"name": chan["name"], "guild": data["name"]} + 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 possibly be handled separately to check for channel removal + # for now, just ignore + pass + + else: + # 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 16ffc86570..38f372255f 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -874,6 +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 (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