#!/usr/bin/env python # -*- coding: utf-8 # Copyright (C) 2013 Stefan Hacker # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # - Redistributions of source code must retain the above copyright notice, # this list of conditions and the following disclaimer. # - Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # - Neither the name of the Mumble Developers nor the names of its # contributors may be used to endorse or promote products derived from this # software without specific prior written permission. # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. # # source.py # This module manages ACL/channel movements based on source # gamestate reported by Mumble positional audio plugins # from mumo_module import (MumoModule, commaSeperatedIntegers, commaSeperatedStrings, x2bool) from db import SourceDB from users import (User, UserRegistry) import re class source(MumoModule): default_game_config = ( ('name', str, "%(game)s"), ('servername', str, "%(server)s"), ('teams', commaSeperatedStrings, ["Lobby", "Spectator", "Team one", "Team two", "Team three", "Team four"]), ('groups', commaSeperatedStrings, ["%(game)s", "%(game)s_%(team)s", "%(game)s_%(team)s_%(channelid)d"]), ('restrict', x2bool, True), ('serverregex', re.compile, re.compile("^\[[\w\d\-\(\):]{1,20}\]$")), ('createifmissing', x2bool, True), ('deleteifunused', x2bool, True) ) default_config = {'source':( ('database', str, "source.sqlite"), ('basechannelid', int, 0), ('servers', commaSeperatedIntegers, []), ('gameregex', re.compile, re.compile("^(tf|dod|cstrike)$")) ), # The generic section defines default values which can be overridden in # optional game specific "game:" sections 'generic': default_game_config, lambda x: re.match('^game:\w+$', x): default_game_config } def __init__(self, name, manager, configuration=None): MumoModule.__init__(self, name, manager, configuration) self.murmur = manager.getMurmurModule() def onStart(self): MumoModule.onStart(self) cfg = self.cfg() self.db = SourceDB(cfg.source.database) def onStop(self): MumoModule.onStop(self) self.db.close() def connected(self): cfg = self.cfg() manager = self.manager() log = self.log() log.debug("Register for Server callbacks") self.meta = manager.getMeta() servers = set(cfg.source.servers) if not servers: servers = manager.SERVERS_ALL self.users = UserRegistry() self.validateChannelDB() manager.subscribeServerCallbacks(self, servers) manager.subscribeMetaCallbacks(self, servers) def validateChannelDB(self): log = self.log() log.debug("Validating channel database") current_sid = -1 current_mumble_server = None for sid, cid, _, _, _ in self.db.registeredChannels(): if current_sid != sid: current_mumble_server = self.meta.getServer(sid) current_sid = sid try: current_mumble_server.getChannelState(cid) #TODO: Verify ACL? except self.murmur.InvalidChannelException: # Channel no longer exists log.debug("(%d) Channel %d no longer exists. Dropped.", sid, cid) self.db.dropChannel(sid, cid) def disconnected(self): pass def userTransition(self, mumble_server, old, new): sid = mumble_server.id() assert(not old or old.valid()) relevant = old or (new and new.valid()) if not relevant: return user_new = not old and new user_gone = old and (not new or not new.valid()) if not user_new: # Nuke previous group memberships if any #TODO: Remove group memberships pass if not user_gone: #TODO: Establish new group memberships moved = self.moveUser(mumble_server, new) else: # User gone assert(old) self.users.remove(sid, old.state.session) moved = True if not new: self.dlog(sid, old.state, "User gone") else: # Move user out of our channel structure self.moveUserToCid(mumble_server, new.state, self.cfg().source.basechannelid) self.dlog(sid, old.state, "User stopped playing") if moved and old: # If moved from a valid game state perform channel use check chan = self.db.channelForCid(sid, old.state.channel) if chan: _, _, game, _, _ = chan if self.gameCfg(game, "deleteifunused"): self.deleteIfUnused(mumble_server, old.state.channel) def getGameName(self, game): return self.gameCfg(game, "name") def getServerName(self, game): return self.gameCfg(game, "servername") def getTeamName(self, game, index): try: return self.gameCfg(game, "teams")[index] except IndexError: return str(index) def getOrCreateGameChannelFor(self, mumble_server, game, server, sid, cfg, log, namevars): game_cid = self.db.cidFor(sid, game) if game_cid == None: game_channel_name = self.getGameName(game) % namevars log.debug("(%d) Creating game channel '%s' below %d", sid, game_channel_name, cfg.source.basechannelid) game_cid = mumble_server.addChannel(game_channel_name, cfg.source.basechannelid) self.db.registerChannel(sid, game_cid, game) # Make sure we don't have orphaned server channels around self.db.unregisterChannel(sid, game, server) log.debug("(%d) Game channel created and registered (cid %d)", sid, game_cid) return game_cid def getOrCreateServerChannelFor(self, mumble_server, game, server, team, sid, log, namevars, game_cid): server_cid = self.db.cidFor(sid, game, server) if server_cid == None: server_channel_name = self.getServerName(game) % namevars log.debug("(%d) Creating server channel '%s' below %d", sid, server_channel_name, game_cid) server_cid = mumble_server.addChannel(server_channel_name, game_cid) self.db.registerChannel(sid, server_cid, game, server) self.db.unregisterChannel(sid, game, server, team) # Make sure we don't have orphaned team channels around log.debug("(%d) Server channel created and registered (cid %d)", sid, server_cid) return server_cid def getOrCreateTeamChannelFor(self, mumble_server, game, server, team, sid, log, server_cid): team_cid = self.db.cidFor(sid, game, server, team) if team_cid == None: team_channel_name = self.getTeamName(game, team) log.debug("(%d) Creating team channel '%s' below %d", sid, team_channel_name, server_cid) team_cid = mumble_server.addChannel(team_channel_name, server_cid) self.db.registerChannel(sid, team_cid, game, server, team) log.debug("(%d) Team channel created and registered (cid %d)", sid, team_cid) return team_cid def getOrCreateChannelFor(self, mumble_server, game, server, team): sid = mumble_server.id() cfg = self.cfg() log = self.log() #TODO: Apply ACLs if needed #TODO: Make robust against channel changes not in the db namevars = {'game' : game, 'server' : server} game_cid = self.getOrCreateGameChannelFor(mumble_server, game, server, sid, cfg, log, namevars) server_cid = self.getOrCreateServerChannelFor(mumble_server, game, server, team, sid, log, namevars, game_cid) team_cid = self.getOrCreateTeamChannelFor(mumble_server, game, server, team, sid, log, server_cid) return team_cid def moveUserToCid(self, server, state, cid): self.dlog(server.id(), state, "Moving from channel %d to %d", state.channel, cid) state.channel = cid server.setState(state) def moveUser(self, mumble_server, user): state = user.state game = user.game server = user.server team = user.identity["team"] sid = mumble_server.id() source_cid = state.channel target_cid = self.getOrCreateChannelFor(mumble_server, game, server, team) if source_cid != target_cid: self.moveUserToCid(mumble_server, state, target_cid) user.state.channel = target_cid self.users.addOrUpdate(sid, state.session, user) return True return False def deleteIfUnused(self, mumble_server, cid): """ Takes the cid of a server or team channel and checks if all related channels (siblings and server) are unused. If true the channel is unused and will be deleted. Note: Assumes tree structure """ sid = mumble_server.id() log = self.log() result = self.db.channelForCid(sid, cid) if not result: return False _, _, cur_game, cur_server, cur_team = result assert(cur_game) if not cur_server: # Don't handle game channels log.debug("(%d) Delete if unused on game channel %d, ignoring", sid, cid) return False server_channel_cid = None relevant = self.db.channelsFor(sid, cur_game, cur_server) for _, cur_cid, _, _, cur_team in relevant: if cur_team == None: server_channel_cid = cur_cid if self.users.usingChannel(sid, cur_cid): log.debug("(%d) Delete if unused: Channel %d in use", sid, cur_cid) return False # Used assert(server_channel_cid != None) # Unused. Delete server and children log.debug("(%s) Channel %d unused. Will be deleted.", sid, server_channel_cid) mumble_server.removeChannel(server_channel_cid) return True def validGameType(self, game): return self.cfg().source.gameregex.match(game) != None def validServer(self, game, server): return self.gameCfg(game, "serverregex").match(server) != None def parseSourceContext(self, context): """ Parse source engine context string. Returns tuple with game name and server identification. Returns None for both if context string is invalid. """ try: prefix, server = context.split('\x00')[0:2] source, game = [s.strip() for s in prefix.split(':', 1)] if source != "Source engine": # Not a source engine context return (None, None) if not self.validGameType(game) or not self.validServer(game, server): return (None, None) return (game, server) except (AttributeError, ValueError),e: return (None, None); def parseSourceIdentity(self, identity): """ Parse comma separated source engine identity string key value pairs and return them as a dict. Returns None for invalid identity strings. Usage: parseSourceIndentity("universe:0;account_type:0;id:00000000;instance:0;team:0") """ try: # For now all values are integers d = {k:int(v) for k, v in [var.split(':', 1) for var in identity.split(';')]} # Make sure mandatory values are present if not "team" in d: return None return d except (AttributeError, ValueError): return None def gameCfg(self, game, variable): """Return the game specific value for the given variable if it exists. Otherwise the generic value""" sectionname = "game:" + game cfg = self.cfg() if sectionname not in cfg: return cfg.generic[variable] return cfg[sectionname][variable] def dlog(self, sid, state, what, *argc): """ Debug log output helper for user state related things """ self.log().debug("(%d) (%d|%d) " + what, sid, state.session, state.userid, *argc) def handle(self, server, new_state): sid = server.id() session = new_state.session self.dlog(sid, new_state, "Handle state change") old_user = self.users.get(sid, session) if old_user and not old_user.hasContextOrIdentityChanged(new_state): # No change in relevant fields. Simply update state for reference old_user.updateState(new_state) self.dlog(sid, new_state, "State change irrelevant for plugin") return game, game_server = self.parseSourceContext(new_state.context) identity = self.parseSourceIdentity(new_state.identity) self.dlog(sid, new_state, "Context: %s -> '%s' / '%s'", repr(new_state.context), game, game_server) self.dlog(sid, new_state, "Identity: %s -> '%s'", repr(new_state.identity), identity) updated_user = User(new_state, identity, game, game_server) self.dlog(sid, new_state, "Starting transition") self.userTransition(server, old_user, updated_user) self.dlog(sid, new_state, "Transition complete") # #--- Server callback functions # def userDisconnected(self, server, state, context=None): sid = server.id() session = state.session self.userTransition(server, self.users.get(sid, session), None) def userStateChanged(self, server, state, context=None): self.handle(server, state) def userConnected(self, server, state, context=None): self.handle(server, state) def channelRemoved(self, server, state, context=None): cid = state.id sid = server.id() self.log().debug("(%d) Channel %d removed.", sid, cid) self.db.dropChannel(sid, cid) def userTextMessage(self, server, user, message, current=None): pass def channelCreated(self, server, state, context=None): pass def channelStateChanged(self, server, state, context=None): pass # #--- Meta callback functions # def started(self, server, context=None): self.log().debug("Started") def stopped(self, server, context=None): self.log().debug("Stopped")