359 lines
11 KiB
Python
359 lines
11 KiB
Python
"""
|
|
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
|
|
|
|
Keyword args:
|
|
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))
|