Add source module to mumo

The source module is inspired by the bf2 module and is supposed to allow on-the-fly context/identity based player management for source engine based games. Currently supported games are TF2, DOD:S, CStrike:Source and HL2DM.

This commit adds the basic structure with unit tests for many of the components. For now only dynamic channel creation and player movement is implemented.

Left todo are dynamic ACL/Group management, unused channel deletion, server channel re-use and, most importantly, actual testing beyond the small unit test coverage.
This commit is contained in:
Stefan Hacker 2013-02-25 10:12:19 +01:00
parent 0ac8b542a9
commit 4b61aa2fec
10 changed files with 1242 additions and 4 deletions

View File

@ -55,6 +55,7 @@ somestr = Blabla
somenum = 10
testfallbacknum = asdas
blubber = Things %(doesnotexistsasdefault)s
serverregex = ^\[[\w\d\-\(\):]{1,20}\]$
[Server_10]
value = False
[Server_9]
@ -66,7 +67,8 @@ value = True
('somestr', str, "fail"),
('somenum', int, 0),
('somenumtest', int, 1),
('blubber', str, "empty")),
('blubber', str, "empty"),
('serverregex', re.compile, '.*')),
(lambda x: re.match("Server_\d+",x)):(('value', x2bool, True),),
'somethingelse':(('bla', str, "test"),)}
@ -121,6 +123,7 @@ value = True
assert(cfg.world.somenum == 10)
self.assertRaises(AttributeError, getattr, cfg.world, "testfallbacknum")
self.assertEqual(cfg.world.blubber, "Things %(doesnotexistsasdefault)s")
self.assertEqual(cfg.world.serverregex, re.compile("^\[[\w\d\-\(\):]{1,20}\]$"))
assert(cfg.somethingelse.bla == "test")
assert(cfg.Server_10.value == False)
assert(cfg.Server_2.value == True)

View File

@ -0,0 +1,77 @@
;
; This is a sample configuration file for the mumo source module.
; The source module manages ACL/channel movements based on source
; gamestate reported by Mumble positional audio plugins
;
; The plugin creates needed channels on demand and re-uses
; existing ones if available. After creation the channel
; is identified by ID and can be renamed without breaking
; the mapping.
[source]
; Database file to hold channel mappings
database = source.sqlite
; Channel ID of root channel to create channels in
basechannelid = 0
; Comma seperated list of mumble servers to operate on, leave empty for all
mumbleservers =
; Regular expression for game name restriction
; Restrict to sane game names to prevent injection of malicious game
; names into the plugin. Can also be used to restrict game types.
gameregex = ^(tf|dod|cstrike|hl2mp)$
; Prefix to use for groups used in this plugin
; Be aware that this combined with gameregex prevents spoofing
; of existing groups
groupprefix = source_
; Configuration section valid for all games for which no
; specfic game rule is given.
[generic]
; name and server channelname support the following variables:
; %(game)s - Shortname of the game
; %(server)s - Unique id of the server
; Game name to use for game channel
name = %(game)s
; Channel to create for server below gamechannel
servername = %(server)s
; Comma seperated list of default team channel names
teams = Lobby, Spectator, Team one, Team two, Team three, Team four
; When creating a channel setup ACLs to restrict it to players
restrict = true
; Create base/server-channels on-demand
createifmissing = true
; Delete channels as soon as the last player is gone
deleteifunused = false
; Regular expression for server restriction.
; Will be checked against steam server id.
; Use this to restrict to private servers.
serverregex = ^\[[\w\d\-\(\):]{1,20}\]$
; Game specific sections overriding settings of the
; [generic] section.
[game:tf]
name = Team Fortress 2
teams = Lobby, Spectator, Blue, Red
[game:dod]
name = Day of Defeat : Source
teams = Lobby, Spectator, U.S. Army, Wehrmacht
[game:cstrike]
name = Counterstrike Source
teams = Lobby, Spectator, Terrorist, CT Forces
[game:hl2mp]
name = HL2: Death Match
teams = Lobby, Spectator, Combine, Rebels

View File

@ -0,0 +1 @@
from source import source

