discord integration

This commit is contained in:
InspectorCaracal 2022-10-14 14:05:27 -06:00
parent 7114aea912
commit cd529281f3
5 changed files with 915 additions and 27 deletions

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 len(self.account.sessions.all()):
self.account.start()
self.db.started = True
def at_repeat(self):
"""
@ -68,21 +67,6 @@ class BotStarter(DefaultScript):
for session in _SESSIONS.sessions_from_account(self.account):
session.update_session_counters(idle=True)
def at_server_reload(self):
"""
If server reloads we don't need to reconnect the protocol
again, this is handled by the portal reconnect mechanism.
"""
self.db.started = True
def at_server_shutdown(self):
"""
Make sure we are shutdown.
"""
self.db.started = False
#
# Bot base class
@ -110,8 +94,7 @@ class Bot(DefaultAccount):
)
self.locks.add(lockstring)
# set the basics of being a bot
script_key = str(self.key)
self.scripts.add(BotStarter, key=script_key)
self.scripts.add(BotStarter, key="bot_starter")
self.is_bot = True
def start(self, **kwargs):
@ -576,3 +559,191 @@ class GrapevineBot(Bot):
self.ndb.ev_channel = self.db.ev_channel
if self.ndb.ev_channel:
self.ndb.ev_channel.msg(text, senders=self)
# Discord
class DiscordBot(Bot):
"""
Discord bot relay. You will need to set up your own bot (https://discord.com/developers/applications)
and add the bot token as `DISCORD_BOT_TOKEN` to `secret_settings.py` to use
"""
factory_path = "evennia.server.portal.discord.DiscordWebsocketServerFactory"
def at_init(self):
"""
Load required channels back into memory
"""
if channel_links := self.db.channels:
# this attribute contains a list of evennia<->discord links in the form of ("evennia_channel", "discord_chan_id")
# grab Evennia channels, cache and connect
channel_set = {evchan for evchan, dcid in channel_links}
self.ndb.ev_channels = {}
for channel_name in list(channel_set):
channel = search.search_channel(channel_name)
if not channel:
raise RuntimeError(f"Evennia Channel {channel_name} not found.")
channel = channel[0]
self.ndb.ev_channels[channel_name] = channel
def start(self):
"""
Tell the Discord protocol to connect.
"""
if not _DISCORD_ENABLED:
self.delete()
return
if self.ndb.ev_channels:
for channel in self.ndb.ev_channels.values():
channel.connect(self)
elif channel_links := self.db.channels:
# this attribute contains a list of evennia<->discord links in the form of ("evennia_channel", "discord_chan_id")
# grab Evennia channels, cache and connect
channel_set = {evchan for evchan, dcid in channel_links}
self.ndb.ev_channels = {}
for channel_name in list(channel_set):
channel = search.search_channel(channel_name)
if not channel:
raise RuntimeError(f"Evennia Channel {channel_name} not found.")
channel = channel[0]
self.ndb.ev_channels[channel_name] = channel
channel.connect(self)
# connect
global _SESSIONS
if not _SESSIONS:
from evennia.server.sessionhandler import SESSIONS as _SESSIONS
# these will be made available as properties on the protocol factory
configdict = {"uid": self.dbid}
logger.log_info("starting discord bot session")
_SESSIONS.start_bot_session(self.factory_path, configdict)
def at_msg_send(self, **kwargs):
"Skip this to avoid looping, presumably"
pass
def at_pre_channel_msg(self, message, channel, senders=None, **kwargs):
"""
Called by the Channel just before passing a message into `channel_msg`.
We overload this to force off the channel tag prefix.
"""
kwargs["no_prefix"] = not self.db.tag_channel
return super().at_pre_channel_msg(message, channel, senders=senders, **kwargs)
def channel_msg(self, message, channel, senders=None, **kwargs):
"""
Passes channel messages received on to discord
Args:
message (str): Incoming text from channel.
channel (Channel): The channel the message is being received from
Keyword Args:
senders (list or None): Object(s) sending the message
"""
if kwargs.get("relayed"):
# don't relay our own relayed messages
return
if channel_list := self.db.channels:
# get all the discord channels connected to this evennia channel
channel_name = channel.name
for dc_chan in [dcid for evchan, dcid in channel_list if evchan == channel_name]:
# send outputfunc channel(msg, discord channel)
super().msg(channel=(strip_ansi(message.strip()), dc_chan))
def direct_msg(self, message, sender, **kwargs):
"""
Called when the Discord bot receives a direct message on Discord.
Args:
message (str) - Incoming text from Discord.
sender (tuple) - The Discord info for the sender in the form (id, nickname)
"""
pass
def relay_to_channel(
self, message, to_channel, sender=None, from_channel=None, from_server=None, **kwargs
):
"""
Formats and sends a Discord -> Evennia message. Called when the Discord bot receives a channel message on Discord.
Args:
message (str) - Incoming text from Discord.
to_channel (Channel) - The Evennia channel receiving the message
Keyword args:
sender (tuple) - The Discord info for the sender in the form (id, nickname)
from_channel (str) - The Discord channel name
from_server (str) - The Discord server name
"""
tag_str = ""
if from_channel and self.db.tag_channel:
tag_str = f"#{from_channel}"
if from_server and self.db.tag_guild:
if tag_str:
tag_str += f"@{from_server}"
else:
tag_str = from_server
if tag_str:
tag_str = f"[{tag_str}] "
if sender:
sender_name = f"|c{sender[1]}|n: "
message = f"{tag_str}{sender_name}{message}"
to_channel.msg(message, senders=None, relayed=True)
def execute_cmd(
self,
txt=None,
session=None,
type=None,
sender=None,
**kwargs,
):
"""
Take incoming data from protocol and send it to connected channel. This is
triggered by the bot_data_in Inputfunc.
"""
# normal channel message
if type == "channel":
channel_id = kwargs.get("channel_id")
channel_name = self.db.discord_channels.get(channel_id, {}).get("name", channel_id)
guild_id = kwargs.get("guild_id")
guild = self.db.guilds.get(guild_id)
if channel_links := self.db.channels:
for ev_channel in [
ev_chan for ev_chan, dc_id in channel_links if dc_id == channel_id
]:
channel = search.channel_search(ev_channel)
if not channel:
continue
channel = channel[0]
self.relay_to_channel(txt, channel, sender, channel_name, guild)
# direct message
elif type == "direct":
# pass on to the DM hook
self.direct_msg(txt, sender, **kwargs)
# guild info update
elif type == "guild":
if guild_id := kwargs.get("guild_id"):
if not self.db.guilds:
self.db.guilds = {}
self.db.guilds[guild_id] = kwargs.get("guild_name", "Unidentified")
if not self.db.discord_channels:
self.db.discord_channels = {}
self.db.discord_channels.update(kwargs.get("channels", {}))

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,181 @@ class CmdGrapevine2Chan(COMMAND_DEFAULT_CLASS):
bot.start(ev_channel=channel, grapevine_channel=grapevine_channel)
self.msg(f"Grapevine connection created {channel} <-> {grapevine_channel}.")
class CmdDiscord2Chan(COMMAND_DEFAULT_CLASS):
"""
Link an Evennia channel to an external Discord channel
Usage:
discord2chan[/switches]
discord2chan[/switches] <evennia_channel> [= <discord_channel_id>]
discord2chan/name <bot_name>
Switches:
/name - Assign a name for the Discord bot to use on Evennia channels
/list - (or no switch) show existing Evennia <-> Discord links
/remove - remove an existing link by link ID
/delete - alias to remove
/guild - toggle the Discord server tag on/off
/channel - toggle the Evennia/Discord channel tags on/off
Example:
discord2chan mydiscord = 555555555555555
This creates a link between an in-game Evennia channel and an external
Discord channel. You must have a valid Discord bot application
(https://discord.com/developers/applications)) and your DISCORD_BOT_TOKEN
must be added to settings. (Please put it in secret_settings !)
"""
key = "discord2chan"
aliases = ("discord",)
switch_options = (
"available",
"channel",
"delete",
"guild",
"list",
"name",
"remove",
)
locks = "cmd:serversetting(DISCORD_ENABLED) and pperm(Developer)"
help_category = "Comms"
def func(self):
"""Manage the Evennia<->Discord channel links"""
if not settings.DISCORD_BOT_TOKEN:
self.msg(
"You must add your Discord bot application token to settings as DISCORD_BOT_TOKEN"
)
return
discord_bot = bots.DiscordBot.objects.filter_family()
if not discord_bot:
if "name" in self.switches:
# create a new discord bot
# TODO: reference settings for custom typeclass
discord_bot = create.create_account(self.lhs, None, None, typeclass=bots.DiscordBot)
discord_bot.start()
else:
self.msg("Please set up your Discord bot first: discord2chan/name <bot_name>")
return
else:
discord_bot = discord_bot[0]
if "name" in self.switches:
new_name = self.args.strip()
if bots.DiscordBot.validate_username(new_name):
discord_bot.name = new_name
self.msg(f"The Discord relay account is now named {new_name} in-game.")
return
if "guild" in self.switches:
discord_bot.db.tag_guild = not discord_bot.db.tag_guild
self.msg(
f"Messages to Evennia |wwill {'' if discord_bot.db.tag_guild else 'not '}|ninclude the Discord server."
)
return
if "channel" in self.switches:
discord_bot.db.tag_channel = not discord_bot.db.tag_channel
self.msg(
f"Relayed messages |wwill {'' if discord_bot.db.tag_channel else 'not '}|ninclude the originating channel."
)
return
if "list" in self.switches or not self.args:
# show all connections
if channel_list := discord_bot.db.channels:
table = self.styled_table(
"|wLink ID|n",
"|wEvennia|n",
"|wDiscord|n",
border="cells",
maxwidth=_DEFAULT_WIDTH,
)
# iterate through the channel links
# load in the pretty names for the discord channels from cache
dc_chan_names = discord_bot.attributes.get("discord_channels", {})
for i, (evchan, dcchan) in enumerate(channel_list):
dc_info = dc_chan_names.get(dcchan, {"name": "unknown", "guild": "unknown"})
table.add_row(
i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}"
)
self.msg(table)
else:
self.msg("No Discord connections found.")
return
if "disconnect" in self.switches or "remove" in self.switches or "delete" in self.switches:
if channel_list := discord_bot.db.channels:
try:
lid = int(self.args.strip())
except ValueError:
self.msg("Usage: discord2chan/remove <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": "unknown", "guild": "unknown"})
table.add_row(
i, evchan, f"#{dc_info.get('name','?')}@{dc_info.get('guild','?')}"
)
results = True
if results:
self.msg(table)
else:
self.msg(f"There are no Discord channels connected to {ev_channel}.")
else:
self.msg("There are no active connections to Discord.")
return
# check if link already exists
if channel_list := discord_bot.db.channels:
if (ev_channel, dc_channel) in channel_list:
self.msg(f"Those channels are already linked.")
return
else:
discord_bot.db.channels = []
# create the new link
channel_obj = search.search_channel(ev_channel)
if not channel_obj:
self.msg(f"There is no channel '{ev_channel}'")
return
channel_obj = channel_obj[0]
discord_bot.db.channels.append((channel_obj.name, dc_channel))
if dc_chans := discord_bot.db.discord_channels:
dc_channel_name = dc_chans.get(dc_channel, {}).get("name", dc_channel)
else:
dc_channel_name = dc_channel
self.msg(f"Discord connection created: {channel_obj.name} <-> #{dc_channel_name}.")

View file

@ -0,0 +1,534 @@
"""
Implements Discord chat channel integration.
The Discord API uses a mix of websockets and REST API endpoints.
In order for this integration to work, you need to have your own
discord bot set up via https://discord.com/developers/applications
with the MESSAGE CONTENT toggle switched on, and your bot token
added to `server/conf/secret_settings.py` as your DISCORD_BOT_TOKEN
"""
import json
import os
from random import random
from io import BytesIO
from autobahn.twisted.websocket import (
WebSocketClientFactory,
WebSocketClientProtocol,
connectWS,
)
from django.conf import settings
from twisted.internet import protocol, reactor, ssl, task
from twisted.web.client import Agent, FileBodyProducer, readBody
from twisted.web.http_headers import Headers
from evennia.server.session import Session
from evennia.utils import class_from_module, get_evennia_version, logger
_AGENT = Agent(reactor)
_BASE_SESSION_CLASS = class_from_module(settings.BASE_SESSION_CLASS)
DISCORD_API_VERSION = 10
# include version number to prevent automatically updating to breaking changes
DISCORD_API_BASE_URL = f"https://discord.com/api/v{DISCORD_API_VERSION}"
DISCORD_USER_AGENT = f"Evennia (https://www.evennia.com, {get_evennia_version(mode='short')})"
DISCORD_BOT_TOKEN = getattr(settings, "DISCORD_BOT_TOKEN", None)
DISCORD_BOT_INTENTS = getattr(settings, "DISCORD_BOT_INTENTS", 105985)
# Discord OP codes, alphabetic
OP_DISPATCH = 0
OP_HEARTBEAT = 1
OP_HEARTBEAT_ACK = 11
OP_HELLO = 10
OP_IDENTIFY = 2
OP_INVALID_SESSION = 9
OP_RECONNECT = 7
OP_RESUME = 6
class DiscordWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory):
"""
A variant of the websocket-factory that auto-reconnects.
"""
initialDelay = 1
factor = 1.5
maxDelay = 60
gateway = None
resume_url = None
def __init__(self, sessionhandler, *args, **kwargs):
self.uid = kwargs.pop("uid")
self.sessionhandler = sessionhandler
self.port = None
self.bot = None
def get_gateway_url(self, *args, **kwargs):
# get the websocket gateway URL from Discord
d = _AGENT.request(
b"GET",
f"{DISCORD_API_BASE_URL}/gateway".encode("utf-8"),
Headers(
{
"User-Agent": [DISCORD_USER_AGENT],
"Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
"Content-Type": ["application/json"],
}
),
None,
)
def cbResponse(response):
# check status code here to verify it was a successful connection first
# then schedule a retry if not
d = readBody(response)
d.addCallback(self.websocket_init, *args, **kwargs)
return d
d.addCallback(cbResponse)
def websocket_init(self, payload, *args, **kwargs):
"""
callback for when the URL is gotten
"""
data = json.loads(str(payload, "utf-8"))
logger.log_info(f"payload: {data}")
if url := data.get("url"):
self.gateway = f"{url}/?v={DISCORD_API_VERSION}&encoding=json".encode("utf-8")
useragent = kwargs.pop("useragent", DISCORD_USER_AGENT)
headers = kwargs.pop(
"headers",
{
"Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
"Content-Type": ["application/json"],
},
)
logger.log_info("Connecting to Discord Gateway...")
WebSocketClientFactory.__init__(
self, url, *args, headers=headers, useragent=useragent, **kwargs
)
self.start()
else:
# TODO: set this up to schedule a retry instead
logger.log_err("Discord did not return a websocket URL; connection cancelled.")
def buildProtocol(self, addr):
"""
Build new instance of protocol
Args:
addr (str): Not used, using factory/settings data
"""
if hasattr(settings, "DISCORD_SESSION_CLASS"):
protocol_class = class_from_module(
settings.DISCORD_SESSION_CLASS, fallback=DiscordClient
)
protocol = protocol_class()
else:
protocol = DiscordClient()
protocol.factory = self
protocol.sessionhandler = self.sessionhandler
return protocol
def startedConnecting(self, connector):
"""
Tracks reconnections for debugging.
Args:
connector (Connector): Represents the connection.
"""
logger.log_info("Attempting connection to Discord...")
def clientConnectionFailed(self, connector, reason):
"""
Called when Client failed to connect.
Args:
connector (Connection): Represents the connection.
reason (str): The reason for the failure.
"""
protocol.ReconnectingClientFactory.clientConnectionLost(self, connector, reason)
def clientConnectionLost(self, connector, reason):
"""
Called when Client loses connection.
Args:
connector (Connection): Represents the connection.
reason (str): The reason for the failure.
"""
# what is the purpose of this check??? do i need it
if not self.bot:
self.retry(connector)
def reconnect(self):
"""
Force a reconnection of the bot protocol. This requires
de-registering the session and then reattaching a new one.
"""
self.bot.stopping = True
self.bot.transport.loseConnection()
self.sessionhandler.server_disconnect(self.bot)
if self.resume_url:
self.url = self.resume_url
elif self.gateway:
self.url = self.gateway
else:
# we don't know where to reconnect to! start from the beginning
self.get_gateway_url()
return
self.start()
def start(self):
"Connect protocol to remote server"
if not self.gateway:
# we can't actually start yet
# get the gateway URL from Discord
self.get_gateway_url()
else:
connectWS(self)
class DiscordClient(WebSocketClientProtocol, _BASE_SESSION_CLASS):
"""
Implements the grapevine client
"""
nextHeartbeatCall = None
pending_heartbeat = False
heartbeat_interval = None
last_sequence = 0
session_id = None
def __init__(self):
WebSocketClientProtocol.__init__(self)
_BASE_SESSION_CLASS.__init__(self)
self.restart_downtime = None
# self.discord_id
def at_login(self):
pass
def onOpen(self):
"""
Called when connection is established.
"""
self.restart_downtime = None
self.restart_task = None
self.stopping = False
self.factory.bot = self
self.init_session("discord", "discord.gg", self.factory.sessionhandler)
self.uid = int(self.factory.uid)
self.logged_in = True
self.sessionhandler.connect(self)
def onMessage(self, payload, isBinary):
"""
Callback fired when a complete WebSocket message was received.
Args:
payload (bytes): The WebSocket message received.
isBinary (bool): Flag indicating whether payload is binary or
UTF-8 encoded text.
"""
if isBinary:
logger.log_info("DISCORD: got a binary payload for some reason")
return
data = json.loads(str(payload, "utf-8"))
if seqid := data.get("s"):
self.last_sequence = seqid
# not sure if that error json format is for websockets
# check for it just in case
if "errors" in data:
self.handle_error(data)
return
# check for discord gateway API op codes first
if data["op"] == OP_HELLO:
self.interval = data["d"]["heartbeat_interval"] / 1000 # convert millisec to seconds
if self.nextHeartbeatCall:
self.nextHeartbeatCall.cancel()
self.nextHeartbeatCall = self.factory._batched_timer.call_later(
self.interval * random(),
self.doHeartbeat,
)
if self.session_id:
# we already have a session; try to resume instead
self.resume()
else:
self.identify()
elif data["op"] == OP_HEARTBEAT_ACK:
self.pending_heartbeat = False
elif data["op"] == OP_HEARTBEAT:
self.doHeartbeat(force=True)
elif data["op"] == OP_INVALID_SESSION:
# reconnect
logger.log_msg("Discord: received 'Invalid Session' opcode. Reconnecting.")
if data["d"] == False:
# can't resume, clear existing resume data
self.session_id = None
self.factory.resume_url = None
self.factory.reconnect()
elif data["op"] == OP_RECONNECT:
logger.log_msg("Discord: received 'Reconnect' opcode. Reconnecting.")
self.factory.reconnect()
elif data["op"] == OP_DISPATCH:
if data["t"] == "READY":
self.connection_ready(data["d"])
else:
# general message, pass on to data_in
self.data_in(data=data)
def onClose(self, wasClean, code=None, reason=None):
"""
This is executed when the connection is lost for whatever
reason. it can also be called directly, from the disconnect
method.
Args:
wasClean (bool): ``True`` if the WebSocket was closed cleanly.
code (int or None): Close status as sent by the WebSocket peer.
reason (str or None): Close reason as sent by the WebSocket peer.
"""
if self.nextHeartbeatCall:
self.nextHeartbeatCall.cancel()
self.disconnect(reason)
if code >= 4000:
logger.log_err(f"Discord connection closed: {reason}")
else:
logger.log_info(f"Discord disconnected: {reason}")
def _send_json(self, data):
"""
Post JSON data to the websocket
Args:
data (dict): content to send.
"""
return self.sendMessage(json.dumps(data).encode("utf-8"))
def _post_json(self, url, data, **kwargs):
"""
Post JSON data to a REST API endpoint
Args:
url (str) -
data (dict) - Content to be sent
"""
url = f"{DISCORD_API_BASE_URL}/{url}"
body = FileBodyProducer(BytesIO(json.dumps(data).encode("utf-8")))
d = _AGENT.request(
b"POST",
url.encode("utf-8"),
Headers(
{
"User-Agent": [DISCORD_USER_AGENT],
"Authorization": [f"Bot {DISCORD_BOT_TOKEN}"],
"Content-Type": ["application/json"],
}
),
body,
)
def cbResponse(response):
# check status code here to verify it was a successful connection first
# then schedule a retry if not
d = readBody(response)
d.addCallback(self.post_response)
return d
d.addCallback(cbResponse)
def post_response(self, body, **kwargs):
"""
Process the response from sending a POST request
Args:
body (bytes) - The post response body
"""
data = json.loads(body)
if "errors" in data:
self.handle_error(data)
def handle_error(self, data, **kwargs):
"""
General hook for processing errors.
Args:
data (dict) - The received error data
"""
logger.log_err(str(data))
def resume(self):
"""
Called after a reconnection to re-identify and replay missed events
"""
if not self.last_sequence or not self.session_id:
# we have no known state to resume from, identify normally
self.identify()
data = {
"op": OP_RESUME,
"d": {
"token": DISCORD_BOT_TOKEN,
"session_id": self.session_id,
"s": self.sequence_id,
},
}
self._send_json(data)
def disconnect(self, reason=None):
"""
Generic hook for the engine to call in order to
disconnect this protocol.
Args:
reason (str or None): Motivation for the disconnection.
"""
self.sessionhandler.disconnect(self)
self.sendClose(self.CLOSE_STATUS_CODE_NORMAL, reason)
def identify(self, *args, **kwargs):
"""
Send Discord authentication. This should be sent once heartbeats begin.
"""
data = {
"op": 2,
"d": {
"token": DISCORD_BOT_TOKEN,
"intents": DISCORD_BOT_INTENTS,
"properties": {
"os": os.name,
"browser": DISCORD_USER_AGENT,
"device": DISCORD_USER_AGENT,
},
},
}
self._send_json(data)
def connection_ready(self, data):
"""
Process READY data for relevant bot info.
"""
self.factory.resume_url = data["resume_gateway_url"]
self.session_id = data["session_id"]
self.discord_id = data["user"]["id"]
def doHeartbeat(self, *args, **kwargs):
"""
Send heartbeat to Discord.
"""
if not self.pending_heartbeat or kwargs.get("force"):
if self.nextHeartbeatCall:
self.nextHeartbeatCall.cancel()
data = {"op": 1, "d": self.last_sequence}
self._send_json(data)
self.pending_heartbeat = True
self.nextHeartbeatCall = self.factory._batched_timer.call_later(
self.interval,
self.doHeartbeat,
)
else:
# we didn't get a response since the last heartbeat; reconnect
self.factory.reconnect()
def send_channel(self, text, channel_id, **kwargs):
"""
Send a message from an Evennia channel to a Discord channel.
Use with session.msg(channel=(message, channel, sender))
"""
data = {"content": text}
data.update(kwargs)
self._post_json(f"channels/{channel_id}/messages", data)
def send_default(self, *args, **kwargs):
"""
Ignore other outputfuncs
"""
pass
def data_in(self, data, **kwargs):
"""
Send data grapevine -> Evennia
Keyword Args:
data (dict): Converted json data.
"""
action_type = data.get("t", "UNKNOWN")
logger.log_msg(f"DISCORD: Received an action of type {action_type}.")
if action_type == "MESSAGE_CREATE":
logger.log_msg(str(data))
data = data["d"]
if data["author"]["id"] == self.discord_id:
return
message = data["content"]
channel_id = data["channel_id"]
keywords = {"channel_id": channel_id}
if "guild_id" in data:
# channel message
keywords["type"] = "channel"
author = data["member"]["nick"] or data["author"]["username"]
author_id = data["author"]["id"]
keywords["sender"] = (author_id, author)
keywords["guild_id"] = data["guild_id"]
else:
# direct message
keywords["type"] = "direct"
author = data["author"]["username"]
author_id = data["author"]["id"]
keywords["sender"] = (author_id, author)
self.sessionhandler.data_in(self, bot_data_in=(message, keywords))
elif action_type in ("GUILD_CREATE", "GUILD_UPDATE"):
data = data["d"]
keywords = {"type": "guild", "guild_id": data["id"], "guild_name": data["name"]}
keywords["channels"] = {
chan["id"]: {"name": chan["name"], "guild": data["name"]}
for chan in data["channels"]
if chan["type"] == 0
}
self.sessionhandler.data_in(self, bot_data_in=("", keywords))
elif "DELETE" in action_type:
# deletes should probably be handled separately to check for channel removal
# for now, just ignore
pass
else:
# send all the data on to the bot as-is for optional bot-side handling
keywords = {"type": action_type}
keywords.update(data["d"])
self.sessionhandler.data_in(self, bot_data_in=("", keywords))

View file

@ -874,6 +874,9 @@ GRAPEVINE_CHANNELS = ["gossip", "testing"]
# them. These are secret and should thus be overridden in secret_settings file
GRAPEVINE_CLIENT_ID = ""
GRAPEVINE_CLIENT_SECRET = ""
# Discord integration
# TODO: add doc comments here
DISCORD_ENABLED = False
######################################################################
# Django web features