Source code for evennia.server.portal.grapevine

"""
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 autobahn.twisted.websocket import (
    WebSocketClientFactory,
    WebSocketClientProtocol,
    connectWS,
)
from django.conf import settings
from twisted.internet import protocol

from evennia.server.session import Session
from evennia.utils import get_evennia_version
from evennia.utils.logger import log_err, log_info

# 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


[docs]class RestartingWebsocketServerFactory(WebSocketClientFactory, protocol.ReconnectingClientFactory): """ A variant of the websocket-factory that auto-reconnects. """ initialDelay = 1 factor = 1.5 maxDelay = 60
[docs] 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)
[docs] 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
[docs] def startedConnecting(self, connector): """ Tracks reconnections for debugging. Args: connector (Connector): Represents the connection. """ log_info("(re)connecting to grapevine channel '%s'" % self.channel)
[docs] 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)
[docs] 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)
[docs] 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()
[docs] 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)
[docs]class GrapevineClient(WebSocketClientProtocol, Session): """ Implements the grapevine client """
[docs] def __init__(self): WebSocketClientProtocol.__init__(self) Session.__init__(self) self.restart_downtime = None
[docs] def at_login(self): pass
[docs] 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()
[docs] 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
[docs] 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"))
[docs] 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
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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)
[docs] def send_default(self, *args, **kwargs): """ Ignore other outputfuncs """ pass
[docs] 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))