├── .github └── workflows │ ├── docker-image_build.yml │ ├── docker-image_publish.yml │ └── python.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── config.py ├── config_test.py ├── docker.md ├── docs ├── setup-ubuntu.md └── third-party-modules.md ├── entrypoint.sh ├── init-script ├── modules-available ├── bf2.ini ├── idlemove.ini ├── onjoin.ini ├── samplecontext.ini ├── seen.ini ├── source.ini └── test.ini ├── modules ├── __init__.py ├── bf2.py ├── idlemove.py ├── onjoin.py ├── samplecontext.py ├── seen.py ├── source │ ├── README.md │ ├── __init__.py │ ├── db.py │ ├── db_test.py │ ├── source.py │ ├── source_test.py │ ├── users.py │ └── users_test.py └── test.py ├── mumo.ini ├── mumo.py ├── mumo_manager.py ├── mumo_manager_test.py ├── mumo_module.py ├── testsuite.py ├── tools ├── __init__.py └── mbf2man.py ├── worker.py └── worker_test.py /.github/workflows/docker-image_build.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Build 2 | on: 3 | push: 4 | branches: ["**","!master"] 5 | pull_request: 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Docker Buildx 12 | uses: docker/setup-buildx-action@v3 13 | - name: Build and push Docker image 14 | uses: docker/build-push-action@v5 15 | with: 16 | tags: mumblevoip/mumo 17 | cache-from: type=gha 18 | cache-to: type=gha,mode=max 19 | -------------------------------------------------------------------------------- /.github/workflows/docker-image_publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image Publish 2 | on: 3 | push: 4 | branches: ["master"] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | environment: DockerHub 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Docker Buildx 12 | uses: docker/setup-buildx-action@v3 13 | - name: Log in to Docker Hub 14 | uses: docker/login-action@v3 15 | with: 16 | username: ${{ secrets.DOCKERHUB_USERNAME }} 17 | password: ${{ secrets.DOCKERHUB_TOKEN }} 18 | - name: Build and push Docker image 19 | uses: docker/build-push-action@v5 20 | with: 21 | push: true 22 | tags: mumblevoip/mumo 23 | cache-from: type=gha 24 | cache-to: type=gha,mode=max 25 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python 2 | on: [push] 3 | jobs: 4 | LintAndTest: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | python-version: ["3.8", "3.9", "3.10"] 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Set up Python ${{ matrix.python-version }} 12 | uses: actions/setup-python@v5 13 | with: 14 | python-version: ${{ matrix.python-version }} 15 | - name: Install dependencies 16 | run: | 17 | python -m pip install --upgrade pip 18 | pip install flake8 pylint pytest 19 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 20 | - name: Lint with pylint 21 | run: pylint --errors-only $(git ls-files '*.py') 22 | continue-on-error: true 23 | - name: Lint with flake8 24 | run: | 25 | # stop the build if there are Python syntax errors or undefined names 26 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 27 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 28 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 29 | - name: Test with pytest 30 | run: | 31 | pytest 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.pytestArgs": [ 3 | "." 4 | ], 5 | "python.testing.unittestEnabled": false, 6 | "python.testing.pytestEnabled": true 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN pip install --no-cache-dir zeroc-ice 4 | 5 | COPY entrypoint.sh /entrypoint.sh 6 | COPY . /mumo 7 | 8 | RUN chmod +x /entrypoint.sh && \ 9 | ln -sf /dev/stdout /mumo/mumo.log 10 | 11 | VOLUME ["/data"] 12 | 13 | WORKDIR /mumo 14 | ENTRYPOINT [ "/entrypoint.sh" ] 15 | 16 | CMD ["/mumo/mumo.py", "--ini", "/data/mumo.ini"] 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mumo - The Mumble Moderator 2 | 3 | Mumo is meant to provide a platform on which Python-based Mumble server ICE extension modules can be built upon. The goal is to reduce the boilerplate needed 4 | to interact with the Mumble server to a minimum. 5 | 6 | To achieve this goal, tasks like Ice interface setup, basic error 7 | handling, configuration management, logging, and more are provided 8 | by mumo. Developers can focus on their specific functionality instead. 9 | 10 | ## Requirements 11 | 12 | mumo requires: 13 | 14 | * python >=3.2 15 | * python3-zeroc-ice 16 | * zeroc-ice-slice 17 | * mumble-server >=1.2.3 (not tested with lower versions) 18 | 19 | ## Setting up 20 | 21 | To configure and run mumo take a look at the `mumo.ini` and the module 22 | specific configurations in `modules-available` folder. Enabling modules 23 | is done by linking the configuration in modules-available to the 24 | `modules-enabled` folder. 25 | 26 | ## Docker image 27 | 28 | An official docker image is available at https://hub.docker.com/r/mumblevoip/mumo. 29 | 30 | More information in [docker.md](docker.md). 31 | 32 | ## Modules for Mumble moderator 33 | 34 | ### Included modules 35 | 36 | Currently, mumo comes with the following modules: 37 | 38 | #### bf2 39 | 40 | Battlefield 2 game management plugin that can dynamically move players into appropriate channels and groups to fit the in-game command structure. This is achieved by using data gathered from Mumble's positional audio system and does not require cooperation from the game server. 41 | 42 | #### idlemove 43 | 44 | Plugin for moving players that have been idle for a configurable amount of time into an idle channel. Optionally the players can be muted/deafened on move. 45 | 46 | #### onjoin 47 | 48 | Moves players into a specific channel on connect regardless of which channel they were in when they left last time. 49 | 50 | #### seen 51 | 52 | Makes the server listen for a configurable keyword to ask for the last time a specific nick was seen on the server. 53 | 54 | #### source 55 | 56 | Source game management plugin that can dynamically move players into on-the-fly-created channel structures representing in-game team setup. 57 | This is achieved by using data gathered from Mumble's positional audio system and does not require cooperation from the game server. 58 | 59 | Currently, the following source-engine-based games are supported: Team Fortress 2, Day of Defeat: Source, Counter-Strike: Source, Half-Life 2: Deathmatch. 60 | 61 | #### test 62 | 63 | A debugging plugin that registers for all possible events and outputs every call with parameters into the debug log. 64 | 65 | ### 3rd party modules 66 | See [docs/third-party-modules.md](docs/third-party-modules.md) 67 | 68 | ## Contributing 69 | We appreciate contributions. For example as issue or suggestion reports and comments, change pull requests, additional modules, or extending documentation. 70 | 71 | You can talk to us in tickets or in chat in [#mumble-dev:matrix.org](https://matrix.to/#/#mumble-dev:matrix.org). 72 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import configparser 33 | import types 34 | 35 | 36 | class Config(object): 37 | """ 38 | Small abstraction for config loading 39 | """ 40 | 41 | def __init__(self, filename=None, default=None): 42 | if (filename and not default) or \ 43 | (not filename and not default): return 44 | 45 | sections = set(default.keys()) 46 | if filename: 47 | cfg = configparser.RawConfigParser() 48 | cfg.optionxform = str 49 | with open(filename) as f: 50 | cfg.read_file(f) 51 | sections.update(cfg.sections()) 52 | 53 | for section in sections: 54 | if isinstance(section, types.FunctionType): 55 | continue 56 | 57 | match = None 58 | for default_section in default.keys(): 59 | try: 60 | if section == default_section or \ 61 | (isinstance(default_section, types.FunctionType) and default_section(section)): 62 | match = default_section 63 | break 64 | except ValueError: 65 | continue 66 | 67 | if match is None: 68 | continue 69 | 70 | optiondefaults = default[match] 71 | 72 | if not optiondefaults: 73 | # Output this whole section as a list of raw key/value tuples 74 | if not filename: 75 | self.__dict__[section] = [] 76 | else: 77 | try: 78 | self.__dict__[section] = cfg.items(section) 79 | except configparser.NoSectionError: 80 | self.__dict__[section] = [] 81 | else: 82 | self.__dict__[section] = Config() 83 | for name, conv, vdefault in optiondefaults: 84 | if not filename: 85 | self.__dict__[section].__dict__[name] = vdefault 86 | else: 87 | try: 88 | self.__dict__[section].__dict__[name] = conv(cfg.get(section, name)) 89 | except (ValueError, configparser.NoSectionError, configparser.NoOptionError): 90 | self.__dict__[section].__dict__[name] = vdefault 91 | 92 | def __getitem__(self, key): 93 | return self.__dict__.__getitem__(key) 94 | 95 | def __contains__(self, key): 96 | return self.__dict__.__contains__(key) 97 | 98 | 99 | def x2bool(s): 100 | """ 101 | Helper function to convert strings from the config to bool 102 | """ 103 | if isinstance(s, bool): 104 | return s 105 | elif isinstance(s, str): 106 | return s.strip().lower() in ['1', 'true'] 107 | raise ValueError() 108 | 109 | 110 | def commaSeperatedIntegers(s): 111 | """ 112 | Helper function to convert a string from the config 113 | containing comma seperated integers into a list of integers 114 | """ 115 | return list(map(int, s.split(','))) 116 | 117 | 118 | def commaSeperatedStrings(s): 119 | """ 120 | Helper function to convert a string from the config 121 | containing comma seperated strings into a list of strings 122 | """ 123 | return list(map(str.strip, s.split(','))) 124 | 125 | 126 | def commaSeperatedBool(s): 127 | """ 128 | Helper function to convert a string from the config 129 | containing comma seperated strings into a list of booleans 130 | """ 131 | return list(map(x2bool, s.split(','))) 132 | -------------------------------------------------------------------------------- /config_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import os 33 | import re 34 | import unittest 35 | from tempfile import mkstemp 36 | 37 | from config import Config, x2bool, commaSeperatedIntegers, commaSeperatedStrings, commaSeperatedBool 38 | 39 | 40 | def create_file(content=None): 41 | """ 42 | Creates a temp file filled with 'content' and returns its path. 43 | The file has to be manually deleted later on 44 | """ 45 | fd, path = mkstemp() 46 | f = os.fdopen(fd, "wb") 47 | if content: 48 | f.write(content.encode()) 49 | f.flush() 50 | f.close() 51 | return path 52 | 53 | 54 | class ConfigTest(unittest.TestCase): 55 | cfg_content = """[world] 56 | domination = True 57 | somestr = Blabla 58 | somenum = 10 59 | testfallbacknum = asdas 60 | blubber = Things %(doesnotexistsasdefault)s 61 | serverregex = ^\[[\w\d\-\(\):]{1,20}\]$ 62 | [Server_10] 63 | value = False 64 | [Server_9] 65 | [Server_2] 66 | value = True 67 | """ 68 | 69 | cfg_default = {'world': (('domination', x2bool, False), 70 | ('somestr', str, "fail"), 71 | ('somenum', int, 0), 72 | ('somenumtest', int, 1), 73 | ('blubber', str, "empty"), 74 | ('serverregex', re.compile, '.*')), 75 | (lambda x: re.match("Server_\d+", x)): (('value', x2bool, True),), 76 | 'somethingelse': (('bla', str, "test"),)} 77 | 78 | def setUp(self): 79 | pass 80 | 81 | def tearDown(self): 82 | pass 83 | 84 | def testEmpty(self): 85 | path = create_file() 86 | try: 87 | cfg = Config(path, self.cfg_default) 88 | assert (cfg.world.domination == False) 89 | assert (cfg.world.somestr == "fail") 90 | assert (cfg.world.somenum == 0) 91 | self.assertRaises(AttributeError, getattr, cfg.world, "testfallbacknum") 92 | assert (cfg.somethingelse.bla == "test") 93 | finally: 94 | os.remove(path) 95 | 96 | def testX2bool(self): 97 | assert (x2bool(" true") == True) 98 | assert (x2bool("false") == False) 99 | assert (x2bool(" TrUe") == True) 100 | assert (x2bool("FaLsE ") == False) 101 | assert (x2bool("0 ") == False) 102 | assert (x2bool("1") == True) 103 | assert (x2bool(" 10") == False) 104 | assert (x2bool("notabool") == False) 105 | 106 | def testCommaSeperatedIntegers(self): 107 | assert (commaSeperatedIntegers(" 1,2 , 333 ") == [1, 2, 333]) 108 | self.assertRaises(ValueError, commaSeperatedIntegers, "1,2,a") 109 | 110 | def testCommaSeperatedStrings(self): 111 | assert (commaSeperatedStrings("Bernd, the, bred !") == ["Bernd", "the", "bred !"]) 112 | 113 | def testCommaSeperatedBool(self): 114 | assert (commaSeperatedBool("tRue ,false, 0, 0, 1,1, test") == [True, False, False, False, True, True, False]) 115 | 116 | def testConfig(self): 117 | path = create_file(self.cfg_content) 118 | try: 119 | try: 120 | cfg = Config(path, self.cfg_default) 121 | except Exception as e: 122 | print(e) 123 | assert (cfg.world.domination == True) 124 | assert (cfg.world.somestr == "Blabla") 125 | assert (cfg.world.somenum == 10) 126 | self.assertRaises(AttributeError, getattr, cfg.world, "testfallbacknum") 127 | self.assertEqual(cfg.world.blubber, "Things %(doesnotexistsasdefault)s") 128 | self.assertEqual(cfg.world.serverregex, re.compile("^\[[\w\d\-\(\):]{1,20}\]$")) 129 | assert (cfg.somethingelse.bla == "test") 130 | assert (cfg.Server_10.value == False) 131 | assert (cfg.Server_2.value == True) 132 | assert (cfg.Server_9.value == True) 133 | finally: 134 | os.remove(path) 135 | 136 | def testLoadDefault(self): 137 | cfg = Config(default=self.cfg_default) 138 | assert (cfg.world.domination == False) 139 | assert (cfg.somethingelse.bla == "test") 140 | assert (cfg.world.somenum == 0) 141 | 142 | def testGetItem(self): 143 | cfg = Config(default=self.cfg_default) 144 | assert (cfg["world"]["domination"] == False) 145 | assert ("world" in cfg) 146 | 147 | def invalidaccess(c): 148 | c["nointhisconfig"] 149 | 150 | self.assertRaises(KeyError, invalidaccess, cfg) 151 | 152 | 153 | if __name__ == "__main__": 154 | # import sys;sys.argv = ['', 'Test.testName'] 155 | unittest.main() 156 | -------------------------------------------------------------------------------- /docker.md: -------------------------------------------------------------------------------- 1 | # Mumo Docker Image 2 | 3 | [Docker](https://en.wikipedia.org/wiki/Docker_(software)) is a containerization and virtualization of applications and application environments. 4 | 5 | An official docker image is available at https://hub.docker.com/r/mumblevoip/mumo. 6 | 7 | ## Network access to Mumble 8 | 9 | Mumo accesses Mumble via the Ice interface. If you run Mumble Server in a docker container too, a network_mode configuration needs to be added so Mumo can access it. 10 | 11 | If you are connecting to a non-containerized/generally-accessible Mumble Server this is not necessary. 12 | 13 | The target is configured in `mumo.ini` with `host` and `port`. 14 | 15 | ## Data Volume - Configuration 16 | 17 | `/data` is a Docker volume. You can bind your own folder to it for configuration and enabling and adding additional Mumo modules. 18 | 19 | ## Changing Enabled/Loaded Modules 20 | 21 | When you add/enable new modules you need the restart the container. 22 | 23 | ## Running the Mumo Docker Image 24 | 25 | The Mumo docker image can be run with: 26 | 27 | ``` 28 | docker run --name mumo --net=container: -d -v /path/to/mumo/folder:/data mumblevoip/mumo 29 | ``` 30 | 31 | ## Docker Compose 32 | 33 | [Docker Compose](https://docs.docker.com/compose/) allows you to configure and run multi-container applications. This is useful to run a Mumble and Mumo container in a connected manner. 34 | 35 | A docker-compose(v2.4) example: 36 | 37 | ``` 38 | mumble-mumo: 39 | image: mumblevoip/mumo 40 | container_name: mumble-mumo 41 | restart: on-failure 42 | volumes: 43 | - /path/to/mumo/folder:/data 44 | network_mode : "service:mumble-server" 45 | depends_on: 46 | - mumble-server 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/setup-ubuntu.md: -------------------------------------------------------------------------------- 1 | # Setting up Mumo on Ubuntu Linux 2 | 3 | *Note: This guide only shows the basic steps for trying out mumo. For a more permanent setup you'll want to run mumo with its own user and a startup script.* 4 | 5 | ## Prepare Mumble Server 6 | 7 | Make sure you are running a recent Mumble Server release (1.2.4 or later). 8 | In the Mumble Server configuration, Ice should be enabled and a writesecret must be set. 9 | 10 | ## Install Dependencies 11 | 12 | ``` 13 | sudo apt-get install python3-zeroc-ice python-daemon git 14 | ``` 15 | 16 | ## Get and Configure Mumo 17 | 18 | Clone the repository, in this example into `~/mumo`: 19 | 20 | ``` 21 | cd ~/ 22 | git clone https://github.com/mumble-voip/mumo.git 23 | ``` 24 | 25 | Adjust the Mumo configuration 26 | 27 | ``` 28 | cd mumo 29 | nano mumo.ini 30 | ``` 31 | 32 | In the editor set your server's Ice writesecret as the secret variable so mumo can control your server. 33 | 34 | ``` 35 | secret = mysecretwritesecret 36 | ``` 37 | 38 | Close and save by pressing Ctrl + X followed by Y and Enter. 39 | 40 | ### Enable Modules 41 | 42 | Configure the modules you want to use by editing their ini file in the `modules-available` folder. 43 | 44 | Enable modules by linking their config file into the `modules-enabled` folder 45 | 46 | ``` 47 | cd modules-enabled 48 | ln -s ../modules-available/moduleyouwanttouse.ini 49 | ``` 50 | 51 | ## Running Mumo 52 | 53 | ``` 54 | ./mumo.py 55 | ``` 56 | 57 | Mumo should now be working with your mumble server. If it doesn't work check the `mumo.log` file for more information. 58 | -------------------------------------------------------------------------------- /docs/third-party-modules.md: -------------------------------------------------------------------------------- 1 | # Third-Party Mumo Modules 2 | 3 | Note that third-party means they are not created, supported, or explicitly verified or advocated by us. Do your own due diligence and assessments. 4 | 5 | ## Chat Img 6 | 7 | [chatimg on GitHub](https://github.com/aselus-hub/chatimg-mumo) 8 | 9 | A more full featured implementation of the same functionality as Url to Image. Allows injection of photos into chat, re-sizing them if they are larger then the size accepted by the mumble protocol and allowing the server admin to set a max width/height for the image so that it is scaled through html or thumbnailing to never be larger then prescribed. Allows the conversion of images within regular chat messages w/o bang commands as an option. Finally the injection of any number of images present after the bang or within a message. 10 | 11 | ## Videoinfo 12 | 13 | [mumo-videoinfo on GitHub](https://github.com/while-loop/mumo-videoinfo) 14 | 15 | Mumo plugin to provide YouTube video information to Mumble. 16 | 17 | ## Max users 18 | 19 | [mumo-maxusers on GitHub](https://github.com/ExplodingFist/mumo-maxusers/) 20 | 21 | This is a MuMo module to provide an administrator the capability of enforcing granular user limits by channel in mumble. 22 | 23 | ## Opcommand 24 | 25 | [mumo-opcommand on GitHub](https://github.com/ExplodingFist/mumo-opcommand) 26 | 27 | Temporarily add user or remove user to/from a group via GUI command line. 28 | 29 | ## mumo-password 30 | 31 | [mumo-password on GitHub](https://github.com/Betriebsrat/mumo-password) 32 | 33 | Generates a random password for mumble which expires in 30 minutes. 34 | 35 | ## mumo-chatlogger 36 | 37 | [mumo-chatlogger on GitHub](https://github.com/braandl/chatlogger-for-mumo) 38 | 39 | Logs server chats and makes them accessible to the users as a history 40 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if [ ! -f /data/mumo.ini ] 4 | then 5 | cp /mumo/mumo.ini /data 6 | 7 | sed -i 's;level =.*;/level = 30;' /data/mumo.ini 8 | 9 | chmod a+rw /data/mumo.ini 10 | cp -r /mumo/modules-available /data 11 | mkdir -p /data/modules-enabled 12 | 13 | echo Created mumo default config data. Exiting. 14 | exit 1 15 | fi 16 | 17 | # Conf class don't read mumo.ini everytime to check custom folder 18 | # so we copy them ... 19 | 20 | cp -r /data/modules /mumo 21 | cp -r /data/modules-available /mumo 22 | cp -r /data/modules-enabled /mumo 23 | 24 | exec "$@" 25 | -------------------------------------------------------------------------------- /init-script: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | # 3 | ### BEGIN INIT INFO 4 | # Provides: mumo 5 | # Required-Start: $network $local_fs $remote_fs dbus 6 | # Required-Stop: $network $local_fs $remote_fs dbus 7 | # Should-Start: $mysql 8 | # Should-Stop: $mysql 9 | # Default-Start: 2 3 4 5 10 | # Default-Stop: 0 1 6 11 | # Short-Description: Mumo bot for Mumble 12 | ### END INIT INFO 13 | 14 | PATH=/sbin:/bin:/usr/sbin:/usr/bin 15 | NAME=mumo 16 | DESC="Mumo bot for Mumble" 17 | WORKDIR=/opt/mumo 18 | PIDDIR=$WORKDIR 19 | PIDFILE=$PIDDIR/mumo.pid 20 | DAEMON=/usr/bin/python3 21 | USER=mumo 22 | GROUP=mumo 23 | 24 | test -x $DAEMON || exit 0 25 | 26 | INIFILE=$WORKDIR/mumo.ini 27 | DAEMON_OPTS="$WORKDIR/mumo.py --daemon --ini $INIFILE" 28 | 29 | # Include defaults if available 30 | if [ -f /etc/default/$NAME ] ; then 31 | . /etc/default/$NAME 32 | fi 33 | 34 | . /lib/init/vars.sh 35 | . /lib/lsb/init-functions 36 | 37 | case "$1" in 38 | start) 39 | [ "$VERBOSE" != no ] && log_daemon_msg "Starting $DESC" "$NAME" 40 | [ -d $PIDDIR ] || install -o $USER -d $PIDDIR 41 | start-stop-daemon --start --quiet \ 42 | --chdir $WORKDIR \ 43 | --pidfile $PIDFILE \ 44 | --chuid $USER:$GROUP \ 45 | --exec $DAEMON \ 46 | -- $DAEMON_OPTS 47 | case "$?" in 48 | 0|1) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 49 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 50 | esac 51 | ;; 52 | stop) 53 | [ "$VERBOSE" != no ] && log_daemon_msg "Stopping $DESC" "$NAME" 54 | start-stop-daemon --stop --quiet \ 55 | --chdir $WORKDIR \ 56 | --pidfile $PIDFILE \ 57 | --user $USER \ 58 | --exec $DAEMON 59 | case "$?" in 60 | 0|1) rm -f $PIDFILE 61 | [ "$VERBOSE" != no ] && log_end_msg 0 62 | ;; 63 | 2) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 64 | esac 65 | ;; 66 | force-reload) 67 | start-stop-daemon --stop --test --quiet \ 68 | --chdir $WORKDIR \ 69 | --pidfile $PIDFILE \ 70 | --user $USER \ 71 | --exec $DAEMON \ 72 | && $0 restart || exit 0 73 | ;; 74 | restart) 75 | [ "$VERBOSE" != no ] && log_daemon_msg "Restarting $DESC" "$NAME" 76 | start-stop-daemon --stop --quiet \ 77 | --chdir $WORKDIR \ 78 | --pidfile $PIDFILE \ 79 | --user $USER \ 80 | --exec $DAEMON 81 | case "$?" in 82 | 0|1) 83 | [ -d $PIDDIR ] || install -o $USER -d $PIDDIR 84 | rm -f $PIDFILE 85 | start-stop-daemon --start --quiet \ 86 | --chdir $WORKDIR \ 87 | --pidfile $PIDFILE \ 88 | --chuid $USER:$GROUP \ 89 | --exec $DAEMON \ 90 | -- $DAEMON_OPTS 91 | case "$?" in 92 | 0) [ "$VERBOSE" != no ] && log_end_msg 0 ;; 93 | *) [ "$VERBOSE" != no ] && log_end_msg 1 ;; 94 | esac 95 | ;; 96 | *) 97 | [ "$VERBOSE" != no ] && log_end_msg 0 98 | ;; 99 | esac 100 | ;; 101 | *) 102 | N=/etc/init.d/$NAME 103 | echo "Usage: $N {start|stop|restart|force-reload}" >&2 104 | exit 3 105 | ;; 106 | esac 107 | 108 | exit 0 109 | -------------------------------------------------------------------------------- /modules-available/bf2.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; This is a sample configuration file for the mumo bf2 module. 3 | ; The bf2 module manages ACL/channel movements based on battlefield 2 4 | ; gamestate reported by Mumble positional audio plugins 5 | ; 6 | 7 | [bf2] 8 | ; Overall count of game sections in this configuration 9 | gamecount = 1 10 | 11 | ; Game sections must be named g0, g1, g2 and so on. They 12 | ; describe independently running bf2 games with their 13 | ; own channel structures. Make sure that the number of 14 | ; game sections present in your configuration matches the 15 | ; gamecount set in the bf2 section. Make sure the numbering 16 | ; is continuous 17 | [g0] 18 | ; Gamename to use for groupnames/logs 19 | ; if left empty the section tag (p.e. g0) 20 | ; will be used 21 | name = 22 | 23 | ; Virtual mumble server this game will 24 | ; run on 25 | mumble_server = 1 26 | 27 | ; Regular expression to filter for a certain ipport combination reported 28 | ; by the plugins. If you have more then one server per virtual server 29 | ; you _must_ seperate the games by setting this 30 | ipport_filter = .* 31 | ; Negates ipport_filter if True 32 | ipport_filter_negate = False 33 | 34 | ; Channel the player is moved to (-1 for don't move) 35 | left = -1 36 | 37 | ; Channels in which the player is moved to 38 | ; if the in-game state matches. -1 for do 39 | ; not move. 40 | 41 | ; NOTE: blufor and opfor variables _most_ _not_ 42 | ; be -1. These channels will be used to store most of 43 | ; the groups managed by the plugin. To understand 44 | ; the inner working better look at the channel/ACL 45 | ; structures created by mbf2man.py from the tools/ 46 | ; directory 47 | 48 | blufor = -1 49 | blufor_commander = -1 50 | blufor_no_squad = -1 51 | blufor_alpha_squad = -1 52 | blufor_alpha_squad_leader = -1 53 | blufor_bravo_squad = -1 54 | blufor_bravo_squad_leader = -1 55 | blufor_charlie_squad = -1 56 | blufor_charlie_squad_leader = -1 57 | blufor_delta_squad = -1 58 | blufor_delta_squad_leader = -1 59 | blufor_echo_squad = -1 60 | blufor_echo_squad_leader = -1 61 | blufor_foxtrot_squad = -1 62 | blufor_foxtrot_squad_leader = -1 63 | blufor_gold_squad = -1 64 | blufor_gold_squad_leader = -1 65 | blufor_hotel_squad = -1 66 | blufor_hotel_squad_leader = -1 67 | blufor_india_squad = -1 68 | blufor_india_squad_leader = -1 69 | 70 | opfor = -1 71 | opfor_commander = -1 72 | opfor_no_squad = -1 73 | opfor_alpha_squad = -1 74 | opfor_alpha_squad_leader = -1 75 | opfor_bravo_squad = -1 76 | opfor_bravo_squad_leader = -1 77 | opfor_charlie_squad = -1 78 | opfor_charlie_squad_leader = -1 79 | opfor_delta_squad = -1 80 | opfor_delta_squad_leader = -1 81 | opfor_echo_squad = -1 82 | opfor_echo_squad_leader = -1 83 | opfor_foxtrot_squad = -1 84 | opfor_foxtrot_squad_leader = -1 85 | opfor_gold_squad = -1 86 | opfor_gold_squad_leader = -1 87 | opfor_hotel_squad = -1 88 | opfor_hotel_squad_leader = -1 89 | opfor_india_squad = -1 90 | opfor_india_squad_leader = -1 91 | -------------------------------------------------------------------------------- /modules-available/idlemove.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; Module for moving/muting/deafening idle players after 3 | ; a certain amount of time and moving them back once 4 | ; they interact again. 5 | ; 6 | 7 | [idlemove] 8 | ; Interval in which to check for idle users in seconds, 9 | ; setting this to low might hurt server performance 10 | interval = 10 11 | 12 | ; Comma seperated list of servers to operate on, leave empty for all 13 | servers = 14 | 15 | [all] 16 | ; All parameters in here also take a comma seperated list of values. 17 | ; You can use this to chain idle thresholds. 18 | 19 | ; Time in seconds after which to consider a player idle 20 | threshold = 3600 21 | ; Mute the player idle 22 | mute = True 23 | ; Deafen the player when idle 24 | deafen = False 25 | ; Id of the channel to move the player to when idle 26 | channel = 0 27 | ; Channel the player has to be in for this treshold rule to affect him (-1 == Any) 28 | ;source_channel = -1 29 | ; Comma seperated list of player names that will not be moved when idle (such as bots) 30 | whitelist = 31 | ; Comma seperated list of channel ids from which players will not be moved even when idle. 32 | ; channel_whitelist = 33 | 34 | ; For every server you want to override the [all] section for create 35 | ; a [server_] section. For example: 36 | ; Overriding [all] for server with the id 1 would look like this 37 | ;[server_1] 38 | ;threshold = 60 39 | ;mute = True 40 | ;deafen = False 41 | ;channel = 1 42 | 43 | -------------------------------------------------------------------------------- /modules-available/onjoin.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; This module allows moving players into a specific channel once 3 | ; they connect regardless of which channel they were in when they left. 4 | ; 5 | 6 | [onjoin] 7 | ; Comma seperated list of servers to operate on, leave empty for all 8 | servers = 9 | 10 | [all] 11 | ; Id of the channel to move users into once they join. 12 | channel = 2 13 | 14 | ; For every server you want to override the [all] section for create 15 | ; a [server_] section. For example: 16 | 17 | ; Overriding [all] for server with the id 1 would look like this 18 | ;[server_1] 19 | ;channel = 4 20 | 21 | -------------------------------------------------------------------------------- /modules-available/samplecontext.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; This module demonstrates how to add additional 3 | ; entries to a user's context menu. 4 | ; 5 | 6 | [samplecontext] 7 | ; Comma seperated list of servers to operate on, leave empty for all 8 | servers = 9 | 10 | -------------------------------------------------------------------------------- /modules-available/seen.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; This module allows asking the server for the last time it saw a specific 3 | ; player 4 | ; 5 | 6 | [seen] 7 | ; Comma seperated list of servers to operate on, leave empty for all 8 | servers = 9 | ; Keyword to which the server reacts 10 | keyword = !seen -------------------------------------------------------------------------------- /modules-available/source.ini: -------------------------------------------------------------------------------- 1 | ; 2 | ; This is a sample configuration file for the mumo source module. 3 | ; The source module manages ACL/channel movements based on source 4 | ; gamestate reported by Mumble positional audio plugins 5 | ; 6 | 7 | ; The plugin creates needed channels on demand and re-uses 8 | ; existing ones if available. After creation the channel 9 | ; is identified by ID and can be renamed without breaking 10 | ; the mapping. 11 | 12 | [source] 13 | ; Database file to hold channel mappings 14 | database = source.sqlite 15 | ; Channel ID of root channel to create channels in 16 | basechannelid = 0 17 | ; Comma seperated list of mumble servers to operate on, leave empty for all 18 | mumbleservers = 19 | ; Regular expression for game name restriction 20 | ; Restrict to sane game names to prevent injection of malicious game 21 | ; names into the plugin. Can also be used to restrict game types. 22 | gameregex = ^(tf|dod|cstrike|hl2mp)$ 23 | 24 | ; Prefix to use for groups used in this plugin 25 | ; Be aware that this combined with gameregex prevents spoofing 26 | ; of existing groups 27 | groupprefix = source_ 28 | 29 | ; Configuration section valid for all games for which no 30 | ; specfic game rule is given. 31 | 32 | [generic] 33 | ; name and server channelname support the following variables: 34 | ; %(game)s - Shortname of the game 35 | ; %(server)s - Unique id of the server 36 | 37 | ; Game name to use for game channel 38 | name = %(game)s 39 | 40 | ; Channel to create for server below gamechannel 41 | servername = %(server)s 42 | 43 | ; Comma seperated list of default team channel names 44 | teams = Lobby, Spectator, Team one, Team two, Team three, Team four 45 | 46 | ; When creating a channel setup ACLs to restrict it to players 47 | restrict = true 48 | 49 | ; Delete server channels as soon as the last player is gone 50 | deleteifunused = true 51 | 52 | ; Regular expression for server restriction. 53 | ; Will be checked against steam server id. 54 | ; Use this to restrict to private servers. 55 | serverregex = ^\[[\w\d\-\(\):]{1,20}\]$ 56 | 57 | ; Game specific sections overriding settings of the 58 | ; [generic] section. 59 | [game:tf] 60 | name = Team Fortress 2 61 | teams = Lobby, Spectator, Blue, Red 62 | 63 | [game:dod] 64 | name = Day of Defeat: Source 65 | teams = Lobby, Spectator, U.S. Army, Wehrmacht 66 | 67 | [game:cstrike] 68 | name = Counter-Strike: Source 69 | teams = Lobby, Spectator, Terrorist, CT Forces 70 | 71 | [game:hl2mp] 72 | name = Half-Life 2: Deathmatch 73 | teams = Lobby, Spectator, Combine, Rebels 74 | 75 | 76 | -------------------------------------------------------------------------------- /modules-available/test.ini: -------------------------------------------------------------------------------- 1 | ; This file is a dummy configuration file for the 2 | ; test module. The test module has heavy debug output 3 | ; and is solely meant for testing the basic framework 4 | ; as well as debugging purposes. Usually you don't want 5 | ; to enable it. 6 | [testing] 7 | tvar = 1 8 | tvar2 = Bernd 9 | tvar3 = -1 10 | tvar4 = True 11 | 12 | [blub] 13 | Bernd = asdad 14 | asdasd = dasdw -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- 1 | # No real module, just here to keep pydev and its 2 | # test runner happy. 3 | -------------------------------------------------------------------------------- /modules/bf2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010-2011 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # bf2.py 34 | # This module manages ACL/channel movements based on battlefield 2 35 | # gamestate reported by Mumble positional audio plugins 36 | # 37 | 38 | import json 39 | import re 40 | 41 | from config import x2bool 42 | from mumo_module import MumoModule 43 | 44 | 45 | class bf2(MumoModule): 46 | default_config = {'bf2': ( 47 | ('gamecount', int, 1), 48 | ), 49 | lambda x: re.match('g\d+', x): ( 50 | ('name', str, ''), 51 | ('mumble_server', int, 1), 52 | ('ipport_filter_negate', x2bool, False), 53 | ('ipport_filter', re.compile, re.compile('.*')), 54 | 55 | ('base', int, 0), 56 | ('left', int, -1), 57 | 58 | ('blufor', int, -1), 59 | ('blufor_commander', int, -1), 60 | ('blufor_no_squad', int, -1), 61 | ('blufor_first_squad', int, -1), 62 | ('blufor_first_squad_leader', int, -1), 63 | ('blufor_second_squad', int, -1), 64 | ('blufor_second_squad_leader', int, -1), 65 | ('blufor_third_squad', int, -1), 66 | ('blufor_third_squad_leader', int, -1), 67 | ('blufor_fourth_squad', int, -1), 68 | ('blufor_fourth_squad_leader', int, -1), 69 | ('blufor_fifth_squad', int, -1), 70 | ('blufor_fifth_squad_leader', int, -1), 71 | ('blufor_sixth_squad', int, -1), 72 | ('blufor_sixth_squad_leader', int, -1), 73 | ('blufor_seventh_squad', int, -1), 74 | ('blufor_seventh_squad_leader', int, -1), 75 | ('blufor_eighth_squad', int, -1), 76 | ('blufor_eighth_squad_leader', int, -1), 77 | ('blufor_ninth_squad', int, -1), 78 | ('blufor_ninth_squad_leader', int, -1), 79 | 80 | ('opfor', int, -1), 81 | ('opfor_commander', int, -1), 82 | ('opfor_no_squad', int, -1), 83 | ('opfor_first_squad', int, -1), 84 | ('opfor_first_squad_leader', int, -1), 85 | ('opfor_second_squad', int, -1), 86 | ('opfor_second_squad_leader', int, -1), 87 | ('opfor_third_squad', int, -1), 88 | ('opfor_third_squad_leader', int, -1), 89 | ('opfor_fourth_squad', int, -1), 90 | ('opfor_fourth_squad_leader', int, -1), 91 | ('opfor_fifth_squad', int, -1), 92 | ('opfor_fifth_squad_leader', int, -1), 93 | ('opfor_sixth_squad', int, -1), 94 | ('opfor_sixth_squad_leader', int, -1), 95 | ('opfor_seventh_squad', int, -1), 96 | ('opfor_seventh_squad_leader', int, -1), 97 | ('opfor_eighth_squad', int, -1), 98 | ('opfor_eighth_squad_leader', int, -1), 99 | ('opfor_ninth_squad', int, -1), 100 | ('opfor_ninth_squad_leader', int, -1) 101 | ), 102 | } 103 | 104 | id_to_squad_name = ["no", "first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth"] 105 | 106 | def __init__(self, name, manager, configuration=None): 107 | MumoModule.__init__(self, name, manager, configuration) 108 | self.murmur = manager.getMurmurModule() 109 | 110 | def connected(self): 111 | cfg = self.cfg() 112 | manager = self.manager() 113 | log = self.log() 114 | log.debug("Register for Server callbacks") 115 | 116 | servers = set() 117 | for i in range(cfg.bf2.gamecount): 118 | try: 119 | servers.add(cfg["g%d" % i].mumble_server) 120 | except KeyError: 121 | log.error("Invalid configuration. Game configuration for 'g%d' not found.", i) 122 | return 123 | 124 | self.sessions = {} # {serverid:{sessionid:laststate}} 125 | manager.subscribeServerCallbacks(self, servers) 126 | manager.subscribeMetaCallbacks(self, servers) 127 | 128 | def disconnected(self): 129 | pass 130 | 131 | # 132 | # --- Module specific state handling code 133 | # 134 | def update_state(self, server, oldstate, newstate): 135 | log = self.log() 136 | sid = server.id() 137 | 138 | session = newstate.session 139 | newoldchannel = newstate.channel 140 | 141 | try: 142 | opc = oldstate.parsedcontext 143 | ogcfgname = opc["gamename"] 144 | ogcfg = opc["gamecfg"] 145 | og = ogcfg.name 146 | opi = oldstate.parsedidentity 147 | except (AttributeError, KeyError): 148 | og = None 149 | 150 | opi = {} 151 | opc = {} 152 | 153 | if oldstate and oldstate.is_linked: 154 | oli = True 155 | else: 156 | oli = False 157 | 158 | try: 159 | npc = newstate.parsedcontext 160 | ngcfgname = npc["gamename"] 161 | ngcfg = npc["gamecfg"] 162 | ng = ngcfg.name 163 | npi = newstate.parsedidentity 164 | except (AttributeError, KeyError): 165 | ng = None 166 | 167 | npi = {} 168 | npc = {} 169 | nli = False 170 | 171 | if newstate and newstate.is_linked: 172 | nli = True 173 | else: 174 | nli = False 175 | 176 | if not oli and nli: 177 | log.debug("User '%s' (%d|%d) on server %d now linked", newstate.name, newstate.session, newstate.userid, 178 | sid) 179 | server.addUserToGroup(0, session, "bf2_linked") 180 | 181 | if opi and opc: 182 | squadname = self.id_to_squad_name[opi["squad"]] 183 | log.debug("Removing user '%s' (%d|%d) on server %d from groups of game %s / squad %s", newstate.name, 184 | newstate.session, newstate.userid, sid, og or ogcfgname, squadname) 185 | server.removeUserFromGroup(ogcfg["base"], session, "bf2_%s_game" % (og or ogcfgname)) 186 | server.removeUserFromGroup(ogcfg[opi["team"]], session, "bf2_commander") 187 | server.removeUserFromGroup(ogcfg[opi["team"]], session, "bf2_squad_leader") 188 | server.removeUserFromGroup(ogcfg[opi["team"]], session, "bf2_%s_squad_leader" % squadname) 189 | server.removeUserFromGroup(ogcfg[opi["team"]], session, "bf2_%s_squad" % squadname) 190 | server.removeUserFromGroup(ogcfg[opi["team"]], session, "bf2_team") 191 | channame = "left" 192 | newstate.channel = ogcfg["left"] 193 | 194 | if npc and npi: 195 | log.debug("Updating user '%s' (%d|%d) on server %d in game %s: %s", newstate.name, newstate.session, 196 | newstate.userid, sid, ng or ngcfgname, str(npi)) 197 | 198 | squadname = self.id_to_squad_name[npi["squad"]] 199 | 200 | # Add to game group 201 | location = "base" 202 | group = "bf2_%s_game" % (ng or ngcfgname) 203 | server.addUserToGroup(ngcfg[location], session, group) 204 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 205 | 206 | # Then add to team group 207 | location = npi["team"] 208 | group = "bf2_team" 209 | server.addUserToGroup(ngcfg[location], session, group) 210 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 211 | 212 | # Then add to squad group 213 | group = "bf2_%s_squad" % squadname 214 | server.addUserToGroup(ngcfg[location], session, group) 215 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 216 | 217 | channame = "%s_%s_squad" % (npi["team"], self.id_to_squad_name[npi["squad"]]) 218 | newstate.channel = ngcfg[channame] 219 | 220 | if npi["squad_leader"]: 221 | # In case the leader flag is set add to leader group 222 | group = "bf2_%s_squad_leader" % squadname 223 | server.addUserToGroup(ngcfg[location], session, group) 224 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 225 | 226 | group = "bf2_squad_leader" 227 | server.addUserToGroup(ngcfg[location], session, group) 228 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 229 | 230 | # Override previous moves 231 | channame = "%s_%s_squad_leader" % (npi["team"], self.id_to_squad_name[npi["squad"]]) 232 | newstate.channel = ngcfg[channame] 233 | 234 | if npi["commander"]: 235 | group = "bf2_commander" 236 | server.addUserToGroup(ngcfg[location], session, group) 237 | log.debug("Added '%s' @ %s to group %s in %s", newstate.name, ng or ngcfgname, group, location) 238 | 239 | # Override previous moves 240 | channame = "%s_commander" % npi["team"] 241 | newstate.channel = ngcfg[channame] 242 | 243 | if oli and not nli: 244 | log.debug("User '%s' (%d|%d) on server %d no longer linked", newstate.name, newstate.session, 245 | newstate.userid, sid) 246 | server.removeUserFromGroup(0, session, "bf2_linked") 247 | 248 | if 0 <= newstate.channel != newoldchannel: 249 | if ng is None: 250 | log.debug("Moving '%s' leaving %s to channel %s", newstate.name, og or ogcfgname, channame) 251 | else: 252 | log.debug("Moving '%s' @ %s to channel %s", newstate.name, ng or ngcfgname, channame) 253 | 254 | server.setState(newstate) 255 | 256 | def handle(self, server, state): 257 | def verify(mdict, key, vtype): 258 | if not isinstance(mdict[key], vtype): 259 | raise ValueError("'%s' of invalid type" % key) 260 | 261 | cfg = self.cfg() 262 | log = self.log() 263 | sid = server.id() 264 | 265 | # Add defaults for our variables to state 266 | state.parsedidentity = {} 267 | state.parsedcontext = {} 268 | state.is_linked = False 269 | 270 | if sid not in self.sessions: # Make sure there is a dict to store states in 271 | self.sessions[sid] = {} 272 | 273 | update = False 274 | if state.session in self.sessions[sid]: 275 | if state.identity != self.sessions[sid][state.session].identity or \ 276 | state.context != self.sessions[sid][state.session].context: 277 | # identity or context changed => update 278 | update = True 279 | else: # id and context didn't change hence the old data must still be valid 280 | state.is_linked = self.sessions[sid][state.session].is_linked 281 | state.parsedcontext = self.sessions[sid][state.session].parsedcontext 282 | state.parsedidentity = self.sessions[sid][state.session].parsedidentity 283 | else: 284 | if state.identity or state.context: 285 | # New user with engaged plugin => update 286 | self.sessions[sid][state.session] = None 287 | update = True 288 | 289 | if not update: 290 | self.sessions[sid][state.session] = state 291 | return 292 | 293 | # The plugin will always prefix "Battlefield 2\0" to the context for the bf2 PA plugin 294 | # don't bother analyzing anything if it isn't there 295 | splitcontext = state.context.split('\0', 1) 296 | if splitcontext[0] == "Battlefield 2": 297 | state.is_linked = True 298 | if state.identity and len(splitcontext) == 1: 299 | # LEGACY: Assume broken Ice 3.2 which doesn't transmit context after \0 300 | splitcontext.append( 301 | '{"ipport":""}') # Obviously this doesn't give full functionality but it doesn't crash either ;-) 302 | 303 | if state.is_linked and len(splitcontext) == 2 and state.identity: 304 | try: 305 | context = json.loads(splitcontext[1]) 306 | verify(context, "ipport", str) 307 | 308 | for i in range(cfg.bf2.gamecount): 309 | # Try to find a matching game 310 | gamename = "g%d" % i 311 | gamecfg = getattr(cfg, gamename) 312 | 313 | if gamecfg.mumble_server == server.id(): 314 | not_matched = (gamecfg.ipport_filter.match(context["ipport"]) is None) 315 | if not_matched == gamecfg.ipport_filter_negate: 316 | break 317 | gamename = None 318 | 319 | if not gamename: 320 | raise ValueError("No matching game found") 321 | 322 | context["gamecfg"] = gamecfg 323 | context["gamename"] = gamename 324 | state.parsedcontext = context 325 | 326 | except (ValueError, KeyError, AttributeError) as e: 327 | log.debug("Invalid context for %s (%d|%d) on server %d: %s", state.name, state.session, state.userid, 328 | sid, repr(e)) 329 | 330 | try: 331 | identity = json.loads(state.identity) 332 | verify(identity, "commander", bool) 333 | verify(identity, "squad_leader", bool) 334 | verify(identity, "squad", int) 335 | if identity["squad"] < 0 or identity["squad"] > 9: 336 | raise ValueError("Invalid squad number") 337 | verify(identity, "team", str) 338 | if identity["team"] != "opfor" and identity["team"] != "blufor": 339 | raise ValueError("Invalid team identified") 340 | # LEGACY: Ice 3.2 cannot handle unicode strings 341 | identity["team"] = str(identity["team"]) 342 | 343 | state.parsedidentity = identity 344 | 345 | except (KeyError, ValueError) as e: 346 | log.debug("Invalid identity for %s (%d|%d) on server %d: %s", state.name, state.session, state.userid, 347 | sid, repr(e)) 348 | 349 | # Update state and remember it 350 | self.update_state(server, self.sessions[sid][state.session], state) 351 | self.sessions[sid][state.session] = state 352 | 353 | # 354 | # --- Server callback functions 355 | # 356 | 357 | def userDisconnected(self, server, state, context=None): 358 | try: 359 | sid = server.id() 360 | del self.sessions[sid][state.session] 361 | except KeyError: 362 | pass 363 | 364 | def userStateChanged(self, server, state, context=None): 365 | self.handle(server, state) 366 | 367 | def userConnected(self, server, state, context=None): 368 | self.handle(server, state) 369 | 370 | def userTextMessage(self, server, user, message, current=None): 371 | pass 372 | 373 | def channelCreated(self, server, state, context=None): 374 | pass 375 | 376 | def channelRemoved(self, server, state, context=None): 377 | pass 378 | 379 | def channelStateChanged(self, server, state, context=None): 380 | pass 381 | 382 | # 383 | # --- Meta callback functions 384 | # 385 | 386 | def started(self, server, context=None): 387 | self.sessions[server.id()] = {} 388 | 389 | def stopped(self, server, context=None): 390 | self.sessions[server.id()] = {} 391 | -------------------------------------------------------------------------------- /modules/idlemove.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010-2011 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # idlemove.py 34 | # 35 | # Module for moving/muting/deafening idle players after 36 | # a certain amount of time and unmuting/undeafening them 37 | # once they become active again 38 | # 39 | 40 | import re 41 | from threading import Timer 42 | 43 | from config import commaSeperatedIntegers, commaSeperatedBool, commaSeperatedStrings 44 | from mumo_module import MumoModule 45 | 46 | 47 | class idlemove(MumoModule): 48 | default_config = {'idlemove': ( 49 | ('interval', float, 0.1), 50 | ('servers', commaSeperatedIntegers, []), 51 | ), 52 | lambda x: re.match('(all)|(server_\d+)', x): ( 53 | ['threshold', commaSeperatedIntegers, [3600]], 54 | ('mute', commaSeperatedBool, [True]), 55 | ('deafen', commaSeperatedBool, [False]), 56 | ('channel', commaSeperatedIntegers, [1]), 57 | ('source_channel', commaSeperatedIntegers, [-1]), 58 | ('whitelist', commaSeperatedStrings, []), 59 | ('channel_whitelist', commaSeperatedIntegers, []) 60 | ), 61 | } 62 | 63 | def __init__(self, name, manager, configuration=None): 64 | MumoModule.__init__(self, name, manager, configuration) 65 | self.murmur = manager.getMurmurModule() 66 | self.watchdog = None 67 | 68 | def connected(self): 69 | self.affectedusers = {} # {serverid:set(sessionids,...)} 70 | 71 | manager = self.manager() 72 | log = self.log() 73 | log.debug("Register for Meta & Server callbacks") 74 | 75 | cfg = self.cfg() 76 | servers = cfg.idlemove.servers 77 | if not servers: 78 | servers = manager.SERVERS_ALL 79 | 80 | manager.subscribeServerCallbacks(self, servers) 81 | manager.subscribeMetaCallbacks(self, servers) 82 | 83 | if not self.watchdog: 84 | self.watchdog = Timer(cfg.idlemove.interval, self.handleIdleMove) 85 | self.watchdog.start() 86 | 87 | def disconnected(self): 88 | self.affectedusers = {} 89 | if self.watchdog: 90 | self.watchdog.stop() 91 | self.watchdog = None 92 | 93 | def handleIdleMove(self): 94 | cfg = self.cfg() 95 | try: 96 | meta = self.manager().getMeta() 97 | 98 | if not cfg.idlemove.servers: 99 | servers = meta.getBootedServers() 100 | else: 101 | servers = [meta.getServer(server) for server in cfg.idlemove.servers] 102 | 103 | for server in servers: 104 | if server: 105 | for user in server.getUsers().values(): 106 | self.UpdateUserAutoAway(server, user) 107 | finally: 108 | # Renew the timer 109 | self.watchdog = Timer(cfg.idlemove.interval, self.handleIdleMove) 110 | self.watchdog.start() 111 | 112 | def UpdateUserAutoAway(self, server, user): 113 | log = self.log() 114 | sid = server.id() 115 | 116 | try: 117 | scfg = getattr(self.cfg(), 'server_%d' % sid) 118 | except AttributeError: 119 | scfg = self.cfg().all 120 | 121 | try: 122 | index = self.affectedusers[sid] 123 | except KeyError: 124 | self.affectedusers[sid] = {} 125 | index = self.affectedusers[sid] 126 | 127 | # Ignore whitelisted users 128 | if user.name in scfg.whitelist: 129 | return 130 | 131 | over_threshold = False 132 | 133 | # Search all our stages top down for a violated treshold and pick the first 134 | for i in range(len(scfg.threshold) - 1, -1, -1): 135 | try: 136 | source_channel = scfg.source_channel[i] 137 | except IndexError: 138 | source_channel = -1 139 | 140 | try: 141 | afkThreshold = scfg.threshold[i] 142 | afkMute = scfg.mute[i] 143 | afkDeafen = scfg.deafen[i] 144 | afkChannel = scfg.channel[i] 145 | except IndexError: 146 | log.warning("Incomplete configuration for stage %d of server %i, ignored", i, server.id()) 147 | continue 148 | 149 | if user.idlesecs > afkThreshold and user.channel not in scfg.channel_whitelist and ( 150 | source_channel == -1 or user.channel == source_channel or user.channel == afkChannel): 151 | 152 | over_threshold = True 153 | # Update if state changes needed 154 | if user.deaf != afkDeafen or user.mute != afkMute or 0 <= afkChannel != user.channel: 155 | index[user.session] = user.channel 156 | log.info( 157 | '%ds > %ds: State transition for user %s (%d/%d) from mute %s -> %s / deaf %s -> %s | channel %d -> %d on server %d', 158 | user.idlesecs, afkThreshold, user.name, user.session, user.userid, 159 | user.mute, afkMute, 160 | user.deaf, afkDeafen, 161 | user.channel, afkChannel, 162 | server.id()) 163 | user.deaf = afkDeafen 164 | user.mute = afkMute 165 | user.channel = afkChannel 166 | server.setState(user) 167 | break 168 | 169 | if not over_threshold and user.session in self.affectedusers[sid]: 170 | isInAfkChannel = False 171 | for i in range(len(scfg.threshold) - 1, -1, -1): 172 | afkChannel = scfg.channel[i] 173 | if user.channel == afkChannel: 174 | isInAfkChannel = True 175 | break 176 | prevChannel = index.pop(user.session, None) 177 | log.info("Restore user %s (%d/%d) on server %d, channel %d -> %d", user.name, user.session, user.userid, server.id(), user.channel, prevChannel) 178 | user.deaf = False 179 | user.mute = False 180 | if prevChannel != None and isInAfkChannel: 181 | user.channel = prevChannel 182 | server.setState(user) 183 | 184 | # 185 | # --- Server callback functions 186 | # 187 | def userDisconnected(self, server, state, context=None): 188 | try: 189 | index = self.affectedusers[server.id()] 190 | if state.session in index: 191 | del index[state.session] 192 | except KeyError: 193 | pass 194 | 195 | def userStateChanged(self, server, state, context=None): 196 | self.UpdateUserAutoAway(server, state) 197 | 198 | def userConnected(self, server, state, context=None): 199 | pass # Unused callbacks 200 | 201 | def userTextMessage(self, server, user, message, current=None): 202 | pass 203 | 204 | def channelCreated(self, server, state, context=None): 205 | pass 206 | 207 | def channelRemoved(self, server, state, context=None): 208 | pass 209 | 210 | def channelStateChanged(self, server, state, context=None): 211 | pass 212 | 213 | # 214 | # --- Meta callback functions 215 | # 216 | 217 | def started(self, server, context=None): 218 | sid = server.id() 219 | self.affectedusers[sid] = {} 220 | self.log().debug('Handling server %d', sid) 221 | 222 | def stopped(self, server, context=None): 223 | sid = server.id() 224 | self.affectedusers[sid] = {} 225 | self.log().debug('Server %d gone', sid) 226 | -------------------------------------------------------------------------------- /modules/onjoin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010-2011 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # onjoin.py 34 | # This module allows moving players into a specific channel once 35 | # they connect regardless of which channel they were in when they left. 36 | # 37 | 38 | import re 39 | 40 | from config import commaSeperatedIntegers 41 | from mumo_module import MumoModule 42 | 43 | 44 | class onjoin(MumoModule): 45 | default_config = {'onjoin': ( 46 | ('servers', commaSeperatedIntegers, []), 47 | ), 48 | 'all': ( 49 | ('channel', int, 1), 50 | ), 51 | lambda x: re.match('server_\d+', x): ( 52 | ('channel', int, 1), 53 | ) 54 | } 55 | 56 | def __init__(self, name, manager, configuration=None): 57 | MumoModule.__init__(self, name, manager, configuration) 58 | self.murmur = manager.getMurmurModule() 59 | 60 | def connected(self): 61 | manager = self.manager() 62 | log = self.log() 63 | log.debug("Register for Server callbacks") 64 | 65 | servers = self.cfg().onjoin.servers 66 | if not servers: 67 | servers = manager.SERVERS_ALL 68 | 69 | manager.subscribeServerCallbacks(self, servers) 70 | 71 | def disconnected(self): 72 | pass 73 | 74 | # 75 | # --- Server callback functions 76 | # 77 | 78 | def userConnected(self, server, state, context=None): 79 | log = self.log() 80 | sid = server.id() 81 | try: 82 | scfg = getattr(self.cfg(), 'server_%d' % sid) 83 | except AttributeError: 84 | scfg = self.cfg().all 85 | 86 | if state.channel != scfg.channel: 87 | log.debug("Moving user '%s' from channel %d to %d on server %d", state.name, state.channel, scfg.channel, 88 | sid) 89 | state.channel = scfg.channel 90 | 91 | try: 92 | server.setState(state) 93 | except self.murmur.InvalidChannelException: 94 | log.error("Moving user '%s' failed, target channel %d does not exist on server %d", state.name, 95 | scfg.channel, sid) 96 | 97 | def userDisconnected(self, server, state, context=None): 98 | pass 99 | 100 | def userStateChanged(self, server, state, context=None): 101 | pass 102 | 103 | def userTextMessage(self, server, user, message, current=None): 104 | pass 105 | 106 | def channelCreated(self, server, state, context=None): 107 | pass 108 | 109 | def channelRemoved(self, server, state, context=None): 110 | pass 111 | 112 | def channelStateChanged(self, server, state, context=None): 113 | pass 114 | -------------------------------------------------------------------------------- /modules/samplecontext.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2015 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # samplecontext.py 34 | # This module demonstrates how to add additional 35 | # entries to a user's context menu. 36 | # 37 | 38 | import cgi 39 | 40 | from config import commaSeperatedIntegers 41 | from mumo_module import MumoModule 42 | 43 | 44 | class samplecontext(MumoModule): 45 | default_config = {'samplecontext': ( 46 | ('servers', commaSeperatedIntegers, []), 47 | ), 48 | } 49 | 50 | def __init__(self, name, manager, configuration=None): 51 | MumoModule.__init__(self, name, manager, configuration) 52 | self.murmur = manager.getMurmurModule() 53 | self.action_poke_user = manager.getUniqueAction() 54 | self.action_info = manager.getUniqueAction() 55 | self.action_remove = manager.getUniqueAction() 56 | 57 | def connected(self): 58 | manager = self.manager() 59 | log = self.log() 60 | log.debug("Register for Server callbacks") 61 | 62 | servers = self.cfg().samplecontext.servers 63 | if not servers: 64 | servers = manager.SERVERS_ALL 65 | 66 | manager.subscribeServerCallbacks(self, servers) 67 | 68 | def disconnected(self): pass 69 | 70 | # 71 | # --- Server callback functions 72 | # 73 | 74 | def __on_poke_user(self, server, action, user, target): 75 | assert action == self.action_poke_user 76 | self.log().info(user.name + " poked " + target.name) 77 | server.sendMessage(target.session, cgi.escape(user.name) + " poked you") 78 | 79 | def __on_info(self, server, action, user, target): 80 | assert action == self.action_info 81 | self.log().info(user.name + " wants info on " + str(target)); 82 | server.sendMessage(user.session, 83 | "
" + cgi.escape(str(target)) + "
") 84 | 85 | def __on_remove_this(self, server, action, user, target): 86 | # This will remove the entry identified by "action" from 87 | # _all_ users on the server. 88 | self.log().info(user.name + " triggered removal") 89 | self.manager().removeContextMenuEntry(server, action) 90 | 91 | def userConnected(self, server, user, context=None): 92 | # Adding the entries here means if mumo starts up after users 93 | # already connected they won't have the new entries before they 94 | # reconnect. You can also use the "connected" callback to 95 | # add the entries to already connected user. For simplicity 96 | # this is not done here. 97 | 98 | self.log().info("Adding menu entries for " + user.name) 99 | 100 | manager = self.manager() 101 | manager.addContextMenuEntry( 102 | server, # Server of user 103 | user, # User which should receive the new entry 104 | self.action_poke_user, # Identifier for the action 105 | "Poke", # Text in the client 106 | self.__on_poke_user, # Callback called when user uses the entry 107 | self.murmur.ContextUser # We only want to show this entry on users 108 | ) 109 | 110 | manager.addContextMenuEntry( 111 | server, 112 | user, 113 | self.action_info, 114 | "Info", 115 | self.__on_info, 116 | self.murmur.ContextUser | self.murmur.ContextChannel # Show for users and channels 117 | ) 118 | 119 | manager.addContextMenuEntry( 120 | server, 121 | user, 122 | self.action_remove, 123 | "Remove this entry from everyone", 124 | self.__on_remove_this, 125 | self.murmur.ContextUser | self.murmur.ContextChannel | self.murmur.ContextServer 126 | ) 127 | 128 | def userDisconnected(self, server, state, context=None): pass 129 | 130 | def userStateChanged(self, server, state, context=None): pass 131 | 132 | def userTextMessage(self, server, user, message, current=None): pass 133 | 134 | def channelCreated(self, server, state, context=None): pass 135 | 136 | def channelRemoved(self, server, state, context=None): pass 137 | 138 | def channelStateChanged(self, server, state, context=None): pass 139 | -------------------------------------------------------------------------------- /modules/seen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2011 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # seen.py 34 | # This module allows asking the server for the last time it saw a specific player 35 | # 36 | 37 | from datetime import timedelta 38 | 39 | from config import commaSeperatedIntegers 40 | from mumo_module import MumoModule 41 | 42 | 43 | class seen(MumoModule): 44 | default_config = {'seen': ( 45 | ('servers', commaSeperatedIntegers, []), 46 | ('keyword', str, '!seen') 47 | ) 48 | } 49 | 50 | def __init__(self, name, manager, configuration=None): 51 | MumoModule.__init__(self, name, manager, configuration) 52 | self.murmur = manager.getMurmurModule() 53 | self.keyword = self.cfg().seen.keyword 54 | 55 | def connected(self): 56 | manager = self.manager() 57 | log = self.log() 58 | log.debug("Register for Server callbacks") 59 | 60 | servers = self.cfg().seen.servers 61 | if not servers: 62 | servers = manager.SERVERS_ALL 63 | 64 | manager.subscribeServerCallbacks(self, servers) 65 | 66 | def disconnected(self): 67 | pass 68 | 69 | def sendMessage(self, server, user, message, msg): 70 | if message.channels: 71 | server.sendMessageChannel(user.channel, False, msg) 72 | else: 73 | server.sendMessage(user.session, msg) 74 | server.sendMessage(message.sessions[0], msg) 75 | 76 | # 77 | # --- Server callback functions 78 | # 79 | 80 | def userTextMessage(self, server, user, message, current=None): 81 | if message.text.startswith(self.keyword) and \ 82 | (len(message.sessions) == 1 or 83 | (len(message.channels) == 1 and \ 84 | message.channels[0] == user.channel)): 85 | 86 | tuname = message.text[len(self.keyword):].strip() 87 | self.log().debug("User %s (%d|%d) on server %d asking for '%s'", 88 | user.name, user.session, user.userid, server.id(), tuname) 89 | 90 | # Check for self referencing 91 | if tuname == user.name: 92 | msg = "User '%s' knows how to spell his name" % tuname 93 | self.sendMessage(server, user, message, msg) 94 | return 95 | 96 | # Check online users 97 | for cuser in server.getUsers().values(): 98 | if tuname == cuser.name: 99 | msg = "User '%s' is currently online, has been idle for %s" % (tuname, 100 | timedelta(seconds=cuser.idlesecs)) 101 | self.sendMessage(server, user, message, msg) 102 | return 103 | 104 | # Check registrations 105 | for cuid, cuname in server.getRegisteredUsers(tuname).items(): 106 | if cuname == tuname: 107 | ureg = server.getRegistration(cuid) 108 | if ureg: 109 | msg = "User '%s' was last seen %s UTC" % (tuname, 110 | ureg[self.murmur.UserInfo.UserLastActive]) 111 | 112 | self.sendMessage(server, user, message, msg) 113 | return 114 | 115 | msg = "I don't know who user '%s' is" % tuname 116 | self.sendMessage(server, user, message, msg) 117 | 118 | def userConnected(self, server, state, context=None): 119 | pass 120 | 121 | def userDisconnected(self, server, state, context=None): 122 | pass 123 | 124 | def userStateChanged(self, server, state, context=None): 125 | pass 126 | 127 | def channelCreated(self, server, state, context=None): 128 | pass 129 | 130 | def channelRemoved(self, server, state, context=None): 131 | pass 132 | 133 | def channelStateChanged(self, server, state, context=None): 134 | pass 135 | -------------------------------------------------------------------------------- /modules/source/README.md: -------------------------------------------------------------------------------- 1 | # Source Engine Module 2 | 3 | The Source game management plugin for Mumo can dynamically move players into on-the-fly created channel structures representing in-game team setup. 4 | This is achieved by using data gathered from Mumble's Positional-Audio and does not require cooperation by the game server. 5 | 6 | The following source engine based games are supported: 7 | 8 | * Team Fortress 2 9 | * Day of Defeat: Source 10 | * Counter-Strike: Source 11 | * Half-Life 2: Deathmatch 12 | 13 | ## Enabling the `source` module 14 | 15 | 1. Link or copy the ''source.ini'' file from the `modules-available` folder into the `modules-enabled` folder\ 16 | `cd modules-enabled`\ 17 | `ln -s ../modules-available/source.ini` 18 | 2. Check whether the defaults in `source.ini` are ok for your setup. They should be sane for basic setups. 19 | 3. Restart mumo 20 | -------------------------------------------------------------------------------- /modules/source/__init__.py: -------------------------------------------------------------------------------- 1 | from .source import source 2 | -------------------------------------------------------------------------------- /modules/source/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import sqlite3 33 | 34 | 35 | # TODO: Functions returning channels probably should return a dict instead of a tuple 36 | 37 | class SourceDB(object): 38 | NO_SERVER = "" 39 | NO_TEAM = -1 40 | 41 | def __init__(self, path=":memory:"): 42 | """ 43 | Initialize the sqlite database in the given path. If no path 44 | is given the database is created in memory. 45 | """ 46 | self.db = sqlite3.connect(path) 47 | if self.db: 48 | self.db.execute(""" 49 | CREATE TABLE IF NOT EXISTS controlled_channels( 50 | sid INTEGER NOT NULL, 51 | cid INTEGER NOT NULL, 52 | game TEXT NOT NULL, 53 | server TEXT NOT NULL default "", 54 | team INTEGER NOT NULL default -1, 55 | UNIQUE(sid, cid), 56 | PRIMARY KEY (sid, game, server, team) 57 | )""") 58 | 59 | self.db.execute(""" 60 | CREATE TABLE IF NOT EXISTS mapped_names ( 61 | sid INTEGER NOT NULL, 62 | game TEXT NOT NULL, 63 | server TEXT NOT NULL default "", 64 | team INTEGER default -1, 65 | name TEXT NOT NULL, 66 | PRIMARY KEY (sid, game, server, team) 67 | )""") 68 | self.db.execute("VACUUM") 69 | self.db.commit() 70 | 71 | def close(self): 72 | """ 73 | Closes the database connection 74 | """ 75 | if self.db: 76 | self.db.commit() 77 | self.db.close() 78 | self.db = None 79 | 80 | def isOk(self): 81 | """ 82 | True if the database is correctly initialized 83 | """ 84 | return self.db is not None 85 | 86 | def nameFor(self, sid, game, server=NO_SERVER, team=NO_TEAM, default=""): 87 | """ 88 | Returns the mapped name for the given parameters or default if no 89 | mapping exists. 90 | """ 91 | assert (sid is not None and game is not None) 92 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 93 | 94 | v = self.db.execute("SELECT name FROM mapped_names WHERE sid is ? and game is ? and server is ? and team is ?", 95 | [sid, game, server, team]).fetchone() 96 | return v[0] if v else default 97 | 98 | def mapName(self, name, sid, game, server=NO_SERVER, team=NO_TEAM): 99 | """ 100 | Stores a mapping for the given (sid, game, server, team) combination 101 | to the given name. The mapping can then be retrieved with nameFor() in 102 | the future. 103 | """ 104 | assert (sid is not None and game is not None) 105 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 106 | 107 | self.db.execute("INSERT OR REPLACE into mapped_names (sid, game, server, team, name) VALUES (?,?,?,?,?)", 108 | [sid, game, server, team, name]) 109 | self.db.commit() 110 | 111 | def cidFor(self, sid, game, server=NO_SERVER, team=NO_TEAM): 112 | """ 113 | Returns the channel id for game specific channel. If only game 114 | is passed the game root channel cid is returned. If additionally 115 | server (and team) are passed the server (/team) channel cid is returned. 116 | 117 | If no channel matching the arguments has been registered with the database 118 | before None is returned. 119 | """ 120 | assert (sid is not None and game is not None) 121 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 122 | 123 | v = self.db.execute( 124 | "SELECT cid FROM controlled_channels WHERE sid is ? and game is ? and server is ? and team is ?", 125 | [sid, game, server, team]).fetchone() 126 | return v[0] if v else None 127 | 128 | def channelForCid(self, sid, cid): 129 | """ 130 | Returns a tuple of (sid, cid, game, server, team) for the given cid. 131 | Returns None if the cid is unknown. 132 | """ 133 | assert (sid is not None and cid is not None) 134 | return self.db.execute( 135 | "SELECT sid, cid, game, server, team FROM controlled_channels WHERE sid is ? and cid is ?", 136 | [sid, cid]).fetchone() 137 | 138 | def channelFor(self, sid, game, server=NO_SERVER, team=NO_TEAM): 139 | """ 140 | Returns matching channel as (sid, cid, game, server, team) tuple. Matching 141 | behavior is the same as for cidFor() 142 | """ 143 | assert (sid is not None and game is not None) 144 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 145 | 146 | v = self.db.execute( 147 | "SELECT sid, cid, game, server, team FROM controlled_channels WHERE sid is ? and game is ? and server is ? and team is ?", 148 | [sid, game, server, team]).fetchone() 149 | return v 150 | 151 | def channelsFor(self, sid, game, server=NO_SERVER, team=NO_TEAM): 152 | """ 153 | Returns matching channels as a list of (sid, cid, game, server, team) tuples. 154 | If only the game is passed all server and team channels are matched. 155 | This can be limited by passing server (and team). 156 | Returns empty list if no matches are found. 157 | """ 158 | assert (sid is not None and game is not None) 159 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 160 | 161 | suffix, params = self.__whereClauseForOptionals(server, team) 162 | return self.db.execute( 163 | "SELECT sid, cid, game, server, team FROM controlled_channels WHERE sid is ? and game is ?" + suffix, 164 | [sid, game] + params).fetchall() 165 | 166 | def registerChannel(self, sid, cid, game, server=NO_SERVER, team=NO_TEAM): 167 | """ 168 | Register a given channel with the database. 169 | """ 170 | assert (sid is not None and game is not None) 171 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 172 | 173 | self.db.execute("INSERT INTO controlled_channels (sid, cid, game, server, team) VALUES (?,?,?,?,?)", 174 | [sid, cid, game, server, team]) 175 | self.db.commit() 176 | return True 177 | 178 | def __whereClauseForOptionals(self, server, team): 179 | """ 180 | Generates where class conditions that interpret missing server 181 | or team as "don't care". 182 | 183 | Returns (suffix, additional parameters) tuple 184 | """ 185 | 186 | if server != self.NO_SERVER and team != self.NO_TEAM: 187 | return " and server is ? and team is ?", [server, team] 188 | elif server != self.NO_SERVER: 189 | return " and server is ?", [server] 190 | else: 191 | return "", [] 192 | 193 | def unregisterChannel(self, sid, game, server=NO_SERVER, team=NO_TEAM): 194 | """ 195 | Unregister a channel previously registered with the database. 196 | """ 197 | assert (sid is not None and game is not None) 198 | assert (not (team != self.NO_TEAM and server == self.NO_SERVER)) 199 | 200 | suffix, params = self.__whereClauseForOptionals(server, team) 201 | self.db.execute("DELETE FROM controlled_channels WHERE sid is ? and game is ?" + suffix, [sid, game] + params) 202 | self.db.commit() 203 | 204 | def dropChannel(self, sid, cid): 205 | """ 206 | Drops channel with given sid + cid 207 | """ 208 | assert (sid is not None and cid is not None) 209 | 210 | self.db.execute("DELETE FROM controlled_channels WHERE sid is ? and cid is ?", [sid, cid]) 211 | self.db.commit() 212 | 213 | def isRegisteredChannel(self, sid, cid): 214 | """ 215 | Returns true if a channel with given sid and cid is registered 216 | """ 217 | assert (sid is not None and cid is not None) 218 | 219 | res = self.db.execute("SELECT cid FROM controlled_channels WHERE sid is ? and cid is ?", [sid, cid]).fetchone() 220 | return res is not None 221 | 222 | def registeredChannels(self): 223 | """ 224 | Returns channels as a list of (sid, cid, game, server team) tuples grouped by sid 225 | """ 226 | return self.db.execute("SELECT sid, cid, game, server, team FROM controlled_channels ORDER by sid").fetchall() 227 | 228 | def reset(self): 229 | """ 230 | Deletes everything in the database 231 | """ 232 | self.db.execute("DELETE FROM mapped_names") 233 | self.db.execute("DELETE FROM controlled_channels") 234 | self.db.commit() 235 | 236 | 237 | if __name__ == "__main__": 238 | pass 239 | -------------------------------------------------------------------------------- /modules/source/db_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import sqlite3 33 | import unittest 34 | 35 | from .db import SourceDB 36 | 37 | 38 | class SourceDBTest(unittest.TestCase): 39 | def setUp(self): 40 | self.db = SourceDB() 41 | 42 | def tearDown(self): 43 | self.db.close() 44 | 45 | def testOk(self): 46 | self.db.reset() 47 | 48 | self.assertTrue(self.db.isOk()) 49 | 50 | def testSingleChannel(self): 51 | self.db.reset() 52 | 53 | sid = 5 54 | cid = 10 55 | game = "tf2" 56 | server = "abc[]def" 57 | team = "1" 58 | self.assertTrue(self.db.registerChannel(sid, cid, game, server, team)) 59 | self.assertEqual(self.db.cidFor(sid, game, server, team), cid) 60 | self.db.unregisterChannel(sid, game, server, team) 61 | self.assertEqual(self.db.cidFor(sid, game, server, team), None) 62 | 63 | def testChannelTree(self): 64 | self.db.reset() 65 | 66 | sid = 5 67 | game = "tf2" 68 | server = "abc[]def" 69 | team = 0 70 | bcid = 10 71 | scid = 11 72 | tcid = 12 73 | 74 | self.assertTrue(self.db.registerChannel(sid, 1, "canary", server, team)) 75 | 76 | # Delete whole tree 77 | 78 | self.assertTrue(self.db.registerChannel(sid, bcid, game)) 79 | self.assertTrue(self.db.registerChannel(sid, scid, game, server)) 80 | self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team)) 81 | 82 | self.assertEqual(self.db.cidFor(sid, game), bcid) 83 | self.assertEqual(self.db.cidFor(sid, game, server), scid) 84 | self.assertEqual(self.db.cidFor(sid, game, server, team), tcid) 85 | self.assertEqual(self.db.cidFor(sid + 1, game, server, team), None) 86 | 87 | self.db.unregisterChannel(sid, game) 88 | 89 | self.assertEqual(self.db.cidFor(sid, game, server, team), None) 90 | self.assertEqual(self.db.cidFor(sid, game, server), None) 91 | self.assertEqual(self.db.cidFor(sid, game), None) 92 | 93 | # Delete server channel 94 | 95 | self.assertTrue(self.db.registerChannel(sid, bcid, game)) 96 | self.assertTrue(self.db.registerChannel(sid, scid, game, server)) 97 | self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team)) 98 | 99 | self.db.unregisterChannel(sid, game, server) 100 | 101 | self.assertEqual(self.db.cidFor(sid, game), bcid) 102 | self.assertEqual(self.db.cidFor(sid, game, server), None) 103 | self.assertEqual(self.db.cidFor(sid, game, server, team), None) 104 | 105 | self.db.unregisterChannel(sid, game) 106 | 107 | # Delete team channel 108 | 109 | self.assertTrue(self.db.registerChannel(sid, bcid, game)) 110 | self.assertTrue(self.db.registerChannel(sid, scid, game, server)) 111 | self.assertTrue(self.db.registerChannel(sid, tcid, game, server, team)) 112 | 113 | self.db.unregisterChannel(sid, game, server, team) 114 | 115 | self.assertEqual(self.db.cidFor(sid, game), bcid) 116 | self.assertEqual(self.db.cidFor(sid, game, server), scid) 117 | self.assertEqual(self.db.cidFor(sid, game, server, team), None) 118 | 119 | self.db.unregisterChannel(sid, game) 120 | 121 | # Check canary 122 | self.assertEqual(self.db.cidFor(sid, "canary", server, team), 1) 123 | self.db.unregisterChannel(sid, "canary", server, team) 124 | 125 | def testDropChannel(self): 126 | self.db.reset() 127 | 128 | sid = 1 129 | cid = 5 130 | game = "tf" 131 | self.db.registerChannel(sid, cid, game) 132 | self.db.dropChannel(sid + 1, cid) 133 | self.assertEqual(self.db.cidFor(sid, game), cid) 134 | self.db.dropChannel(sid, cid) 135 | self.assertEqual(self.db.cidFor(sid, game), None) 136 | 137 | def testRegisteredChannels(self): 138 | self.db.reset() 139 | 140 | sid = 5 141 | game = "tf2" 142 | server = "abc[]def" 143 | team = 1 144 | bcid = 10 145 | scid = 11 146 | tcid = 12 147 | 148 | self.db.registerChannel(sid, bcid, game) 149 | self.db.registerChannel(sid, scid, game, server) 150 | self.db.registerChannel(sid + 1, tcid, game, server, team) 151 | self.db.registerChannel(sid, tcid, game, server, team) 152 | 153 | expected = [(sid, bcid, game, self.db.NO_SERVER, self.db.NO_TEAM), 154 | (sid, scid, game, server, self.db.NO_TEAM), 155 | (sid, tcid, game, server, team), 156 | (sid + 1, tcid, game, server, team)] 157 | 158 | self.assertEqual(self.db.registeredChannels(), expected) 159 | 160 | def testIsRegisteredChannel(self): 161 | self.db.reset() 162 | sid = 1 163 | cid = 0 164 | game = "tf" 165 | self.db.registerChannel(sid, cid, game) 166 | 167 | self.assertTrue(self.db.isRegisteredChannel(sid, cid)) 168 | self.assertFalse(self.db.isRegisteredChannel(sid + 1, cid)) 169 | self.assertFalse(self.db.isRegisteredChannel(sid, cid + 1)) 170 | 171 | self.db.unregisterChannel(sid, game) 172 | 173 | self.assertFalse(self.db.isRegisteredChannel(sid, cid)) 174 | 175 | def testChannelFor(self): 176 | self.db.reset() 177 | sid = 1 178 | cid = 0 179 | game = "tf" 180 | server = "serv" 181 | team = 0 182 | self.db.registerChannel(sid, cid, game) 183 | self.db.registerChannel(sid, cid + 1, game, server) 184 | self.db.registerChannel(sid, cid + 2, game, server, team) 185 | 186 | res = self.db.channelFor(sid, game, server, team) 187 | self.assertEqual(res, (sid, cid + 2, game, server, team)) 188 | 189 | res = self.db.channelFor(sid, game, server) 190 | self.assertEqual(res, (sid, cid + 1, game, server, self.db.NO_TEAM)) 191 | 192 | res = self.db.channelFor(sid, game) 193 | self.assertEqual(res, (sid, cid, game, self.db.NO_SERVER, self.db.NO_TEAM)) 194 | 195 | res = self.db.channelFor(sid, game, server, team + 5) 196 | self.assertEqual(res, None) 197 | 198 | def testChannelForCid(self): 199 | self.db.reset() 200 | sid = 1 201 | cid = 0 202 | game = "tf" 203 | server = "serv" 204 | team = 0 205 | self.db.registerChannel(sid, cid, game) 206 | self.db.registerChannel(sid, cid + 1, game, server) 207 | self.db.registerChannel(sid, cid + 2, game, server, team) 208 | 209 | res = self.db.channelForCid(sid, cid) 210 | self.assertEqual(res, (sid, cid, game, self.db.NO_SERVER, self.db.NO_TEAM)) 211 | 212 | res = self.db.channelForCid(sid, cid + 1) 213 | self.assertEqual(res, (sid, cid + 1, game, server, self.db.NO_TEAM)) 214 | 215 | res = self.db.channelForCid(sid, cid + 2) 216 | self.assertEqual(res, (sid, cid + 2, game, server, team)) 217 | 218 | res = self.db.channelForCid(sid, cid + 3) 219 | self.assertEqual(res, None) 220 | 221 | def testChannelsFor(self): 222 | self.db.reset() 223 | sid = 1 224 | cid = 0 225 | game = "tf" 226 | server = "serv" 227 | team = 0 228 | self.db.registerChannel(sid, cid, game) 229 | self.db.registerChannel(sid, cid + 1, game, server) 230 | self.db.registerChannel(sid, cid + 2, game, server, team) 231 | 232 | chans = ((sid, cid + 2, game, server, team), 233 | (sid, cid + 1, game, server, self.db.NO_TEAM), 234 | (sid, cid, game, self.db.NO_SERVER, self.db.NO_TEAM)) 235 | 236 | res = self.db.channelsFor(sid, game, server, team) 237 | self.assertCountEqual(res, chans[0:1]) 238 | 239 | res = self.db.channelsFor(sid, game, server) 240 | self.assertCountEqual(res, chans[0:2]) 241 | 242 | res = self.db.channelsFor(sid, game) 243 | self.assertCountEqual(res, chans) 244 | 245 | res = self.db.channelsFor(sid + 1, game) 246 | self.assertCountEqual(res, []) 247 | 248 | def testChannelTableConstraints(self): 249 | self.db.reset() 250 | 251 | # cid constraint 252 | sid = 1 253 | cid = 0 254 | game = "tf" 255 | server = "serv" 256 | team = 0 257 | self.db.registerChannel(sid, cid, game) 258 | self.assertRaises(sqlite3.IntegrityError, self.db.registerChannel, sid, cid, "cstrike") 259 | 260 | # combination constraint 261 | self.assertRaises(sqlite3.IntegrityError, self.db.registerChannel, sid, cid + 1000, game) 262 | 263 | self.db.registerChannel(sid, cid + 1, game, server) 264 | self.assertRaises(sqlite3.IntegrityError, self.db.registerChannel, sid, cid + 100, game, server) 265 | 266 | self.db.registerChannel(sid, cid + 2, game, server, team) 267 | self.assertRaises(sqlite3.IntegrityError, self.db.registerChannel, sid, cid + 200, game, server, team) 268 | 269 | def testChannelNameMappingTableConstraints(self): 270 | self.db.reset() 271 | 272 | sid = 1 273 | game = "tf" 274 | 275 | # mapName performs an INSERT OR REPLACE which relies on the UNIQUE constraint 276 | self.db.mapName("SomeTestName", sid, game) 277 | self.db.mapName("SomeOtherName", sid, game) 278 | self.assertEqual(self.db.nameFor(sid, game), "SomeOtherName") 279 | 280 | def testNameMapping(self): 281 | self.db.reset() 282 | 283 | sid = 1 284 | game = "tf" 285 | server = "[12313]" 286 | team = 2 287 | self.assertEqual(self.db.nameFor(sid, game, default="test"), "test") 288 | 289 | self.db.mapName("Game", sid, game) 290 | self.db.mapName("Game Server", sid, game, server) 291 | self.db.mapName("Game Server Team", sid, game, server, team) 292 | self.db.mapName("Game Server Team 2", sid + 1, game, server, team) 293 | self.db.mapName("Game Server Team 2", sid, "cstrike", server, team) 294 | 295 | self.assertEqual(self.db.nameFor(sid, game), "Game") 296 | self.assertEqual(self.db.nameFor(sid, game, server), "Game Server") 297 | self.assertEqual(self.db.nameFor(sid, game, server, team), "Game Server Team") 298 | 299 | 300 | if __name__ == "__main__": 301 | # import sys;sys.argv = ['', 'Test.testName'] 302 | unittest.main() 303 | -------------------------------------------------------------------------------- /modules/source/source_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import queue 33 | import re 34 | import unittest 35 | 36 | import config 37 | from . import source 38 | from .users import User 39 | 40 | 41 | class InvalidChannelExceptionMock(Exception): 42 | pass 43 | 44 | 45 | class StateMock(): 46 | def __init__(self, cid=0, session=0, userid=-1): 47 | self.channel = cid 48 | self.session = session 49 | self.userid = userid 50 | 51 | 52 | class ChannelStateMock(): 53 | def __init__(self, cid, name, parent, groups, acls): 54 | self.id = cid 55 | self.name = name 56 | self.parent = parent 57 | self.groups = groups 58 | self.acls = acls 59 | 60 | 61 | class ServerMock(): 62 | def __init__(self, sid): 63 | self.sid = sid 64 | self._reset() 65 | 66 | def id(self): 67 | return self.sid 68 | 69 | def _lastChannelID(self): 70 | return self.uid 71 | 72 | def addChannel(self, name, parent): 73 | self.uid += 1 74 | assert (not self.uid in self.channels) 75 | self.channels[self.uid] = ChannelStateMock(self.uid, 76 | name, 77 | parent, 78 | {}, 79 | []) 80 | return self.uid 81 | 82 | def addUserToGroup(self, cid, session, group): 83 | c = self._getChan(cid) 84 | 85 | if session in c.groups: 86 | c.groups[session].add(group) 87 | else: 88 | c.groups[session] = {group} 89 | 90 | def _getChan(self, cid): 91 | if cid not in self.channels: 92 | raise InvalidChannelExceptionMock() 93 | 94 | return self.channels[cid] 95 | 96 | def getChannelState(self, cid): 97 | return self._getChan(cid) 98 | 99 | def setState(self, state): 100 | self.user_state.append(state) 101 | 102 | def setACL(self, cid, acls, groups, inherit): 103 | c = self._getChan(cid) 104 | c.acls = acls 105 | 106 | def _reset(self): 107 | self.uid = 1000 108 | self.channels = {} # See addChannel 109 | self.user_state = [] 110 | 111 | 112 | class ACLMock(object): 113 | def __init__(self, applyHere, applySubs, userid, group, deny=0, allow=0): 114 | self.applyHere = applyHere 115 | self.applySubs = applySubs 116 | self.userid = userid 117 | self.group = group 118 | self.deny = deny 119 | self.allow = allow 120 | 121 | 122 | class MurmurMock(object): 123 | InvalidChannelException = InvalidChannelExceptionMock 124 | ACL = ACLMock 125 | PermissionEnter = 1 126 | PermissionTraverse = 2 127 | PermissionWhisper = 4 128 | PermissionSpeak = 8 129 | 130 | def _reset(self): pass 131 | 132 | def __init__(self): 133 | pass 134 | 135 | 136 | class MockACLHelper(object): 137 | E = MurmurMock.PermissionEnter 138 | T = MurmurMock.PermissionTraverse 139 | W = MurmurMock.PermissionWhisper 140 | S = MurmurMock.PermissionSpeak 141 | 142 | EAT = E | T 143 | ALL = E | T | W | S 144 | 145 | 146 | ACLS = MockACLHelper 147 | 148 | 149 | class MetaMock(): 150 | def __init__(self): 151 | # TODO: Create range of server (or even cretae them on demand) 152 | self.servers = {1: ServerMock(1), 153 | 5: ServerMock(5), 154 | 10: ServerMock(10)} 155 | self.s = self.servers[1] # Shorthand 156 | 157 | def getServer(self, sid): 158 | return self.servers.get(sid, None) 159 | 160 | def _reset(self): 161 | for server in self.servers.values(): 162 | server._reset() 163 | 164 | 165 | class ManagerMock(): 166 | SERVERS_ALL = [-1] 167 | 168 | def __init__(self): 169 | self.q = queue.Queue() 170 | self.m = MurmurMock() 171 | self.meta = MetaMock() 172 | 173 | def getQueue(self): 174 | return self.q 175 | 176 | def getMurmurModule(self): 177 | return self.m 178 | 179 | def getMeta(self): 180 | return self.meta 181 | 182 | def subscribeServerCallbacks(self, callback, servers): 183 | self.serverCB = {'callback': callback, 'servers': servers} 184 | 185 | def subscribeMetaCallbacks(self, callback, servers): 186 | self.metaCB = {'callback': callback, 'servers': servers} 187 | 188 | 189 | class Test(unittest.TestCase): 190 | 191 | def setUp(self): 192 | self.mm = ManagerMock(); 193 | self.mserv = self.mm.meta.getServer(1) 194 | 195 | testconfig = config.Config(None, source.default_config) 196 | testconfig.source.database = ":memory:" 197 | 198 | # As it is hard to create the read only config structure from 199 | # hand use a spare one to steal from 200 | spare = config.Config(None, source.default_config) 201 | testconfig.__dict__['game:tf'] = spare.generic 202 | testconfig.__dict__['game:tf'].name = "Team Fortress 2" 203 | testconfig.__dict__['game:tf'].teams = ["Lobby", "Spectator", "Blue", "Red"] 204 | testconfig.__dict__['game:tf'].serverregex = re.compile("^\[A-1:123\]$") 205 | testconfig.__dict__['game:tf'].servername = "Test %(game)s %(server)s" 206 | 207 | self.s = source("source", self.mm, testconfig) 208 | self.mm.s = self.s 209 | 210 | # Since we don't want to run threaded if we don't have to 211 | # emulate startup to the derived class function 212 | self.s.onStart() 213 | self.s.connected() 214 | 215 | # Critical test assumption 216 | self.assertEqual(self.mm.metaCB['callback'], self.s) 217 | self.assertEqual(self.mm.serverCB['callback'], self.s) 218 | 219 | def resetDB(self): 220 | self.s.db.reset() 221 | 222 | def resetState(self): 223 | self.resetDB() 224 | self.mm.m._reset() 225 | self.mm.meta._reset() 226 | 227 | def tearDown(self): 228 | self.s.disconnected() 229 | self.s.onStop() 230 | 231 | def testDefaultConfig(self): 232 | self.resetState() 233 | 234 | mm = ManagerMock() 235 | INVALIDFORCEDEFAULT = "" 236 | s = source("source", mm, INVALIDFORCEDEFAULT) 237 | self.assertNotEqual(s.cfg(), None) 238 | 239 | def testConfiguration(self): 240 | self.resetState() 241 | 242 | # Ensure the default configuration makes sense 243 | self.assertEqual(self.mm.serverCB['servers'], self.mm.SERVERS_ALL) 244 | self.assertEqual(self.mm.metaCB['servers'], self.mm.SERVERS_ALL) 245 | 246 | self.assertEqual(self.s.cfg().source.basechannelid, 0) 247 | self.assertEqual(self.s.cfg().generic.name, "%(game)s") 248 | 249 | self.assertEqual(self.s.getGameConfig("wugu", "name"), "%(game)s") 250 | self.assertEqual(self.s.getGameConfig("tf", "name"), "Team Fortress 2") 251 | 252 | def testIdentityParser(self): 253 | self.resetState() 254 | 255 | expected = {"universe": 1, 256 | "account_type": 2, 257 | "id": 3, 258 | "instance": 4, 259 | "team": 5} 260 | 261 | got = self.s.parseSourceIdentity("universe:1;account_type:2;id:00000003;instance:4;team:5") 262 | self.assertDictEqual(expected, got) 263 | 264 | got = self.s.parseSourceIdentity("universe:1;account_type:2;id:00000003;instance:4;") 265 | self.assertEqual(got, None, "Required team variable missing") 266 | 267 | self.assertEqual(self.s.parseSourceIdentity(None), None) 268 | self.assertEqual(self.s.parseSourceIdentity(""), None) 269 | self.assertEqual(self.s.parseSourceIdentity("whatever:4;dskjfskjdfkjsfkjsfkj"), None) 270 | 271 | def testContextParser(self): 272 | self.resetState() 273 | 274 | none = (None, None) 275 | self.assertEqual(self.s.parseSourceContext(None), none) 276 | self.assertEqual(self.s.parseSourceContext(""), none) 277 | self.assertEqual(self.s.parseSourceContext("whatever:4;skjdakjkjwqdkjqkj"), none) 278 | 279 | expected = ("dod", "[A-1:2807761920(3281)]") 280 | actual = self.s.parseSourceContext("Source engine: dod\x00[A-1:2807761920(3281)]\x00") 281 | self.assertEqual(expected, actual) 282 | 283 | expected = ("dod", "[0:1]") 284 | actual = self.s.parseSourceContext("Source engine: dod\x00[0:1]\x00") 285 | self.assertEqual(expected, actual) 286 | 287 | expected = ("cstrike", "[0:1]") 288 | actual = self.s.parseSourceContext("Source engine: cstrike\x00[0:1]\x00") 289 | self.assertEqual(expected, actual) 290 | 291 | actual = self.s.parseSourceContext("Source engine: fake\x00[A-1:2807761920(3281)]\x00") 292 | self.assertEqual(none, actual) 293 | 294 | actual = self.s.parseSourceContext("Source engine: cstrike\x0098vcv98re98ver98ver98v\x00") 295 | self.assertEqual(none, actual) 296 | 297 | # Check alternate serverregex 298 | expected = ("tf", "[A-1:123]") 299 | actual = self.s.parseSourceContext("Source engine: tf\x00[A-1:123]\x00") 300 | self.assertEqual(expected, actual) 301 | 302 | actual = self.s.parseSourceContext("Source engine: tf\x00[A-1:2807761920(3281)]\x00") 303 | self.assertEqual(none, actual) 304 | 305 | def checkACLThings(self, acls, things): 306 | self.assertEqual(len(things), len(acls)) 307 | 308 | i = 0 309 | for thing in things: 310 | acl = acls[i] 311 | for attr, val in thing.items(): 312 | self.assertEqual(getattr(acl, attr), val) 313 | i += 1 314 | 315 | def testGetOrCreateChannelFor(self): 316 | mumble_server = self.mserv 317 | 318 | prev = mumble_server._lastChannelID() 319 | game = "tf" 320 | server = "[A-1:123]" 321 | team = 3 322 | cid = self.s.getOrCreateChannelFor(mumble_server, game, server, team) 323 | 324 | self.assertEqual(3, cid - prev) 325 | 326 | c = mumble_server.channels 327 | 328 | self.assertEqual(c[prev + 1].parent, 0) 329 | self.assertEqual(c[prev + 2].parent, prev + 1) 330 | self.assertEqual(c[prev + 3].parent, prev + 2) 331 | 332 | self.assertEqual(c[prev + 1].name, "Team Fortress 2") 333 | self.assertEqual(c[prev + 2].name, "Test tf [A-1:123]") 334 | self.assertEqual(c[prev + 3].name, "Red") 335 | 336 | sid = mumble_server.id() 337 | 338 | self.assertEqual(self.s.db.cidFor(sid, game), prev + 1) 339 | self.assertEqual(self.s.db.cidFor(sid, game, server), prev + 2) 340 | self.assertEqual(self.s.db.cidFor(sid, game, server, team), prev + 3) 341 | 342 | gotcid = self.s.getOrCreateChannelFor(mumble_server, game, server, team) 343 | self.assertEqual(cid, gotcid) 344 | 345 | c = mumble_server.channels 346 | 347 | self.checkACLThings(c[prev + 3].acls, [{'group': '~source_tf_[A-1:123]_3'}]) 348 | self.checkACLThings(c[prev + 2].acls, [{'group': '~source_tf_[A-1:123]'}]) 349 | self.checkACLThings(c[prev + 1].acls, [{}, 350 | {'group': '~source_tf'}]) 351 | 352 | # print self.s.db.db.execute("SELECT * FROM source").fetchall() 353 | 354 | def testGetGameName(self): 355 | self.resetState() 356 | 357 | self.assertEqual(self.s.getGameName("tf"), "Team Fortress 2") 358 | self.assertEqual(self.s.getGameName("invalid"), "%(game)s") 359 | 360 | def testGetServerName(self): 361 | self.resetState() 362 | 363 | self.assertEqual(self.s.getServerName("tf"), "Test %(game)s %(server)s") 364 | self.assertEqual(self.s.getServerName("invalid"), "%(server)s") 365 | 366 | def testGetTeamName(self): 367 | self.resetState() 368 | 369 | self.assertEqual(self.s.getTeamName("tf", 2), "Blue") 370 | self.assertEqual(self.s.getTeamName("tf", 100), "100") # oob 371 | 372 | self.assertEqual(self.s.getTeamName("invalid", 2), "Team one") 373 | self.assertEqual(self.s.getTeamName("invalid", 100), "100") # oob 374 | 375 | def testValidGameType(self): 376 | self.resetState() 377 | 378 | self.assertTrue(self.s.isValidGameType("dod")) 379 | self.assertTrue(self.s.isValidGameType("cstrike")) 380 | self.assertTrue(self.s.isValidGameType("tf")) 381 | 382 | self.assertFalse(self.s.isValidGameType("dodx")) 383 | self.assertFalse(self.s.isValidGameType("xdod")) 384 | self.assertFalse(self.s.isValidGameType("")) 385 | 386 | def testValidServer(self): 387 | self.resetState() 388 | 389 | self.assertTrue(self.s.isValidServer("dod", "[A-1:2807761920(3281)]")) 390 | 391 | self.assertFalse(self.s.isValidServer("dod", "A-1:2807761920(3281)]")) 392 | self.assertFalse(self.s.isValidServer("dod", "[A-1:2807761920(3281)")) 393 | self.assertFalse(self.s.isValidServer("dod", "[A-1:2807761920(3281)&]")) 394 | 395 | self.assertTrue(self.s.isValidServer("tf", "[A-1:123]")) 396 | 397 | self.assertFalse(self.s.isValidServer("tf", "x[A-1:123]")) 398 | self.assertFalse(self.s.isValidServer("tf", "[A-1:123]x")) 399 | 400 | def testMoveUser(self): 401 | self.resetState() 402 | 403 | mumble_server = self.mserv 404 | user_state = StateMock() 405 | prev = self.mserv._lastChannelID() 406 | 407 | TEAM_BLUE = 2 408 | TEAM_RED = 3 409 | 410 | BASE_SID = 0 411 | GAME_SID = prev + 1 412 | SERVER_SID = prev + 2 413 | TEAM_RED_SID = prev + 3 414 | TEAM_BLUE_SID = prev + 4 415 | 416 | user = User(user_state, {'team': TEAM_BLUE}, "tf", "[A-1:123]") 417 | self.s.moveUser(self.mserv, user) 418 | c = mumble_server.channels 419 | self.assertEqual(c[prev + 1].parent, BASE_SID) 420 | self.assertEqual(c[prev + 2].parent, GAME_SID) 421 | self.assertEqual(c[prev + 3].parent, SERVER_SID) 422 | 423 | self.assertEqual(c[prev + 1].name, "Team Fortress 2") 424 | self.assertEqual(c[prev + 2].name, "Test tf [A-1:123]") 425 | self.assertEqual(c[prev + 3].name, "Blue") 426 | self.assertEqual(len(c), 3) 427 | 428 | self.assertEqual(user_state.channel, TEAM_RED_SID) 429 | self.assertEqual(mumble_server.user_state[0], user_state) 430 | 431 | user.identity['team'] = TEAM_RED 432 | self.s.moveUser(self.mserv, user) 433 | 434 | self.assertEqual(c[prev + 4].parent, SERVER_SID) 435 | self.assertEqual(c[prev + 4].name, "Red") 436 | self.assertEqual(len(c), 4) 437 | 438 | self.assertEqual(user_state.channel, TEAM_BLUE_SID) 439 | self.assertEqual(mumble_server.user_state[0], user_state) 440 | 441 | def testValidateChannelDB(self): 442 | self.resetState() 443 | 444 | self.s.db.registerChannel(5, 6, "7") 445 | self.s.db.registerChannel(5, 7, "7", "8") 446 | self.s.db.registerChannel(5, 8, "7", "8", 9) 447 | self.s.db.registerChannel(6, 9, "8", "9", 10) 448 | self.s.db.registerChannel(5, 10, "7", "123", 9) 449 | 450 | game = 'cstrike' 451 | server = '[A123:123]' 452 | team = 1 453 | self.s.getOrCreateChannelFor(self.mserv, game, server, team) 454 | self.s.validateChannelDB() 455 | self.assertEqual(len(self.s.db.registeredChannels()), 3) 456 | 457 | def testSetACLsForGameChannel(self): 458 | self.resetState() 459 | 460 | mumble_server = self.mserv 461 | cid = mumble_server.addChannel("test", 1) 462 | game = "dod" 463 | 464 | self.s.setACLsForGameChannel(mumble_server, cid, game) 465 | acls = mumble_server.channels[cid].acls 466 | 467 | self.checkACLThings(acls, [{'applyHere': True, 468 | 'applySubs': True, 469 | 'userid': -1, 470 | 'group': 'all', 471 | 'deny': ACLS.ALL, 472 | 'allow': 0}, 473 | 474 | {'applyHere': True, 475 | 'applySubs': False, 476 | 'userid': -1, 477 | 'group': '~source_dod', 478 | 'deny': 0, 479 | 'allow': ACLS.EAT}]) 480 | 481 | def testSetACLsForServerChannel(self): 482 | self.resetState() 483 | 484 | mumble_server = self.mserv 485 | cid = mumble_server.addChannel("test", 1) 486 | game = "tf" 487 | server = "[A-1:SomeServer]" 488 | self.s.setACLsForServerChannel(mumble_server, cid, game, server) 489 | acls = mumble_server.channels[cid].acls 490 | 491 | self.checkACLThings(acls, [{'applyHere': True, 492 | 'applySubs': False, 493 | 'userid': -1, 494 | 'group': '~source_tf_[A-1:SomeServer]', 495 | 'deny': 0, 496 | 'allow': ACLS.EAT}]) 497 | 498 | def testSetACLsForTeamChannel(self): 499 | self.resetState() 500 | 501 | mumble_server = self.mserv 502 | cid = mumble_server.addChannel("test", 1) 503 | game = "tf" 504 | server = "[A-1:SomeServer]" 505 | team = 2 506 | 507 | self.s.setACLsForTeamChannel(mumble_server, cid, game, server, team) 508 | acls = mumble_server.channels[cid].acls 509 | 510 | self.checkACLThings(acls, [{'applyHere': True, 511 | 'applySubs': False, 512 | 'userid': -1, 513 | 'group': '~source_tf_[A-1:SomeServer]_2', 514 | 'deny': 0, 515 | 'allow': ACLS.ALL}]) 516 | 517 | def testAddToGroups(self): 518 | self.resetState() 519 | 520 | mumble_server = self.mserv 521 | prev = mumble_server._lastChannelID() 522 | 523 | session = 10 524 | game = 'cstrike' 525 | server = '[A-1:12345]' 526 | team = 1 527 | self.s.getOrCreateChannelFor(mumble_server, game, server, team) 528 | 529 | # Test 530 | self.s.addToGroups(mumble_server, session, game, server, team) 531 | 532 | groups = mumble_server.channels[prev + 1].groups[session] 533 | self.assertIn("source_cstrike", groups) 534 | self.assertIn("source_cstrike_[A-1:12345]", groups) 535 | self.assertIn("source_cstrike_[A-1:12345]_1", groups) 536 | 537 | def testChannelNameMapping(self): 538 | self.resetState() 539 | 540 | mumble_server = self.mserv 541 | 542 | game = 'cstrike' 543 | server = '[A-1:12345]' 544 | team = 1 545 | self.s.getOrCreateChannelFor(mumble_server, game, server, team) 546 | cids = [] 547 | for c in mumble_server.channels.values(): 548 | c.name = str(c.id) 549 | self.s.channelStateChanged(mumble_server, c) 550 | cids.append(c.id) 551 | 552 | mumble_server._reset() 553 | self.s.validateChannelDB() 554 | self.assertEqual(len(mumble_server.channels), 0) 555 | self.assertEqual(len(self.s.db.registeredChannels()), 0) 556 | 557 | self.s.getOrCreateChannelFor(mumble_server, game, server, team) 558 | for cid in cids: 559 | self.assertEqual(mumble_server._getChan(cid).name, str(cid)) 560 | 561 | 562 | if __name__ == "__main__": 563 | # logging.basicConfig(level = logging.DEBUG) 564 | # import sys;sys.argv = ['', 'Test.testName'] 565 | unittest.main() 566 | -------------------------------------------------------------------------------- /modules/source/users.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | class User(object): 33 | """ 34 | User to hold state as well as parsed data fields in a 35 | sane fashion. 36 | """ 37 | 38 | def __init__(self, state, identity=None, game=None, server=None): 39 | self.state = state 40 | self.identity = identity or {} 41 | self.server = server 42 | self.game = game 43 | 44 | def valid(self): 45 | """ 46 | True if valid data is available for all fields 47 | """ 48 | return self.state and self.identity and self.server and self.game 49 | 50 | def hasContextOrIdentityChanged(self, otherstate): 51 | """ 52 | Checks whether the given state diverges from this users's 53 | """ 54 | return self.state.context != otherstate.context or \ 55 | self.state.identity != otherstate.identity 56 | 57 | def updateState(self, state): 58 | """ 59 | Updates the state of this user 60 | """ 61 | self.state = state 62 | 63 | def updateData(self, identity, game, server): 64 | """ 65 | Updates the data fields for this user 66 | """ 67 | self.identity = identity 68 | self.game = game 69 | self.server = server 70 | 71 | 72 | class UserRegistry(object): 73 | """ 74 | Registry to store User objects for given servers 75 | and sessions. 76 | """ 77 | 78 | def __init__(self): 79 | self.users = {} # {session:user, ...} 80 | 81 | def get(self, sid, session): 82 | """ 83 | Return user or None from registry 84 | """ 85 | try: 86 | return self.users[sid][session] 87 | except KeyError: 88 | return None 89 | 90 | def add(self, sid, session, user): 91 | """ 92 | Add new user to registry 93 | """ 94 | assert (isinstance(user, User)) 95 | 96 | if not sid in self.users: 97 | self.users[sid] = {session: user} 98 | elif not session in self.users[sid]: 99 | self.users[sid][session] = user 100 | else: 101 | return False 102 | return True 103 | 104 | def addOrUpdate(self, sid, session, user): 105 | """ 106 | Add user or overwrite existing one (identified by sid + session) 107 | """ 108 | assert (isinstance(user, User)) 109 | 110 | if not sid in self.users: 111 | self.users[sid] = {session: user} 112 | else: 113 | self.users[sid][session] = user 114 | 115 | return True 116 | 117 | def remove(self, sid, session): 118 | """ 119 | Remove user from registry 120 | """ 121 | try: 122 | del self.users[sid][session] 123 | except KeyError: 124 | return False 125 | return True 126 | 127 | def usingChannel(self, sid, cid): 128 | """ 129 | Return true if any user in the registry is occupying the given channel 130 | """ 131 | for user in self.users[sid].values(): 132 | if user.state and user.state.channel == cid: 133 | return True 134 | 135 | return False 136 | -------------------------------------------------------------------------------- /modules/source/users_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import unittest 33 | 34 | from .users import User, UserRegistry 35 | 36 | 37 | class Test(unittest.TestCase): 38 | 39 | def getSomeUsers(self, n=5): 40 | sid = [] 41 | session = [] 42 | user = [] 43 | for i in range(n): 44 | s = str(i) 45 | sid.append(i) 46 | session.append(i) 47 | user.append(User("state" + s, "identity" + s, "game" + s, "server" + s)) 48 | 49 | return sid, session, user 50 | 51 | def testRegistryCRUDOps(self): 52 | r = UserRegistry() 53 | 54 | sid, session, user = self.getSomeUsers() 55 | 56 | # Create & Read 57 | self.assertTrue(r.add(sid[0], session[0], user[0])) 58 | self.assertFalse(r.add(sid[0], session[0], user[0])) 59 | self.assertEqual(r.get(sid[0], session[0]), user[0]) 60 | 61 | self.assertTrue(r.addOrUpdate(sid[1], session[1], user[1])) 62 | self.assertEqual(r.get(sid[1], session[1]), user[1]) 63 | 64 | # Update 65 | self.assertTrue(r.addOrUpdate(sid[0], session[0], user[2])) 66 | self.assertEqual(r.get(sid[0], session[0]), user[2]) 67 | 68 | # Delete 69 | self.assertTrue(r.remove(sid[1], session[1])) 70 | self.assertFalse(r.remove(sid[1], session[1])) 71 | self.assertEqual(r.get(sid[1], session[1]), None) 72 | 73 | self.assertTrue(r.remove(sid[0], session[0])) 74 | self.assertFalse(r.remove(sid[0], session[0])) 75 | self.assertEqual(r.get(sid[0], session[0]), None) 76 | 77 | def testUser(self): 78 | u = User("State", {'team': 2}, "tf", "Someserver") 79 | self.assertTrue(u.valid()) 80 | self.assertFalse(User("State").valid()) 81 | 82 | 83 | if __name__ == "__main__": 84 | # import sys;sys.argv = ['', 'Test.testName'] 85 | unittest.main() 86 | -------------------------------------------------------------------------------- /modules/test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010-2011 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # test.py 34 | # The test module has heavy debug output was solely 35 | # written for testing the basic framework as well as 36 | # debugging purposes. Usually you don't want 37 | # to use this. 38 | # 39 | from mumo_module import MumoModule, logModFu 40 | 41 | 42 | class test(MumoModule): 43 | default_config = {'testing': (('tvar', int, 1), 44 | ('novar', str, 'no bernd'))} 45 | 46 | def __init__(self, name, manager, configuration=None): 47 | MumoModule.__init__(self, name, manager, configuration) 48 | log = self.log() 49 | cfg = self.cfg() 50 | log.debug("tvar: %s", cfg.testing.tvar) 51 | log.debug("novar: %s", cfg.testing.novar) 52 | 53 | @logModFu 54 | def connected(self): 55 | manager = self.manager() 56 | log = self.log() 57 | log.debug("Ice connected, register for everything out there") 58 | manager.subscribeMetaCallbacks(self) 59 | manager.subscribeServerCallbacks(self, manager.SERVERS_ALL) 60 | manager.subscribeContextCallbacks(self, manager.SERVERS_ALL) 61 | 62 | @logModFu 63 | def disconnected(self): 64 | self.log().debug("Ice disconnected") 65 | 66 | # 67 | # --- Meta callback functions 68 | # 69 | 70 | @logModFu 71 | def started(self, server, context=None): 72 | pass 73 | 74 | @logModFu 75 | def stopped(self, server, context=None): 76 | pass 77 | 78 | # 79 | # --- Server callback functions 80 | # 81 | @logModFu 82 | def userConnected(self, server, state, context=None): 83 | pass 84 | 85 | @logModFu 86 | def userDisconnected(self, server, state, context=None): 87 | pass 88 | 89 | @logModFu 90 | def userStateChanged(self, server, state, context=None): 91 | pass 92 | 93 | @logModFu 94 | def userTextMessage(self, server, user, message, current=None): 95 | pass 96 | 97 | @logModFu 98 | def channelCreated(self, server, state, context=None): 99 | pass 100 | 101 | @logModFu 102 | def channelRemoved(self, server, state, context=None): 103 | pass 104 | 105 | @logModFu 106 | def channelStateChanged(self, server, state, context=None): 107 | pass 108 | 109 | # 110 | # --- Server context callback functions 111 | # 112 | @logModFu 113 | def contextAction(self, server, action, user, session, channelid, context=None): 114 | pass 115 | -------------------------------------------------------------------------------- /mumo.ini: -------------------------------------------------------------------------------- 1 | 2 | ; 3 | ;Ice configuration 4 | ; 5 | [ice] 6 | 7 | ; Host and port of the Ice interface on 8 | ; the target Murmur server. 9 | 10 | host = 127.0.0.1 11 | port = 6502 12 | 13 | ; Slicefile to use (e.g. /etc/slice/Murmur.ice), 14 | ; if empty MuMo will load the slice file from the 15 | ; target server at startup. 16 | 17 | slice = 18 | 19 | ; Semicolon seperated list of slice include directories 20 | ; to consider. This is only used on legacy platforms 21 | ; with old or broken Ice versions. 22 | slicedirs = /usr/share/slice;/usr/share/Ice/slice 23 | 24 | ; Shared secret between the MuMo and the Murmur 25 | ; server. For security reason you should always 26 | ; use a shared secret. 27 | 28 | secret = 29 | 30 | ;Check Ice connection every x seconds 31 | 32 | watchdog = 15 33 | 34 | [murmur] 35 | ; Comma seperated list of server ids to listen on (empty for all) 36 | ; note that if a server isn't listed here no events for it can 37 | ; be received in any module 38 | servers = 39 | 40 | [modules] 41 | mod_dir = modules/ 42 | cfg_dir = modules-enabled/ 43 | timeout = 2 44 | 45 | [system] 46 | pidfile = mumo.pid 47 | 48 | 49 | ; Logging configuration 50 | [log] 51 | ; Available loglevels: 10 = DEBUG (default) | 20 = INFO | 30 = WARNING | 40 = ERROR 52 | 53 | level = 54 | file = mumo.log 55 | 56 | 57 | [iceraw] 58 | Ice.ThreadPool.Server.Size = 5 59 | -------------------------------------------------------------------------------- /mumo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010-2013 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import logging 33 | import os 34 | import sys 35 | import tempfile 36 | from logging import (debug, 37 | info, 38 | warning, 39 | error, 40 | critical, 41 | exception, 42 | getLogger) 43 | from optparse import OptionParser 44 | from threading import Timer 45 | 46 | import Ice 47 | import IcePy 48 | 49 | from config import (Config, 50 | commaSeperatedIntegers) 51 | from mumo_manager import MumoManager 52 | 53 | # 54 | # --- Default configuration values 55 | # 56 | cfgfile = 'mumo.ini' 57 | default = MumoManager.cfg_default.copy() 58 | default.update({'ice': (('host', str, '127.0.0.1'), 59 | ('port', int, 6502), 60 | ('slice', str, ''), 61 | ('secret', str, ''), 62 | ('slicedirs', str, '/usr/share/slice;/usr/share/Ice/slice'), 63 | ('watchdog', int, 30), 64 | ('callback_host', str, '127.0.0.1'), 65 | ('callback_port', int, -1)), 66 | 67 | 'iceraw': None, 68 | 'murmur': (('servers', commaSeperatedIntegers, []),), 69 | 'system': (('pidfile', str, 'mumo.pid'),), 70 | 'log': (('level', int, logging.DEBUG), 71 | ('file', str, 'mumo.log'))}) 72 | 73 | 74 | def load_slice(slice): 75 | # 76 | # --- Loads a given slicefile, used by dynload_slice and fsload_slice 77 | # This function works around a number of differences between Ice python 78 | # versions and distributions when it comes to slice include directories. 79 | # 80 | fallback_slicedirs = ["-I" + sdir for sdir in cfg.ice.slicedirs.split(';')] 81 | 82 | if not hasattr(Ice, "getSliceDir"): 83 | Ice.loadSlice('-I%s %s' % (" ".join(fallback_slicedirs), slice)) 84 | else: 85 | slicedir = Ice.getSliceDir() 86 | if not slicedir: 87 | slicedirs = fallback_slicedirs 88 | else: 89 | slicedirs = ['-I' + slicedir] 90 | 91 | Ice.loadSlice('', slicedirs + [slice]) 92 | 93 | 94 | def dynload_slice(prx): 95 | # 96 | # --- Dynamically retrieves the slice file from the target server 97 | # 98 | info("Loading slice from server...") 99 | try: 100 | # Check IcePy version as this internal function changes between version. 101 | # In case it breaks with future versions use slice2py and search for 102 | # "IcePy.Operation('getSlice'," for updates in the generated bindings. 103 | op = None 104 | if IcePy.intVersion() < 30500: 105 | # Old 3.4 signature with 9 parameters 106 | op = IcePy.Operation('getSlice', Ice.OperationMode.Idempotent, Ice.OperationMode.Idempotent, True, (), (), 107 | (), IcePy._t_string, ()) 108 | 109 | else: 110 | # New 3.5 signature with 10 parameters. 111 | op = IcePy.Operation('getSlice', Ice.OperationMode.Idempotent, Ice.OperationMode.Idempotent, True, None, (), 112 | (), (), ((), IcePy._t_string, False, 0), ()) 113 | 114 | slice = op.invoke(prx, ((), None)) 115 | (dynslicefiledesc, dynslicefilepath) = tempfile.mkstemp(suffix='.ice') 116 | dynslicefile = os.fdopen(dynslicefiledesc, 'w') 117 | dynslicefile.write(slice) 118 | dynslicefile.flush() 119 | debug("Loading slice file %s", dynslicefilepath) 120 | load_slice(dynslicefilepath) 121 | dynslicefile.close() 122 | os.remove(dynslicefilepath) 123 | except Exception as e: 124 | error("Retrieving slice from server failed") 125 | exception(e) 126 | raise 127 | 128 | 129 | def fsload_slice(slice): 130 | # 131 | # --- Load slice from file system 132 | # 133 | debug("Loading slice from filesystem: %s" % slice) 134 | load_slice(slice) 135 | 136 | 137 | def do_main_program(): 138 | # 139 | # --- Moderator implementation 140 | # All of this has to go in here so we can correctly daemonize the tool 141 | # without loosing the file descriptors opened by the Ice module 142 | 143 | debug('Initializing Ice...') 144 | initdata = Ice.InitializationData() 145 | initdata.properties = Ice.createProperties([], initdata.properties) 146 | for prop, val in cfg.iceraw: 147 | initdata.properties.setProperty(prop, val) 148 | 149 | initdata.properties.setProperty('Ice.ImplicitContext', 'Shared') 150 | initdata.properties.setProperty('Ice.Default.EncodingVersion', '1.0') 151 | initdata.logger = CustomLogger() 152 | 153 | ice = Ice.initialize(initdata) 154 | prxstr = 'Meta:tcp -h %s -p %d' % (cfg.ice.host, cfg.ice.port) 155 | prx = ice.stringToProxy(prxstr) 156 | 157 | if not cfg.ice.slice: 158 | dynload_slice(prx) 159 | else: 160 | fsload_slice(cfg.ice.slice) 161 | 162 | # noinspection PyUnresolvedReferences 163 | try: 164 | import MumbleServer 165 | except ModuleNotFoundError: 166 | # Try to import Mumble <1.5 name `Murmur` instead 167 | import Murmur as MumbleServer 168 | 169 | class mumoIceApp(Ice.Application): 170 | def __init__(self, manager): 171 | Ice.Application.__init__(self) 172 | self.manager = manager 173 | 174 | def run(self, args): 175 | self.shutdownOnInterrupt() 176 | 177 | if not self.initializeIceConnection(): 178 | return 1 179 | 180 | if cfg.ice.watchdog > 0: 181 | self.metaUptime = -1 182 | self.checkConnection() 183 | 184 | # Serve till we are stopped 185 | self.communicator().waitForShutdown() 186 | self.watchdog.cancel() 187 | 188 | if self.interrupted(): 189 | warning('Caught interrupt, shutting down') 190 | 191 | return 0 192 | 193 | def initializeIceConnection(self): 194 | """ 195 | Establishes the two-way Ice connection and adds MuMo to the 196 | configured servers 197 | """ 198 | ice = self.communicator() 199 | 200 | if cfg.ice.secret: 201 | debug('Using shared ice secret') 202 | ice.getImplicitContext().put("secret", cfg.ice.secret) 203 | else: 204 | warning('Consider using an ice secret to improve security') 205 | 206 | info('Connecting to Ice server (%s:%d)', cfg.ice.host, cfg.ice.port) 207 | base = ice.stringToProxy(prxstr) 208 | self.meta = MumbleServer.MetaPrx.uncheckedCast(base) 209 | 210 | if cfg.ice.callback_port > 0: 211 | cbp = ' -p %d' % cfg.ice.callback_port 212 | else: 213 | cbp = '' 214 | 215 | adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 216 | 'tcp -h %s%s' % (cfg.ice.callback_host, cbp)) 217 | adapter.activate() 218 | self.adapter = adapter 219 | self.manager.setClientAdapter(adapter) 220 | 221 | metacbprx = adapter.addWithUUID(metaCallback(self)) 222 | self.metacb = MumbleServer.MetaCallbackPrx.uncheckedCast(metacbprx) 223 | 224 | return self.attachCallbacks() 225 | 226 | def attachCallbacks(self): 227 | """ 228 | Attaches all callbacks 229 | """ 230 | 231 | # Ice.ConnectionRefusedException 232 | debug('Attaching callbacks') 233 | try: 234 | info('Attaching meta callback') 235 | self.meta.addCallback(self.metacb) 236 | 237 | for server in self.meta.getBootedServers(): 238 | sid = server.id() 239 | if not cfg.murmur.servers or sid in cfg.murmur.servers: 240 | info('Setting callbacks for virtual server %d', sid) 241 | servercbprx = self.adapter.addWithUUID(serverCallback(self.manager, server, sid)) 242 | servercb = MumbleServer.ServerCallbackPrx.uncheckedCast(servercbprx) 243 | server.addCallback(servercb) 244 | 245 | except (MumbleServer.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException) as e: 246 | if isinstance(e, Ice.ConnectionRefusedException): 247 | error('Server refused connection') 248 | elif isinstance(e, MumbleServer.InvalidSecretException) or \ 249 | isinstance(e, Ice.UnknownUserException) and ( 250 | e.unknown == 'MumbleServer::InvalidSecretException'): 251 | error('Invalid ice secret') 252 | else: 253 | # We do not actually want to handle this one, re-raise it 254 | raise e 255 | 256 | self.connected = False 257 | self.manager.announceDisconnected() 258 | return False 259 | 260 | self.connected = True 261 | self.manager.announceConnected(self.meta) 262 | return True 263 | 264 | def checkConnection(self): 265 | """ 266 | Tries to retrieve the server uptime to determine wheter the server is 267 | still responsive or has restarted in the meantime 268 | """ 269 | # debug('Watchdog run') 270 | try: 271 | uptime = self.meta.getUptime() 272 | if self.metaUptime > 0: 273 | # Check if the server didn't restart since we last checked, we assume 274 | # since the last time we ran this check the watchdog interval +/- 5s 275 | # have passed. This should be replaced by implementing a Keepalive in 276 | # Murmur. 277 | if not ((uptime - 5) <= (self.metaUptime + cfg.ice.watchdog) <= (uptime + 5)): 278 | # Seems like the server restarted, re-attach the callbacks 279 | self.attachCallbacks() 280 | 281 | self.metaUptime = uptime 282 | except Ice.Exception as e: 283 | error('Connection to server lost, will try to reestablish callbacks in next watchdog run (%ds)', 284 | cfg.ice.watchdog) 285 | debug(str(e)) 286 | self.attachCallbacks() 287 | 288 | # Renew the timer 289 | self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection) 290 | self.watchdog.start() 291 | 292 | def checkSecret(func): 293 | """ 294 | Decorator that checks whether the server transmitted the right secret 295 | if a secret is supposed to be used. 296 | """ 297 | if not cfg.ice.secret: 298 | return func 299 | 300 | def newfunc(*args, **kws): 301 | if 'current' in kws: 302 | current = kws["current"] 303 | else: 304 | current = args[-1] 305 | 306 | if not current or 'secret' not in current.ctx or current.ctx['secret'] != cfg.ice.secret: 307 | error('Server transmitted invalid secret. Possible injection attempt.') 308 | raise MumbleServer.InvalidSecretException() 309 | 310 | return func(*args, **kws) 311 | 312 | return newfunc 313 | 314 | def fortifyIceFu(retval=None, exceptions=(Ice.Exception,)): 315 | """ 316 | Decorator that catches exceptions,logs them and returns a safe retval 317 | value. This helps to prevent getting stuck in 318 | critical code paths. Only exceptions that are instances of classes 319 | given in the exceptions list are not caught. 320 | 321 | The default is to catch all non-Ice exceptions. 322 | """ 323 | 324 | def newdec(func): 325 | def newfunc(*args, **kws): 326 | try: 327 | return func(*args, **kws) 328 | except Exception as e: 329 | catch = True 330 | for ex in exceptions: 331 | if isinstance(e, ex): 332 | catch = False 333 | break 334 | 335 | if catch: 336 | critical('Unexpected exception caught') 337 | exception(e) 338 | return retval 339 | raise 340 | 341 | return newfunc 342 | 343 | return newdec 344 | 345 | class metaCallback(MumbleServer.MetaCallback): 346 | def __init__(self, app): 347 | MumbleServer.MetaCallback.__init__(self) 348 | self.app = app 349 | 350 | @fortifyIceFu() 351 | @checkSecret 352 | def started(self, server, current=None): 353 | """ 354 | This function is called when a virtual server is started 355 | and makes sure the callbacks get attached if needed. 356 | """ 357 | sid = server.id() 358 | if not cfg.murmur.servers or sid in cfg.murmur.servers: 359 | info('Setting callbacks for virtual server %d', server.id()) 360 | try: 361 | servercbprx = self.app.adapter.addWithUUID(serverCallback(self.app.manager, server, sid)) 362 | servercb = MumbleServer.ServerCallbackPrx.uncheckedCast(servercbprx) 363 | server.addCallback(servercb) 364 | 365 | # Apparently this server was restarted without us noticing 366 | except (MumbleServer.InvalidSecretException, Ice.UnknownUserException) as e: 367 | if hasattr(e, "unknown") and e.unknown != "MumbleServer::InvalidSecretException": 368 | # Special handling for Murmur 1.2.2 servers with invalid slice files 369 | raise e 370 | 371 | error('Invalid ice secret') 372 | return 373 | else: 374 | debug('Virtual server %d got started', sid) 375 | 376 | self.app.manager.announceMeta(sid, "started", server, current) 377 | 378 | @fortifyIceFu() 379 | @checkSecret 380 | def stopped(self, server, current=None): 381 | """ 382 | This function is called when a virtual server is stopped 383 | """ 384 | if self.app.connected: 385 | # Only try to output the server id if we think we are still connected to prevent 386 | # flooding of our thread pool 387 | try: 388 | sid = server.id() 389 | if not cfg.murmur.servers or sid in cfg.murmur.servers: 390 | info('Watched virtual server %d got stopped', sid) 391 | else: 392 | debug('Virtual server %d got stopped', sid) 393 | self.app.manager.announceMeta(sid, "stopped", server, current) 394 | return 395 | except Ice.ConnectionRefusedException: 396 | self.app.connected = False 397 | self.app.manager.announceDisconnected() 398 | 399 | debug('Server shutdown stopped a virtual server') 400 | 401 | def forwardServer(fu): 402 | def new_fu(self, *args, **kwargs): 403 | self.manager.announceServer(self.sid, fu.__name__, self.server, *args, **kwargs) 404 | 405 | return new_fu 406 | 407 | class serverCallback(MumbleServer.ServerCallback): 408 | def __init__(self, manager, server, sid): 409 | MumbleServer.ServerCallback.__init__(self) 410 | self.manager = manager 411 | self.sid = sid 412 | self.server = server 413 | 414 | # Hack to prevent every call to server.id() from the client callbacks 415 | # from having to go over Ice 416 | def id_replacement(): 417 | return self.sid 418 | 419 | server.id = id_replacement 420 | 421 | @checkSecret 422 | @forwardServer 423 | def userStateChanged(self, u, current=None): pass 424 | 425 | @checkSecret 426 | @forwardServer 427 | def userDisconnected(self, u, current=None): pass 428 | 429 | @checkSecret 430 | @forwardServer 431 | def userConnected(self, u, current=None): pass 432 | 433 | @checkSecret 434 | @forwardServer 435 | def channelCreated(self, c, current=None): pass 436 | 437 | @checkSecret 438 | @forwardServer 439 | def channelRemoved(self, c, current=None): pass 440 | 441 | @checkSecret 442 | @forwardServer 443 | def channelStateChanged(self, c, current=None): pass 444 | 445 | @checkSecret 446 | @forwardServer 447 | def userTextMessage(self, u, m, current=None): pass 448 | 449 | class customContextCallback(MumbleServer.ServerContextCallback): 450 | def __init__(self, contextActionCallback, *ctx): 451 | MumbleServer.ServerContextCallback.__init__(self) 452 | self.cb = contextActionCallback 453 | self.ctx = ctx 454 | 455 | @checkSecret 456 | def contextAction(self, *args, **argv): 457 | # (action, user, target_session, target_chanid, current=None) 458 | self.cb(*(self.ctx + args), **argv) 459 | 460 | # 461 | # --- Start of moderator 462 | # 463 | info('Starting mumble moderator') 464 | debug('Initializing manager') 465 | manager = MumoManager(MumbleServer, customContextCallback) 466 | manager.start() 467 | manager.loadModules() 468 | manager.startModules() 469 | 470 | debug("Initializing mumoIceApp") 471 | app = mumoIceApp(manager) 472 | state = app.main(sys.argv[:1], initData=initdata) 473 | 474 | manager.stopModules() 475 | manager.stop() 476 | info('Shutdown complete') 477 | return state 478 | 479 | 480 | class CustomLogger(Ice.Logger): 481 | """ 482 | Logger implementation to pipe Ice log messages into 483 | our own log 484 | """ 485 | 486 | def __init__(self): 487 | Ice.Logger.__init__(self) 488 | self._log = getLogger('Ice') 489 | 490 | def _print(self, message): 491 | self._log.info(message) 492 | 493 | def trace(self, category, message): 494 | self._log.debug('Trace %s: %s', category, message) 495 | 496 | def warning(self, message): 497 | self._log.warning(message) 498 | 499 | def error(self, message): 500 | self._log.error(message) 501 | 502 | 503 | # 504 | # --- Start of program 505 | # 506 | if __name__ == '__main__': 507 | # Parse commandline options 508 | parser = OptionParser() 509 | parser.add_option('-i', '--ini', 510 | help='load configuration from INI', default=cfgfile) 511 | parser.add_option('-v', '--verbose', action='store_true', dest='verbose', 512 | help='verbose output [default]', default=True) 513 | parser.add_option('-q', '--quiet', action='store_false', dest='verbose', 514 | help='only error output') 515 | parser.add_option('-d', '--daemon', action='store_true', dest='force_daemon', 516 | help='run as daemon', default=False) 517 | parser.add_option('-a', '--app', action='store_true', dest='force_app', 518 | help='do not run as daemon', default=False) 519 | (option, args) = parser.parse_args() 520 | 521 | if option.force_daemon and option.force_app: 522 | parser.print_help() 523 | sys.exit(1) 524 | 525 | # Load configuration 526 | try: 527 | cfg = Config(option.ini, default) 528 | except Exception as e: 529 | print('Fatal error, could not load config file from "%s"' % cfgfile, file=sys.stderr) 530 | print(e, file=sys.stderr) 531 | sys.exit(1) 532 | 533 | # Initialise logger 534 | if cfg.log.file: 535 | try: 536 | logfile = open(cfg.log.file, 'a') 537 | except IOError as e: 538 | # print>>sys.stderr, str(e) 539 | print('Fatal error, could not open logfile "%s"' % cfg.log.file, file=sys.stderr) 540 | sys.exit(1) 541 | else: 542 | logfile = logging.sys.stdout 543 | 544 | if option.verbose: 545 | level = cfg.log.level 546 | else: 547 | level = logging.ERROR 548 | 549 | logging.basicConfig(level=level, 550 | format='%(asctime)s %(levelname)s %(name)s %(message)s', 551 | stream=logfile) 552 | 553 | info("Using config file %s", option.ini) 554 | info("Logging level %d into %s", level, cfg.log.file) 555 | 556 | # As the default try to run as daemon. Silently degrade to running as a normal application if this fails 557 | # unless the user explicitly defined what he expected with the -a / -d parameter. 558 | try: 559 | if option.force_app: 560 | raise ImportError # Pretend that we couldn't import the daemon lib 561 | import daemon 562 | 563 | try: 564 | from daemon.pidfile import TimeoutPIDLockFile 565 | except ImportError: # Version < 1.6 566 | from daemon.pidlockfile import TimeoutPIDLockFile 567 | except ImportError: 568 | if option.force_daemon: 569 | print('Fatal error, could not daemonize process due to missing "daemon" library, ' 570 | 'please install the missing dependency and restart the application', file=sys.stderr) 571 | sys.exit(1) 572 | ret = do_main_program() 573 | else: 574 | pidfile = TimeoutPIDLockFile(cfg.system.pidfile, 5) 575 | if pidfile.is_locked(): 576 | try: 577 | os.kill(pidfile.read_pid(), 0) 578 | print('Mumo already running as %s' % pidfile.read_pid(), file=sys.stderr) 579 | sys.exit(1) 580 | except OSError: 581 | print('Found stale mumo pid file but no process, breaking lock', file=sys.stderr) 582 | pidfile.break_lock() 583 | 584 | context = daemon.DaemonContext(working_directory=sys.path[0], 585 | stderr=logfile, 586 | pidfile=pidfile) 587 | context.__enter__() 588 | try: 589 | ret = do_main_program() 590 | finally: 591 | context.__exit__(None, None, None) 592 | sys.exit(ret) 593 | -------------------------------------------------------------------------------- /mumo_manager.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import os 33 | import queue 34 | import sys 35 | import uuid 36 | 37 | from config import Config 38 | from worker import Worker, local_thread, local_thread_blocking 39 | 40 | 41 | class FailedLoadModuleException(Exception): 42 | pass 43 | 44 | 45 | class FailedLoadModuleConfigException(FailedLoadModuleException): 46 | pass 47 | 48 | 49 | class FailedLoadModuleImportException(FailedLoadModuleException): 50 | pass 51 | 52 | 53 | class FailedLoadModuleInitializationException(FailedLoadModuleException): 54 | pass 55 | 56 | 57 | def debug_log(enable=True): 58 | def new_dec(fu): 59 | def new_fu(*args, **kwargs): 60 | self = args[0] 61 | log = self.log() 62 | skwargs = ','.join(['%s=%s' % (karg, repr(arg)) for karg, arg in kwargs]) 63 | sargs = ','.join([str(arg) for arg in args[1:]]) + '' if not skwargs else (',' + str(skwargs)) 64 | 65 | call = "%s(%s)" % (fu.__name__, sargs) 66 | log.debug(call) 67 | res = fu(*args, **kwargs) 68 | log.debug("%s -> %s", call, repr(res)) 69 | return res 70 | 71 | return new_fu if enable else fu 72 | 73 | return new_dec 74 | 75 | 76 | debug_me = True 77 | 78 | 79 | class MumoManagerRemote(object): 80 | """ 81 | Manager object handed to MumoModules. This module 82 | acts as a remote for the MumoModule with which it 83 | can register/unregister to/from callbacks as well 84 | as do other signaling to the master MumoManager. 85 | """ 86 | 87 | SERVERS_ALL = [-1] ## Applies to all servers 88 | 89 | def __init__(self, master, name, queue): 90 | self.__master = master 91 | self.__name = name 92 | self.__queue = queue 93 | 94 | self.__context_callbacks = {} # server -> action -> callback 95 | 96 | def getQueue(self): 97 | return self.__queue 98 | 99 | def subscribeMetaCallbacks(self, handler, servers=SERVERS_ALL): 100 | """ 101 | Subscribe to meta callbacks. Subscribes the given handler to the following 102 | callbacks: 103 | 104 | >>> started(self, server, context = None) 105 | >>> stopped(self, server, context = None) 106 | 107 | @param servers: List of server IDs for which to subscribe. To subscribe to all 108 | servers pass SERVERS_ALL. 109 | @param handler: Object on which to call the callback functions 110 | """ 111 | return self.__master.subscribeMetaCallbacks(self.__queue, handler, servers) 112 | 113 | def unsubscribeMetaCallbacks(self, handler, servers=SERVERS_ALL): 114 | """ 115 | Unsubscribe from meta callbacks. Unsubscribes the given handler from callbacks 116 | for the given servers. 117 | 118 | @param servers: List of server IDs for which to unsubscribe. To unsubscribe from all 119 | servers pass SERVERS_ALL. 120 | @param handler: Subscribed handler 121 | """ 122 | return self.__master.unscubscribeMetaCallbacks(self.__queue, handler, servers) 123 | 124 | def subscribeServerCallbacks(self, handler, servers=SERVERS_ALL): 125 | """ 126 | Subscribe to server callbacks. Subscribes the given handler to the following 127 | callbacks: 128 | 129 | >>> userConnected(self, state, context = None) 130 | >>> userDisconnected(self, state, context = None) 131 | >>> userStateChanged(self, state, context = None) 132 | >>> channelCreated(self, state, context = None) 133 | >>> channelRemoved(self, state, context = None) 134 | >>> channelStateChanged(self, state, context = None) 135 | 136 | @param servers: List of server IDs for which to subscribe. To subscribe to all 137 | servers pass SERVERS_ALL. 138 | @param handler: Object on which to call the callback functions 139 | """ 140 | return self.__master.subscribeServerCallbacks(self.__queue, handler, servers) 141 | 142 | def unsubscribeServerCallbacks(self, handler, servers=SERVERS_ALL): 143 | """ 144 | Unsubscribe from server callbacks. Unsubscribes the given handler from callbacks 145 | for the given servers. 146 | 147 | @param servers: List of server IDs for which to unsubscribe. To unsubscribe from all 148 | servers pass SERVERS_ALL. 149 | @param handler: Subscribed handler 150 | """ 151 | return self.__master.unsubscribeServerCallbacks(self.__queue, handler, servers) 152 | 153 | def getUniqueAction(self): 154 | """ 155 | Returns a unique action string that can be used in addContextMenuEntry. 156 | 157 | :return: Unique action string 158 | """ 159 | return str(uuid.uuid4()) 160 | 161 | def addContextMenuEntry(self, server, user, action, text, handler, context): 162 | """ 163 | Adds a new context callback menu entry with the given text for the given user. 164 | 165 | You can use the same action identifier for multiple users entries to 166 | simplify your handling. However make sure an action identifier is unique 167 | to your module. The easiest way to achieve this is to use getUniqueAction 168 | to generate a guaranteed unique one. 169 | 170 | Your handler should be of form: 171 | >>> handler(self, server, action, user, target) 172 | 173 | Here server is the server the user who triggered the action resides on. 174 | Target identifies what the context action was invoked on. It can be either 175 | a User, Channel or None. 176 | 177 | @param server: Server the user resides on 178 | @param user: User to add entry for 179 | @param action: Action identifier passed to your callback (see above) 180 | @param text: Text for the menu entry 181 | @param handler: Handler function to call when the menu item is used 182 | @param context: Contexts to show entry in (can be a combination of ContextServer, ContextChannel and 183 | ContextUser) 184 | """ 185 | 186 | server_actions = self.__context_callbacks.get(server.id()) 187 | if not server_actions: 188 | server_actions = {} 189 | self.__context_callbacks[server.id()] = server_actions 190 | 191 | action_cb = server_actions.get(action) 192 | if not action_cb: 193 | # We need to create an register a new context callback 194 | action_cb = self.__master.createContextCallback(self.__handle_context_callback, handler, server) 195 | server_actions[action] = action_cb 196 | 197 | server.addContextCallback(user.session, action, text, action_cb, context) 198 | 199 | def __handle_context_callback(self, handler, server, action, user, target_session, target_channelid, current=None): 200 | """ 201 | Small callback wrapper for context menu operations. 202 | 203 | Translates the given target into the corresponding object and 204 | schedules a call to the actual user context menu handler which 205 | will be executed in the modules thread. 206 | """ 207 | 208 | if target_session != 0: 209 | target = server.getState(target_session) 210 | elif target_channelid != -1: 211 | target = server.getChannelState(target_channelid) 212 | else: 213 | target = None 214 | 215 | # Schedule a call to the handler 216 | self.__queue.put((None, handler, [server, action, user, target], {})) 217 | 218 | def removeContextMenuEntry(self, server, action): 219 | """ 220 | Removes a previously created context action callback from a server. 221 | 222 | Applies to all users that share the action on this server. 223 | 224 | @param server Server the action should be removed from. 225 | @param action Action to remove 226 | """ 227 | 228 | try: 229 | cb = self.__context_callbacks[server.id()].pop(action) 230 | except KeyError: 231 | # Nothing to unregister 232 | return 233 | 234 | server.removeContextCallback(cb) 235 | 236 | def getMurmurModule(self): 237 | """ 238 | Returns the Murmur module generated from the slice file 239 | """ 240 | return self.__master.getMurmurModule() 241 | 242 | def getMeta(self): 243 | """ 244 | Returns the connected servers meta module or None if it is not available 245 | """ 246 | return self.__master.getMeta() 247 | 248 | 249 | class MumoManager(Worker): 250 | MAGIC_ALL = -1 251 | 252 | cfg_default = {'modules': (('mod_dir', str, "modules/"), 253 | ('cfg_dir', str, "modules-enabled/"), 254 | ('timeout', int, 2))} 255 | 256 | def __init__(self, murmur, context_callback_type, cfg=Config(default=cfg_default)): 257 | Worker.__init__(self, "MumoManager") 258 | self.queues = {} # {queue:module} 259 | self.modules = {} # {name:module} 260 | self.imports = {} # {name:import} 261 | self.cfg = cfg 262 | 263 | self.murmur = murmur 264 | self.meta = None 265 | self.client_adapter = None 266 | 267 | self.metaCallbacks = {} # {sid:{queue:[handler]}} 268 | self.serverCallbacks = {} 269 | 270 | self.context_callback_type = context_callback_type 271 | 272 | def setClientAdapter(self, client_adapter): 273 | """ 274 | Sets the ice adapter used for client-side callbacks. This is needed 275 | in case per-module callbacks have to be attached during run-time 276 | as is the case for context callbacks. 277 | 278 | :param client_adapter: Ice object adapter 279 | """ 280 | self.client_adapter = client_adapter 281 | 282 | def __add_to_dict(self, mdict, queue, handler, servers): 283 | for server in servers: 284 | if server in mdict: 285 | if queue in mdict[server]: 286 | if not handler in mdict[server][queue]: 287 | mdict[server][queue].append(handler) 288 | else: 289 | mdict[server][queue] = [handler] 290 | else: 291 | mdict[server] = {queue: [handler]} 292 | 293 | def __rem_from_dict(self, mdict, queue, handler, servers): 294 | for server in servers: 295 | try: 296 | mdict[server][queue].remove(handler) 297 | except KeyError as ValueError: 298 | pass 299 | 300 | def __announce_to_dict(self, mdict, server, function, *args, **kwargs): 301 | """ 302 | Call function on handlers for specific servers in one of our handler 303 | dictionaries. 304 | 305 | @param mdict Dictionary to announce to 306 | @param server Server to announce to, ALL is always implied 307 | @param function Function the handler should call 308 | @param args Arguments for the function 309 | @param kwargs Keyword arguments for the function 310 | """ 311 | 312 | # Announce to all handlers of the given serverlist 313 | if server == self.MAGIC_ALL: 314 | servers = iter(mdict.keys()) 315 | else: 316 | servers = [self.MAGIC_ALL, server] 317 | 318 | for server in servers: 319 | try: 320 | for queue, handlers in mdict[server].items(): 321 | for handler in handlers: 322 | self.__call_remote(queue, handler, function, *args, **kwargs) 323 | except KeyError: 324 | # No handler registered for that server 325 | pass 326 | 327 | def __call_remote(self, queue, handler, function, *args, **kwargs): 328 | try: 329 | func = getattr(handler, function) # Find out what to call on target 330 | queue.put((None, func, args, kwargs)) 331 | except AttributeError as e: 332 | mod = self.queues.get(queue, None) 333 | myname = "" 334 | for name, mymod in self.modules.items(): 335 | if mod == mymod: 336 | myname = name 337 | if myname: 338 | self.log().error("Handler class registered by module '%s' does not handle function '%s'. Call failed.", 339 | myname, function) 340 | else: 341 | self.log().exception(e) 342 | 343 | # 344 | # -- Module multiplexing functionality 345 | # 346 | 347 | @local_thread 348 | def announceConnected(self, meta=None): 349 | """ 350 | Call connected handler on all handlers 351 | """ 352 | self.meta = meta 353 | for queue, module in self.queues.items(): 354 | self.__call_remote(queue, module, "connected") 355 | 356 | @local_thread 357 | def announceDisconnected(self): 358 | """ 359 | Call disconnected handler on all handlers 360 | """ 361 | for queue, module in self.queues.items(): 362 | self.__call_remote(queue, module, "disconnected") 363 | 364 | @local_thread 365 | def announceMeta(self, server, function, *args, **kwargs): 366 | """ 367 | Call a function on the meta handlers 368 | 369 | @param server Server to announce to 370 | @param function Name of the function to call on the handler 371 | @param args List of arguments 372 | @param kwargs List of keyword arguments 373 | """ 374 | self.__announce_to_dict(self.metaCallbacks, server, function, *args, **kwargs) 375 | 376 | @local_thread 377 | def announceServer(self, server, function, *args, **kwargs): 378 | """ 379 | Call a function on the server handlers 380 | 381 | @param server Server to announce to 382 | @param function Name of the function to call on the handler 383 | @param args List of arguments 384 | @param kwargs List of keyword arguments 385 | """ 386 | self.__announce_to_dict(self.serverCallbacks, server, function, *args, **kwargs) 387 | 388 | # 389 | # --- Module self management functionality 390 | # 391 | 392 | @local_thread 393 | def subscribeMetaCallbacks(self, queue, handler, servers): 394 | """ 395 | @param queue Target worker queue 396 | @see MumoManagerRemote 397 | """ 398 | return self.__add_to_dict(self.metaCallbacks, queue, handler, servers) 399 | 400 | @local_thread 401 | def unsubscribeMetaCallbacks(self, queue, handler, servers): 402 | """ 403 | @param queue Target worker queue 404 | @see MumoManagerRemote 405 | """ 406 | return self.__rem_from_dict(self.metaCallbacks, queue, handler, servers) 407 | 408 | @local_thread 409 | def subscribeServerCallbacks(self, queue, handler, servers): 410 | """ 411 | @param queue Target worker queue 412 | @see MumoManagerRemote 413 | """ 414 | return self.__add_to_dict(self.serverCallbacks, queue, handler, servers) 415 | 416 | @local_thread 417 | def unsubscribeServerCallbacks(self, queue, handler, servers): 418 | """ 419 | @param queue Target worker queue 420 | @see MumoManagerRemote 421 | """ 422 | return self.__rem_from_dict(self.serverCallbacks, queue, handler, servers) 423 | 424 | def getMurmurModule(self): 425 | """ 426 | Returns the Murmur module generated from the slice file 427 | """ 428 | return self.murmur 429 | 430 | def createContextCallback(self, callback, *ctx): 431 | """ 432 | Creates a new context callback handler class instance. 433 | 434 | @param callback Callback to set for handler 435 | @param *ctx Additional context parameters passed to callback 436 | before the actual parameters. 437 | @return Murmur ServerContextCallbackPrx object for the context 438 | callback handler class. 439 | """ 440 | contextcbprx = self.client_adapter.addWithUUID(self.context_callback_type(callback, *ctx)) 441 | contextcb = self.murmur.ServerContextCallbackPrx.uncheckedCast(contextcbprx) 442 | 443 | return contextcb 444 | 445 | def getMeta(self): 446 | """ 447 | Returns the connected servers meta module or None if it is not available 448 | """ 449 | return self.meta 450 | 451 | # --- Module load/start/stop/unload functionality 452 | # 453 | @local_thread_blocking 454 | @debug_log(debug_me) 455 | def loadModules(self, names=None): 456 | """ 457 | Loads a list of modules from the mumo directory structure by name. 458 | 459 | @param names List of names of modules to load 460 | @return: List of modules loaded 461 | """ 462 | loadedmodules = {} 463 | 464 | if not names: 465 | # If no names are given load all modules that have a configuration in the cfg_dir 466 | if not os.path.isdir(self.cfg.modules.cfg_dir): 467 | msg = "Module configuration directory '%s' not found" % self.cfg.modules.cfg_dir 468 | self.log().error(msg) 469 | raise FailedLoadModuleImportException(msg) 470 | 471 | names = [] 472 | for f in os.listdir(self.cfg.modules.cfg_dir): 473 | if os.path.isfile(self.cfg.modules.cfg_dir + f): 474 | base, ext = os.path.splitext(f) 475 | if not ext or ext.lower() == ".ini" or ext.lower() == ".conf": 476 | names.append(base) 477 | 478 | for name in names: 479 | try: 480 | modinst = self._loadModule_noblock(name) 481 | loadedmodules[name] = modinst 482 | except FailedLoadModuleException: 483 | pass 484 | 485 | return loadedmodules 486 | 487 | @local_thread_blocking 488 | def loadModuleCls(self, name, modcls, module_cfg=None): 489 | return self._loadModuleCls_noblock(name, modcls, module_cfg) 490 | 491 | @debug_log(debug_me) 492 | def _loadModuleCls_noblock(self, name, modcls, module_cfg=None): 493 | log = self.log() 494 | 495 | if name in self.modules: 496 | log.error("Module '%s' already loaded", name) 497 | return 498 | 499 | modqueue = queue.Queue() 500 | modmanager = MumoManagerRemote(self, name, modqueue) 501 | 502 | try: 503 | modinst = modcls(name, modmanager, module_cfg) 504 | except Exception as e: 505 | msg = "Module '%s' failed to initialize" % name 506 | log.error(msg) 507 | log.exception(e) 508 | raise FailedLoadModuleInitializationException(msg) 509 | 510 | # Remember it 511 | self.modules[name] = modinst 512 | self.queues[modqueue] = modinst 513 | 514 | return modinst 515 | 516 | @local_thread_blocking 517 | def loadModule(self, name): 518 | """ 519 | Loads a single module either by name 520 | 521 | @param name Name of the module to load 522 | @return Module instance 523 | """ 524 | self._loadModule_noblock(name) 525 | 526 | @debug_log(debug_me) 527 | def _loadModule_noblock(self, name): 528 | # Make sure this module is not already loaded 529 | log = self.log() 530 | log.debug("loadModuleByName('%s')", name) 531 | 532 | if name in self.modules: 533 | log.warning("Tried to load already loaded module %s", name) 534 | return 535 | 536 | # Check whether there is a configuration file for this module 537 | confpath = self.cfg.modules.cfg_dir + name + '.ini' 538 | if not os.path.isfile(confpath): 539 | msg = "Module configuration file '%s' not found" % confpath 540 | log.error(msg) 541 | raise FailedLoadModuleConfigException(msg) 542 | 543 | # Make sure the module directory is in our python path and exists 544 | if not self.cfg.modules.mod_dir in sys.path: 545 | if not os.path.isdir(self.cfg.modules.mod_dir): 546 | msg = "Module directory '%s' not found" % self.cfg.modules.mod_dir 547 | log.error(msg) 548 | raise FailedLoadModuleImportException(msg) 549 | sys.path.insert(0, self.cfg.modules.mod_dir) 550 | 551 | # Import the module and instanciate it 552 | try: 553 | mod = __import__(name) 554 | self.imports[name] = mod 555 | except ImportError as e: 556 | msg = "Failed to import module '%s', reason: %s" % (name, str(e)) 557 | log.error(msg) 558 | raise FailedLoadModuleImportException(msg) 559 | 560 | try: 561 | try: 562 | modcls = mod.mumo_module_class # First check if there's a magic mumo_module_class variable 563 | log.debug("Magic mumo_module_class found") 564 | except AttributeError: 565 | modcls = getattr(mod, name) 566 | except AttributeError: 567 | msg = "Module does not contain required class '%s'" % name 568 | log.error(msg) 569 | raise FailedLoadModuleInitializationException(msg) 570 | 571 | return self._loadModuleCls_noblock(name, modcls, confpath) 572 | 573 | @local_thread_blocking 574 | @debug_log(debug_me) 575 | def startModules(self, names=None): 576 | """ 577 | Start a module by name 578 | 579 | @param names List of names of modules to start 580 | @return A dict of started module names and instances 581 | """ 582 | log = self.log() 583 | startedmodules = {} 584 | 585 | if not names: 586 | # If no names are given start all models 587 | names = iter(self.modules.keys()) 588 | 589 | for name in names: 590 | try: 591 | modinst = self.modules[name] 592 | if not modinst.is_alive(): 593 | modinst.start() 594 | log.debug("Module '%s' started", name) 595 | else: 596 | log.debug("Module '%s' already running", name) 597 | startedmodules[name] = modinst 598 | except KeyError: 599 | log.error("Could not start unknown module '%s'", name) 600 | 601 | return startedmodules 602 | 603 | @local_thread_blocking 604 | @debug_log(debug_me) 605 | def stopModules(self, names=None, force=False): 606 | """ 607 | Stop a list of modules by name. Note that this only works 608 | for well behaved modules. At this point if a module is really going 609 | rampant you will have to restart mumo. 610 | 611 | @param names List of names of modules to unload 612 | @param force Unload the module asap dropping messages queued for it 613 | @return A dict of stopped module names and instances 614 | """ 615 | log = self.log() 616 | stoppedmodules = {} 617 | 618 | if not names: 619 | # If no names are given start all models 620 | names = iter(self.modules.keys()) 621 | 622 | for name in names: 623 | try: 624 | modinst = self.modules[name] 625 | stoppedmodules[name] = modinst 626 | except KeyError: 627 | log.warning("Asked to stop unknown module '%s'", name) 628 | continue 629 | 630 | if force: 631 | # We will have to drain the modules queues 632 | for queue, module in self.queues.items(): 633 | if module in self.modules: 634 | try: 635 | while queue.get_nowait(): pass 636 | except queue.Empty: 637 | pass 638 | 639 | for modinst in stoppedmodules.values(): 640 | if modinst.is_alive(): 641 | modinst.stop() 642 | log.debug("Module '%s' is being stopped", name) 643 | else: 644 | log.debug("Module '%s' already stopped", name) 645 | 646 | for modinst in stoppedmodules.values(): 647 | modinst.join(timeout=self.cfg.modules.timeout) 648 | 649 | return stoppedmodules 650 | 651 | def stop(self, force=True): 652 | """ 653 | Stops all modules and shuts down the manager. 654 | """ 655 | self.log().debug("Stopping") 656 | self.stopModules() 657 | Worker.stop(self, force) 658 | -------------------------------------------------------------------------------- /mumo_manager_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import unittest 33 | from logging import getLogger 34 | from threading import Event 35 | 36 | from mumo_manager import MumoManager 37 | from mumo_module import MumoModule 38 | 39 | 40 | class MumoManagerTest(unittest.TestCase): 41 | def setUp(self): 42 | l = getLogger("MumoManager") 43 | l.disabled = True 44 | 45 | class MyModule(MumoModule): 46 | def __init__(self, name, manager, configuration=None): 47 | MumoModule.__init__(self, name, manager, configuration) 48 | 49 | self.estarted = Event() 50 | self.estopped = Event() 51 | self.econnected = Event() 52 | self.edisconnected = Event() 53 | 54 | self.emeta = Event() 55 | self.econtext = Event() 56 | self.eserver = Event() 57 | 58 | def onStart(self): 59 | self.estarted.set() 60 | 61 | def onStop(self): 62 | self.estopped.set() 63 | 64 | def connected(self): 65 | man = self.manager() 66 | man.subscribeMetaCallbacks(self) 67 | man.subscribeServerCallbacks(self) 68 | self.econnected.set() 69 | 70 | def disconnected(self): 71 | self.edisconnected.set() 72 | 73 | def metaCallMe(self, arg1, arg2): 74 | if arg1 == "arg1" and arg2 == "arg2": 75 | self.emeta.set() 76 | 77 | def contextCallMe(self, server, arg1, arg2): 78 | if arg1 == "arg1" and arg2 == "arg2": 79 | self.econtext.set() 80 | 81 | def serverCallMe(self, server, arg1, arg2): 82 | if arg1 == "arg1" and arg2 == "arg2": 83 | self.eserver.set() 84 | 85 | self.mymod = MyModule 86 | 87 | class conf(object): 88 | pass # Dummy class 89 | 90 | self.cfg = conf() 91 | self.cfg.test = 10 92 | 93 | # 94 | # --- Helpers for independent test env creation 95 | # 96 | def up(self): 97 | man = MumoManager(None, None) 98 | man.start() 99 | 100 | mod = man.loadModuleCls("MyModule", self.mymod, self.cfg) 101 | man.startModules() 102 | 103 | return man, mod 104 | 105 | def down(self, man, mod): 106 | man.stopModules() 107 | man.stop() 108 | man.join(timeout=1) 109 | 110 | # 111 | # --- Tests 112 | # 113 | def testModuleStarted(self): 114 | man, mod = self.up() 115 | 116 | mod.estarted.wait(timeout=1) 117 | assert (mod.estarted.is_set()) 118 | 119 | self.down(man, mod) 120 | 121 | def testModuleStopStart(self): 122 | man, mod = self.up() 123 | 124 | tos = ["MyModule"] 125 | self.assertEqual(list(man.stopModules(tos).keys()), tos) 126 | mod.estopped.wait(timeout=1) 127 | assert (mod.estopped.is_set()) 128 | 129 | self.down(man, mod) 130 | 131 | def testModuleConnectAndDisconnect(self): 132 | man, mod = self.up() 133 | 134 | man.announceConnected() 135 | mod.econnected.wait(timeout=1) 136 | assert (mod.econnected.is_set()) 137 | man.announceDisconnected() 138 | mod.edisconnected.wait(timeout=1) 139 | assert (mod.edisconnected.is_set()) 140 | 141 | self.down(man, mod) 142 | 143 | def testMetaCallback(self): 144 | man, mod = self.up() 145 | man.announceConnected() 146 | mod.econnected.wait(timeout=1) 147 | assert (mod.econnected.is_set()) 148 | man.announceMeta(man.MAGIC_ALL, "metaCallMe", "arg1", arg2="arg2") 149 | mod.emeta.wait(timeout=1) 150 | assert (mod.emeta.is_set()) 151 | man.announceDisconnected() 152 | self.down(man, mod) 153 | 154 | # FIXME: Test ContextCallbacks correctly 155 | # def testContextCallback(self): 156 | # man, mod = self.up() 157 | # man.announceConnected() 158 | # mod.econnected.wait(timeout=1) 159 | # assert (mod.econnected.is_set()) 160 | # man.announceContext(man.MAGIC_ALL, "contextCallMe", "server", "arg1", arg2="arg2") 161 | # mod.econtext.wait(timeout=1) 162 | # assert (mod.econtext.is_set()) 163 | # man.announceDisconnected() 164 | # self.down(man, mod) 165 | 166 | def testServerCallback(self): 167 | man, mod = self.up() 168 | man.announceConnected() 169 | mod.econnected.wait(timeout=1) 170 | assert (mod.econnected.is_set()) 171 | man.announceServer(man.MAGIC_ALL, "serverCallMe", "server", "arg1", arg2="arg2") 172 | mod.eserver.wait(timeout=1) 173 | assert (mod.eserver.is_set()) 174 | man.announceDisconnected() 175 | self.down(man, mod) 176 | 177 | def tearDown(self): 178 | pass 179 | 180 | 181 | if __name__ == "__main__": 182 | # import sys;sys.argv = ['', 'Test.testName'] 183 | unittest.main() 184 | -------------------------------------------------------------------------------- /mumo_module.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | from config import (Config) 33 | 34 | from worker import Worker 35 | 36 | 37 | class MumoModule(Worker): 38 | default_config = {} 39 | 40 | def __init__(self, name, manager, configuration=None): 41 | Worker.__init__(self, name, manager.getQueue()) 42 | self.__manager = manager 43 | 44 | if isinstance(configuration, str): 45 | # If we are passed a string expect a config file there 46 | if configuration: 47 | self.__cfg = Config(configuration, self.default_config) 48 | elif self.default_config: 49 | self.__cfg = Config(default=self.default_config) 50 | else: 51 | self.__cfg = None 52 | else: 53 | # If we aren't passed a string it will be a config object or None 54 | self.__cfg = configuration 55 | 56 | self.log().info("Initialized") 57 | 58 | # --- Accessors 59 | def manager(self): 60 | return self.__manager 61 | 62 | def cfg(self): 63 | return self.__cfg 64 | 65 | # --- Module control 66 | 67 | def onStart(self): 68 | self.log().info("Start") 69 | 70 | def onStop(self): 71 | self.log().info("Stop") 72 | 73 | # --- Events 74 | 75 | def connected(self): 76 | # Called once the Ice connection to the murmur server 77 | # is established. 78 | # 79 | # All event registration should happen here 80 | 81 | pass 82 | 83 | def disconnected(self): 84 | # Called once a loss of Ice connectivity is detected. 85 | # 86 | 87 | pass 88 | 89 | 90 | def logModFu(fu): 91 | def new_fu(self, *args, **kwargs): 92 | log = self.log() 93 | argss = '' if len(args) == 0 else ',' + ','.join(['"%s"' % str(arg) for arg in args]) 94 | kwargss = '' if len(kwargs) == 0 else ','.join('%s="%s"' % (kw, str(arg)) for kw, arg in kwargs.items()) 95 | log.debug("%s(%s%s%s)", fu.__name__, str(self), argss, kwargss) 96 | return fu(self, *args, **kwargs) 97 | 98 | return new_fu 99 | -------------------------------------------------------------------------------- /testsuite.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | if __name__ == "__main__": 33 | from modules.source.db_test import * 34 | 35 | # import sys;sys.argv = ['', 'Test.testName'] 36 | unittest.main() 37 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- 1 | # No real module, just here to keep pydev and its 2 | # test runner happy. 3 | -------------------------------------------------------------------------------- /tools/mbf2man.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | # 33 | # mbf2man.py 34 | # This small programm is for creating a possible channel/acl structure for 35 | # the mumo bf2 module as well as the corresponding bf2.ini configuration file. 36 | 37 | import os 38 | import sys 39 | import tempfile 40 | from optparse import OptionParser 41 | 42 | import Ice 43 | import IcePy 44 | 45 | # Default settings 46 | 47 | if __name__ == "__main__": 48 | parser = OptionParser() 49 | parser.add_option('-t', '--target', 50 | help='Host to connect to', default="127.0.0.1") 51 | parser.add_option('-p', '--port', 52 | help='Port to connect to', default="6502") 53 | parser.add_option('-b', '--base', 54 | help='Channel id of the base channel', default='0') 55 | parser.add_option('-v', '--vserver', 56 | help='Virtual server id', default='1') 57 | parser.add_option('-i', '--ice', 58 | help='Path to slice file', default='Murmur.ice') 59 | parser.add_option('-s', '--secret', 60 | help='Ice secret', default='') 61 | parser.add_option('-l', '--linkteams', action='store_true', 62 | help='Link teams so opposing players can hear each other', default=False) 63 | parser.add_option('-n', '--name', 64 | help='Treename', default='BF2') 65 | parser.add_option('-o', '--out', default='bf2.ini', 66 | help='File to output configuration to') 67 | parser.add_option('-d', '--slicedir', 68 | help='System slice directory used when getSliceDir is not available', default='/usr/share/slice') 69 | (option, args) = parser.parse_args() 70 | 71 | host = option.target 72 | slicedir = option.slicedir 73 | try: 74 | port = int(option.port) 75 | except ValueError: 76 | print("Port value '%s' is invalid" % option.port) 77 | sys.exit(1) 78 | 79 | try: 80 | basechan = int(option.base) 81 | if basechan < 0: raise ValueError 82 | except ValueError: 83 | print("Base channel value '%s' invalid" % option.base) 84 | sys.exit(1) 85 | 86 | try: 87 | sid = int(option.vserver) 88 | if sid < 1: raise ValueError 89 | except ValueError: 90 | print("Virtual server id value '%s' invalid" % option.vserver) 91 | sys.exit(1) 92 | 93 | name = option.name 94 | 95 | prxstr = "Meta:tcp -h %s -p %d -t 1000" % (host, port) 96 | secret = option.secret 97 | 98 | props = Ice.createProperties(sys.argv) 99 | props.setProperty("Ice.ImplicitContext", "Shared") 100 | idata = Ice.InitializationData() 101 | idata.properties = props 102 | 103 | ice = Ice.initialize(idata) 104 | prx = ice.stringToProxy(prxstr) 105 | print("Done") 106 | 107 | 108 | def lslice(slf): 109 | if not hasattr(Ice, "getSliceDir"): 110 | Ice.loadSlice('-I%s %s' % (slicedir, slf)) 111 | else: 112 | Ice.loadSlice('', ['-I' + Ice.getSliceDir(), slf]) 113 | 114 | 115 | try: 116 | print("Trying to retrieve slice dynamically from server...", end=' ') 117 | op = IcePy.Operation('getSlice', Ice.OperationMode.Idempotent, Ice.OperationMode.Idempotent, True, (), (), (), 118 | IcePy._t_string, ()) 119 | if hasattr(Ice, "getSliceDir"): 120 | slice = op.invoke(prx, ((), None)) 121 | else: 122 | slice = op.invoke(prx, (), None) 123 | (dynslicefiledesc, dynslicefilepath) = tempfile.mkstemp(suffix='.ice') 124 | dynslicefile = os.fdopen(dynslicefiledesc, 'w') 125 | dynslicefile.write(slice) 126 | dynslicefile.flush() 127 | lslice(dynslicefilepath) 128 | dynslicefile.close() 129 | os.remove(dynslicefilepath) 130 | print("Success") 131 | except Exception as e: 132 | print("Failed") 133 | print(str(e)) 134 | slicefile = option.ice 135 | print("Load slice (%s)..." % slicefile, end=' ') 136 | lslice(slicefile) 137 | print("Done") 138 | 139 | print("Import dynamically compiled murmur class...", end=' ') 140 | import Murmur # pylint: disable=E0401 # pyright: ignore 141 | 142 | print("Done") 143 | print("Establish ice connection...", end=' ') 144 | 145 | if secret: 146 | print("[protected]...", end=' ') 147 | ice.getImplicitContext().put("secret", secret) 148 | 149 | murmur = Murmur.MetaPrx.checkedCast(prx) 150 | print("Done") 151 | 152 | print("Get server...", end=' ') 153 | server = murmur.getServer(sid) 154 | print("Done (%d)" % sid) 155 | 156 | ini = {'mumble_server': sid, 'name': name, 'ipport_filter': '.*'} 157 | 158 | print("Creating channel structure:") 159 | ACL = Murmur.ACL 160 | EAT = Murmur.PermissionEnter | Murmur.PermissionTraverse 161 | W = Murmur.PermissionWhisper 162 | S = Murmur.PermissionSpeak 163 | print(name) 164 | ini['left'] = basechan 165 | gamechan = server.addChannel(name, basechan) 166 | 167 | # Relevant function signatures 168 | # Murmur.ACL(self, applyHere=False, applySubs=False, 169 | # inherited=False, userid=0, group='', allow=0, deny=0) 170 | 171 | # server.setACL(self, channelid, acls, groups, inherit, _ctx=None) 172 | # 173 | server.setACL(gamechan, 174 | [ACL(applyHere=True, 175 | applySubs=True, 176 | userid=-1, 177 | group='all', 178 | deny=EAT | W | S), 179 | ACL(applyHere=True, 180 | applySubs=True, 181 | userid=-1, 182 | group='~bf2_%s_game' % name, 183 | allow=S), 184 | ACL(applyHere=True, 185 | applySubs=False, 186 | userid=-1, 187 | group='~bf2_%s_game' % name, 188 | allow=EAT | W)], 189 | [], True) 190 | 191 | gamechanstate = server.getChannelState(gamechan) 192 | 193 | teams = { 194 | "opfor": "Team 1", 195 | "blufor": "Team 2" 196 | } 197 | id_to_squad_name = { 198 | "no": "No Squad", 199 | "first": "Squad 1", 200 | "second": "Squad 2", 201 | "third": "Squad 3", 202 | "fourth": "Squad 4", 203 | "fifth": "Squad 5", 204 | "sixth": "Squad 6", 205 | "seventh": "Squad 7", 206 | "eighth": "Squad 8", 207 | "ninth": "Squad 9" 208 | } 209 | for team, team_name in list(teams.items()): 210 | print(name + "/" + team_name) 211 | cid = server.addChannel(team_name, gamechan) 212 | teamchanstate = server.getChannelState(cid) 213 | if option.linkteams: 214 | gamechanstate.links.append(cid) 215 | 216 | ini[team] = cid 217 | 218 | server.setACL(ini[team], 219 | [ACL(applyHere=True, 220 | applySubs=False, 221 | userid=-1, 222 | group='~bf2_team', 223 | allow=EAT | W)], 224 | [], True) 225 | 226 | print(name + "/" + team_name + "/Commander") 227 | cid = server.addChannel("Commander", ini[team]) 228 | teamchanstate.links.append(cid) 229 | ini[team + "_commander"] = cid 230 | 231 | server.setACL(ini[team + "_commander"], 232 | [ACL(applyHere=True, 233 | applySubs=False, 234 | userid=-1, 235 | group='~bf2_commander', 236 | allow=EAT | W), 237 | ACL(applyHere=True, 238 | applySubs=False, 239 | userid=-1, 240 | group='~bf2_squad_leader', 241 | allow=W)], 242 | [], True) 243 | 244 | state = server.getChannelState(ini[team + "_commander"]) 245 | state.position = -1 246 | server.setChannelState(state) 247 | 248 | for squad, squad_name in list(id_to_squad_name.items()): 249 | print(name + "/" + team_name + "/" + squad_name) 250 | cid = server.addChannel(squad_name, ini[team]) 251 | teamchanstate.links.append(cid) 252 | ini[team + "_" + squad + "_squad"] = cid 253 | 254 | ini[team + "_" + squad + "_squad_leader"] = ini[team + "_" + squad + "_squad"] 255 | server.setACL(ini[team + "_" + squad + "_squad"], 256 | [ACL(applyHere=True, 257 | applySubs=False, 258 | userid=-1, 259 | group='~bf2_%s_squad' % squad, 260 | allow=EAT | W), 261 | ACL(applyHere=True, 262 | applySubs=False, 263 | userid=-1, 264 | group='~bf2_commander', 265 | allow=EAT | W), 266 | ACL(applyHere=True, 267 | applySubs=False, 268 | userid=-1, 269 | group='~bf2_squad_leader', 270 | allow=W)], 271 | [], True) 272 | server.setChannelState(teamchanstate) 273 | server.setChannelState(gamechanstate) 274 | print("Channel structure created") 275 | 276 | print("Writing configuration to output file '%s'..." % option.out, end=' ') 277 | f = open(option.out, "w") 278 | print("; Configuration created by mbf2man\n", file=f) 279 | print("[bf2]\ngamecount = 1\n", file=f) 280 | print("[g0]", file=f) 281 | 282 | for key in sorted(ini): 283 | value = ini[key] 284 | print("%s = %s" % (key, value), file=f) 285 | 286 | f.close() 287 | print("Done") 288 | -------------------------------------------------------------------------------- /worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | from logging import getLogger 33 | from queue import Queue, Empty 34 | from threading import Thread 35 | 36 | 37 | def local_thread(fu): 38 | """ 39 | Decorator which makes a function execute in the local worker thread 40 | Return values are discarded 41 | """ 42 | 43 | def new_fu(*args, **kwargs): 44 | self = args[0] 45 | self.message_queue().put((None, fu, args, kwargs)) 46 | 47 | return new_fu 48 | 49 | 50 | def local_thread_blocking(fu, timeout=None): 51 | """ 52 | Decorator which makes a function execute in the local worker thread 53 | The function will block until return values are available or timeout 54 | seconds passed. 55 | 56 | @param timeout Timeout in seconds 57 | """ 58 | 59 | def new_fu(*args, **kwargs): 60 | self = args[0] 61 | out = Queue() 62 | self.message_queue().put((out, fu, args, kwargs)) 63 | ret, ex = out.get(True, timeout) 64 | if ex: 65 | raise ex 66 | 67 | return ret 68 | 69 | return new_fu 70 | 71 | 72 | class Worker(Thread): 73 | def __init__(self, name, message_queue=None): 74 | """ 75 | Implementation of a basic Queue based Worker thread. 76 | 77 | @param name Name of the thread to run the worker in 78 | @param message_queue Message queue on which to receive commands 79 | """ 80 | 81 | Thread.__init__(self, name=name) 82 | self.daemon = True 83 | self.__in = message_queue if message_queue != None else Queue() 84 | self.__log = getLogger(name) 85 | self.__name = name 86 | 87 | # --- Accessors 88 | def log(self): 89 | return self.__log 90 | 91 | def name(self): 92 | return self.__name 93 | 94 | def message_queue(self): 95 | return self.__in 96 | 97 | # --- Overridable convience stuff 98 | def onStart(self): 99 | """ 100 | Override this function to perform actions on worker startup 101 | """ 102 | pass 103 | 104 | def onStop(self): 105 | """ 106 | Override this function to perform actions on worker shutdown 107 | """ 108 | pass 109 | 110 | # --- Thread / Control 111 | def run(self): 112 | self.log().debug("Enter message loop") 113 | self.onStart() 114 | while True: 115 | msg = self.__in.get() 116 | if msg is None: 117 | break 118 | 119 | (out, fu, args, kwargs) = msg 120 | try: 121 | res = fu(*args, **kwargs) 122 | ex = None 123 | except Exception as e: 124 | self.log().exception(e) 125 | res = None 126 | ex = e 127 | finally: 128 | if out is not None: 129 | out.put((res, ex)) 130 | 131 | self.onStop() 132 | self.log().debug("Leave message loop") 133 | 134 | def stop(self, force=True): 135 | if force: 136 | try: 137 | while True: 138 | self.__in.get_nowait() 139 | except Empty: 140 | pass 141 | 142 | self.__in.put(None) 143 | 144 | # --- Helpers 145 | 146 | @local_thread 147 | def call_by_name(self, handler, function_name, *args, **kwargs): 148 | return getattr(handler, function_name)(*args, **kwargs) 149 | 150 | @local_thread_blocking 151 | def call_by_name_blocking(self, handler, function_name, *args, **kwargs): 152 | return getattr(handler, function_name)(*args, **kwargs) 153 | -------------------------------------------------------------------------------- /worker_test.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 3 | 4 | # Copyright (C) 2010 Stefan Hacker 5 | # All rights reserved. 6 | # 7 | # Redistribution and use in source and binary forms, with or without 8 | # modification, are permitted provided that the following conditions 9 | # are met: 10 | 11 | # - Redistributions of source code must retain the above copyright notice, 12 | # this list of conditions and the following disclaimer. 13 | # - Redistributions in binary form must reproduce the above copyright notice, 14 | # this list of conditions and the following disclaimer in the documentation 15 | # and/or other materials provided with the distribution. 16 | # - Neither the name of the Mumble Developers nor the names of its 17 | # contributors may be used to endorse or promote products derived from this 18 | # software without specific prior written permission. 19 | 20 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | # `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR 24 | # CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 25 | # EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 26 | # PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 27 | # PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 28 | # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 29 | # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 30 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | 32 | import logging 33 | import unittest 34 | from logging import ERROR 35 | from logging.handlers import BufferingHandler 36 | from queue import Queue 37 | from threading import Event 38 | from time import sleep 39 | 40 | from worker import Worker, local_thread, local_thread_blocking 41 | 42 | 43 | class WorkerTest(unittest.TestCase): 44 | def setUp(self): 45 | def set_ev(fu): 46 | def new_fu(*args, **kwargs): 47 | s = args[0] 48 | s.event.set() 49 | s.val = (args, kwargs) 50 | return fu(*args, **kwargs) 51 | 52 | return new_fu 53 | 54 | class ATestWorker(Worker): 55 | def __init__(self, name, message_queue): 56 | Worker.__init__(self, name, message_queue) 57 | self.event = Event() 58 | self.val = None 59 | self.started = False 60 | self.stopped = False 61 | 62 | @local_thread 63 | @set_ev 64 | def echo(self, val): 65 | return val 66 | 67 | @local_thread_blocking 68 | @set_ev 69 | def echo_block(self, val): 70 | return val 71 | 72 | def onStart(self): 73 | self.started = True 74 | 75 | def onStop(self): 76 | self.stopped = True 77 | 78 | @local_thread 79 | def raise_(self, ex): 80 | raise ex 81 | 82 | @local_thread_blocking 83 | def raise_blocking(self, ex): 84 | raise ex 85 | 86 | @set_ev 87 | def call_me_by_name(self, arg1, arg2): 88 | return 89 | 90 | def call_me_by_name_blocking(self, arg1, arg2): 91 | return arg1, arg2 92 | 93 | self.buha = BufferingHandler(10000) 94 | 95 | q = Queue() 96 | self.q = q 97 | 98 | NAME = "Test" 99 | l = logging.getLogger(NAME) 100 | 101 | self.w = ATestWorker(NAME, q) 102 | self.assertEqual(self.w.log(), l) 103 | 104 | l.propagate = 0 105 | l.addHandler(self.buha) 106 | 107 | self.assertFalse(self.w.started) 108 | self.w.start() 109 | sleep(0.05) 110 | self.assertTrue(self.w.started) 111 | 112 | def testName(self): 113 | assert (self.w.name() == "Test") 114 | 115 | def testMessageQueue(self): 116 | assert (self.w.message_queue() == self.q) 117 | 118 | def testLocalThread(self): 119 | s = "Testing" 120 | self.w.event.clear() 121 | self.w.echo(s) 122 | self.w.event.wait(5) 123 | args, kwargs = self.w.val 124 | 125 | assert (args[1] == s) 126 | 127 | def testLocalThreadException(self): 128 | self.buha.flush() 129 | self.w.raise_(Exception()) 130 | sleep(0.1) # hard delay 131 | assert (len(self.buha.buffer) != 0) 132 | assert (self.buha.buffer[0].levelno == ERROR) 133 | 134 | def testCallByName(self): 135 | self.w.event.clear() 136 | self.w.call_by_name(self.w, "call_me_by_name", "arg1", arg2="arg2") 137 | self.w.event.wait(5) 138 | args, kwargs = self.w.val 139 | 140 | assert (args[1] == "arg1") 141 | assert (kwargs["arg2"] == "arg2") 142 | 143 | def testLocalThreadBlocking(self): 144 | s = "Testing" 145 | assert (s == self.w.echo_block(s)) 146 | 147 | def testLocalThreadExceptionBlocking(self): 148 | class TestException(Exception): pass 149 | 150 | self.assertRaises(TestException, self.w.raise_blocking, TestException()) 151 | 152 | def testCallByNameBlocking(self): 153 | arg1, arg2 = self.w.call_by_name_blocking(self.w, "call_me_by_name_blocking", "arg1", arg2="arg2") 154 | 155 | assert (arg1 == "arg1") 156 | assert (arg2 == "arg2") 157 | 158 | def tearDown(self): 159 | assert (self.w.stopped is False) 160 | self.w.stop() 161 | self.w.join(5) 162 | assert self.w.stopped 163 | 164 | 165 | if __name__ == "__main__": 166 | # import sys;sys.argv = ['', 'Test.testName'] 167 | unittest.main() 168 | --------------------------------------------------------------------------------