Merge pull request #3001 from InspectorCaracal/discord-integration
Add discord chat integration
This commit is contained in:
commit
a71534cc3b
7 changed files with 1181 additions and 27 deletions
173
docs/source/Setup/Channels-to-Discord.md
Normal file
173
docs/source/Setup/Channels-to-Discord.md
Normal 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.
|
||||||
|
|
@ -11,21 +11,19 @@ from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from evennia.accounts.accounts import DefaultAccount
|
from evennia.accounts.accounts import DefaultAccount
|
||||||
from evennia.scripts.scripts import DefaultScript
|
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
|
_IDLE_TIMEOUT = settings.IDLE_TIMEOUT
|
||||||
|
|
||||||
_IRC_ENABLED = settings.IRC_ENABLED
|
_IRC_ENABLED = settings.IRC_ENABLED
|
||||||
_RSS_ENABLED = settings.RSS_ENABLED
|
_RSS_ENABLED = settings.RSS_ENABLED
|
||||||
_GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED
|
_GRAPEVINE_ENABLED = settings.GRAPEVINE_ENABLED
|
||||||
|
_DISCORD_ENABLED = settings.DISCORD_ENABLED and hasattr(settings, "DISCORD_BOT_TOKEN")
|
||||||
|
|
||||||
_SESSIONS = None
|
_SESSIONS = None
|
||||||
|
|
||||||
|
|
||||||
# Bot helper utilities
|
|
||||||
|
|
||||||
|
|
||||||
class BotStarter(DefaultScript):
|
class BotStarter(DefaultScript):
|
||||||
"""
|
"""
|
||||||
This non-repeating script has the
|
This non-repeating script has the
|
||||||
|
|
@ -42,16 +40,17 @@ class BotStarter(DefaultScript):
|
||||||
self.key = "botstarter"
|
self.key = "botstarter"
|
||||||
self.desc = "bot start/keepalive"
|
self.desc = "bot start/keepalive"
|
||||||
self.persistent = True
|
self.persistent = True
|
||||||
self.db.started = False
|
|
||||||
|
def at_server_start(self):
|
||||||
|
self.at_start()
|
||||||
|
|
||||||
def at_start(self):
|
def at_start(self):
|
||||||
"""
|
"""
|
||||||
Kick bot into gear.
|
Kick bot into gear.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not self.db.started:
|
if not self.account.sessions.all():
|
||||||
self.account.start()
|
self.account.start()
|
||||||
self.db.started = True
|
|
||||||
|
|
||||||
def at_repeat(self):
|
def at_repeat(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -68,21 +67,6 @@ class BotStarter(DefaultScript):
|
||||||
for session in _SESSIONS.sessions_from_account(self.account):
|
for session in _SESSIONS.sessions_from_account(self.account):
|
||||||
session.update_session_counters(idle=True)
|
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
|
# Bot base class
|
||||||
|
|
@ -110,8 +94,7 @@ class Bot(DefaultAccount):
|
||||||
)
|
)
|
||||||
self.locks.add(lockstring)
|
self.locks.add(lockstring)
|
||||||
# set the basics of being a bot
|
# set the basics of being a bot
|
||||||
script_key = str(self.key)
|
self.scripts.add(BotStarter, key="bot_starter")
|
||||||
self.scripts.add(BotStarter, key=script_key)
|
|
||||||
self.is_bot = True
|
self.is_bot = True
|
||||||
|
|
||||||
def start(self, **kwargs):
|
def start(self, **kwargs):
|
||||||
|
|
@ -576,3 +559,202 @@ class GrapevineBot(Bot):
|
||||||
self.ndb.ev_channel = self.db.ev_channel
|
self.ndb.ev_channel = self.db.ev_channel
|
||||||
if self.ndb.ev_channel:
|
if self.ndb.ev_channel:
|
||||||
self.ndb.ev_channel.msg(text, senders=self)
|
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", {}))
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class AccountCmdSet(CmdSet):
|
||||||
self.add(comms.CmdIRCStatus())
|
self.add(comms.CmdIRCStatus())
|
||||||
self.add(comms.CmdRSS2Chan())
|
self.add(comms.CmdRSS2Chan())
|
||||||
self.add(comms.CmdGrapevine2Chan())
|
self.add(comms.CmdGrapevine2Chan())
|
||||||
|
self.add(comms.CmdDiscord2Chan())
|
||||||
# self.add(comms.CmdChannels())
|
# self.add(comms.CmdChannels())
|
||||||
# self.add(comms.CmdAddCom())
|
# self.add(comms.CmdAddCom())
|
||||||
# self.add(comms.CmdDelCom())
|
# self.add(comms.CmdDelCom())
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ Communication commands:
|
||||||
|
|
||||||
- channel
|
- channel
|
||||||
- page
|
- 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.comms import DefaultChannel
|
||||||
from evennia.comms.models import Msg
|
from evennia.comms.models import Msg
|
||||||
from evennia.locks.lockhandler import LockException
|
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.evmenu import ask_yes_no
|
||||||
from evennia.utils.logger import tail_log_file
|
from evennia.utils.logger import tail_log_file
|
||||||
from evennia.utils.utils import class_from_module, strip_unsafe_input
|
from evennia.utils.utils import class_from_module, strip_unsafe_input
|
||||||
|
|
@ -34,6 +34,7 @@ __all__ = (
|
||||||
"CmdIRCStatus",
|
"CmdIRCStatus",
|
||||||
"CmdRSS2Chan",
|
"CmdRSS2Chan",
|
||||||
"CmdGrapevine2Chan",
|
"CmdGrapevine2Chan",
|
||||||
|
"CmdDiscord2Chan",
|
||||||
)
|
)
|
||||||
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
_DEFAULT_WIDTH = settings.CLIENT_DEFAULT_WIDTH
|
||||||
|
|
||||||
|
|
@ -1908,3 +1909,173 @@ class CmdGrapevine2Chan(COMMAND_DEFAULT_CLASS):
|
||||||
|
|
||||||
bot.start(ev_channel=channel, grapevine_channel=grapevine_channel)
|
bot.start(ev_channel=channel, grapevine_channel=grapevine_channel)
|
||||||
self.msg(f"Grapevine connection created {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}.")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class TestBatchProcess(BaseEvenniaCommandTest):
|
||||||
"""
|
"""
|
||||||
Test the batch processor.
|
Test the batch processor.
|
||||||
|
|
|
||||||
560
evennia/server/portal/discord.py
Normal file
560
evennia/server/portal/discord.py
Normal 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))
|
||||||
|
|
@ -874,6 +874,21 @@ GRAPEVINE_CHANNELS = ["gossip", "testing"]
|
||||||
# them. These are secret and should thus be overridden in secret_settings file
|
# them. These are secret and should thus be overridden in secret_settings file
|
||||||
GRAPEVINE_CLIENT_ID = ""
|
GRAPEVINE_CLIENT_ID = ""
|
||||||
GRAPEVINE_CLIENT_SECRET = ""
|
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
|
# Django web features
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue