More cleanup, merged pymumble into the project so I don't have to maintain 2 separate things, tested working version.

This commit is contained in:
Storm Dragon
2025-06-14 00:23:09 -04:00
parent bfbfe30be4
commit 947ec68754
36 changed files with 5861 additions and 551 deletions

11
pymumble_py3/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
#
# pymumble_py3 - Integrated from https://github.com/azlux/pymumble
# This is a complete copy of the pymumble library integrated into Bragi
# to avoid external dependency issues after the original project maintenance stopped.
#
import os
# Fix protobuf compatibility issue with Python 3.13
os.environ.setdefault('PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION', 'python')
from .mumble import Mumble

140
pymumble_py3/acl.py Normal file
View File

@@ -0,0 +1,140 @@
# -*- coding: utf-8 -*-
import time
from .errors import ACLChanGroupNotExist
from threading import Lock
from . import messages
class ACL(dict):
def __init__(self, mumble_object, channel_id):
self.mumble_object = mumble_object
self.channel_id = channel_id # channel id attached to the ACLS
self.inherit_acls = False
self.groups = {}
self.acls = {}
self.lock = Lock()
def update(self, message):
self.lock.acquire()
self.inherit_acls = bool(message.inherit_acls)
for msg_group in message.groups:
if msg_group.name in self.groups:
self.groups[msg_group.name].update(msg_group)
else:
self.groups[msg_group.name] = ChanGroup()
self.groups[msg_group.name].update(msg_group)
for msg_acl in message.acls:
if msg_acl.group in self.acls:
self.acls[msg_acl.group].update(msg_acl)
else:
self.acls[msg_acl.group] = ChanACL()
self.acls[msg_acl.group].update(msg_acl)
self.lock.release()
def request_group_update(self, group_name):
if group_name not in self.groups:
self.mumble_object.channels[self.channel_id].request_acl()
i = 0
while group_name not in self.groups and i < 20:
time.sleep(0.2)
i += 1
if i == 20:
raise ACLChanGroupNotExist(group_name)
def add_user(self, group_name, user_id):
self.request_group_update(group_name)
if user_id not in self.groups[group_name].add:
self.groups[group_name].add.append(user_id)
self.send_update()
def del_user(self, group_name, user_id):
self.request_group_update(group_name)
self.groups[group_name].add.remove(user_id)
self.send_update()
def add_remove_user(self, group_name, user_id):
self.request_group_update(group_name)
if user_id not in self.groups[group_name].remove:
self.groups[group_name].remove.append(user_id)
self.send_update()
def del_remove_user(self, group_name, user_id):
self.request_group_update(group_name)
self.groups[group_name].remove.remove(user_id)
self.send_update()
def send_update(self):
all_groups = self.groups.items()
res_group = [vars(i[1]) for i in all_groups] # Transform the Class into a dictionary
all_acls = self.acls.items()
res_acl = [vars(i[1]) for i in all_acls] # Transform the Class into a dictionary
cmd = messages.UpdateACL(channel_id=self.channel_id, inherit_acls=self.inherit_acls, chan_group=res_group, chan_acl=res_acl)
self.mumble_object.execute_command(cmd)
class ChanGroup(dict):
"""Object that stores and update all ChanGroups ACL"""
def __init__(self):
self.name = None
self.acl = None
self.inherited = None
self.inherit = None
self.inheritable = None
self.add = []
self.remove = []
self.inherited_members = []
def update(self, message):
"""Update a ACL information, based on the incoming message"""
self.name = str(message.name)
if message.HasField('inherit'):
self.inherit = bool(message.inherit)
if message.HasField('inherited'):
self.inherited = bool(message.inherited)
if message.HasField('inheritable'):
self.inheritable = bool(message.inheritable)
if message.add:
for user in message.add:
self.add.append(int(user))
if message.remove:
for user in message.remove:
self.remove.append(int(user))
if message.inherited_members:
for user in message.inherited_members:
self.inherited_members.append(int(user))
class ChanACL(dict):
"""Object that stores and update all ChanACL ACL"""
def __init__(self):
self.apply_here = None
self.apply_subs = None
self.inherited = None
self.user_id = None
self.group = None
self.grant = None
self.deny = None
def update(self, message):
"""Update a ACL information, based on the incoming message"""
if message.HasField('apply_here'):
self.apply_here = bool(message.apply_here)
if message.HasField('apply_subs'):
self.apply_subs = bool(message.apply_subs)
if message.HasField('inherited'):
self.inherited = bool(message.inherited)
if message.HasField('user_id'):
self.user_id = int(message.user_id)
if message.HasField('group'):
self.group = str(message.group)
if message.HasField('grant'):
self.grant = int(message.grant)
if message.HasField('deny'):
self.deny = int(message.deny)

42
pymumble_py3/blobs.py Normal file
View File

@@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
import struct
from .constants import *
from .mumble_pb2 import RequestBlob
class Blobs(dict):
"""
Manage the Blob library
"""
def __init__(self, mumble_object):
self.mumble_object = mumble_object
def get_user_comment(self, hash):
"""Request the comment of a user"""
if hash in self:
return
request = RequestBlob()
request.session_comment.extend(struct.unpack("!5I", hash))
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)
def get_user_texture(self, hash):
"""Request the image of a user"""
if hash in self:
return
request = RequestBlob()
request.session_texture.extend(struct.unpack("!5I", hash))
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)
def get_channel_description(self, hash):
"""Request the description/comment of a channel"""
if hash in self:
return
request = RequestBlob()
request.channel_description.extend(struct.unpack("!5I", hash))
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_REQUESTBLOB, request)

94
pymumble_py3/callbacks.py Normal file
View File

@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
from .errors import UnknownCallbackError
from .constants import *
import threading
class CallBacks(dict):
"""
Define the callbacks that can be registered by the application.
Multiple functions can be assigned to a callback using "add_callback"
The call is done from within the pymumble loop thread, it's important to
keep processing short to avoid delays on audio transmission
"""
def __init__(self):
self.update({
PYMUMBLE_CLBK_CONNECTED: None, # Connection succeeded
PYMUMBLE_CLBK_DISCONNECTED: None, # Connection as been dropped
PYMUMBLE_CLBK_CHANNELCREATED: None, # send the created channel object as parameter
PYMUMBLE_CLBK_CHANNELUPDATED: None, # send the updated channel object and a dict with all the modified fields as parameter
PYMUMBLE_CLBK_CHANNELREMOVED: None, # send the removed channel object as parameter
PYMUMBLE_CLBK_USERCREATED: None, # send the added user object as parameter
PYMUMBLE_CLBK_USERUPDATED: None, # send the updated user object and a dict with all the modified fields as parameter
PYMUMBLE_CLBK_USERREMOVED: None, # send the removed user object and the mumble message as parameter
PYMUMBLE_CLBK_SOUNDRECEIVED: None, # send the user object that received the sound and the SoundChunk object itself
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED: None, # Send the received message
PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED: None, # Send the contextaction message
PYMUMBLE_CLBK_ACLRECEIVED: None, # Send the received ACL permissions object
PYMUMBLE_CLBK_PERMISSIONDENIED: None, # Permission Denied for some action, send information
})
def set_callback(self, callback, dest):
"""Define the function to call for a specific callback. Suppress any existing callback function"""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
self[callback] = [dest]
def add_callback(self, callback, dest):
"""Add the function to call for a specific callback."""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
if self[callback] is None:
self[callback] = list()
self[callback].append(dest)
def get_callback(self, callback):
"""Get the functions assigned to a callback as a list. Return None if no callback defined"""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
return self[callback]
def remove_callback(self, callback, dest):
"""Remove a specific function from a specific callback. Function object must be the one added before."""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
if self[callback] is None or dest not in self[callback]:
raise UnknownCallbackError("Function not registered for callback \"%s\"." % callback)
self[callback].remove(dest)
if len(self[callback]) == 0:
self[callback] = None
def reset_callback(self, callback):
"""remove functions for a defined callback"""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
self[callback] = None
def call_callback(self, callback, *pos_parameters):
"""Call all the registered function for a specific callback."""
if callback not in self:
raise UnknownCallbackError("Callback \"%s\" does not exists." % callback)
if self[callback]:
for func in self[callback]:
if callback is PYMUMBLE_CLBK_TEXTMESSAGERECEIVED:
thr = threading.Thread(target=func, args=pos_parameters)
thr.start()
else:
func(*pos_parameters)
def __call__(self, callback, *pos_parameters):
"""shortcut to be able to call the dict element as a function"""
self.call_callback(callback, *pos_parameters)
def get_callbacks_list(self):
"""Get a list of all callbacks"""
return list(self.keys())

274
pymumble_py3/channels.py Normal file
View File

