Add grapevine protocol
This commit is contained in:
parent
2c607b5bb3
commit
ceb7d7bd26
1 changed files with 372 additions and 0 deletions
372
evennia/server/portal/grapevine.py
Normal file
372
evennia/server/portal/grapevine.py
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
"""
|
||||||
|
Grapevine network connection
|
||||||
|
|
||||||
|
This is an implementation of the Grapevine Websocket protocol v 1.0.0 as
|
||||||
|
outlined here: https://grapevine.haus/docs
|
||||||
|
|
||||||
|
This will allow the linked game to transfer status as well as connects
|
||||||
|
the grapevine client to in-game channels.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from twisted.internet import protocol
|
||||||
|
from django.conf import settings
|
||||||
|
from evennia.server.session import Session
|
||||||
|
from evennia.utils import get_evennia_version
|
||||||
|
from evennia.utils.logger import log_info, log_err
|
||||||
|
from autobahn.twisted.websocket import (
|
||||||
|
WebSocketClientProtocol, WebSocketClientFactory, connectWS)
|
||||||
|
|
||||||
|
# There is only one at this time
|
||||||
|
GRAPEVINE_URI = "wss://grapevine.haus/socket"
|
||||||
|
|
||||||
|
GRAPEVINE_CLIENT_ID = settings.GRAPEVINE_CLIENT_ID
|
||||||
|
GRAPEVINE_CLIENT_SECRET = settings.GRAPEVINE_CLIENT_SECRET
|
||||||
|
GRAPEVINE_CHANNELS = settings.GRAPEVINE_CHANNELS
|
||||||
|
|
||||||
|
# defined error codes
|
||||||
|
CLOSE_NORMAL = 1000
|
||||||
|
GRAPEVINE_AUTH_ERROR = 4000
|
||||||
|
GRAPEVINE_HEARTBEAT_FAILURE = 4001
|
||||||
|
|
||||||
|
|
||||||
|
class RestartingWebsocketServerFactory(WebSocketClientFactory,
|
||||||
|
protocol.ReconnectingClientFactory):
|
||||||
|
"""
|
||||||
|
A variant of the websocket-factory that auto-reconnects.
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
initialDelay = 1
|
||||||
|
factor = 1.5
|
||||||
|
maxDelay = 60
|
||||||
|
|
||||||
|
def __init__(self, sessionhandler, *args, **kwargs):
|
||||||
|
|
||||||
|
self.uid = kwargs.pop('uid')
|
||||||
|
self.channel = kwargs.pop('grapevine_channel')
|
||||||
|
self.sessionhandler = sessionhandler
|
||||||
|
|
||||||
|
# self.noisy = False
|
||||||
|
self.port = None
|
||||||
|
self.bot = None
|
||||||
|
|
||||||
|
WebSocketClientFactory.__init__(self, GRAPEVINE_URI, *args, **kwargs)
|
||||||
|
|
||||||
|
def buildProtocol(self, addr):
|
||||||
|
"""
|
||||||
|
Build new instance of protocol
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addr (str): Not used, using factory/settings data
|
||||||
|
|
||||||
|
"""
|
||||||
|
protocol = GrapevineClient()
|
||||||
|
protocol.factory = self
|
||||||
|
protocol.channel = self.channel
|
||||||
|
protocol.sessionhandler = self.sessionhandler
|
||||||
|
return protocol
|
||||||
|
|
||||||
|
def startedConnecting(self, connector):
|
||||||
|
"""
|
||||||
|
Tracks reconnections for debugging.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connector (Connector): Represents the connection.
|
||||||
|
|
||||||
|
"""
|
||||||
|
log_info("(re)connecting to grapevine channel '%s'" % self.channel)
|
||||||
|
|
||||||
|
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 not (self.bot or (self.bot and self.bot.stopping)):
|
||||||
|
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,
|
||||||
|
otherwise you end up with an ever growing number of bot
|
||||||
|
sessions.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.bot.stopping = True
|
||||||
|
self.bot.transport.loseConnection()
|
||||||
|
self.sessionhandler.server_disconnect(self.bot)
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"Connect protocol to remote server"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from twisted.internet import ssl
|
||||||
|
except ImportError:
|
||||||
|
log_err("To use Grapevine, The PyOpenSSL module must be installed.")
|
||||||
|
else:
|
||||||
|
context_factory = ssl.ClientContextFactory() if self.isSecure else None
|
||||||
|
connectWS(self, context_factory)
|
||||||
|
# service.name = "websocket/grapevine"
|
||||||
|
# self.sessionhandler.portal.services.addService(service)
|
||||||
|
|
||||||
|
|
||||||
|
class GrapevineClient(WebSocketClientProtocol, Session):
|
||||||
|
"""
|
||||||
|
Implements the grapevine client
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
WebSocketClientProtocol.__init__(self)
|
||||||
|
Session.__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.stopping = False
|
||||||
|
self.factory.bot = self
|
||||||
|
|
||||||
|
self.init_session("grapevine", GRAPEVINE_URI, self.factory.sessionhandler)
|
||||||
|
self.uid = int(self.factory.uid)
|
||||||
|
self.logged_in = True
|
||||||
|
self.sessionhandler.connect(self)
|
||||||
|
|
||||||
|
self.send_authenticate()
|
||||||
|
|
||||||
|
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 not isBinary:
|
||||||
|
data = json.loads(str(payload, 'utf-8'))
|
||||||
|
self.data_in(data=data)
|
||||||
|
self.retry_task = None
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
"""
|
||||||
|
self.disconnect(reason)
|
||||||
|
|
||||||
|
if code == GRAPEVINE_HEARTBEAT_FAILURE:
|
||||||
|
log_err("Grapevine connection lost (Heartbeat error)")
|
||||||
|
elif code == GRAPEVINE_AUTH_ERROR:
|
||||||
|
log_err("Grapevine connection lost (Auth error)")
|
||||||
|
elif self.restart_downtime:
|
||||||
|
# server previously warned us about downtime and told us to be
|
||||||
|
# ready to reconnect.
|
||||||
|
log_info("Grapevine connection lost (Server restart).")
|
||||||
|
|
||||||
|
def _send_json(self, data):
|
||||||
|
"""
|
||||||
|
Send (json-) data to client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (str): Text to send.
|
||||||
|
|
||||||
|
"""
|
||||||
|
return self.sendMessage(json.dumps(data).encode('utf-8'))
|
||||||
|
|
||||||
|
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)
|
||||||
|
# autobahn-python: 1000 for a normal close, 3000-4999 for app. specific,
|
||||||
|
# in case anyone wants to expose this functionality later.
|
||||||
|
#
|
||||||
|
# sendClose() under autobahn/websocket/interfaces.py
|
||||||
|
self.sendClose(CLOSE_NORMAL, reason)
|
||||||
|
|
||||||
|
# send_* method are automatically callable through .msg(heartbeat={}) etc
|
||||||
|
|
||||||
|
def send_authenticate(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Send grapevine authentication. This should be send immediately upon connection.
|
||||||
|
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"event": "authenticate",
|
||||||
|
"payload": {
|
||||||
|
"client_id": GRAPEVINE_CLIENT_ID,
|
||||||
|
"client_secret": GRAPEVINE_CLIENT_SECRET,
|
||||||
|
"supports": ["channels"],
|
||||||
|
"channels": GRAPEVINE_CHANNELS,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"user_agent": get_evennia_version('pretty')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# override on-the-fly
|
||||||
|
data.update(kwargs)
|
||||||
|
|
||||||
|
self._send_json(data)
|
||||||
|
|
||||||
|
def send_heartbeat(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Send heartbeat to remote grapevine server.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# pass along all connected players
|
||||||
|
data = {
|
||||||
|
"event": "heartbeat",
|
||||||
|
"payload": {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessions = self.sessionhandler.get_sessions(include_unloggedin=False)
|
||||||
|
data['payload']['players'] = [sess.account.key for sess in sessions
|
||||||
|
if hasattr(sess, "account")]
|
||||||
|
|
||||||
|
self._send_json(data)
|
||||||
|
|
||||||
|
def send_subscribe(self, channelname, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Subscribe to new grapevine channel
|
||||||
|
|
||||||
|
Use with session.msg(subscribe="channelname")
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"event": "channels/subscribe",
|
||||||
|
"payload": {
|
||||||
|
"channel": channelname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self._send_json(data)
|
||||||
|
|
||||||
|
def send_unsubscribe(self, channelname, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Un-subscribe to a grapevine channel
|
||||||
|
|
||||||
|
Use with session.msg(unsubscribe="channelname")
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"event": "channels/unsubscribe",
|
||||||
|
"payload": {
|
||||||
|
"channel": channelname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self._send_json(data)
|
||||||
|
|
||||||
|
def send_channel(self, text, channel, sender, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Send text type Evennia -> grapevine
|
||||||
|
|
||||||
|
This is the channels/send message type
|
||||||
|
|
||||||
|
Use with session.msg(channel=(message, channel, sender))
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"event": "channels/send",
|
||||||
|
"payload": {
|
||||||
|
"message": text,
|
||||||
|
"channel": channel,
|
||||||
|
"name": sender
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self._send_json(data)
|
||||||
|
|
||||||
|
def send_default(self, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Ignore other outputfuncs
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def data_in(self, data, **kwargs):
|
||||||
|
"""
|
||||||
|
Send data grapevine -> Evennia
|
||||||
|
|
||||||
|
Kwargs:
|
||||||
|
data (dict): Converted json data.
|
||||||
|
|
||||||
|
"""
|
||||||
|
event = data['event']
|
||||||
|
if event == "authenticate":
|
||||||
|
# server replies to our auth handshake
|
||||||
|
if data['status'] != "success":
|
||||||
|
log_err("Grapevine authentication failed.")
|
||||||
|
self.disconnect()
|
||||||
|
else:
|
||||||
|
log_info("Connected and authenticated to Grapevine network.")
|
||||||
|
elif event == "heartbeat":
|
||||||
|
# server sends heartbeat - we have to send one back
|
||||||
|
self.send_heartbeat()
|
||||||
|
elif event == "restart":
|
||||||
|
# set the expected downtime
|
||||||
|
self.restart_downtime = data['payload']['downtime']
|
||||||
|
elif event == "channels/subscribe":
|
||||||
|
# subscription verification
|
||||||
|
if data.get('status', 'success') == "failure":
|
||||||
|
err = data.get("error", "N/A")
|
||||||
|
self.sessionhandler.data_in(bot_data_in=((f"Grapevine error: {err}"),
|
||||||
|
{'event': event}))
|
||||||
|
elif event == "channels/unsubscribe":
|
||||||
|
# unsubscribe-verification
|
||||||
|
pass
|
||||||
|
elif event == "channels/broadcast":
|
||||||
|
# incoming broadcast from network
|
||||||
|
payload = data["payload"]
|
||||||
|
|
||||||
|
print("channels/broadcast:", payload['channel'], self.channel)
|
||||||
|
if str(payload['channel']) != self.channel:
|
||||||
|
# only echo from channels this particular bot actually listens to
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# correct channel
|
||||||
|
self.sessionhandler.data_in(
|
||||||
|
self, bot_data_in=(
|
||||||
|
str(payload['message'],),
|
||||||
|
{"event": event,
|
||||||
|
"grapevine_channel": str(payload['channel']),
|
||||||
|
"sender": str(payload['name']),
|
||||||
|
"game": str(payload['game'])}))
|
||||||
|
elif event == "channels/send":
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.sessionhandler.data_in(self, bot_data_in=("", kwargs))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue