diff --git a/README.md b/README.md index 85473ab..bee26d6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ mumblepy ======== -Python Mumble for Humans™. +WORK IN PROGRESS diff --git a/mumble/__init__.py b/mumble/__init__.py new file mode 100644 index 0000000..a2f98fe --- /dev/null +++ b/mumble/__init__.py @@ -0,0 +1,3 @@ +from .meta import Meta +from .hooks import * +from .server import Server \ No newline at end of file diff --git a/mumble/channel.py b/mumble/channel.py new file mode 100644 index 0000000..26bf88f --- /dev/null +++ b/mumble/channel.py @@ -0,0 +1,23 @@ +class Channel(object): + def __init__(self, server, channel): + self.__server = server + self.__channel = channel + + def delete(self): + self.__server.remove_channel(self.__channel.id) + + def update(self, **kwargs): + for key, value in kwargs.items(): + setattr(self.__channel, key, value) + self.__server.set_channel_state(self.__channel) + + def serialize(self): + return { + 'id': self.__channel.id, + 'parent': self.__channel.parent, + 'links': self.__channel.links, + 'name': self.__channel.name, + 'description': self.__channel.description, + 'temporary': self.__channel.temporary, + 'position': self.__channel.position, + } \ No newline at end of file diff --git a/mumble/hooks.py b/mumble/hooks.py new file mode 100644 index 0000000..d02e0f2 --- /dev/null +++ b/mumble/hooks.py @@ -0,0 +1,117 @@ +class MetaCallback(object): + definition = ('MetaCallback', 'addCallback', 'removeCallback') + + def __init__(self, meta): + self.meta = meta + + def started(self, server): + """Called when a server is started. The server is up and running when this event is sent, + so all methods that need a running server will work.""" + pass + + def stopped(self, server): + """Called when a server is stopped. The server is already stopped when this event is sent, + so no methods that need a running server will work.""" + pass + + +class ServerCallback(object): + definition = ('ServerCallback', 'addCallback', 'removeCallback') + + def __init__(self, server_id): + self.id = server_id + + def user_connected(self, state): + """Called when a user connects to the server. """ + pass + + def user_disconnected(self, state): + """Called when a user disconnects from the server.""" + pass + + def user_state_changed(self, state): + """Called when a user state changes. This is called if the user moves, is renamed, is muted, + deafened etc.""" + pass + + def user_text_message(self, state, message): + """Called when user writes a text message.""" + pass + + def channel_created(self, state): + """Called when a new channel is created.""" + pass + + def channel_removed(self, state): + """Called when a channel is removed.""" + pass + + def channel_state_changed(self, state): + """Called when a new channel state changes. This is called if the channel is moved, renamed + or if new links are added.""" + pass + + +class ServerContextCallback(object): + definition = ('ServerContextCallback', 'addContextCallback', 'removeContextCallback') + + def __init__(self, server_id): + self.id = server_id + + def context_action(self, action, user, session, channelid): + pass + + +class ServerAuthenticator(object): + definition = ('ServerAuthenticator', 'setAuthenticator', None) + fallthrough_values = dict( + authenticate=(-2, None, None), + get_info=(False, None,), + name_to_id=-2, + id_to_name='', + id_to_texture=None, + ) + + def __init__(self, server_id): + self.id = server_id + + def authenticate(self, name, password, certificates, certhash, certstrong): + raise NotImplementedError + + def get_info(self, user_id): + raise NotImplementedError + + def name_to_id(self, name): + raise NotImplementedError + + def id_to_name(self, user_id): + raise NotImplementedError + + def id_to_texture(self, user_id): + raise NotImplementedError + + +class ServerUpdatingAuthenticator(ServerAuthenticator): + definition = ('ServerUpdatingAuthenticator', 'setAuthenticator', None) + fallthrough_values = dict( + register_user=-2, + unregister_user=-1, + get_registered_users={}, + set_info=-1, + set_texture=-1, + ) + + def register_user(self, info): + raise NotImplementedError + + def unregister_user(self, user_id): + raise NotImplementedError + + def get_registered_users(self, filter): + raise NotImplementedError + + def set_info(self, user_id, info): + raise NotImplementedError + + def set_texture(self, user_id, texture): + raise NotImplementedError \ No newline at end of file diff --git a/mumble/iceutil.py b/mumble/iceutil.py new file mode 100644 index 0000000..03747d2 --- /dev/null +++ b/mumble/iceutil.py @@ -0,0 +1,37 @@ +import re + + +def ice_method(attr): + # Convert event name from CamelCase to underscores. + attr = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', attr) + attr = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', attr).lower() + # Strip out the `current` value which is the final one alwaus. + return lambda self, *args: getattr(self.callback, attr)(*args[:-1]) + + +def ice_callback(name, bases, attrs): + def __init__(self, callback): + self.callback = callback + attrs['__init__'] = __init__ + + # Detect Ice methods and wrap in more pythonic callbacks. + for base in bases: + for attr in dir(base): + if attr.startswith('_op_') and not attr.startswith('_op_ice_'): + attr = attr[4:] + attrs[attr] = ice_method(attr) + + return type(name, bases, attrs) + + +_ice_class_cache = {} + + +def ice_init(from_, name, *args, **kwargs): + try: + cls = _ice_class_cache[name] + except KeyError: + class MurmurClass(getattr(from_, name)): + __metaclass__ = ice_callback + cls = _ice_class_cache[name] = MurmurClass + return cls(*args, **kwargs) \ No newline at end of file diff --git a/mumble/meta.py b/mumble/meta.py new file mode 100644 index 0000000..ff520c1 --- /dev/null +++ b/mumble/meta.py @@ -0,0 +1,134 @@ +import Ice +import IcePy +import sys +import tempfile +import os +import logging +from .iceutil import ice_init +from .server import Server + + +class Logger(Ice.Logger): + def _print(self, message): + logging.info(message) + + def trace(self, category, message): + pass + + def warning(self, message): + logging.warning(message) + + def error(self, message): + logging.error(message) + + +class Meta(object): + def __init__(self, secret=None): + self.secret = secret + + self.__meta = None + self.__ice = None + self.__adapter = None + + self.connect() + + def __del__(self): + self.disconnect() + + def load_slice(self, proxy): + mumble_slice = IcePy.Operation( + 'getSlice', + Ice.OperationMode.Idempotent, + Ice.OperationMode.Idempotent, + True, + (), + (), + (), + IcePy._t_string, + () + ).invoke(proxy, ((), None)) + + _, temp = tempfile.mkstemp(suffix='.ice') + + with open(temp, 'w') as slice_file: + slice_file.write(mumble_slice) + slice_file.flush() + Ice.loadSlice('', ['-I' + Ice.getSliceDir(), temp]) + + os.remove(temp) + + def connect(self): + init_data = Ice.InitializationData() + init_data.properties = Ice.createProperties(sys.argv) + init_data.properties.setProperty('Ice.ImplicitContext', 'Shared') + init_data.logger = Logger() + + self.__ice = Ice.initialize(init_data) + + if self.secret: + self.__ice.getImplicitContext().put('secret', self.secret) + + self.__adapter = self.__ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h 127.0.0.1') + self.__adapter.activate() + + proxy = self.__ice.stringToProxy('Meta:tcp -h 127.0.0.1 -p 6502') + + self.load_slice(proxy) + + import Murmur + self.__meta = Murmur.MetaPrx.checkedCast(proxy) + + def disconnect(self): + self.__ice.shutdown() + + def add_callback(self, callback): + import Murmur + callback_prx = Murmur.MetaCallbackPrx.uncheckedCast(self.__adapter.addWithUUID(callback)) + self.__meta.addCallback(callback_prx) + + def remove_callback(self, callback): + self.__meta.removeCallback(callback) + + def get_version(self): + return self.__meta.getVersion() + + def get_booted_servers(self): + return [Server(self, server) for server in self.__meta.getBootedServers()] + + def get_all_servers(self): + return [Server(self, server) for server in self.__meta.getAllServers()] + + def get_default_conf(self): + return self.__meta.getDefaultConf() + + def new_server(self): + return Server(self, self.__meta.newServer()) + + def get_server(self, server_id): + server = self.__meta.getServer(server_id) + if server: + return Server(self, server) + return None + + def get_uptime(self): + return self.__meta.getUptime() + + def add_hook(self, cls): + return self.add_hook_to(self.__meta, cls, self) + + def add_hook_to(self, target, cls, *args, **kwargs): + import Murmur + name, add_func_name, _ = cls.definition + hook = ice_init(Murmur, name, cls(*args, **kwargs)) + hook_with_uuid = self.__adapter.addWithUUID(hook) + hook_prx = getattr(Murmur, '%sPrx' % name).checkedCast(hook_with_uuid) + return getattr(target, add_func_name)(hook_prx) + + def remove_hook(self, cls, hook_prx): + self.remove_hook_from(self.__meta, cls, hook_prx) + + def remove_hook_from(self, target, cls, hook_prx): + _, _, remove_func_name = cls.definition + if not remove_func_name: + return + getattr(target, remove_func_name).addCallback(hook_prx) diff --git a/mumble/server.py b/mumble/server.py new file mode 100644 index 0000000..9c381d8 --- /dev/null +++ b/mumble/server.py @@ -0,0 +1,96 @@ +from .user import User +from .channel import Channel + + +class Server(object): + def __init__(self, meta, server): + self.id = server.id() + + self.__meta = meta + self.__server = server + + def __len__(self): + return + + @property + def running(self): + return bool(self.__server.isRunning()) + + def start(self): + if not self.running: + return self.__server.start() + + def stop(self): + if self.running: + return self.__server.stop() + + def delete(self): + self.stop() + return self.__server.delete() + + # Conf + + def get_all_conf(self): + conf = self.__meta.get_default_conf() + conf.update(self.__server.getAllConf()) + return conf + + def get_conf(self, key): + return self.__server.getConf(key) + + def set_conf(self, key, value): + return self.__server.setConf(key, value) + + # Channels + + def get_channels(self): + return [Channel(self, channel) for channel in self.__server.getChannels().values()] + + def get_channel(self, channel_id): + channel = self.__server.getChannelState(channel_id) + + if channel is None: + return None + + return Channel(self, channel) + + def set_channel_state(self, channel): + self.__server.setChannelState(channel) + + def add_channel(self, name, parent): + return self.__server.addChannel(name, parent) + + def remove_channel(self, channel_id): + self.__server.removeChannel(channel_id) + + # Users + + def get_users(self): + return [User(self, user) for user in self.__server.getUsers().values()] + + def get_user(self, session): + user = self.__server.getState(session) + + if user is None: + return None + + return User(self, user) + + def kick_user(self, session, reason=''): + self.__server.kickUser(session, reason) + + # Bans + + def get_bans(self): + return self.__server.getBans() + + def set_bans(self, bans): + self.__server.setBans(bans) + + # Hooks + + def add_hook(self, cls): + self.__meta.add_hook_to(self.__server, cls, self.id) + + def remove_hook(self, cls, hook): + self.__meta.remove_hook_from(self.__server, cls, hook) \ No newline at end of file diff --git a/mumble/user.py b/mumble/user.py new file mode 100644 index 0000000..106ae6a --- /dev/null +++ b/mumble/user.py @@ -0,0 +1,38 @@ +import time + + +class User(object): + def __init__(self, server, user): + self.__server = server + self.__user = user + + def ban(self, reason='', bits=128, duration=360): + from Murmur import Ban + bans = self.__server.get_bans() + bans.append(Ban( + reason=reason, + bits=bits, + duration=duration, + start=int(time.time()), + address=self.__user.address, + )) + self.__server.set_bans(bans) + + def serialize(self): + return { + 'session': self.__user.session, + 'id': self.__user.userid, + 'priority_speaker': self.__user.prioritySpeaker, + 'mute': self.__user.mute, + 'deaf': self.__user.deaf, + 'suppress': self.__user.suppress, + 'channel': self.__user.channel, + 'name': self.__user.name, + 'online_secs': self.__user.onlinesecs, + 'comment': self.__user.comment, + 'self_mute': self.__user.selfMute, + 'self_deaf': self.__user.selfDeaf, + 'idle_secs': self.__user.idlesecs, + 'ip': '.'.join(map(unicode, self.__user.address[-4:])), + 'os': self.__user.osversion + } \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e135e9e --- /dev/null +++ b/setup.py @@ -0,0 +1,27 @@ +# coding=utf-8 + +from setuptools import setup, find_packages + +DESCRIPTION = 'Python Mumble for Humans™' + +with open('README.md') as f: + LONG_DESCRIPTION = f.read() + +VERSION = '0.1.0' + +setup( + name='mumble', + version=VERSION, + packages=find_packages(), + author='Stanislav Vishnevskiy', + author_email='vishnevskiy@gmail.com', + url='https://github.com/vishnevskiy/mumblepy', + license='MIT', + include_package_data=True, + description=DESCRIPTION, + long_description=LONG_DESCRIPTION, + install_requires=[], + platforms=['any'], + classifiers=[], + test_suite='tests', +)