@@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
from .constants import *
from threading import Lock
from .errors import UnknownChannelError, TextTooLongError, ImageTooBigError
from .acl import ACL
from . import messages
class Channels(dict):
"""
Object that Stores all channels and their properties.
"""
def __init__(self, mumble_object, callbacks):
self.mumble_object = mumble_object
self.callbacks = callbacks
self.lock = Lock()
def update(self, message):
"""Update the channel information based on an incoming message"""
self.lock.acquire()
if message.channel_id not in self: # create the channel
self[message.channel_id] = Channel(self.mumble_object, message)
self.callbacks(PYMUMBLE_CLBK_CHANNELCREATED, self[message.channel_id])
else: # update the channel
actions = self[message.channel_id].update(message)
self.callbacks(PYMUMBLE_CLBK_CHANNELUPDATED, self[message.channel_id], actions)
self.lock.release()
def remove(self, id):
"""Delete a channel when server signal the channel is removed"""
self.lock.acquire()
if id in self:
channel = self[id]
del self[id]
self.callbacks(PYMUMBLE_CLBK_CHANNELREMOVED, channel)
self.lock.release()
def find_by_tree(self, tree):
"""Find a channel by its full path (a list with an element for each leaf)"""
if not getattr(tree, '__iter__', False):
tree = tree # function use argument as a list
current = self[0]
for name in tree: # going up the tree
found = False
for subchannel in self.get_childs(current):
if subchannel["name"] == name:
current = subchannel
found = True
break
if not found: # channel not found
err = "Cannot find channel %s" % str(tree)
raise UnknownChannelError(err)
return current
def get_childs(self, channel):
"""Get the child channels of a channel in a list"""
childs = list()
for item in self.values():
if item.get('parent') is not None and item["parent"] == channel["channel_id"]:
childs.append(item)
return childs
def get_descendants(self, channel):
"""Get all the descendant of a channel, in nested lists"""
descendants = list()
for subchannel in self.get_childs(channel):
descendants.append(self.get_childs(subchannel))
return descendants
def get_tree(self, channel):
"""Get the whole list of channels, in a multidimensional list"""
tree = list()
current = channel
while current["channel_id"] != 0:
tree.insert(0, current)
current = self[current["channel_id"]]
tree.insert(0, self[0])
return tree
def find_by_name(self, name):
"""Find a channel by name. Stop on the first that match"""
if name == "":
return self[0]
for obj in list(self.values()):
if obj["name"] == name:
return obj
err = "Channel %s does not exists" % name
raise UnknownChannelError(err)
def new_channel(self, parent_id, name, temporary=False):
cmd = messages.CreateChannel(parent_id, name, temporary)
self.mumble_object.execute_command(cmd)
def remove_channel(self, channel_id):
cmd = messages.RemoveChannel(channel_id)
self.mumble_object.execute_command(cmd)
def unlink_every_channel(self):
"""
Unlink every channels in server.
So there will be no channel linked to other channel.
"""
for channel in list(self.values()):
if "links" in channel:
cmd = messages.UnlinkChannel({"channel_id": channel['channel_id'], "remove_ids": channel["links"]})
self.mumble_object.execute_command(cmd)
class Channel(dict):
"""
Stores information about one specific channel
"""
def __init__(self, mumble_object, message):
self.mumble_object = mumble_object
self["channel_id"] = message.channel_id
self.acl = ACL(mumble_object=mumble_object, channel_id=self["channel_id"])
self.update(message)
def get_users(self):
users = []
for user in list(self.mumble_object.users.values()):
if user["channel_id"] == self["channel_id"]:
users.append(user)
return users
def update(self, message):
"""Update a channel based on an incoming message"""
actions = dict()
for (field, value) in message.ListFields():
if field.name in ("session", "actor", "description_hash"):
continue
actions.update(self.update_field(field.name, value))
if message.HasField("description_hash"):
actions.update(self.update_field("description_hash", message.description_hash))
if message.HasField("description"):
self.mumble_object.blobs[message.description_hash] = message.description
else:
self.mumble_object.blobs.get_channel_description(message.description_hash)
return actions # return a dict with updates performed, useful for the callback functions
def update_acl(self, message):
self.acl.update(message)
def get_id(self):
return self["channel_id"]
def update_field(self, name, field):
"""Update one value"""
actions = dict()
if name not in self or self[name] != field:
self[name] = field
actions[name] = field
return actions # return a dict with updates performed, useful for the callback functions
def get_property(self, property):
if property in self:
return self[property]
else:
return None
def move_in(self, session=None):
"""Ask to move a session in a specific channel. By default move pymumble own session"""
if session is None:
session = self.mumble_object.users.myself_session
cmd = messages.MoveCmd(session, self["channel_id"])
self.mumble_object.execute_command(cmd)
def remove(self):
cmd = messages.RemoveChannel(self["channel_id"])
self.mumble_object.execute_command(cmd)
def send_text_message(self, message):
"""Send a text message to the channel."""
# TODO: This check should be done inside execute_command()
# However, this is currently not possible because execute_command() does
# not actually execute the command.
if len(message) > self.mumble_object.get_max_image_length() != 0:
raise ImageTooBigError(self.mumble_object.get_max_image_length())
if not ("<img" in message and "src" in message):
if len(message) > self.mumble_object.get_max_message_length() != 0:
raise TextTooLongError(self.mumble_object.get_max_message_length())
session = self.mumble_object.users.myself_session
cmd = messages.TextMessage(session, self["channel_id"], message)
self.mumble_object.execute_command(cmd)
def link(self, channel_id):
"""Link selected channel with other channel"""
cmd = messages.LinkChannel({"channel_id": self["channel_id"], "add_id": channel_id})
self.mumble_object.execute_command(cmd)
def unlink(self, channel_id):
"""Unlink one channel which is linked to a specific channel."""
cmd = messages.UnlinkChannel({"channel_id": self["channel_id"], "remove_ids": [channel_id]})
self.mumble_object.execute_command(cmd)
def unlink_all(self):
"""Unlink all channels which is linked to a specific channel."""
if "links" in self:
cmd = messages.UnlinkChannel({"channel_id": self["channel_id"], "remove_ids": self["links"]})
self.mumble_object.execute_command(cmd)
def rename_channel(self, name):
params = {
'channel_id': self['channel_id'],
'name': name
}
cmd = messages.UpdateChannel(params)
self.mumble_object.execute_command(cmd)
def move_channel(self, new_parent_id):
params = {
'channel_id': self['channel_id'],
'parent': new_parent_id
}
cmd = messages.UpdateChannel(params)
self.mumble_object.execute_command(cmd)
def set_channel_position(self, position):
params = {
'channel_id': self['channel_id'],
'position': position
}
cmd = messages.UpdateChannel(params)
self.mumble_object.execute_command(cmd)
def set_channel_max_users(self, max_users):
params = {
'channel_id': self['channel_id'],
'max_users': max_users
}
cmd = messages.UpdateChannel(params)
self.mumble_object.execute_command(cmd)
def set_channel_description(self, description):
params = {
'channel_id': self['channel_id'],
'description': description
}
cmd = messages.UpdateChannel(params)
self.mumble_object.execute_command(cmd)
def request_acl(self):
cmd = messages.QueryACL(self["channel_id"])
self.mumble_object.execute_command(cmd)

54
pymumble_py3/commands.py Normal file
View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
from threading import Lock
from collections import deque
class Commands:
"""
Store to commands to be sent to the murmur server,
from whatever tread.
Each command has it's own lock semaphore to signal is received an answer
"""
def __init__(self):
self.id = 0
self.queue = deque()
self.lock = Lock()
def new_cmd(self, cmd):
"""Add a command to the queue"""
self.lock.acquire()
self.id += 1
cmd.cmd_id = self.id
self.queue.appendleft(cmd)
cmd.lock.acquire()
self.lock.release()
return cmd.lock
def is_cmd(self):
"""Check if there is a command waiting in the queue"""
if len(self.queue) > 0:
return True
else:
return False
def pop_cmd(self):
"""Return the next command and remove it from the queue"""
self.lock.acquire()
if len(self.queue) > 0:
question = self.queue.pop()
self.lock.release()
return question
else:
self.lock.release()
return None
def answer(self, cmd):
"""Unlock the command to signal it's completion"""
cmd.lock.release()

99
pymumble_py3/constants.py Normal file
View File

@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import platform
import sys
PYMUMBLE_VERSION = "1.7"
# ============================================================================
# Tunable parameters
# ============================================================================
PYMUMBLE_CONNECTION_RETRY_INTERVAL = 10 # in sec
PYMUMBLE_AUDIO_PER_PACKET = float(20)/1000 # size of one audio packet in sec
PYMUMBLE_BANDWIDTH = 50 * 1000 # total outgoing bitrate in bit/seconds
PYMUMBLE_LOOP_RATE = 0.01 # pause done between two iteration of the main loop of the mumble thread, in sec
# should be small enough to manage the audio output, so smaller than PYMUMBLE_AUDIO_PER_PACKET
# ============================================================================
# Constants
# ============================================================================
PYMUMBLE_PROTOCOL_VERSION = (1, 4, 287)
PYMUMBLE_VERSION_STRING = "PyMumble %s" % PYMUMBLE_VERSION
PYMUMBLE_OS_STRING = "PyMumble %s" % PYMUMBLE_VERSION
PYMUMBLE_OS_VERSION_STRING = "Python %s - %s %s" % (sys.version, platform.system(), platform.release())
PYMUMBLE_PING_DELAY = 10 # interval between 2 pings in sec
PYMUMBLE_SAMPLERATE = 48000 # in hz
PYMUMBLE_SEQUENCE_DURATION = float(10)/1000 # in sec
PYMUMBLE_SEQUENCE_RESET_INTERVAL = 5 # in sec
PYMUMBLE_READ_BUFFER_SIZE = 4096 # how much bytes to read at a time from the control socket, in bytes
# client connection state
PYMUMBLE_CONN_STATE_NOT_CONNECTED = 0
PYMUMBLE_CONN_STATE_AUTHENTICATING = 1
PYMUMBLE_CONN_STATE_CONNECTED = 2
PYMUMBLE_CONN_STATE_FAILED = 3
# Mumble control messages types
PYMUMBLE_MSG_TYPES_VERSION = 0
PYMUMBLE_MSG_TYPES_UDPTUNNEL = 1
PYMUMBLE_MSG_TYPES_AUTHENTICATE = 2
PYMUMBLE_MSG_TYPES_PING = 3
PYMUMBLE_MSG_TYPES_REJECT = 4
PYMUMBLE_MSG_TYPES_SERVERSYNC = 5
PYMUMBLE_MSG_TYPES_CHANNELREMOVE = 6
PYMUMBLE_MSG_TYPES_CHANNELSTATE = 7
PYMUMBLE_MSG_TYPES_USERREMOVE = 8
PYMUMBLE_MSG_TYPES_USERSTATE = 9
PYMUMBLE_MSG_TYPES_BANLIST = 10
PYMUMBLE_MSG_TYPES_TEXTMESSAGE = 11
PYMUMBLE_MSG_TYPES_PERMISSIONDENIED = 12
PYMUMBLE_MSG_TYPES_ACL = 13
PYMUMBLE_MSG_TYPES_QUERYUSERS = 14
PYMUMBLE_MSG_TYPES_CRYPTSETUP = 15
PYMUMBLE_MSG_TYPES_CONTEXTACTIONMODIFY = 16
PYMUMBLE_MSG_TYPES_CONTEXTACTION = 17
PYMUMBLE_MSG_TYPES_USERLIST = 18
PYMUMBLE_MSG_TYPES_VOICETARGET = 19
PYMUMBLE_MSG_TYPES_PERMISSIONQUERY = 20
PYMUMBLE_MSG_TYPES_CODECVERSION = 21
PYMUMBLE_MSG_TYPES_USERSTATS = 22
PYMUMBLE_MSG_TYPES_REQUESTBLOB = 23
PYMUMBLE_MSG_TYPES_SERVERCONFIG = 24
# callbacks names
PYMUMBLE_CLBK_CONNECTED = "connected"
PYMUMBLE_CLBK_DISCONNECTED = "disconnected"
PYMUMBLE_CLBK_CHANNELCREATED = "channel_created"
PYMUMBLE_CLBK_CHANNELUPDATED = "channel_updated"
PYMUMBLE_CLBK_CHANNELREMOVED = "channel_remove"
PYMUMBLE_CLBK_USERCREATED = "user_created"
PYMUMBLE_CLBK_USERUPDATED = "user_updated"
PYMUMBLE_CLBK_USERREMOVED = "user_removed"
PYMUMBLE_CLBK_SOUNDRECEIVED = "sound_received"
PYMUMBLE_CLBK_TEXTMESSAGERECEIVED = "text_received"
PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED = "contextAction_received"
PYMUMBLE_CLBK_ACLRECEIVED = "acl_received"
PYMUMBLE_CLBK_PERMISSIONDENIED = "permission_denied"
# audio types
PYMUMBLE_AUDIO_TYPE_CELT_ALPHA = 0
PYMUMBLE_AUDIO_TYPE_PING = 1
PYMUMBLE_AUDIO_TYPE_SPEEX = 2
PYMUMBLE_AUDIO_TYPE_CELT_BETA = 3
PYMUMBLE_AUDIO_TYPE_OPUS = 4
PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE = "audio" # "voip"
# command names
PYMUMBLE_CMD_MOVE = "move"
PYMUMBLE_CMD_MODUSERSTATE = "update_user"
PYMUMBLE_CMD_TEXTMESSAGE = "text_message"
PYMUMBLE_CMD_TEXTPRIVATEMESSAGE = "text_private_message"
PYMUMBLE_CMD_LINKCHANNEL = "link"
PYMUMBLE_CMD_UNLINKCHANNEL = "unlink"
PYMUMBLE_CMD_QUERYACL = "get_acl"
PYMUMBLE_CMD_UPDATEACL = "update_acl"
PYMUMBLE_CMD_REMOVEUSER = "remove_user"
PYMUMBLE_CMD_UPDATECHANNEL = "update_channel"

383
pymumble_py3/crypto.py Normal file
View File

@@ -0,0 +1,383 @@
'''
OCB2 crypto, broadly following the implementation from Mumble
'''
from typing import Tuple
import struct
import time
from math import ceil
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
AES_BLOCK_SIZE = 128 // 8 # Number of bytes in a block
AES_KEY_SIZE_BITS = 128
AES_KEY_SIZE_BYTES = AES_KEY_SIZE_BITS // 8
SHIFTBITS = 63 # Shift size for S2 operation
MAX64 = (1 << 64) - 1 # Maximum value of uint64
class EncryptFailedException(Exception):
pass
class DecryptFailedException(Exception):
pass
class CryptStateOCB2:
"""
State tracker for AES-OCB2 crypto.
All encryption/decryption should be done through this class
and not the `ocb_*` functions.
A random key and IVs are chosen upon initialization; these can be
replaced using `set_key`.
Attributes intended for external access:
raw_key
encrypt_iv
decrypt_iv
decrypt_history
uiGood
uiLate
uiLost
tLastGood
"""
_raw_key: bytes # AES key; access through `raw_key` property
_aes: object # pycrypto AES cipher object, replaced when `raw_key` is changed
_encrypt_iv: bytearray # IV for encryption, access through `encrypt_iv` property
_decrypt_iv: bytearray # IV for decryption, access through `decrypt_iv` property
decrypt_history: bytearray # History of previous decrypt_iv values
# Statistics:
uiGood: int # Number of packets successfully decrypted
uiLate: int # Number of packets which arrived out of order
uiLost: int # Number of packets which did not arrive in order (may arrive late)
tLastGood: float # time.perf_counter() value for latest good packet
def __init__(self):
self.uiGood = 0
self.uiLate = 0
self.uiLost = 0
self.tLastGood = 0
self._raw_key = get_random_bytes(AES_KEY_SIZE_BYTES)
self._encrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
self._decrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
self._aes = None
self.decrypt_history = bytearray(0x100)
@property
def raw_key(self) -> bytes:
return self._raw_key
@raw_key.setter
def raw_key(self, rkey: bytes):
if len(rkey) != AES_KEY_SIZE_BYTES:
raise Exception('raw_key has wrong length')
self._raw_key = bytes(rkey)
self._aes = AES.new(key=self.raw_key, mode=AES.MODE_ECB)
@property
def encrypt_iv(self) -> bytearray:
return self._encrypt_iv
@encrypt_iv.setter
def encrypt_iv(self, eiv: bytearray):
if len(eiv) != AES_BLOCK_SIZE:
raise Exception('encrypt_iv wrong length')
self._encrypt_iv = bytearray(eiv)
@property
def decrypt_iv(self) -> bytearray:
return self._decrypt_iv
@decrypt_iv.setter
def decrypt_iv(self, div: bytearray):
if len(div) != AES_BLOCK_SIZE:
raise Exception('decrypt_iv has wrong length')
self._decrypt_iv = bytearray(div)
def gen_key(self):
"""
Randomly generate new keys
"""
self.raw_key = get_random_bytes(AES_KEY_SIZE_BYTES)
self.encrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
self.decrypt_iv = get_random_bytes(AES_BLOCK_SIZE)
def set_key(self, raw_key: bytes, encrypt_iv: bytearray, decrypt_iv: bytearray):
"""
Set new keys
Args:
raw_key: AES key
encrypt_iv: IV for encryption
decrypt_iv: IV for decrpytion
"""
self.raw_key = raw_key
self.encrypt_iv = encrypt_iv
self.decrypt_iv = decrypt_iv
def encrypt(self, source: bytes) -> bytes:
"""
Encrypt a message
Args:
source: The plaintext bytes to be encrypted
Returns:
Encrypted (ciphertext) bytes
Raises:
EncryptFailedException if `source` would result in a vulnerable packet
"""
eiv = increment_iv(self.encrypt_iv)
self.encrypt_iv = eiv
dst, tag = ocb_encrypt(self._aes, source, bytes(eiv))
head = bytes((eiv[0], *tag[:3]))
return head + dst
def decrypt(self, source: bytes, len_plain: int) -> bytes:
"""
Decrypt a message
Args:
source: The ciphertext bytes to be decrypted
len_plain: The length of the plaintext
Returns:
Decrypted (plaintext) bytes
Raises:
DecryptFailedException:
- if `source` is too short
- packet is out of order or duplicate
- packet was could have been tampered with
"""
if len(source) < 4:
raise DecryptFailedException('Source <4 bytes long!')
div = self.decrypt_iv.copy()
ivbyte = source[0]
late = False
lost = 0
if (div[0] + 1) & 0xFF == ivbyte:
# In order as expected.
if ivbyte > div[0]:
div[0] = ivbyte
elif ivbyte < div[0]:
div[0] = ivbyte
div = increment_iv(div, 1)
else:
raise DecryptFailedException('ivbyte == decrypt_iv[0]')
else:
# This is either out of order or a repeat.
diff = ivbyte - div[0]
if diff > 128:
diff -= 256
elif diff < -128:
diff += 256
if ivbyte < div[0] and -30 < diff < 0:
# Late packet, but no wraparound.
late = True
lost = -1
div[0] = ivbyte
elif ivbyte > div[0] and -30 < diff < 0:
# Last was 0x02, here comes 0xff from last round
late = True
lost = -1
div[0] = ivbyte
div = decrement_iv(div, 1)
elif ivbyte > div[0] and diff > 0:
# Lost a few packets, but beyond that we're good.
lost = ivbyte - div[0] - 1
div[0] = ivbyte
elif ivbyte < div[0] and diff > 0:
# Lost a few packets, and wrapped around
lost = 0x100 - div[0] + ivbyte - 1
div[0] = ivbyte
div = increment_iv(div, 1)
else:
raise DecryptFailedException('Lost too many packets?')
if self.decrypt_history[div[0]] == div[1]:
raise DecryptFailedException('decrypt_iv in history')
dst, tag = ocb_decrypt(self._aes, source[4:], bytes(div), len_plain)
if tag[:3] != source[1:4]:
raise DecryptFailedException('Tag did not match!')
self.decrypt_history[div[0]] = div[1]
if not late:
self.decrypt_iv = div
else:
self.uiLate += 1
self.uiGood += 1
self.uiLost += lost
self.tLastGood = time.perf_counter()
return dst
def ocb_encrypt(aes: object,
plain: bytes,
nonce: bytes,
*,
insecure=False,
) -> Tuple[bytes, bytes]:
"""
Encrypt a message.
This should be called from CryptStateOCB2.encrypt() and not independently.
Args:
aes: AES-ECB cipher object
plain: The plaintext bytes to be encrypted
nonce: The encryption IV
Returns:
Encrypted (ciphertext) bytes and tag
Raises:
EncryptFailedException if `source` would result in a vulnerable packet
"""
delta = aes.encrypt(nonce)
checksum = bytes(AES_BLOCK_SIZE)
plain_block = b''
pos = 0
encrypted = bytearray(ceil(len(plain) / AES_BLOCK_SIZE) * AES_BLOCK_SIZE)
while len(plain) - pos > AES_BLOCK_SIZE:
plain_block = plain[pos:pos + AES_BLOCK_SIZE]
delta = S2(delta)
encrypted_block = xor(delta, aes.encrypt(xor(delta, plain_block)))
checksum = xor(checksum, plain_block)
encrypted[pos:pos + AES_BLOCK_SIZE] = encrypted_block
pos += AES_BLOCK_SIZE
# Counter-cryptanalysis described in section 9 of https://eprint.iacr.org/2019/311
# For an attack, the second to last block (i.e. the last iteration of this loop)
# must be all 0 except for the last byte (which may be 0 - 128).
if not insecure and bytes(plain_block[:-1]) == bytes(AES_BLOCK_SIZE - 1):
raise EncryptFailedException('Insecure input block: ' +
'see section 9 of https://eprint.iacr.org/2019/311')
len_remaining = len(plain) - pos
delta = S2(delta)
pad_in = struct.pack('>QQ', 0, len_remaining * 8)
pad = aes.encrypt(xor(pad_in, delta))
plain_block = plain[pos:] + pad[len_remaining - AES_BLOCK_SIZE:]
checksum = xor(checksum, plain_block)
encrypted_block = xor(pad, plain_block)
encrypted[pos:] = encrypted_block
delta = xor(delta, S2(delta))
tag = aes.encrypt(xor(delta, checksum))
return encrypted, tag
def ocb_decrypt(aes: object,
encrypted: bytes,
nonce: bytes,
len_plain: int,
*,
insecure=False,
) -> Tuple[bytes, bytes]:
"""
Decrypt a message.
This should be called from CryptStateOCB2.decrypt() and not independently.
Args:
aes: AES-ECB cipher object
encrypted: The ciphertext bytes to be decrypted
nonce: The decryption IV
len_plain: The length of the desired plaintext
Returns:
Decrypted (plaintext) bytes and tag
Raises:
DecryptFailedException:
- if `source` is too short
- packet is out of order or duplicate
- packet was could have been tampered with
"""
delta = aes.encrypt(nonce)
checksum = bytes(AES_BLOCK_SIZE)
plain = bytearray(len_plain)
pos = 0
while len_plain - pos > AES_BLOCK_SIZE:
encrypted_block = encrypted[pos:pos + AES_BLOCK_SIZE]
delta = S2(delta)
tmp = aes.decrypt(xor(delta, encrypted_block))
plain_block = xor(delta, tmp)
checksum = xor(checksum, plain_block)
plain[pos:pos + AES_BLOCK_SIZE] = plain_block
pos += AES_BLOCK_SIZE
len_remaining = len_plain - pos
delta = S2(delta)
pad_in = struct.pack('>QQ', 0, len_remaining * 8)
pad = aes.encrypt(xor(pad_in, delta))
encrypted_zeropad = encrypted[pos:] + bytes(AES_BLOCK_SIZE - len_remaining)
plain_block = xor(encrypted_zeropad, pad)
checksum = xor(checksum, plain_block)
plain[pos:] = plain_block[:len_remaining]
# Counter-cryptanalysis described in section 9 of https://eprint.iacr.org/2019/311
# In an attack, the decrypted last block would need to equal `delta ^ len(128)`.
# With a bit of luck (or many packets), smaller values than 128 (i.e. non-full blocks) are also
# feasible, so we check `plain_block` instead of `plain`.
# Since our `len` only ever modifies the last byte, we simply check all remaining ones.
if not insecure and plain_block[:-1] == delta[:-1]:
raise DecryptFailedException('Possibly tampered/able block, discarding.')
delta = xor(delta, S2(delta))
tag = aes.encrypt(xor(delta, checksum))
return plain, tag
def increment_iv(iv: bytearray, start: int = 0) -> bytearray:
for i in range(start, AES_BLOCK_SIZE):
iv[i] = (iv[i] + 1) % 0x100
if iv[i] != 0:
break
return iv
def decrement_iv(iv: bytearray, start: int = 0) -> bytearray:
for i in range(start, AES_BLOCK_SIZE):
iv[i] = (iv[i] - 1) % 0x100
if iv[i] != 0xFF:
break
return iv
def xor(a: bytes, b: bytes) -> bytes:
return bytes(aa ^ bb for aa, bb in zip(a, b))
def S2(block: bytes) -> bytes:
ll, uu = struct.unpack('>QQ', block)
carry = ll >> 63
block = struct.pack('>QQ',
((ll << 1) | (uu >> 63)) & MAX64,
((uu << 1) ^ (carry * 0x87)) & MAX64)
return block

101
pymumble_py3/errors.py Normal file
View File

@@ -0,0 +1,101 @@
# -*- coding: utf-8 -*-
class CodecNotSupportedError(Exception):
"""Thrown when receiving an audio packet from an unsupported codec"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class ConnectionRejectedError(Exception):
"""Thrown when server reject the connection"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class InvalidFormatError(Exception):
"""Thrown when receiving a packet not understood"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class UnknownCallbackError(Exception):
"""Thrown when asked for an unknown callback"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class UnknownChannelError(Exception):
"""Thrown when using an unknown channel"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class InvalidSoundDataError(Exception):
"""Thrown when trying to send an invalid audio pcm data"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class InvalidVarInt(Exception):
"""Thrown when trying to decode an invalid varint"""
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
class TextTooLongError(Exception):
"""Thrown when trying to send a message which is longer than allowed"""
def __init__(self, value):
self.value = value
def __str__(self):
return 'Maximum Text allowed length: {}'.format(self.value)
class ImageTooBigError(Exception):
"""Thrown when trying to send a message or image which is longer than allowed"""
def __init__(self, value):
self.value = value
def __str__(self):
return 'Maximum Text/Image allowed length: {}'.format(self.value)
class ACLChanGroupNotExist(Exception):
"""Thrown when trying to update an non-existant ACL ChanGroup"""
def __init__(self, value):
self.value = value
def __str__(self):
return 'ACL ChanGroup does not exist: {}'.format(self.value)

158
pymumble_py3/messages.py Normal file
View File