96
modules/source/db.py Normal file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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.
import sqlite3
class SourceDB(object):
def __init__(self, path = ":memory:"):
self.db = sqlite3.connect(path)
if self.db:
self.db.execute("CREATE TABLE IF NOT EXISTS source(sid INTEGER, cid INTEGER, game TEXT, server TEXT, team INTEGER)")
self.db.commit()
def close(self):
if self.db:
self.db.commit()
self.db.close()
self.db = None
def isOk(self):
""" True if the database is correctly initialized """
return self.db != None
def cidFor(self, sid, game, server = None, team = None):
assert(sid != None and game != None)
assert(not (team != None and server == None))
v = self.db.execute("SELECT cid FROM source WHERE sid is ? and game is ? and server is ? and team is ?", [sid, game, server, team]).fetchone()
return v[0] if v else None
def registerChannel(self, sid, cid, game, server = None, team = None):
assert(sid != None and game != None)
assert(not (team != None and server == None))
self.db.execute("INSERT INTO source (sid, cid, game, server, team) VALUES (?,?,?,?,?)", [sid, cid, game, server, team])
self.db.commit()
return True
def unregisterChannel(self, sid, game, server = None, team = None):
assert(sid != None and game != None)
assert(not (team != None and server == None))
base = "DELETE FROM source WHERE sid is ? and game is ?"
if server != None and team != None:
self.db.execute(base + " and server is ? and team is ?", [sid, game, server, team])
elif server != None:
self.db.execute(base + " and server is ?", [sid, game, server])
else:
self.db.execute(base, [sid, game])
self.db.commit()
def dropChannel(self, sid, cid):
""" Drops channel with given sid + cid """
self.db.execute("DELETE FROM source WHERE sid is ? and cid is ?", [sid, cid])
self.db.commit()
def registeredChannels(self):
""" Returns channels as a list of (sid, cid, game, server team) tuples grouped by sid """
return self.db.execute("SELECT sid, cid, game, server, team FROM source ORDER by sid").fetchall()
def reset(self):
""" Deletes everything in the database """
self.db.execute("DELETE FROM source")
self.db.commit()
if __name__ == "__main__":
pass

145
modules/source/db_test.py Normal file
View File

@ -0,0 +1,145 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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.
import unittest
from db import SourceDB
class SourceDBTest(unittest.TestCase):
def setUp(self):
self.db = SourceDB()
def tearDown(self):
self.db.close()
def testOk(self):
self.db.reset()
self.assertTrue(self.db.isOk())
def testSingleChannel(self):
self.db.reset()
sid = 5; cid = 10; game = "tf2"; server = "abc[]def"; team = "1"
self.assertTrue(self.db.registerChannel(sid, cid, game, server, team))
self.assertEqual(self.db.cidFor(sid, game, server, team), cid)
self.db.unregisterChannel(sid, game, server, team)
self.assertEqual(self.db.cidFor(sid, game, server, team), None)
def testChannelTree(self):
self.db.reset()
sid = 5; game = "tf2"; server = "abc[]def"; team = 0
bcid = 10; scid = 11; tcid = 12
self.assertTrue(self.db.registerChannel(sid, 1, "canary", server, team))
# Delete whole tree
self.assertTrue(self.db.registerChannel(sid, bcid, game))
self.assertTrue(self.db.registerChannel(sid, scid, game, server))
self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team))
self.assertEqual(self.db.cidFor(sid, game), bcid)
self.assertEqual(self.db.cidFor(sid, game, server), scid)
self.assertEqual(self.db.cidFor(sid, game, server, team), tcid)
self.assertEqual(self.db.cidFor(sid+1, game, server, team), None)
self.db.unregisterChannel(sid, game)
self.assertEqual(self.db.cidFor(sid, game, server, team), None)
self.assertEqual(self.db.cidFor(sid, game, server), None)
self.assertEqual(self.db.cidFor(sid, game), None)
# Delete server channel
self.assertTrue(self.db.registerChannel(sid, bcid, game))
self.assertTrue(self.db.registerChannel(sid, scid, game, server))
self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team))
self.db.unregisterChannel(sid, game, server)
self.assertEqual(self.db.cidFor(sid, game), bcid)
self.assertEqual(self.db.cidFor(sid, game, server), None)
self.assertEqual(self.db.cidFor(sid, game, server, team), None)
self.db.unregisterChannel(sid, game)
# Delete team channel
self.assertTrue(self.db.registerChannel(sid, bcid, game))
self.assertTrue(self.db.registerChannel(sid, scid, game, server))
self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team))
self.db.unregisterChannel(sid, game, server, team)
self.assertEqual(self.db.cidFor(sid, game), bcid)
self.assertEqual(self.db.cidFor(sid, game, server), scid)
self.assertEqual(self.db.cidFor(sid, game, server, team), None)
self.db.unregisterChannel(sid, game)
# Check canary
self.assertEqual(self.db.cidFor(sid, "canary", server, team), 1)
self.db.unregisterChannel(sid, "canary", server, team)
def testDropChannel(self):
self.db.reset()
sid = 1; cid = 5; game = "tf"
self.db.registerChannel(sid, cid, game)
self.db.dropChannel(sid + 1, cid)
self.assertEqual(self.db.cidFor(sid, game), cid)
self.db.dropChannel(sid, cid)
self.assertEqual(self.db.cidFor(sid, game), None)
def testRegisteredChannels(self):
self.db.reset()
sid = 5; game = "tf2"; server = "abc[]def"; team = 1
bcid = 10; scid = 11; tcid = 12;
self.db.registerChannel(sid, bcid, game)
self.db.registerChannel(sid, scid, game, server)
self.db.registerChannel(sid+1, tcid, game, server, team)
self.db.registerChannel(sid, tcid, game, server, team)
expected = [(sid, bcid, game, None, None),
(sid, scid, game, server, None),
(sid, tcid, game, server, team),
(sid+1, tcid, game, server, team)]
self.assertEqual(self.db.registeredChannels(), expected)
if __name__ == "__main__":
#import sys;sys.argv = ['', 'Test.testName']
unittest.main()

