Merge pull request #3001 from InspectorCaracal/discord-integration

Add discord chat integration
This commit is contained in:
Griatch 2022-11-30 23:55:39 +01:00 committed by GitHub
commit 1cfd6a0289
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 1181 additions and 27 deletions

View file

@ -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 = '<your 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 <evennia_channel> = <discord_channel_id>
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 = '<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 <evennia channel> = <discord channel id>
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.

View file

@ -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", {}))

View file

@ -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())

View file

@ -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] <evennia_channel> [= <discord_channel_id>]
discord2chan/name <bot_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 <link id>")
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}.")

View file

@ -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.

View file

@ -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))

View file

@ -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