@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
from .constants import *
from threading import Lock
class Cmd:
"""
Define a command object, used to ask an action from the pymumble thread,
usually to forward to the murmur server
"""
def __init__(self):
self.cmd_id = None
self.lock = Lock()
self.cmd = None
self.parameters = None
self.response = None
class MoveCmd(Cmd):
"""Command to move a user from channel"""
def __init__(self, session, channel_id):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_MOVE
self.parameters = {"session": session,
"channel_id": channel_id}
class TextMessage(Cmd):
"""Command to send a text message"""
def __init__(self, session, channel_id, message):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_TEXTMESSAGE
self.parameters = {"session": session,
"channel_id": channel_id,
"message": message}
class TextPrivateMessage(Cmd):
"""Command to send a private text message"""
def __init__(self, session, message):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_TEXTPRIVATEMESSAGE
self.parameters = {"session": session,
"message": message}
class ModUserState(Cmd):
"""Command to change a user state"""
def __init__(self, session, params):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_MODUSERSTATE
self.parameters = params
class RemoveUser(Cmd):
"""Command to kick (ban=False) or ban (ban=True) a user"""
def __init__(self, session, params):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_REMOVEUSER
self.parameters = params
class CreateChannel(Cmd):
"""Command to create channel"""
def __init__(self, parent, name, temporary):
Cmd.__init__(self)
self.cmd = PYMUMBLE_MSG_TYPES_CHANNELSTATE
self.parameters = {"parent": parent,
"name": name,
"temporary": temporary}
class RemoveChannel(Cmd):
"""Command to create channel"""
def __init__(self, channel_id):
Cmd.__init__(self)
self.cmd = PYMUMBLE_MSG_TYPES_CHANNELREMOVE
self.parameters = {"channel_id": channel_id}
class UpdateChannel(Cmd):
"""Command to update channel"""
def __init__(self, params):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_UPDATECHANNEL
self.parameters = params
class VoiceTarget(Cmd):
"""Command to create a whisper"""
def __init__(self, voice_id, targets):
Cmd.__init__(self)
self.cmd = PYMUMBLE_MSG_TYPES_VOICETARGET
self.parameters = {"id": voice_id,
"targets": targets}
class LinkChannel(Cmd):
"""Command to link channel"""
def __init__(self, params):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_LINKCHANNEL
self.parameters = params
class UnlinkChannel(Cmd):
"""Command to unlink channel"""
def __init__(self, params):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_UNLINKCHANNEL
self.parameters = params
class QueryACL(Cmd):
"""Command to query ACL information for channel"""
def __init__(self, channel_id):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_QUERYACL
self.parameters = {"channel_id": channel_id}
class UpdateACL(Cmd):
"""Command to Update ACL information for channel"""
def __init__(self, channel_id, inherit_acls, chan_group, chan_acl):
Cmd.__init__(self)
self.cmd = PYMUMBLE_CMD_UPDATEACL
self.parameters = {"channel_id": channel_id,
"inherit_acls": inherit_acls,
"chan_group": chan_group,
"chan_acl": chan_acl}

802
pymumble_py3/mumble.py Normal file
View File