371
modules/source/source.py Normal file
View File

@ -0,0 +1,371 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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, False)
)
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:<gameshorthand>" 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, server, old, new):
sid = 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
self.moveUser(server,
new.state,
new.game,
new.server,
new.identity["team"])
else:
# User gone
if not new:
self.dlog(sid, old.state, "User gone")
else:
self.dlog(sid, old.state, "User stopped playing")
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, state, game, server, team):
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)
# TODO: Source channel deletion if unused
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):
log = self.log()
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'", game, game_server)
self.dlog(sid, new_state, "Identity: '%s'", identity)
updated_user = User(new_state, identity, game, game_server)
self.dlog(sid, new_state, "Starting transition")
self.userTransition(server, old_user, updated_user)
if updated_user.valid():
self.users.addOrUpdate(sid, session, updated_user)
self.dlog(sid, new_state, "Transition completed")
else:
# User isn't relevant for this plugin
self.users.remove(sid, session)
self.dlog(sid, new_state, "User not of concern for plugin")
#
#--- 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")

View File

@ -0,0 +1,345 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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.
import unittest
import Queue
import config
import re
import logging
import source
class InvalidChannelExceptionMock(Exception):
pass
class StateMock():
def __init__(self, cid = 0, session = 0, userid = -1):
self.channel = cid
self.session = session
self.userid = userid
class ServerMock():
def __init__(self, sid):
self.sid = sid
self.reset()
def id(self):
return self.sid
def lastChannelID(self):
return self.uid
def addChannel(self, name, parent):
self.name.append(name)
self.parent.append(parent)
self.uid += 1
return self.uid
def getChannelState(self, cid):
if not cid in self.channel:
raise InvalidChannelExceptionMock()
return {'fake':True}
def setState(self, state):
self.user_state.append(state)
def reset(self):
self.uid = 1000
self.name = []
self.parent = []
self.user_state = []
class MurmurMock():
InvalidChannelException = InvalidChannelExceptionMock
def __init__(self):
self.s = ServerMock(1)
def getServer(self, sid):
assert(sid == self.s.id())
return self.s
def reset(self):
self.s.reset()
class ManagerMock():
SERVERS_ALL = [-1]
def __init__(self):
self.q = Queue.Queue()
self.m = MurmurMock()
def getQueue(self):
return self.q
def getMurmurModule(self):
return self.m
def subscribeServerCallbacks(self, callback, servers):
self.serverCB = {'callback' : callback, 'servers' : servers}
def subscribeMetaCallbacks(self, callback, servers):
self.metaCB = {'callback' : callback, 'servers' : servers}
class Test(unittest.TestCase):
def setUp(self):
self.mm = ManagerMock();
self.mserv = self.mm.m.getServer(1)
testconfig = config.Config(None, source.source.default_config)
testconfig.source.database = ":memory:"
# As it is hard to create the read only config structure from
# hand use a spare one to steal from
spare = config.Config(None, source.source.default_config)
testconfig.__dict__['game:tf'] = spare.generic
testconfig.__dict__['game:tf'].name = "Team Fortress 2"
testconfig.__dict__['game:tf'].teams = ["Lobby", "Spectator", "Blue", "Red"]
testconfig.__dict__['game:tf'].serverregex = re.compile("^\[A-1:123\]$")
testconfig.__dict__['game:tf'].servername = "Test %(game)s %(server)s"
self.s = source.source("source", self.mm, testconfig)
self.mm.s = self.s
# Since we don't want to run threaded if we don't have to
# emulate startup to the derived class function
self.s.onStart()
self.s.connected()
# Critical test assumption
self.assertEqual(self.mm.metaCB['callback'], self.s)
self.assertEqual(self.mm.serverCB['callback'], self.s)
def resetDB(self):
self.s.db.db.execute("DELETE FROM source");
def resetState(self):
self.resetDB()
self.mm.m.reset()
def tearDown(self):
self.s.disconnected()
self.s.onStop()
def testDefaultConfig(self):
self.resetState()
mm = ManagerMock()
INVALIDFORCEDEFAULT = ""
s = source.source("source", mm, INVALIDFORCEDEFAULT)
self.assertNotEqual(s.cfg(), None)
def testConfiguration(self):
self.resetState()
# Ensure the default configuration makes sense
self.assertEqual(self.mm.serverCB['servers'], self.mm.SERVERS_ALL)
self.assertEqual(self.mm.metaCB['servers'], self.mm.SERVERS_ALL)
self.assertEqual(self.s.cfg().source.basechannelid, 0)
self.assertEqual(self.s.cfg().generic.name, "%(game)s")
self.assertEqual(self.s.gameCfg("wugu", "name"), "%(game)s")
self.assertEqual(self.s.gameCfg("tf", "name"), "Team Fortress 2")
def testIdentityParser(self):
self.resetState()
expected = {"universe" : 1,
"account_type" : 2,
"id" : 3,
"instance" : 4,
"team" : 5}
got = self.s.parseSourceIdentity("universe:1;account_type:2;id:00000003;instance:4;team:5")
self.assertDictEqual(expected, got)
got = self.s.parseSourceIdentity("universe:1;account_type:2;id:00000003;instance:4;")
self.assertEqual(got, None, "Required team variable missing")
self.assertEqual(self.s.parseSourceIdentity(None), None)
self.assertEqual(self.s.parseSourceIdentity(""), None)
self.assertEqual(self.s.parseSourceIdentity("whatever:4;dskjfskjdfkjsfkjsfkj"), None)
def testContextParser(self):
self.resetState()
none = (None, None)
self.assertEqual(self.s.parseSourceContext(None), none)
self.assertEqual(self.s.parseSourceContext(""), none)
self.assertEqual(self.s.parseSourceContext("whatever:4;skjdakjkjwqdkjqkj"), none)
expected = ("dod", "[A-1:2807761920(3281)]")
actual = self.s.parseSourceContext("Source engine: dod\x00[A-1:2807761920(3281)]\x00")
self.assertEqual(expected, actual)
expected = ("dod", "[0:1]")
actual = self.s.parseSourceContext("Source engine: dod\x00[0:1]\x00")
self.assertEqual(expected, actual)
expected = ("cstrike", "[0:1]")
actual = self.s.parseSourceContext("Source engine: cstrike\x00[0:1]\x00")
self.assertEqual(expected, actual)
actual = self.s.parseSourceContext("Source engine: fake\x00[A-1:2807761920(3281)]\x00")
self.assertEqual(none, actual)
actual = self.s.parseSourceContext("Source engine: cstrike\x0098vcv98re98ver98ver98v\x00")
self.assertEqual(none, actual)
# Check alternate serverregex
expected = ("tf", "[A-1:123]")
actual = self.s.parseSourceContext("Source engine: tf\x00[A-1:123]\x00")
self.assertEqual(expected, actual)
actual = self.s.parseSourceContext("Source engine: tf\x00[A-1:2807761920(3281)]\x00")
self.assertEqual(none, actual)
def testGetOrCreateChannelFor(self):
mumble_server = self.mserv
prev = mumble_server.lastChannelID()
game = "tf"; server = "[A-1:123]"; team = 3
cid = self.s.getOrCreateChannelFor(mumble_server, game, server, team)
self.assertEqual(3, cid - prev)
self.assertEqual(mumble_server.parent[0], 0)
self.assertEqual(mumble_server.parent[1], prev + 1)
self.assertEqual(mumble_server.parent[2], prev + 2)
self.assertEqual(mumble_server.name[0], "Team Fortress 2")
self.assertEqual(mumble_server.name[1], "Test tf [A-1:123]")
self.assertEqual(mumble_server.name[2], "Red")
sid = mumble_server.id()
self.assertEqual(self.s.db.cidFor(sid, game), prev + 1);
self.assertEqual(self.s.db.cidFor(sid, game, server), prev + 2);
self.assertEqual(self.s.db.cidFor(sid, game, server, team), prev + 3);
gotcid = self.s.getOrCreateChannelFor(mumble_server, game, server, team)
self.assertEqual(cid, gotcid)
#print self.s.db.db.execute("SELECT * FROM source").fetchall()
def testGetGameName(self):
self.resetState()
self.assertEqual(self.s.getGameName("tf"), "Team Fortress 2")
self.assertEqual(self.s.getGameName("invalid"), "%(game)s");
def testGetServerName(self):
self.resetState()
self.assertEqual(self.s.getServerName("tf"), "Test %(game)s %(server)s")
self.assertEqual(self.s.getServerName("invalid"), "%(server)s");
def testGetTeamName(self):
self.resetState()
self.assertEqual(self.s.getTeamName("tf", 2), "Blue")
self.assertEqual(self.s.getTeamName("tf", 100), "100") #oob
self.assertEqual(self.s.getTeamName("invalid", 2), "Team one")
self.assertEqual(self.s.getTeamName("invalid", 100), "100") #oob
def testValidGameType(self):
self.resetState()
self.assertTrue(self.s.validGameType("dod"))
self.assertTrue(self.s.validGameType("cstrike"))
self.assertTrue(self.s.validGameType("tf"))
self.assertFalse(self.s.validGameType("dodx"))
self.assertFalse(self.s.validGameType("xdod"))
self.assertFalse(self.s.validGameType(""))
def testValidServer(self):
self.resetState()
self.assertTrue(self.s.validServer("dod", "[A-1:2807761920(3281)]"))
self.assertFalse(self.s.validServer("dod", "A-1:2807761920(3281)]"))
self.assertFalse(self.s.validServer("dod", "[A-1:2807761920(3281)"))
self.assertFalse(self.s.validServer("dod", "[A-1:2807761920(3281)&]"))
self.assertTrue(self.s.validServer("tf", "[A-1:123]"))
self.assertFalse(self.s.validServer("tf", "x[A-1:123]"))
self.assertFalse(self.s.validServer("tf", "[A-1:123]x"))
def testMoveUser(self):
self.resetState()
mumble_server = self.mserv
user_state = StateMock()
prev = self.mserv.lastChannelID()
TEAM_BLUE = 2
TEAM_RED = 3
BASE_SID = 0
GAME_SID = prev + 1
SERVER_SID = prev + 2
TEAM_RED_SID = prev + 3
TEAM_BLUE_SID = prev + 4
self.s.moveUser(self.mserv, user_state, "tf", "[A-1:123]", TEAM_BLUE)
self.assertEqual(mumble_server.parent[0], BASE_SID)
self.assertEqual(mumble_server.parent[1], GAME_SID)
self.assertEqual(mumble_server.parent[2], SERVER_SID)
self.assertEqual(mumble_server.name[0], "Team Fortress 2")
self.assertEqual(mumble_server.name[1], "Test tf [A-1:123]")
self.assertEqual(mumble_server.name[2], "Blue")
self.assertEqual(len(mumble_server.name), 3)
self.assertEqual(user_state.channel, TEAM_RED_SID)
self.assertEqual(mumble_server.user_state[0], user_state)
self.s.moveUser(self.mserv, user_state, "tf", "[A-1:123]", TEAM_RED)
self.assertEqual(mumble_server.parent[3], SERVER_SID)
self.assertEqual(mumble_server.name[3], "Red")
self.assertEqual(len(mumble_server.parent), 4)
self.assertEqual(user_state.channel, TEAM_BLUE_SID)
self.assertEqual(mumble_server.user_state[0], user_state)
if __name__ == "__main__":
#logging.basicConfig(level = logging.DEBUG)
#import sys;sys.argv = ['', 'Test.testName']
unittest.main()