@@ -0,0 +1,802 @@
# -*- coding: utf-8 -*-
import threading
import logging
import time
import select
import socket
import ssl
import struct
from .errors import *
from .constants import *
from . import users
from . import channels
from . import blobs
from . import commands
from . import callbacks
from . import tools
from . import mumble_pb2
def _wrap_socket(sock, keyfile=None, certfile=None, verify_mode=ssl.CERT_NONE, server_hostname=None):
try:
ssl_context = ssl.create_default_context()
if certfile:
ssl_context.load_cert_chain(certfile, keyfile)
ssl_context.check_hostname = (verify_mode != ssl.CERT_NONE) and (server_hostname is not None)
ssl_context.verify_mode = verify_mode
return ssl_context.wrap_socket(sock, server_hostname=server_hostname)
except AttributeError:
try:
return ssl.wrap_socket(sock, keyfile, certfile, cert_reqs=verify_mode, ssl_version=ssl.PROTOCOL_TLS)
except AttributeError:
return ssl.wrap_socket(sock, keyfile, certfile, cert_reqs=verify_mode, ssl_version=ssl.PROTOCOL_TLSv1)
class Mumble(threading.Thread):
"""
Mumble client library main object.
basically a thread
"""
def __init__(self, host, user, port=64738, password='', certfile=None, keyfile=None, reconnect=False, tokens=None, stereo=False, debug=False, client_type=0):
"""
host=mumble server hostname or address
port=mumble server port
user=user to use for the connection
password=password for the connection
certfile=client certificate to authenticate the connection
keyfile=private key associated with the client certificate
reconnect=if True, try to reconnect if disconnected
tokens=channel access tokens as a list of strings
stereo=enable stereo transmission
debug=if True, send debugging messages (lot of...) to the stdout
client_type=if 1, flag connection as bot
"""
# TODO: use UDP audio
threading.Thread.__init__(self)
if tokens is None:
tokens = []
self.Log = logging.getLogger("PyMumble") # logging object for errors and debugging
if debug:
self.Log.setLevel(logging.DEBUG)
else:
self.Log.setLevel(logging.ERROR)
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s-%(name)s-%(levelname)s-%(message)s')
ch.setFormatter(formatter)
self.Log.addHandler(ch)
self.parent_thread = threading.current_thread() # main thread of the calling application
self.mumble_thread = None # thread of the mumble client library
self.host = host
self.port = port
self.user = user
self.password = password
self.certfile = certfile
self.keyfile = keyfile
self.reconnect = reconnect
self.tokens = tokens
self.__opus_profile = PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE
self.stereo = stereo
self.client_type = client_type
if stereo:
self.Log.debug("Working in STEREO mode.")
else:
self.Log.debug("Working in MONO mode.")
self.receive_sound = False # set to True to treat incoming audio, otherwise it is simply ignored
self.loop_rate = PYMUMBLE_LOOP_RATE
self.application = PYMUMBLE_VERSION_STRING
self.callbacks = callbacks.CallBacks() # callbacks management
self.ready_lock = threading.Lock() # released when the connection is fully established with the server
self.ready_lock.acquire()
self.positional = None
def init_connection(self):
"""Initialize variables that are local to a connection, (needed if the client automatically reconnect)"""
self.ready_lock.acquire(False) # reacquire the ready-lock in case of reconnection
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
self.control_socket = None
self.media_socket = None # Not implemented - for UDP media
self.bandwidth = PYMUMBLE_BANDWIDTH # reset the outgoing bandwidth to it's default before connecting
self.server_max_bandwidth = None
self.udp_active = False
# defaults according to https://wiki.mumble.info/wiki/Murmur.ini
self.server_allow_html = True
self.server_max_message_length = 5000
self.server_max_image_message_length = 131072
self.users = users.Users(self, self.callbacks) # contains the server's connected users information
self.channels = channels.Channels(self, self.callbacks) # contains the server's channels information
self.blobs = blobs.Blobs(self) # manage the blob objects
if self.receive_sound:
from . import soundoutput
self.sound_output = soundoutput.SoundOutput(self, PYMUMBLE_AUDIO_PER_PACKET, self.bandwidth, stereo=self.stereo, opus_profile=self.__opus_profile) # manage the outgoing sounds
else:
self.sound_output = None
self.commands = commands.Commands() # manage commands sent between the main and the mumble threads
self.receive_buffer = bytes() # initialize the control connection input buffer
self.ping_stats = {"last_rcv": 0, "time_send": 0, "nb": 0, "avg": 40.0, "var": 0.0} # Set / reset ping stats
def run(self):
"""Connect to the server and start the loop in its thread. Retry if requested"""
self.mumble_thread = threading.current_thread()
# loop if auto-reconnect is requested
while True:
self.init_connection() # reset the connection-specific object members
if self.connect() >= PYMUMBLE_CONN_STATE_FAILED: # some error occurred, exit here
self.ready_lock.release()
if not self.reconnect or not self.parent_thread.is_alive():
raise ConnectionRejectedError("Connection error with the Mumble (murmur) Server")
else:
time.sleep(PYMUMBLE_CONNECTION_RETRY_INTERVAL)
continue
try:
self.loop()
except socket.error:
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
if not self.reconnect or not self.parent_thread.is_alive():
self.callbacks(PYMUMBLE_CLBK_DISCONNECTED)
break
self.callbacks(PYMUMBLE_CLBK_DISCONNECTED)
time.sleep(PYMUMBLE_CONNECTION_RETRY_INTERVAL)
def connect(self):
"""Connect to the server"""
try:
# Get IPv4/IPv6 server address
server_info = socket.getaddrinfo(self.host, self.port, type=socket.SOCK_STREAM)
# Connect the SSL tunnel
self.Log.debug("connecting to %s (%s) on port %i.", self.host, server_info[0][1], self.port)
std_sock = socket.socket(server_info[0][0], socket.SOCK_STREAM)
std_sock.settimeout(10)
except socket.error:
self.connected = PYMUMBLE_CONN_STATE_FAILED
return self.connected
# FIXME: Default verify_mode and server_hostname are not safe, as no
# certificate checks are performed.
self.control_socket = _wrap_socket(std_sock, self.keyfile, self.certfile)
try:
self.control_socket.connect((self.host, self.port))
self.control_socket.setblocking(False)
# Perform the Mumble authentication
version = mumble_pb2.Version()
if PYMUMBLE_PROTOCOL_VERSION[2] > 255:
version.version_v1 = (PYMUMBLE_PROTOCOL_VERSION[0] << 16) + (PYMUMBLE_PROTOCOL_VERSION[1] << 8) + 255
else:
version.version_v1 = (PYMUMBLE_PROTOCOL_VERSION[0] << 16) + (PYMUMBLE_PROTOCOL_VERSION[1] << 8) + (PYMUMBLE_PROTOCOL_VERSION[2])
version.version_v2 = (PYMUMBLE_PROTOCOL_VERSION[0] << 48) + (PYMUMBLE_PROTOCOL_VERSION[1] << 32) + (PYMUMBLE_PROTOCOL_VERSION[2] << 16)
version.release = self.application
version.os = PYMUMBLE_OS_STRING
version.os_version = PYMUMBLE_OS_VERSION_STRING
self.Log.debug("sending: version: %s", version)
self.send_message(PYMUMBLE_MSG_TYPES_VERSION, version)
authenticate = mumble_pb2.Authenticate()
authenticate.username = self.user
authenticate.password = self.password
authenticate.tokens.extend(self.tokens)
authenticate.opus = True
authenticate.client_type = self.client_type
self.Log.debug("sending: authenticate: %s", authenticate)
self.send_message(PYMUMBLE_MSG_TYPES_AUTHENTICATE, authenticate)
except socket.error:
self.connected = PYMUMBLE_CONN_STATE_FAILED
return self.connected
self.connected = PYMUMBLE_CONN_STATE_AUTHENTICATING
return self.connected
def loop(self):
"""
Main loop
waiting for a message from the server for maximum self.loop_rate time
take care of sending the ping
take care of sending the queued commands to the server
check on every iteration for outgoing sound
check for disconnection
"""
self.Log.debug("entering loop")
self.exit = False
last_ping = time.time() # keep track of the last ping time
# loop as long as the connection and the parent thread are alive
while self.connected not in (PYMUMBLE_CONN_STATE_NOT_CONNECTED, PYMUMBLE_CONN_STATE_FAILED) and self.parent_thread.is_alive() and not self.exit:
if last_ping + PYMUMBLE_PING_DELAY <= time.time(): # when it is time, send the ping
self.ping()
last_ping = time.time()
if self.connected == PYMUMBLE_CONN_STATE_CONNECTED:
while self.commands.is_cmd():
self.treat_command(self.commands.pop_cmd()) # send the commands coming from the application to the server
if self.sound_output:
self.sound_output.send_audio() # send outgoing audio if available
(rlist, wlist, xlist) = select.select([self.control_socket], [], [self.control_socket], self.loop_rate) # wait for a socket activity
if self.control_socket in rlist: # something to be read on the control socket
self.read_control_messages()
elif self.control_socket in xlist: # socket was closed
self.control_socket.close()
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
def ping(self):
"""Send the keepalive through available channels"""
ping = mumble_pb2.Ping()
ping.timestamp = int(time.time())
ping.tcp_ping_avg = self.ping_stats['avg']
ping.tcp_ping_var = self.ping_stats['var']
ping.tcp_packets = self.ping_stats['nb']
self.Log.debug("sending: ping: %s", ping)
self.send_message(PYMUMBLE_MSG_TYPES_PING, ping)
self.ping_stats['time_send'] = int(time.time() * 1000)
self.Log.debug(self.ping_stats['last_rcv'])
if self.ping_stats['last_rcv'] != 0 and int(time.time() * 1000) > self.ping_stats['last_rcv'] + (60 * 1000):
self.Log.debug("Ping too long ! Disconnected ?")
self.connected = PYMUMBLE_CONN_STATE_NOT_CONNECTED
def ping_response(self, mess):
self.ping_stats['last_rcv'] = int(time.time() * 1000)
ping = int(time.time() * 1000) - self.ping_stats['time_send']
old_avg = self.ping_stats['avg']
nb = self.ping_stats['nb']
new_avg = ((self.ping_stats['avg'] * nb) + ping) / (nb + 1)
try:
self.ping_stats['var'] = self.ping_stats['var'] + pow(old_avg - new_avg, 2) + (1 / nb) * pow(ping - new_avg, 2)
except ZeroDivisionError:
pass
self.ping_stats['avg'] = new_avg
self.ping_stats['nb'] += 1
def send_message(self, type, message):
"""Send a control message to the server"""
packet = struct.pack("!HL", type, message.ByteSize()) + message.SerializeToString()
while len(packet) > 0:
self.Log.debug("sending message")
sent = self.control_socket.send(packet)
if sent < 0:
raise socket.error("Server socket error")
packet = packet[sent:]
def read_control_messages(self):
"""Read control messages coming from the server"""
# from tools import tohex # for debugging
try:
buffer = self.control_socket.recv(PYMUMBLE_READ_BUFFER_SIZE)
self.receive_buffer += buffer
except socket.error:
pass
while len(self.receive_buffer) >= 6: # header is present (type + length)
self.Log.debug("read control connection")
header = self.receive_buffer[0:6]
if len(header) < 6:
break
(type, size) = struct.unpack("!HL", header) # decode header
if len(self.receive_buffer) < size + 6: # if not length data, read further
break
# self.Log.debug("message received : " + tohex(self.receive_buffer[0:size+6])) # for debugging
message = self.receive_buffer[6:size + 6] # get the control message
self.receive_buffer = self.receive_buffer[size + 6:] # remove from the buffer the read part
self.dispatch_control_message(type, message)
def dispatch_control_message(self, type, message):
"""Dispatch control messages based on their type"""
self.Log.debug("dispatch control message")
if type == PYMUMBLE_MSG_TYPES_UDPTUNNEL: # audio encapsulated in control message
if self.sound_output:
self.sound_received(message)
elif type == PYMUMBLE_MSG_TYPES_VERSION:
mess = mumble_pb2.Version()
mess.ParseFromString(message)
self.Log.debug("message: Version : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_AUTHENTICATE:
mess = mumble_pb2.Authenticate()
mess.ParseFromString(message)
self.Log.debug("message: Authenticate : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_PING:
mess = mumble_pb2.Ping()
mess.ParseFromString(message)
self.Log.debug("message: Ping : %s", mess)
self.ping_response(mess)
elif type == PYMUMBLE_MSG_TYPES_REJECT:
mess = mumble_pb2.Reject()
mess.ParseFromString(message)
self.Log.debug("message: reject : %s", mess)
self.connected = PYMUMBLE_CONN_STATE_FAILED
self.ready_lock.release()
raise ConnectionRejectedError(mess.reason)
elif type == PYMUMBLE_MSG_TYPES_SERVERSYNC: # this message finish the connection process
mess = mumble_pb2.ServerSync()
mess.ParseFromString(message)
self.Log.debug("message: serversync : %s", mess)
self.users.set_myself(mess.session)
self.server_max_bandwidth = mess.max_bandwidth
self.set_bandwidth(mess.max_bandwidth)
if self.connected == PYMUMBLE_CONN_STATE_AUTHENTICATING:
self.connected = PYMUMBLE_CONN_STATE_CONNECTED
self.ready_lock.release() # release the ready-lock
self.callbacks(PYMUMBLE_CLBK_CONNECTED)
elif type == PYMUMBLE_MSG_TYPES_CHANNELREMOVE:
mess = mumble_pb2.ChannelRemove()
mess.ParseFromString(message)
self.Log.debug("message: ChannelRemove : %s", mess)
self.channels.remove(mess.channel_id)
elif type == PYMUMBLE_MSG_TYPES_CHANNELSTATE:
mess = mumble_pb2.ChannelState()
mess.ParseFromString(message)
self.Log.debug("message: channelstate : %s", mess)
self.channels.update(mess)
elif type == PYMUMBLE_MSG_TYPES_USERREMOVE:
mess = mumble_pb2.UserRemove()
mess.ParseFromString(message)
self.Log.debug("message: UserRemove : %s", mess)
self.users.remove(mess)
elif type == PYMUMBLE_MSG_TYPES_USERSTATE:
mess = mumble_pb2.UserState()
mess.ParseFromString(message)
self.Log.debug("message: userstate : %s", mess)
self.users.update(mess)
elif type == PYMUMBLE_MSG_TYPES_BANLIST:
mess = mumble_pb2.BanList()
mess.ParseFromString(message)
self.Log.debug("message: BanList : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_TEXTMESSAGE:
mess = mumble_pb2.TextMessage()
mess.ParseFromString(message)
self.Log.debug("message: TextMessage : %s", mess)
self.callbacks(PYMUMBLE_CLBK_TEXTMESSAGERECEIVED, mess)
elif type == PYMUMBLE_MSG_TYPES_PERMISSIONDENIED:
mess = mumble_pb2.PermissionDenied()
mess.ParseFromString(message)
self.Log.debug("message: PermissionDenied : %s", mess)
self.callbacks(PYMUMBLE_CLBK_PERMISSIONDENIED, mess)
elif type == PYMUMBLE_MSG_TYPES_ACL:
mess = mumble_pb2.ACL()
mess.ParseFromString(message)
self.Log.debug("message: ACL : %s", mess)
self.channels[mess.channel_id].update_acl(mess)
self.callbacks(PYMUMBLE_CLBK_ACLRECEIVED, mess)
elif type == PYMUMBLE_MSG_TYPES_QUERYUSERS:
mess = mumble_pb2.QueryUsers()
mess.ParseFromString(message)
self.Log.debug("message: QueryUsers : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_CRYPTSETUP:
mess = mumble_pb2.CryptSetup()
mess.ParseFromString(message)
self.Log.debug("message: CryptSetup : %s", mess)
self.ping()
elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTIONMODIFY:
mess = mumble_pb2.ContextActionModify()
mess.ParseFromString(message)
self.Log.debug("message: ContextActionModify : %s", mess)
self.callbacks(PYMUMBLE_CLBK_CONTEXTACTIONRECEIVED, mess)
elif type == PYMUMBLE_MSG_TYPES_CONTEXTACTION:
mess = mumble_pb2.ContextAction()
mess.ParseFromString(message)
self.Log.debug("message: ContextAction : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_USERLIST:
mess = mumble_pb2.UserList()
mess.ParseFromString(message)
self.Log.debug("message: UserList : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_VOICETARGET:
mess = mumble_pb2.VoiceTarget()
mess.ParseFromString(message)
self.Log.debug("message: VoiceTarget : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_PERMISSIONQUERY:
mess = mumble_pb2.PermissionQuery()
mess.ParseFromString(message)
self.Log.debug("message: PermissionQuery : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_CODECVERSION:
mess = mumble_pb2.CodecVersion()
mess.ParseFromString(message)
self.Log.debug("message: CodecVersion : %s", mess)
if self.sound_output:
self.sound_output.set_default_codec(mess)
elif type == PYMUMBLE_MSG_TYPES_USERSTATS:
mess = mumble_pb2.UserStats()
mess.ParseFromString(message)
self.Log.debug("message: UserStats : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_REQUESTBLOB:
mess = mumble_pb2.RequestBlob()
mess.ParseFromString(message)
self.Log.debug("message: RequestBlob : %s", mess)
elif type == PYMUMBLE_MSG_TYPES_SERVERCONFIG:
mess = mumble_pb2.ServerConfig()
mess.ParseFromString(message)
self.Log.debug("message: ServerConfig : %s", mess)
for line in str(mess).split('\n'):
items = line.split(':')
if len(items) != 2:
continue
if items[0] == 'allow_html':
self.server_allow_html = items[1].strip() == 'true'
elif items[0] == 'message_length':
self.server_max_message_length = int(items[1].strip())
elif items[0] == 'image_message_length':
self.server_max_image_message_length = int(items[1].strip())
def set_bandwidth(self, bandwidth):
"""Set the total allowed outgoing bandwidth"""
if self.server_max_bandwidth is not None and bandwidth > self.server_max_bandwidth:
self.bandwidth = self.server_max_bandwidth
else:
self.bandwidth = bandwidth
if self.sound_output:
self.sound_output.set_bandwidth(self.bandwidth) # communicate the update to the outgoing audio manager
def sound_received(self, message):
"""Manage a received sound message"""
# from tools import tohex # for debugging
pos = 0
# self.Log.debug("sound packet : " + tohex(message)) # for debugging
(header,) = struct.unpack("!B", bytes([message[pos]])) # extract the header
type = (header & 0b11100000) >> 5
target = header & 0b00011111
pos += 1
if type == PYMUMBLE_AUDIO_TYPE_PING:
return
session = tools.VarInt() # decode session id
pos += session.decode(message[pos:pos + 10])
sequence = tools.VarInt() # decode sequence number
pos += sequence.decode(message[pos:pos + 10])
self.Log.debug("audio packet received from %i, sequence %i, type:%i, target:%i, length:%i", session.value, sequence.value, type, target, len(message))
terminator = False # set to true if it's the last 10 ms audio frame for the packet (used with CELT codec)
while (pos < len(message)) and not terminator: # get the audio frames one by one
if type == PYMUMBLE_AUDIO_TYPE_OPUS:
size = tools.VarInt() # OPUS use varint for the frame length
pos += size.decode(message[pos:pos + 10])
size = size.value
if not (size & 0x2000): # terminator is 0x2000 in the resulting int.
terminator = True # should actually always be 0 as OPUS can use variable length audio frames
size &= 0x1fff # isolate the size from the terminator
else:
(header,) = struct.unpack("!B", message[pos:pos + 1]) # CELT length and terminator is encoded in a 1 byte int
if not (header & 0b10000000):
terminator = True
size = header & 0b01111111
pos += 1
self.Log.debug("Audio frame : time:%f, last:%s, size:%i, type:%i, target:%i, pos:%i", time.time(), str(terminator), size, type, target, pos - 1)
if size > 0:
try:
newsound = self.users[session.value].sound.add(message[pos:pos + size],
sequence.value,
type,
target) # add the sound to the user's sound queue
if newsound is None: # In case audio have been disable for specific users
return
self.callbacks(PYMUMBLE_CLBK_SOUNDRECEIVED, self.users[session.value], newsound)
sequence.value += int(round(newsound.duration / 1000 * 10)) # add 1 sequence per 10ms of audio
self.Log.debug("Audio frame : time:%f last:%s, size:%i, uncompressed:%i, type:%i, target:%i", time.time(), str(terminator), size, newsound.size, type, target)
except CodecNotSupportedError as msg:
print(msg)
except KeyError: # sound received after user removed
pass
# if len(message) - pos < size:
# raise InvalidFormatError("Invalid audio frame size")
pos += size # go further in the packet, after the audio frame
# TODO: get position info
def set_application_string(self, string):
"""Set the application name, that can be viewed by other clients on the server"""
self.application = string
def set_loop_rate(self, rate):
"""Set the current main loop rate (pause per iteration)"""
self.loop_rate = rate
def get_loop_rate(self):
"""Get the current main loop rate (pause per iteration)"""
return self.loop_rate
def set_codec_profile(self, profile):
"""set the audio profile"""
if profile in ["audio", "voip", "restricted_lowdelay"]:
self.__opus_profile = profile
else:
raise ValueError("Unknown profile: " + str(profile))
def get_codec_profile(self):
"""return the audio profile string"""
return self.__opus_profile
def set_receive_sound(self, value):
"""Enable or disable the management of incoming sounds"""
if value:
self.receive_sound = True
else:
self.receive_sound = False
def is_ready(self):
"""Wait for the connection to be fully completed. To be used in the main thread"""
self.ready_lock.acquire()
self.ready_lock.release()
def execute_command(self, cmd, blocking=True):
"""Create a command to be sent to the server. To be used in the main thread"""
self.is_ready()
lock = self.commands.new_cmd(cmd)
if blocking and self.mumble_thread is not threading.current_thread():
lock.acquire()
lock.release()
return lock
# TODO: manage a timeout for blocking commands. Currently, no command actually waits for the server to execute
# The result of these commands should actually be checked against incoming server updates
def treat_command(self, cmd):
"""Send the awaiting commands to the server. Used in the pymumble thread."""
if cmd.cmd == PYMUMBLE_CMD_MOVE:
userstate = mumble_pb2.UserState()
userstate.session = cmd.parameters["session"]
userstate.channel_id = cmd.parameters["channel_id"]
self.Log.debug("Moving to channel")
self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_TEXTMESSAGE:
textmessage = mumble_pb2.TextMessage()
textmessage.session.append(cmd.parameters["session"])
textmessage.channel_id.append(cmd.parameters["channel_id"])
textmessage.message = cmd.parameters["message"]
self.send_message(PYMUMBLE_MSG_TYPES_TEXTMESSAGE, textmessage)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_TEXTPRIVATEMESSAGE:
textprivatemessage = mumble_pb2.TextMessage()
textprivatemessage.session.append(cmd.parameters["session"])
textprivatemessage.message = cmd.parameters["message"]
self.send_message(PYMUMBLE_MSG_TYPES_TEXTMESSAGE, textprivatemessage)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_MSG_TYPES_CHANNELSTATE:
channelstate = mumble_pb2.ChannelState()
channelstate.parent = cmd.parameters["parent"]
channelstate.name = cmd.parameters["name"]
channelstate.temporary = cmd.parameters["temporary"]
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_MSG_TYPES_CHANNELREMOVE:
channelremove = mumble_pb2.ChannelRemove()
channelremove.channel_id = cmd.parameters["channel_id"]
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELREMOVE, channelremove)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_UPDATECHANNEL:
channelstate = mumble_pb2.ChannelState()
for key, value in cmd.parameters.items():
setattr(channelstate, key, value)
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_LINKCHANNEL:
channelstate = mumble_pb2.ChannelState()
channelstate.channel_id = cmd.parameters["channel_id"]
channelstate.links_add.append(cmd.parameters["add_id"])
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_UNLINKCHANNEL:
channelstate = mumble_pb2.ChannelState()
channelstate.channel_id = cmd.parameters["channel_id"]
for remove_id in cmd.parameters["remove_ids"]:
channelstate.links_remove.append(remove_id)
self.send_message(PYMUMBLE_MSG_TYPES_CHANNELSTATE, channelstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_MSG_TYPES_VOICETARGET:
textvoicetarget = mumble_pb2.VoiceTarget()
textvoicetarget.id = cmd.parameters["id"]
targets = []
if cmd.parameters["id"] == 1:
voicetarget = mumble_pb2.VoiceTarget.Target()
voicetarget.channel_id = cmd.parameters["targets"][0]
targets.append(voicetarget)
else:
for target in cmd.parameters["targets"]:
voicetarget = mumble_pb2.VoiceTarget.Target()
voicetarget.session.append(target)
targets.append(voicetarget)
textvoicetarget.targets.extend(targets)
self.send_message(PYMUMBLE_MSG_TYPES_VOICETARGET, textvoicetarget)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_MODUSERSTATE:
userstate = mumble_pb2.UserState()
userstate.session = cmd.parameters["session"]
if "mute" in cmd.parameters:
userstate.mute = cmd.parameters["mute"]
if "self_mute" in cmd.parameters:
userstate.self_mute = cmd.parameters["self_mute"]
if "deaf" in cmd.parameters:
userstate.deaf = cmd.parameters["deaf"]
if "self_deaf" in cmd.parameters:
userstate.self_deaf = cmd.parameters["self_deaf"]
if "suppress" in cmd.parameters:
userstate.suppress = cmd.parameters["suppress"]
if "recording" in cmd.parameters:
userstate.recording = cmd.parameters["recording"]
if "comment" in cmd.parameters:
userstate.comment = cmd.parameters["comment"]
if "texture" in cmd.parameters:
userstate.texture = cmd.parameters["texture"]
if "user_id" in cmd.parameters:
userstate.user_id = cmd.parameters["user_id"]
if "plugin_context" in cmd.parameters:
userstate.plugin_context = cmd.parameters["plugin_context"]
if "listening_channel_add" in cmd.parameters:
userstate.listening_channel_add.extend(cmd.parameters["listening_channel_add"])
if "listening_channel_remove" in cmd.parameters:
userstate.listening_channel_remove.extend(cmd.parameters["listening_channel_remove"])
self.send_message(PYMUMBLE_MSG_TYPES_USERSTATE, userstate)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_REMOVEUSER:
userremove = mumble_pb2.UserRemove()
userremove.session = cmd.parameters["session"]
userremove.reason = cmd.parameters["reason"]
userremove.ban = cmd.parameters["ban"]
self.send_message(PYMUMBLE_MSG_TYPES_USERREMOVE, userremove)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_QUERYACL:
acl = mumble_pb2.ACL()
acl.channel_id = cmd.parameters["channel_id"]
acl.query = True
self.send_message(PYMUMBLE_MSG_TYPES_ACL, acl)
cmd.response = True
self.commands.answer(cmd)
elif cmd.cmd == PYMUMBLE_CMD_UPDATEACL:
acl = mumble_pb2.ACL()
acl.channel_id = cmd.parameters["channel_id"]
acl.inherit_acls = cmd.parameters["inherit_acls"]
for msg_group in cmd.parameters["chan_group"]:
chan_group = mumble_pb2.ACL.ChanGroup()
chan_group.name = msg_group['name']
if msg_group['inherited'] is not None:
chan_group.inherited = msg_group['inherited']
if msg_group['inherit'] is not None:
chan_group.inherit = msg_group['inherit']
if msg_group['inheritable'] is not None:
chan_group.inheritable = msg_group['inheritable']
for add_id in msg_group['add']:
chan_group.add.append(add_id)
for remove_id in msg_group['remove']:
chan_group.remove.append(remove_id)
acl.groups.append(chan_group)
for msg_acl in cmd.parameters["chan_acl"]:
chan_acl = mumble_pb2.ACL.ChanACL()
if msg_acl['apply_here'] is not None:
chan_acl.apply_here = msg_acl['apply_here']
if msg_acl['apply_subs'] is not None:
chan_acl.apply_subs = msg_acl['apply_subs']
if msg_acl['inherited'] is not None:
chan_acl.inherited = msg_acl['inherited']
if msg_acl['user_id'] is not None:
chan_acl.user_id = msg_acl['user_id']
if msg_acl['group'] is not None:
chan_acl.group = msg_acl['group']
if msg_acl['grant'] is not None:
chan_acl.grant = msg_acl['grant']
if msg_acl['deny'] is not None:
chan_acl.deny = msg_acl['deny']
if not chan_acl.inherited:
acl.acls.append(chan_acl)
acl.query = False
self.send_message(PYMUMBLE_MSG_TYPES_ACL, acl)
cmd.response = True
self.commands.answer(cmd)
def get_max_message_length(self):
return self.server_max_message_length
def get_max_image_length(self):
return self.server_max_image_message_length
def my_channel(self):
return self.channels[self.users.myself["channel_id"]]
def denial_type(self, n):
return mumble_pb2.PermissionDenied.DenyType.Name(n)
def stop(self):
self.reconnect = None
self.exit = True
self.control_socket.close()

2589
pymumble_py3/mumble_pb2.py Normal file

File diff suppressed because one or more lines are too long

215
pymumble_py3/soundoutput.py Normal file
View File

@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
from time import time
import struct
import threading
import socket
import opuslib
from .constants import *
from .errors import CodecNotSupportedError
from .tools import VarInt
from .messages import VoiceTarget
class SoundOutput:
"""
Class managing the sounds that must be sent to the server (best sent in a multiple of audio_per_packet samples)
The buffering is the responsibility of the caller, any partial sound will be sent without delay
"""
def __init__(self, mumble_object, audio_per_packet, bandwidth, stereo=False, opus_profile=PYMUMBLE_AUDIO_TYPE_OPUS_PROFILE):
"""
audio_per_packet=packet audio duration in sec
bandwidth=maximum total outgoing bandwidth
"""
self.mumble_object = mumble_object
self.Log = self.mumble_object.Log
self.pcm = []
self.lock = threading.Lock()
self.codec = None # codec currently requested by the server
self.encoder = None # codec instance currently used to encode
self.encoder_framesize = None # size of an audio frame for the current codec (OPUS=audio_per_packet, CELT=0.01s)
self.opus_profile = opus_profile
self.channels = 1 if not stereo else 2
self.set_audio_per_packet(audio_per_packet)
self.set_bandwidth(bandwidth)
self.codec_type = None # codec type number to be used in audio packets
self.target = 0 # target is not implemented yet, so always 0
self.sequence_start_time = 0 # time of sequence 1
self.sequence_last_time = 0 # time of the last emitted packet
self.sequence = 0 # current sequence
def send_audio(self):
"""send the available audio to the server, taking care of the timing"""
if not self.encoder or len(self.pcm) == 0: # no codec configured or no audio sent
return ()
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
while len(self.pcm) > 0 and self.sequence_last_time + self.audio_per_packet <= time(): # audio to send and time to send it (since last packet)
current_time = time()
if self.sequence_last_time + PYMUMBLE_SEQUENCE_RESET_INTERVAL <= current_time: # waited enough, resetting sequence to 0
self.sequence = 0
self.sequence_start_time = current_time
self.sequence_last_time = current_time
elif self.sequence_last_time + (self.audio_per_packet * 2) <= current_time: # give some slack (2*audio_per_frame) before interrupting a continuous sequence
# calculating sequence after a pause
self.sequence = int((current_time - self.sequence_start_time) / PYMUMBLE_SEQUENCE_DURATION)
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
else: # continuous sound
self.sequence += int(self.audio_per_packet / PYMUMBLE_SEQUENCE_DURATION)
self.sequence_last_time = self.sequence_start_time + (self.sequence * PYMUMBLE_SEQUENCE_DURATION)
payload = bytearray() # content of the whole packet, without tcptunnel header
audio_encoded = 0 # audio time already in the packet
while len(self.pcm) > 0 and audio_encoded < self.audio_per_packet: # more audio to be sent and packet not full
self.lock.acquire()
to_encode = self.pcm.pop(0)
self.lock.release()
if len(to_encode) != samples: # pad to_encode if needed to match sample length
to_encode += b'\x00' * (samples - len(to_encode))
try:
encoded = self.encoder.encode(to_encode, len(to_encode) // (2 * self.channels))
except opuslib.exceptions.OpusError:
encoded = b''
audio_encoded += self.encoder_framesize
# create the audio frame header
if self.codec_type == PYMUMBLE_AUDIO_TYPE_OPUS:
frameheader = VarInt(len(encoded)).encode()
else:
frameheader = len(encoded)
if audio_encoded < self.audio_per_packet and len(self.pcm) > 0: # if not last frame for the packet, set the terminator bit
frameheader += (1 << 7)
frameheader = struct.pack('!B', frameheader)
payload += frameheader + encoded # add the frame to the packet
header = self.codec_type << 5 # encapsulate in audio packet
sequence = VarInt(self.sequence).encode()
udppacket = struct.pack('!B', header | self.target) + sequence + payload
if self.mumble_object.positional:
udppacket += struct.pack("fff", self.mumble_object.positional[0], self.mumble_object.positional[1], self.mumble_object.positional[2])
self.Log.debug("audio packet to send: sequence:{sequence}, type:{type}, length:{len}".format(
sequence=self.sequence,
type=self.codec_type,
len=len(udppacket)
))
tcppacket = struct.pack("!HL", PYMUMBLE_MSG_TYPES_UDPTUNNEL, len(udppacket)) + udppacket # encapsulate in tcp tunnel
while len(tcppacket) > 0:
sent = self.mumble_object.control_socket.send(tcppacket)
if sent < 0:
raise socket.error("Server socket error")
tcppacket = tcppacket[sent:]
def get_audio_per_packet(self):
"""return the configured length of a audio packet (in ms)"""
return self.audio_per_packet
def set_audio_per_packet(self, audio_per_packet):
"""set the length of an audio packet (in ms)"""
self.audio_per_packet = audio_per_packet
self.create_encoder()
def get_bandwidth(self):
"""get the configured bandwidth for the audio output"""
return self.bandwidth
def set_bandwidth(self, bandwidth):
"""set the bandwidth for the audio output"""
self.bandwidth = bandwidth
self._set_bandwidth()
def _set_bandwidth(self):
"""do the calculation of the overhead and configure the actual bitrate for the codec"""
if self.encoder:
overhead_per_packet = 20 # IP header in bytes
overhead_per_packet += (3 * int(self.audio_per_packet / self.encoder_framesize)) # overhead per frame
if self.mumble_object.udp_active:
overhead_per_packet += 12 # UDP header
else:
overhead_per_packet += 20 # TCP header
overhead_per_packet += 6 # TCPTunnel encapsulation
overhead_per_second = int(overhead_per_packet * 8 / self.audio_per_packet) # in bits
self.Log.debug(
"Bandwidth is {bandwidth}, downgrading to {bitrate} due to the protocol overhead".format(bandwidth=self.bandwidth, bitrate=self.bandwidth - overhead_per_second))
self.encoder.bitrate = self.bandwidth - overhead_per_second
def add_sound(self, pcm):
"""add sound to be sent (in PCM 16 bits signed format)"""
if len(pcm) % 2 != 0: # check that the data is align on 16 bits
raise Exception("pcm data must be 16 bits")
samples = int(self.encoder_framesize * PYMUMBLE_SAMPLERATE * 2 * self.channels) # number of samples in an encoder frame
self.lock.acquire()
if len(self.pcm) and len(self.pcm[-1]) < samples:
initial_offset = samples - len(self.pcm[-1])
self.pcm[-1] += pcm[:initial_offset]
else:
initial_offset = 0
for i in range(initial_offset, len(pcm), samples):
self.pcm.append(pcm[i:i + samples])
self.lock.release()
def clear_buffer(self):
self.lock.acquire()
self.pcm = []
self.lock.release()
def get_buffer_size(self):
"""return the size of the unsent buffer in sec"""
return sum(len(chunk) for chunk in self.pcm) / 2. / PYMUMBLE_SAMPLERATE / self.channels
def set_default_codec(self, codecversion):
"""Set the default codec to be used to send packets"""
self.codec = codecversion
self.create_encoder()
def create_encoder(self):
"""create the encoder instance, and set related constants"""
if not self.codec:
return ()
if self.codec.opus:
self.encoder = opuslib.Encoder(PYMUMBLE_SAMPLERATE, self.channels, self.opus_profile)
self.encoder_framesize = self.audio_per_packet
self.codec_type = PYMUMBLE_AUDIO_TYPE_OPUS
else:
raise CodecNotSupportedError('')
self._set_bandwidth()
def set_whisper(self, target_id, channel=False):
if not target_id:
return
if type(target_id) is int:
target_id = [target_id]
self.target = 2
if channel:
self.target = 1
cmd = VoiceTarget(self.target, target_id)
self.mumble_object.execute_command(cmd)
def remove_whisper(self):
self.target = 0
cmd = VoiceTarget(self.target, [])
self.mumble_object.execute_command(cmd)

142
pymumble_py3/soundqueue.py Normal file
View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
import time
from threading import Lock
from collections import deque
import opuslib
from .constants import *
class SoundQueue:
"""
Per user storage of received audio frames
Takes care of the decoding of the received audio
"""
def __init__(self, mumble_object):
self.mumble_object = mumble_object
self.queue = deque()
self.start_sequence = None
self.start_time = None
self.receive_sound = True
self.lock = Lock()
# to be sure, create every supported decoders for all users
# sometime, clients still use a codec for a while after server request another...
self.decoders = {
PYMUMBLE_AUDIO_TYPE_OPUS: opuslib.Decoder(PYMUMBLE_SAMPLERATE, 1)
}
def set_receive_sound(self, value):
"""Define if received sounds must be kept or discarded in this specific queue (user)"""
if value:
self.receive_sound = True
else:
self.receive_sound = False
def add(self, audio, sequence, type, target):
"""Add a new audio frame to the queue, after decoding"""
if not self.receive_sound:
return None
self.lock.acquire()
try:
pcm = self.decoders[type].decode(audio, PYMUMBLE_READ_BUFFER_SIZE)
if not self.start_sequence or sequence <= self.start_sequence:
# New sequence started
self.start_time = time.time()
self.start_sequence = sequence
calculated_time = self.start_time
else:
# calculating position in current sequence
calculated_time = self.start_time + (sequence - self.start_sequence) * PYMUMBLE_SEQUENCE_DURATION
newsound = SoundChunk(pcm, sequence, len(pcm), calculated_time, type, target)
if not self.mumble_object.callbacks.get_callback(PYMUMBLE_CLBK_SOUNDRECEIVED):
self.queue.appendleft(newsound)
if len(self.queue) > 1 and self.queue[0].time < self.queue[1].time:
# sort the audio chunk if it came out of order
cpt = 0
while cpt < len(self.queue) - 1 and self.queue[cpt].time < self.queue[cpt+1].time:
tmp = self.queue[cpt+1]
self.queue[cpt+1] = self.queue[cpt]
self.queue[cpt] = tmp
self.lock.release()
return newsound
except KeyError:
self.lock.release()
self.mumble_object.Log.error("Codec not supported (audio packet type {0})".format(type))
except Exception as e:
self.lock.release()
self.mumble_object.Log.error("error while decoding audio. sequence:{seq}, type:{type}. {error}".format(seq=sequence, type=type, error=str(e)))
def is_sound(self):
"""Boolean to check if there is a sound frame in the queue"""
if len(self.queue) > 0:
return True
else:
return False
def get_sound(self, duration=None):
"""Return the first sound of the queue and discard it"""
self.lock.acquire()
if len(self.queue) > 0:
if duration is None or self.first_sound().duration <= duration:
result = self.queue.pop()
else:
result = self.first_sound().extract_sound(duration)
else:
result = None
self.lock.release()
return result
def first_sound(self):
"""Return the first sound of the queue, but keep it"""
if len(self.queue) > 0:
return self.queue[-1]
else:
return None
class SoundChunk:
"""
Object that contains the actual audio frame, in PCM format"""
def __init__(self, pcm, sequence, size, calculated_time, type, target, timestamp=time.time()):
self.timestamp = timestamp # measured time of arrival of the sound
self.time = calculated_time # calculated time of arrival of the sound (based on sequence)
self.pcm = pcm # audio data
self.sequence = sequence # sequence of the packet
self.size = size # size
self.duration = float(size) / 2 / PYMUMBLE_SAMPLERATE # duration in sec
self.type = type # type of the audio (codec)
self.target = target # target of the audio
def extract_sound(self, duration):
"""Extract part of the chunk, leaving a valid chunk for the remaining part"""
size = int(duration*2*PYMUMBLE_SAMPLERATE)
result = SoundChunk(
self.pcm[:size],
self.sequence,
size,
self.time,
self.type,
self.target,
self.timestamp
)
self.pcm = self.pcm[size:]
self.duration -= duration
self.time += duration
self.size -= size
return result

191
pymumble_py3/test_crypto.py Normal file
View File

@@ -0,0 +1,191 @@
'''
Ported from Mumble src/tests/TestCrypt/TestCrypt.cpp
=============================================================
Copyright 2005-2020 The Mumble Developers. All rights reserved.
Use of this source code is governed by a BSD-style license
that can be found in the LICENSE file at the root of the
Mumble source tree or at <https://www.mumble.info/LICENSE>.
'''
import pytest
from .crypto import CryptStateOCB2, AES_BLOCK_SIZE, EncryptFailedException, DecryptFailedException
from .crypto import ocb_encrypt, ocb_decrypt
@pytest.fixture()
def rawkey():
return bytes((0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f))
@pytest.fixture()
def nonce():
return bytes((0xff, 0xee, 0xdd, 0xcc, 0xbb, 0xaa, 0x99, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, 0x00))
def test_reverserecovery():
enc = CryptStateOCB2()
dec = CryptStateOCB2()
enc.gen_key()
# For our testcase, we're going to FORCE iv
enc.encrypt_iv = bytes((0x55,) * AES_BLOCK_SIZE)
dec.set_key(enc.raw_key, enc.decrypt_iv, enc.encrypt_iv)
secret = "abcdefghi".encode('ascii')
crypted = [enc.encrypt(secret) for _ in range(128)]
i = 0
for crypt in reversed(crypted[-30:]):
print(i)
i += 1
dec.decrypt(crypt, len(secret))
for crypt in reversed(crypted[:-30]):
with pytest.raises(DecryptFailedException):
dec.decrypt(crypt, len(secret))
for crypt in reversed(crypted[-30:]):
with pytest.raises(DecryptFailedException):
dec.decrypt(crypt, len(secret))
# Extensive replay attack test
crypted = [enc.encrypt(secret) for _ in range(512)]
for crypt in crypted:
decr = dec.decrypt(crypt, len(secret))
for crypt in crypted:
with pytest.raises(DecryptFailedException):
dec.decrypt(crypt, len(secret))
def test_ivrecovery():
enc = CryptStateOCB2()
dec = CryptStateOCB2()
enc.gen_key()
# For our testcase, we're going to FORCE iv
enc.encrypt_iv = bytes((0x55,) * AES_BLOCK_SIZE)
dec.set_key(enc.raw_key, enc.decrypt_iv, enc.encrypt_iv)
secret = "abcdefghi".encode('ascii')
crypted = enc.encrypt(secret)
# Can decrypt
decr = dec.decrypt(crypted, len(secret))
# ... correctly.
assert secret == decr
# But will refuse to reuse same IV.
with pytest.raises(DecryptFailedException):
dec.decrypt(crypted, len(secret))
# Recover from lost packet.
for i in range(16):
crypted = enc.encrypt(secret)
decr = dec.decrypt(crypted, len(secret))
# Wraparound.
for i in range(128):
dec.uiLost = 0
for j in range(15):
crypted = enc.encrypt(secret)
decr = dec.decrypt(crypted, len(secret))
assert dec.uiLost == 14
assert enc.encrypt_iv == dec.decrypt_iv
# Wrap too far
for i in range(257):
crypted = enc.encrypt(secret);
with pytest.raises(DecryptFailedException):
dec.decrypt(crypted, len(secret))
# Sync it
dec.decrypt_iv = enc.encrypt_iv
crypted = enc.encrypt(secret)
decr = dec.decrypt(crypted, len(secret))
def test_testvectors(rawkey):
# Test vectors are from draft-krovetz-ocb-00.txt
cs = CryptStateOCB2()
cs.set_key(rawkey, rawkey, rawkey)
_, tag = ocb_encrypt(cs._aes, bytes(), rawkey)
blanktag = bytes((0xBF,0x31,0x08,0x13,0x07,0x73,0xAD,0x5E,0xC7,0x0E,0xC6,0x9E,0x78,0x75,0xA7,0xB0))
assert len(blanktag) == AES_BLOCK_SIZE
assert tag == blanktag
source = bytes(range(40))
crypt, tag = ocb_encrypt(cs._aes, source, rawkey)
longtag = bytes((0x9D,0xB0,0xCD,0xF8,0x80,0xF7,0x3E,0x3E,0x10,0xD4,0xEB,0x32,0x17,0x76,0x66,0x88))
crypted = bytes((0xF7,0x5D,0x6B,0xC8,0xB4,0xDC,0x8D,0x66,0xB8,0x36,0xA2,0xB0,0x8B,0x32,0xA6,0x36,0x9F,0x1C,0xD3,0xC5,0x22,0x8D,0x79,0xFD,
0x6C,0x26,0x7F,0x5F,0x6A,0xA7,0xB2,0x31,0xC7,0xDF,0xB9,0xD5,0x99,0x51,0xAE,0x9C))
assert tag == longtag
assert crypt[:len(crypted)] == crypted
def test_authcrypt(rawkey, nonce):
cs = CryptStateOCB2()
for ll in range(128):
cs.set_key(rawkey, nonce, nonce)
src = bytes((i + 1 for i in range(ll)))
encrypted, enctag = ocb_encrypt(cs._aes, src, nonce)
decrypted, dectag = ocb_decrypt(cs._aes, encrypted, nonce, len(src))
assert enctag == dectag
assert src == decrypted
def test_xexstarAttack(rawkey, nonce):
''' Test prevention of the attack described in section 4.1 of https://eprint.iacr.org/2019/311 '''
cs = CryptStateOCB2()
cs.set_key(rawkey, nonce, nonce)
# Set first block to `len(secondBlock)`
# Set second block to arbitrary value
src = bytearray(AES_BLOCK_SIZE) + bytearray([42] * AES_BLOCK_SIZE)
src[AES_BLOCK_SIZE - 1] = AES_BLOCK_SIZE * 8
print(src)
with pytest.raises(EncryptFailedException):
ocb_encrypt(cs._aes, src, nonce)
encrypted, enctag = ocb_encrypt(cs._aes, src, nonce, insecure=True)
# Perform the attack
encrypted = bytearray(encrypted)
enctag = bytearray(enctag)
encrypted[AES_BLOCK_SIZE - 1] ^= AES_BLOCK_SIZE * 8
for i in range(AES_BLOCK_SIZE):
enctag[i] = src[AES_BLOCK_SIZE + i] ^ encrypted[AES_BLOCK_SIZE + i]
with pytest.raises(DecryptFailedException):
dc, dct = ocb_decrypt(cs._aes, encrypted, nonce, AES_BLOCK_SIZE)
print(dc, dct, enctag == dct)
decrypted, dectag = ocb_decrypt(cs._aes, encrypted, nonce, AES_BLOCK_SIZE, insecure=True)
# Verify forged tag (should match if attack is properly implemented)
assert enctag == dectag
def test_tamper(rawkey, nonce):
cs = CryptStateOCB2()
cs.set_key(rawkey, nonce, nonce)
msg = "It was a funky funky town!".encode('ascii')
encrypted = bytearray(cs.encrypt(msg))
for i in range(len(msg) * 8):
encrypted[i // 8] ^= 1 << (i % 8)
with pytest.raises(DecryptFailedException):
cs.decrypt(encrypted, len(msg))
encrypted[i // 8] ^= 1 << (i % 8)
decrypted = cs.decrypt(encrypted, len(msg))

128
pymumble_py3/tools.py Normal file
View File

@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
import struct
class InvalidVarInt(Exception):
pass
class VarInt:
"""Implement the varint type used in mumble"""
def __init__(self, value=0):
self.value = value
def encode(self):
"""Encode an integer in the VarInt format, returning a binary string"""
result = bytearray()
value = abs(self.value)
if self.value < 0:
if self.value >= -3:
return struct.pack("!B", (0b11111100 | value))
else:
result = struct.pack("!B", 0b11111000)
if value <= 0x7f:
return result + struct.pack("!B", value)
elif value <= 0x3fff:
return result + struct.pack("!H", 0x8000 | value)
elif value <= 0x1fffff:
return result + struct.pack("!BH", 0xc0 | (value >> 16), 0xffff & value)
elif value <= 0xfffffff:
return result + struct.pack("!L", 0xe0000000 | value)
elif value <= 0xffffffff:
return result + struct.pack("!BL", 0b11110000, value)
else:
return result + struct.pack("!BQ", 0b11110100, value)
def decode(self, value):
"""Decode a VarInt contained in a binary string, returning an integer"""
varint = value
is_negative = False
result = None
size = 0
if len(varint) <= 0:
raise InvalidVarInt("length can't be 0")
(first, ) = struct.unpack("!B", varint[0:1])
if first & 0b11111100 == 0b11111000:
is_negative = True
size += 1
if len(varint) < 2:
raise InvalidVarInt("Too short negative varint")
varint = varint[1:]
(first, ) = struct.unpack("!B", varint[0:1])
if first & 0b10000000 == 0b00000000:
(result, ) = struct.unpack("!B", varint[0:1])
size += 1
elif first & 0b11111100 == 0b11111100:
(result, ) = struct.unpack("!B", varint[0:1])
result &= 0b00000011
is_negative = True
size += 1
elif first & 0b11000000 == 0b10000000:
if len(varint) < 2:
raise InvalidVarInt("Too short 2 bytes varint")
(result, ) = struct.unpack("!H", varint[:2])
result &= 0b0011111111111111
size += 2
elif first & 0b11100000 == 0b11000000:
if len(varint) < 3:
raise InvalidVarInt("Too short 3 bytes varint")
(result, ) = struct.unpack("!B", varint[0:1])
result &= 0b00011111
(tmp, ) = struct.unpack("!H", varint[1:3])
result = (result << 16) + tmp
size += 3
elif first & 0b11110000 == 0b11100000:
if len(varint) < 4:
raise InvalidVarInt("Too short 4 bytes varint")
(result, ) = struct.unpack("!L", varint[:4])
result &= 0x0fffffff
size += 4
elif first & 0b11111100 == 0b11110000:
if len(varint) < 5:
raise InvalidVarInt("Too short 5 bytes varint")
(result, ) = struct.unpack("!L", varint[1:5])
size += 5
elif first & 0b11111100 == 0b11110100:
if len(varint) < 9:
raise InvalidVarInt("Too short 9 bytes varint")
(result, ) = struct.unpack("!Q", varint[1:9])
size += 9
if is_negative:
self.value = - result
else:
self.value = result
return size
def tohex(buffer):
"""Used for debugging. Output a sting in hex format"""
result = "\n"
cpt1 = 0
cpt2 = 0
for byte in buffer:
result += hex(ord(byte))[2:].zfill(2)
cpt1 += 1
if cpt1 >= 4:
result += " "
cpt1 = 0
cpt2 += 1
if cpt2 >= 10:
result += "\n"
cpt2 = 0
return result

283
pymumble_py3/users.py Normal file
View File

@@ -0,0 +1,283 @@
# -*- coding: utf-8 -*-
from .constants import *
from .errors import TextTooLongError, ImageTooBigError
from threading import Lock
from . import messages
from . import mumble_pb2
class Users(dict):
"""Object that stores and update all connected users"""
def __init__(self, mumble_object, callbacks):
self.mumble_object = mumble_object
self.callbacks = callbacks
self.myself = None # user object of the pymumble thread itself
self.myself_session = None # session number of the pymumble thread itself
self.lock = Lock()
def update(self, message):
"""Update a user information, based on the incoming message"""
self.lock.acquire()
if message.session not in self:
self[message.session] = User(self.mumble_object, message)
self.callbacks(PYMUMBLE_CLBK_USERCREATED, self[message.session])
if message.session == self.myself_session:
self.myself = self[message.session]
else:
actions = self[message.session].update(message)
self.callbacks(PYMUMBLE_CLBK_USERUPDATED, self[message.session], actions)
self.lock.release()
def remove(self, message):
"""Remove a user object based on server info"""
self.lock.acquire()
if message.session in self:
user = self[message.session]
del self[message.session]
self.callbacks(PYMUMBLE_CLBK_USERREMOVED, user, message)
self.lock.release()
def set_myself(self, session):
"""Set the "myself" user"""
self.myself_session = session
if session in self:
self.myself = self[session]
def count(self):
"""Return the count of connected users"""
return len(self)
class User(dict):
"""Object that store one user"""
def __init__(self, mumble_object, message):
self.mumble_object = mumble_object
self["session"] = message.session
self["channel_id"] = 0
self.update(message)
if mumble_object.receive_sound:
from . import soundqueue
self.sound = soundqueue.SoundQueue(self.mumble_object) # will hold this user incoming audio
else:
self.sound = None
def update(self, message):
"""Update user state, based on an incoming message"""
actions = dict()
if message.HasField("actor"):
actions["actor"] = message.actor
for (field, value) in message.ListFields():
if field.name in ("session", "actor", "comment", "texture", "plugin_context", "plugin_identity"):
continue
actions.update(self.update_field(field.name, value))
if message.HasField("comment_hash"):
if message.HasField("comment"):
self.mumble_object.blobs[message.comment_hash] = message.comment
else:
self.mumble_object.blobs.get_user_comment(message.comment_hash)
if message.HasField("texture_hash"):
if message.HasField("texture"):
self.mumble_object.blobs[message.texture_hash] = message.texture
else:
self.mumble_object.blobs.get_user_texture(message.texture_hash)
return actions # return a dict, useful for the callback functions
def update_field(self, name, field):
"""Update one state value for a user"""
actions = dict()
if name not in self or self[name] != field:
self[name] = field
actions[name] = field
return actions
def get_property(self, property):
if property in self:
return self[property]
else:
return None
def mute(self):
"""Mute a user"""
params = {"session": self["session"]}
if self["session"] == self.mumble_object.users.myself_session:
params["self_mute"] = True
else:
params["mute"] = True
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def unmute(self):
"""Unmute a user"""
params = {"session": self["session"]}
if self["session"] == self.mumble_object.users.myself_session:
params["self_mute"] = False
else:
params["mute"] = False
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def deafen(self):
"""Deafen a user"""
params = {"session": self["session"]}
if self["session"] == self.mumble_object.users.myself_session:
params["self_deaf"] = True
else:
params["deaf"] = True
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def undeafen(self):
"""Undeafen a user"""
params = {"session": self["session"]}
if self["session"] == self.mumble_object.users.myself_session:
params["self_deaf"] = False
else:
params["deaf"] = False
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def suppress(self):
"""Disable a user"""
params = {"session": self["session"],
"suppress": True}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def unsuppress(self):
"""Enable a user"""
params = {"session": self["session"],
"suppress": False}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def recording(self):
"""Set the user as recording"""
params = {"session": self["session"],
"recording": True}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def unrecording(self):
"""Set the user as not recording"""
params = {"session": self["session"],
"recording": False}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def comment(self, comment):
"""Set the user comment"""
params = {"session": self["session"],
"comment": comment}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def texture(self, texture):
"""Set the user texture"""
params = {"session": self["session"],
"texture": texture}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def register(self):
"""Register the user (mostly for myself)"""
params = {"session": self["session"],
"user_id": 0}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def update_context(self, context_name):
params = {"session": self["session"],
"plugin_context": context_name}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def move_in(self, channel_id, token=None):
if token:
authenticate = mumble_pb2.Authenticate()
authenticate.username = self.mumble_object.user
authenticate.password = self.mumble_object.password
authenticate.tokens.extend(self.mumble_object.tokens)
authenticate.tokens.extend([token])
authenticate.opus = True
self.mumble_object.Log.debug("sending: authenticate: %s", authenticate)
self.mumble_object.send_message(PYMUMBLE_MSG_TYPES_AUTHENTICATE, authenticate)
session = self.mumble_object.users.myself_session
cmd = messages.MoveCmd(session, channel_id)
self.mumble_object.execute_command(cmd)
def send_text_message(self, message):
"""Send a text message to the user."""
# TODO: This check should be done inside execute_command()
# However, this is currently not possible because execute_command() does
# not actually execute the command.
if len(message) > self.mumble_object.get_max_image_length() != 0:
raise ImageTooBigError(self.mumble_object.get_max_image_length())
if not ("<img" in message and "src" in message):
if len(message) > self.mumble_object.get_max_message_length() != 0:
raise TextTooLongError(self.mumble_object.get_max_message_length())
cmd = messages.TextPrivateMessage(self["session"], message)
self.mumble_object.execute_command(cmd)
def kick(self, reason=""):
params = {"session": self["session"],
"reason": reason,
"ban": False}
cmd = messages.RemoveUser(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def ban(self, reason=""):
params = {"session": self["session"],
"reason": reason,
"ban": True}
cmd = messages.RemoveUser(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def add_listening_channels(self, channel):
"""Add user to listening channel"""
params = {"session": self["session"],
"listening_channel_add": channel}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)
def remove_listening_channels(self, channel):
"""Remove user from listening channel"""
params = {"session": self["session"],
"listening_channel_remove": channel}
cmd = messages.ModUserState(self.mumble_object.users.myself_session, params)
self.mumble_object.execute_command(cmd)