109
modules/source/users.py Normal file
View File

@ -0,0 +1,109 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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.
class User(object):
"""
User to hold state as well as parsed data fields in a
sane fashion.
"""
def __init__(self, state, identity=None, game=None, server=None):
self.state = state
self.identity = identity or {}
self.server = server
self.game = game
def valid(self):
""" True if valid data is available for all fields """
return self.state and self.identity and self.server and self.game
def hasContextOrIdentityChanged(self, otherstate):
""" Checks whether the given state diverges from this users's """
return self.state.context != otherstate.context or \
self.state.identity != otherstate.identity
def updateState(self, state):
""" Updates the state of this user """
self.state = state
def updateData(self, identity, game, server):
""" Updates the data fields for this user """
self.identity = identity
self.game = game
self.server = server
class UserRegistry(object):
"""
Registry to store User objects for given servers
and sessions.
"""
def __init__(self):
self.users = {} # {session:user, ...}
def get(self, sid, session):
""" Return user or None from registry """
try:
return self.users[sid][session]
except KeyError:
return None
def add(self, sid, session, user):
""" Add new user to registry """
assert(isinstance(user, User))
if not sid in self.users:
self.users[sid] = {session:user}
elif not session in self.users[sid]:
self.users[sid][session] = user
else:
return False
return True
def addOrUpdate(self, sid, session, user):
""" Add user or overwrite existing one """
assert(isinstance(user, User))
if not sid in self.users:
self.users[sid] = {session:user}
else:
self.users[sid][session] = user
return True
def remove(self, sid, session):
""" Remove user from registry """
try:
del self.users[sid][session]
except KeyError:
return False
return True

View File

@ -0,0 +1,81 @@
#!/usr/bin/env python
# -*- coding: utf-8
# Copyright (C) 2013 Stefan Hacker <dd0t@users.sourceforge.net>
# 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.
import unittest
from users import User, UserRegistry
class Test(unittest.TestCase):
def getSomeUsers(self, n =5):
sid = []; session = []; user = []
for i in range(n):
s=str(i)
sid.append(i) ; session.append(i)
user.append(User("state"+s, "identity"+s, "game"+s, "server"+s))
return sid, session, user
def testRegistryCRUDOps(self):
r = UserRegistry()
sid, session, user = self.getSomeUsers()
# Create & Read
self.assertTrue(r.add(sid[0], session[0], user[0]))
self.assertFalse(r.add(sid[0], session[0], user[0]))
self.assertEqual(r.get(sid[0], session[0]), user[0])
self.assertTrue(r.addOrUpdate(sid[1], session[1], user[1]))
self.assertEqual(r.get(sid[1], session[1]), user[1])
# Update
self.assertTrue(r.addOrUpdate(sid[0], session[0], user[2]))
self.assertEqual(r.get(sid[0], session[0]), user[2])
# Delete
self.assertTrue(r.remove(sid[1], session[1]))
self.assertFalse(r.remove(sid[1], session[1]))
self.assertEqual(r.get(sid[1], session[1]), None)
self.assertTrue(r.remove(sid[0], session[0]))
self.assertFalse(r.remove(sid[0], session[0]))
self.assertEqual(r.get(sid[0], session[0]), None)
def testUser(self):
u = User("State", {'team':2} , "tf", "Someserver")
self.assertTrue(u.valid())
self.assertFalse(User("State").valid())
if __name__ == "__main__":
#import sys;sys.argv = ['', 'Test.testName']
unittest.main()

View File

@ -29,6 +29,16 @@
# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
from worker_test import *
from config_test import *
from mumo_manager_test import *
if __name__ == "__main__":
import unittest
from worker_test import *
from config_test import *
from mumo_manager_test import *
from modules.source.source_test import *
from modules.source.users_test import *
from modules.source.db_test import *
#import sys;sys.argv = ['', 'Test.testName']
unittest.main()