48 changed files with 1505 additions and 496 deletions
@ -0,0 +1,13 @@ |
|||
language: python |
|||
|
|||
cache: pip |
|||
|
|||
python: |
|||
- '2.7' |
|||
- '3.3' |
|||
- '3.4' |
|||
- '3.5' |
|||
- '3.6' |
|||
- 'nightly' |
|||
|
|||
script: 'python setup.py test' |
@ -1 +1 @@ |
|||
VERSION = '0.0.7' |
|||
VERSION = '0.0.8' |
|||
|
@ -1,15 +0,0 @@ |
|||
import inspect |
|||
import importlib |
|||
|
|||
from .base import BaseProvider |
|||
|
|||
|
|||
def load_provider(name): |
|||
try: |
|||
mod = importlib.import_module('disco.bot.providers.' + name) |
|||
except ImportError: |
|||
mod = importlib.import_module(name) |
|||
|
|||
for entry in filter(inspect.isclass, map(lambda i: getattr(mod, i), dir(mod))): |
|||
if issubclass(entry, BaseProvider) and entry != BaseProvider: |
|||
return entry |
@ -1,134 +0,0 @@ |
|||
import six |
|||
import pickle |
|||
|
|||
from six.moves import map, UserDict |
|||
|
|||
|
|||
ROOT_SENTINEL = u'\u200B' |
|||
SEP_SENTINEL = u'\u200D' |
|||
OBJ_SENTINEL = u'\u200C' |
|||
CAST_SENTINEL = u'\u24EA' |
|||
|
|||
|
|||
def join_key(*args): |
|||
nargs = [] |
|||
for arg in args: |
|||
if not isinstance(arg, six.string_types): |
|||
arg = CAST_SENTINEL + pickle.dumps(arg) |
|||
nargs.append(arg) |
|||
return SEP_SENTINEL.join(nargs) |
|||
|
|||
|
|||
def true_key(key): |
|||
key = key.rsplit(SEP_SENTINEL, 1)[-1] |
|||
if key.startswith(CAST_SENTINEL): |
|||
return pickle.loads(key) |
|||
return key |
|||
|
|||
|
|||
class BaseProvider(object): |
|||
def __init__(self, config): |
|||
self.config = config |
|||
self.data = {} |
|||
|
|||
def exists(self, key): |
|||
return key in self.data |
|||
|
|||
def keys(self, other): |
|||
count = other.count(SEP_SENTINEL) + 1 |
|||
for key in self.data.keys(): |
|||
if key.startswith(other) and key.count(SEP_SENTINEL) == count: |
|||
yield key |
|||
|
|||
def get_many(self, keys): |
|||
for key in keys: |
|||
yield key, self.get(key) |
|||
|
|||
def get(self, key): |
|||
return self.data[key] |
|||
|
|||
def set(self, key, value): |
|||
self.data[key] = value |
|||
|
|||
def delete(self, key): |
|||
del self.data[key] |
|||
|
|||
def load(self): |
|||
pass |
|||
|
|||
def save(self): |
|||
pass |
|||
|
|||
def root(self): |
|||
return StorageDict(self) |
|||
|
|||
|
|||
class StorageDict(UserDict): |
|||
def __init__(self, parent_or_provider, key=None): |
|||
if isinstance(parent_or_provider, BaseProvider): |
|||
self.provider = parent_or_provider |
|||
self.parent = None |
|||
else: |
|||
self.parent = parent_or_provider |
|||
self.provider = self.parent.provider |
|||
self._key = key or ROOT_SENTINEL |
|||
|
|||
def keys(self): |
|||
return map(true_key, self.provider.keys(self.key)) |
|||
|
|||
def values(self): |
|||
for key in self.keys(): |
|||
yield self.provider.get(key) |
|||
|
|||
def items(self): |
|||
for key in self.keys(): |
|||
yield (true_key(key), self.provider.get(key)) |
|||
|
|||
def ensure(self, key, typ=dict): |
|||
if key not in self: |
|||
self[key] = typ() |
|||
return self[key] |
|||
|
|||
def update(self, obj): |
|||
for k, v in six.iteritems(obj): |
|||
self[k] = v |
|||
|
|||
@property |
|||
def data(self): |
|||
obj = {} |
|||
|
|||
for raw, value in self.provider.get_many(self.provider.keys(self.key)): |
|||
key = true_key(raw) |
|||
|
|||
if value == OBJ_SENTINEL: |
|||
value = self.__class__(self, key=key).data |
|||
obj[key] = value |
|||
return obj |
|||
|
|||
@property |
|||
def key(self): |
|||
if self.parent is not None: |
|||
return join_key(self.parent.key, self._key) |
|||
return self._key |
|||
|
|||
def __setitem__(self, key, value): |
|||
if isinstance(value, dict): |
|||
obj = self.__class__(self, key) |
|||
obj.update(value) |
|||
value = OBJ_SENTINEL |
|||
|
|||
self.provider.set(join_key(self.key, key), value) |
|||
|
|||
def __getitem__(self, key): |
|||
res = self.provider.get(join_key(self.key, key)) |
|||
|
|||
if res == OBJ_SENTINEL: |
|||
return self.__class__(self, key) |
|||
|
|||
return res |
|||
|
|||
def __delitem__(self, key): |
|||
return self.provider.delete(join_key(self.key, key)) |
|||
|
|||
def __contains__(self, key): |
|||
return self.provider.exists(join_key(self.key, key)) |
@ -1,54 +0,0 @@ |
|||
import os |
|||
import gevent |
|||
|
|||
from disco.util.serializer import Serializer |
|||
from .base import BaseProvider |
|||
|
|||
|
|||
class DiskProvider(BaseProvider): |
|||
def __init__(self, config): |
|||
super(DiskProvider, self).__init__(config) |
|||
self.format = config.get('format', 'pickle') |
|||
self.path = config.get('path', 'storage') + '.' + self.format |
|||
self.fsync = config.get('fsync', False) |
|||
self.fsync_changes = config.get('fsync_changes', 1) |
|||
|
|||
self.autosave_task = None |
|||
self.change_count = 0 |
|||
|
|||
def autosave_loop(self, interval): |
|||
while True: |
|||
gevent.sleep(interval) |
|||
self.save() |
|||
|
|||
def _on_change(self): |
|||
if self.fsync: |
|||
self.change_count += 1 |
|||
|
|||
if self.change_count >= self.fsync_changes: |
|||
self.save() |
|||
self.change_count = 0 |
|||
|
|||
def load(self): |
|||
if not os.path.exists(self.path): |
|||
return |
|||
|
|||
if self.config.get('autosave', True): |
|||
self.autosave_task = gevent.spawn( |
|||
self.autosave_loop, |
|||
self.config.get('autosave_interval', 120)) |
|||
|
|||
with open(self.path, 'r') as f: |
|||
self.data = Serializer.loads(self.format, f.read()) |
|||
|
|||
def save(self): |
|||
with open(self.path, 'w') as f: |
|||
f.write(Serializer.dumps(self.format, self.data)) |
|||
|
|||
def set(self, key, value): |
|||
super(DiskProvider, self).set(key, value) |
|||
self._on_change() |
|||
|
|||
def delete(self, key): |
|||
super(DiskProvider, self).delete(key) |
|||
self._on_change() |
@ -1,5 +0,0 @@ |
|||
from .base import BaseProvider |
|||
|
|||
|
|||
class MemoryProvider(BaseProvider): |
|||
pass |
@ -1,48 +0,0 @@ |
|||
from __future__ import absolute_import |
|||
|
|||
import redis |
|||
|
|||
from itertools import izip |
|||
|
|||
from disco.util.serializer import Serializer |
|||
from .base import BaseProvider, SEP_SENTINEL |
|||
|
|||
|
|||
class RedisProvider(BaseProvider): |
|||
def __init__(self, config): |
|||
super(RedisProvider, self).__init__(config) |
|||
self.format = config.get('format', 'pickle') |
|||
self.conn = None |
|||
|
|||
def load(self): |
|||
self.conn = redis.Redis( |
|||
host=self.config.get('host', 'localhost'), |
|||
port=self.config.get('port', 6379), |
|||
db=self.config.get('db', 0)) |
|||
|
|||
def exists(self, key): |
|||
return self.conn.exists(key) |
|||
|
|||
def keys(self, other): |
|||
count = other.count(SEP_SENTINEL) + 1 |
|||
for key in self.conn.scan_iter(u'{}*'.format(other)): |
|||
key = key.decode('utf-8') |
|||
if key.count(SEP_SENTINEL) == count: |
|||
yield key |
|||
|
|||
def get_many(self, keys): |
|||
keys = list(keys) |
|||
if not len(keys): |
|||
raise StopIteration |
|||
|
|||
for key, value in izip(keys, self.conn.mget(keys)): |
|||
yield (key, Serializer.loads(self.format, value)) |
|||
|
|||
def get(self, key): |
|||
return Serializer.loads(self.format, self.conn.get(key)) |
|||
|
|||
def set(self, key, value): |
|||
self.conn.set(key, Serializer.dumps(self.format, value)) |
|||
|
|||
def delete(self, key): |
|||
self.conn.delete(key) |
@ -1,52 +0,0 @@ |
|||
from __future__ import absolute_import |
|||
|
|||
import six |
|||
import rocksdb |
|||
|
|||
from itertools import izip |
|||
from six.moves import map |
|||
|
|||
from disco.util.serializer import Serializer |
|||
from .base import BaseProvider, SEP_SENTINEL |
|||
|
|||
|
|||
class RocksDBProvider(BaseProvider): |
|||
def __init__(self, config): |
|||
super(RocksDBProvider, self).__init__(config) |
|||
self.format = config.get('format', 'pickle') |
|||
self.path = config.get('path', 'storage.db') |
|||
self.db = None |
|||
|
|||
@staticmethod |
|||
def k(k): |
|||
return bytes(k) if six.PY3 else str(k.encode('utf-8')) |
|||
|
|||
def load(self): |
|||
self.db = rocksdb.DB(self.path, rocksdb.Options(create_if_missing=True)) |
|||
|
|||
def exists(self, key): |
|||
return self.db.get(self.k(key)) is not None |
|||
|
|||
# TODO prefix extractor |
|||
def keys(self, other): |
|||
count = other.count(SEP_SENTINEL) + 1 |
|||
it = self.db.iterkeys() |
|||
it.seek_to_first() |
|||
|
|||
for key in it: |
|||
key = key.decode('utf-8') |
|||
if key.startswith(other) and key.count(SEP_SENTINEL) == count: |
|||
yield key |
|||
|
|||
def get_many(self, keys): |
|||
for key, value in izip(keys, self.db.multi_get(list(map(self.k, keys)))): |
|||
yield (key, Serializer.loads(self.format, value.decode('utf-8'))) |
|||
|
|||
def get(self, key): |
|||
return Serializer.loads(self.format, self.db.get(self.k(key)).decode('utf-8')) |
|||
|
|||
def set(self, key, value): |
|||
self.db.put(self.k(key), Serializer.dumps(self.format, value)) |
|||
|
|||
def delete(self, key): |
|||
self.db.delete(self.k(key)) |
@ -1,26 +1,87 @@ |
|||
from .providers import load_provider |
|||
import os |
|||
|
|||
from six.moves import UserDict |
|||
|
|||
class Storage(object): |
|||
def __init__(self, ctx, config): |
|||
from disco.util.hashmap import HashMap |
|||
from disco.util.serializer import Serializer |
|||
|
|||
|
|||
class StorageHashMap(HashMap): |
|||
def __init__(self, data): |
|||
self.data = data |
|||
|
|||
|
|||
class ContextAwareProxy(UserDict): |
|||
def __init__(self, ctx): |
|||
self.ctx = ctx |
|||
self.config = config |
|||
self.provider = load_provider(config.provider)(config.config) |
|||
self.provider.load() |
|||
self.root = self.provider.root() |
|||
|
|||
@property |
|||
def plugin(self): |
|||
return self.root.ensure('plugins').ensure(self.ctx['plugin'].name) |
|||
def data(self): |
|||
return self.ctx() |
|||
|
|||
@property |
|||
def guild(self): |
|||
return self.plugin.ensure('guilds').ensure(self.ctx['guild'].id) |
|||
|
|||
@property |
|||
def channel(self): |
|||
return self.plugin.ensure('channels').ensure(self.ctx['channel'].id) |
|||
class StorageDict(UserDict): |
|||
def __init__(self, parent, data): |
|||
self._parent = parent |
|||
self.data = data |
|||
|
|||
@property |
|||
def user(self): |
|||
return self.plugin.ensure('users').ensure(self.ctx['user'].id) |
|||
def update(self, other): |
|||
self.data.update(other) |
|||
self._parent._update() |
|||
|
|||
def __setitem__(self, key, value): |
|||
self.data[key] = value |
|||
self._parent._update() |
|||
|
|||
def __delitem__(self, key): |
|||
del self.data[key] |
|||
self._parent._update() |
|||
|
|||
|
|||
class Storage(object): |
|||
def __init__(self, ctx, config): |
|||
self._ctx = ctx |
|||
self._path = config.path |
|||
self._serializer = config.serializer |
|||
self._fsync = config.fsync |
|||
self._data = {} |
|||
|
|||
if os.path.exists(self._path): |
|||
with open(self._path, 'r') as f: |
|||
self._data = Serializer.loads(self._serializer, f.read()) |
|||
|
|||
def __getitem__(self, key): |
|||
if key not in self._data: |
|||
self._data[key] = {} |
|||
return StorageHashMap(StorageDict(self, self._data[key])) |
|||
|
|||
def _update(self): |
|||
if self._fsync: |
|||
self.save() |
|||
|
|||
def save(self): |
|||
if not self._path: |
|||
return |
|||
|
|||
with open(self._path, 'w') as f: |
|||
f.write(Serializer.dumps(self._serializer, self._data)) |
|||
|
|||
def guild(self, key): |
|||
return ContextAwareProxy( |
|||
lambda: self['_g{}:{}'.format(self._ctx['guild'].id, key)] |
|||
) |
|||
|
|||
def channel(self, key): |
|||
return ContextAwareProxy( |
|||
lambda: self['_c{}:{}'.format(self._ctx['channel'].id, key)] |
|||
) |
|||
|
|||
def plugin(self, key): |
|||
return ContextAwareProxy( |
|||
lambda: self['_p{}:{}'.format(self._ctx['plugin'].name, key)] |
|||
) |
|||
|
|||
def user(self, key): |
|||
return ContextAwareProxy( |
|||
lambda: self['_u{}:{}'.format(self._ctx['user'].id, key)] |
|||
) |
|||
|
@ -0,0 +1,71 @@ |
|||
import gevent |
|||
|
|||
""" |
|||
Object.chain -> creates a chain where each action happens after the last |
|||
pass_result = False -> whether the result of the last action is passed, or the original |
|||
|
|||
Object.async_chain -> creates an async chain where each action happens at the same time |
|||
""" |
|||
|
|||
|
|||
class Chainable(object): |
|||
__slots__ = [] |
|||
|
|||
def chain(self, pass_result=True): |
|||
return Chain(self, pass_result=pass_result, async_=False) |
|||
|
|||
def async_chain(self): |
|||
return Chain(self, pass_result=False, async_=True) |
|||
|
|||
|
|||
class Chain(object): |
|||
def __init__(self, obj, pass_result=True, async_=False): |
|||
self._obj = obj |
|||
self._pass_result = pass_result |
|||
self._async = async_ |
|||
self._parts = [] |
|||
|
|||
@property |
|||
def obj(self): |
|||
if isinstance(self._obj, Chain): |
|||
return self._obj._next() |
|||
return self._obj |
|||
|
|||
def __getattr__(self, item): |
|||
func = getattr(self.obj, item) |
|||
if not func or not callable(func): |
|||
return func |
|||
|
|||
def _wrapped(*args, **kwargs): |
|||
inst = gevent.spawn(func, *args, **kwargs) |
|||
self._parts.append(inst) |
|||
|
|||
# If async, just return instantly |
|||
if self._async: |
|||
return self |
|||
|
|||
# Otherwise return a chain |
|||
return Chain(self) |
|||
return _wrapped |
|||
|
|||
def _next(self): |
|||
res = self._parts[0].get() |
|||
if self._pass_result: |
|||
return res |
|||
return self |
|||
|
|||
def then(self, func, *args, **kwargs): |
|||
inst = gevent.spawn(func, *args, **kwargs) |
|||
self._parts.append(inst) |
|||
if self._async: |
|||
return self |
|||
return Chain(self) |
|||
|
|||
def first(self): |
|||
return self._obj |
|||
|
|||
def get(self, timeout=None): |
|||
return gevent.wait(self._parts, timeout=timeout) |
|||
|
|||
def wait(self, timeout=None): |
|||
gevent.joinall(self._parts, timeout=None) |
@ -0,0 +1,32 @@ |
|||
import re |
|||
|
|||
|
|||
# Zero width (non-rendering) space that can be used to escape mentions |
|||
ZERO_WIDTH_SPACE = u'\u200B' |
|||
|
|||
# A grave-looking character that can be used to escape codeblocks |
|||
MODIFIER_GRAVE_ACCENT = u'\u02CB' |
|||
|
|||
# Regex which matches all possible mention combinations, this may be over-zealous |
|||
# but its better safe than sorry. |
|||
MENTION_RE = re.compile('<?([@|#][!|&]?[0-9]+|@everyone|@here)>?') |
|||
|
|||
|
|||
def _re_sub_mention(mention): |
|||
mention = mention.group(1) |
|||
if '#' in mention: |
|||
return (u'#' + ZERO_WIDTH_SPACE).join(mention.split('#', 1)) |
|||
elif '@' in mention: |
|||
return (u'@' + ZERO_WIDTH_SPACE).join(mention.split('@', 1)) |
|||
else: |
|||
return mention |
|||
|
|||
|
|||
def S(text, escape_mentions=True, escape_codeblocks=False): |
|||
if escape_mentions: |
|||
text = MENTION_RE.sub(_re_sub_mention, text) |
|||
|
|||
if escape_codeblocks: |
|||
text = text.replace('`', MODIFIER_GRAVE_ACCENT) |
|||
|
|||
return text |
@ -0,0 +1,3 @@ |
|||
from disco.voice.client import * |
|||
from disco.voice.player import * |
|||
from disco.voice.playable import * |
@ -0,0 +1,149 @@ |
|||
import sys |
|||
import array |
|||
import ctypes |
|||
import ctypes.util |
|||
|
|||
from holster.enum import Enum |
|||
|
|||
from disco.util.logging import LoggingClass |
|||
|
|||
|
|||
c_int_ptr = ctypes.POINTER(ctypes.c_int) |
|||
c_int16_ptr = ctypes.POINTER(ctypes.c_int16) |
|||
c_float_ptr = ctypes.POINTER(ctypes.c_float) |
|||
|
|||
|
|||
class EncoderStruct(ctypes.Structure): |
|||
pass |
|||
|
|||
|
|||
class DecoderStruct(ctypes.Structure): |
|||
pass |
|||
|
|||
|
|||
EncoderStructPtr = ctypes.POINTER(EncoderStruct) |
|||
DecoderStructPtr = ctypes.POINTER(DecoderStruct) |
|||
|
|||
|
|||
class BaseOpus(LoggingClass): |
|||
BASE_EXPORTED = { |
|||
'opus_strerror': ([ctypes.c_int], ctypes.c_char_p), |
|||
} |
|||
|
|||
EXPORTED = {} |
|||
|
|||
def __init__(self, library_path=None): |
|||
self.path = library_path or self.find_library() |
|||
self.lib = ctypes.cdll.LoadLibrary(self.path) |
|||
|
|||
methods = {} |
|||
methods.update(self.BASE_EXPORTED) |
|||
methods.update(self.EXPORTED) |
|||
|
|||
for name, item in methods.items(): |
|||
func = getattr(self.lib, name) |
|||
|
|||
if item[0]: |
|||
func.argtypes = item[0] |
|||
|
|||
func.restype = item[1] |
|||
|
|||
setattr(self, name, func) |
|||
|
|||
@staticmethod |
|||
def find_library(): |
|||
if sys.platform == 'win32': |
|||
raise Exception('Cannot auto-load opus on Windows, please specify full library path') |
|||
|
|||
return ctypes.util.find_library('opus') |
|||
|
|||
|
|||
Application = Enum( |
|||
AUDIO=2049, |
|||
VOIP=2048, |
|||
LOWDELAY=2051 |
|||
) |
|||
|
|||
|
|||
Control = Enum( |
|||
SET_BITRATE=4002, |
|||
SET_BANDWIDTH=4008, |
|||
SET_FEC=4012, |
|||
SET_PLP=4014, |
|||
) |
|||
|
|||
|
|||
class OpusEncoder(BaseOpus): |
|||
EXPORTED = { |
|||
'opus_encoder_get_size': ([ctypes.c_int], ctypes.c_int), |
|||
'opus_encoder_create': ([ctypes.c_int, ctypes.c_int, ctypes.c_int, c_int_ptr], EncoderStructPtr), |
|||
'opus_encode': ([EncoderStructPtr, c_int16_ptr, ctypes.c_int, ctypes.c_char_p, ctypes.c_int32], ctypes.c_int32), |
|||
'opus_encoder_ctl': (None, ctypes.c_int32), |
|||
'opus_encoder_destroy': ([EncoderStructPtr], None), |
|||
} |
|||
|
|||
def __init__(self, sampling_rate, channels, application=Application.AUDIO, library_path=None): |
|||
super(OpusEncoder, self).__init__(library_path) |
|||
self.sampling_rate = sampling_rate |
|||
self.channels = channels |
|||
self.application = application |
|||
|
|||
self._inst = None |
|||
|
|||
@property |
|||
def inst(self): |
|||
if not self._inst: |
|||
self._inst = self.create() |
|||
self.set_bitrate(128) |
|||
self.set_fec(True) |
|||
self.set_expected_packet_loss_percent(0.15) |
|||
return self._inst |
|||
|
|||
def set_bitrate(self, kbps): |
|||
kbps = min(128, max(16, int(kbps))) |
|||
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_BITRATE), kbps * 1024) |
|||
|
|||
if ret < 0: |
|||
raise Exception('Failed to set bitrate to {}: {}'.format(kbps, ret)) |
|||
|
|||
def set_fec(self, value): |
|||
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_FEC), int(value)) |
|||
|
|||
if ret < 0: |
|||
raise Exception('Failed to set FEC to {}: {}'.format(value, ret)) |
|||
|
|||
def set_expected_packet_loss_percent(self, perc): |
|||
ret = self.opus_encoder_ctl(self.inst, int(Control.SET_PLP), min(100, max(0, int(perc * 100)))) |
|||
|
|||
if ret < 0: |
|||
raise Exception('Failed to set PLP to {}: {}'.format(perc, ret)) |
|||
|
|||
def create(self): |
|||
ret = ctypes.c_int() |
|||
result = self.opus_encoder_create(self.sampling_rate, self.channels, self.application.value, ctypes.byref(ret)) |
|||
|
|||
if ret.value != 0: |
|||
raise Exception('Failed to create opus encoder: {}'.format(ret.value)) |
|||
|
|||
return result |
|||
|
|||
def __del__(self): |
|||
if hasattr(self, '_inst') and self._inst: |
|||
self.opus_encoder_destroy(self._inst) |
|||
self._inst = None |
|||
|
|||
def encode(self, pcm, frame_size): |
|||
max_data_bytes = len(pcm) |
|||
pcm = ctypes.cast(pcm, c_int16_ptr) |
|||
data = (ctypes.c_char * max_data_bytes)() |
|||
|
|||
ret = self.opus_encode(self.inst, pcm, frame_size, data, max_data_bytes) |
|||
if ret < 0: |
|||
raise Exception('Failed to encode: {}'.format(ret)) |
|||
|
|||
# TODO: py3 |
|||
return array.array('b', data[:ret]).tostring() |
|||
|
|||
|
|||
class OpusDecoder(BaseOpus): |
|||
pass |
@ -0,0 +1,354 @@ |
|||
import abc |
|||
import six |
|||
import types |
|||
import gevent |
|||
import struct |
|||
import subprocess |
|||
|
|||
from gevent.lock import Semaphore |
|||
from gevent.queue import Queue |
|||
|
|||
from disco.voice.opus import OpusEncoder |
|||
|
|||
|
|||
try: |
|||
from cStringIO import cStringIO as BufferedIO |
|||
except: |
|||
if six.PY2: |
|||
from StringIO import StringIO as BufferedIO |
|||
else: |
|||
from io import BytesIO as BufferedIO |
|||
|
|||
|
|||
OPUS_HEADER_SIZE = struct.calcsize('<h') |
|||
|
|||
|
|||
class AbstractOpus(object): |
|||
def __init__(self, sampling_rate=48000, frame_length=20, channels=2): |
|||
self.sampling_rate = sampling_rate |
|||
self.frame_length = frame_length |
|||
self.channels = 2 |
|||
self.sample_size = 2 * self.channels |
|||
self.samples_per_frame = int(self.sampling_rate / 1000 * self.frame_length) |
|||
self.frame_size = self.samples_per_frame * self.sample_size |
|||
|
|||
|
|||
class BaseUtil(object): |
|||
def pipe(self, other, *args, **kwargs): |
|||
child = other(self, *args, **kwargs) |
|||
setattr(child, 'metadata', self.metadata) |
|||
setattr(child, '_parent', self) |
|||
return child |
|||
|
|||
@property |
|||
def metadata(self): |
|||
return getattr(self, '_metadata', None) |
|||
|
|||
@metadata.setter |
|||
def metadata(self, value): |
|||
self._metadata = value |
|||
|
|||
|
|||
@six.add_metaclass(abc.ABCMeta) |
|||
class BasePlayable(BaseUtil): |
|||
@abc.abstractmethod |
|||
def next_frame(self): |
|||
raise NotImplementedError |
|||
|
|||
|
|||
@six.add_metaclass(abc.ABCMeta) |
|||
class BaseInput(BaseUtil): |
|||
@abc.abstractmethod |
|||
def read(self, size): |
|||
raise NotImplementedError |
|||
|
|||
@abc.abstractmethod |
|||
def fileobj(self): |
|||
raise NotImplementedError |
|||
|
|||
|
|||
class OpusFilePlayable(BasePlayable, AbstractOpus): |
|||
""" |
|||
An input which reads opus data from a file or file-like object. |
|||
""" |
|||
def __init__(self, fobj, *args, **kwargs): |
|||
super(OpusFilePlayable, self).__init__(*args, **kwargs) |
|||
self.fobj = fobj |
|||
self.done = False |
|||
|
|||
def next_frame(self): |
|||
if self.done: |
|||
return None |
|||
|
|||
header = self.fobj.read(OPUS_HEADER_SIZE) |
|||
if len(header) < OPUS_HEADER_SIZE: |
|||
self.done = True |
|||
return None |
|||
|
|||
data_size = struct.unpack('<h', header)[0] |
|||
data = self.fobj.read(data_size) |
|||
if len(data) < data_size: |
|||
self.done = True |
|||
return None |
|||
|
|||
return data |
|||
|
|||
|
|||
class FFmpegInput(BaseInput, AbstractOpus): |
|||
def __init__(self, source='-', command='avconv', streaming=False, **kwargs): |
|||
super(FFmpegInput, self).__init__(**kwargs) |
|||
if source: |
|||
self.source = source |
|||
self.streaming = streaming |
|||
self.command = command |
|||
|
|||
self._buffer = None |
|||
self._proc = None |
|||
|
|||
def read(self, sz): |
|||
if self.streaming: |
|||
raise TypeError('Cannot read from a streaming FFmpegInput') |
|||
|
|||
# First read blocks until the subprocess finishes |
|||
if not self._buffer: |
|||
data, _ = self.proc.communicate() |
|||
self._buffer = BufferedIO(data) |
|||
|
|||
# Subsequent reads can just do dis thang |
|||
return self._buffer.read(sz) |
|||
|
|||
def fileobj(self): |
|||
if self.streaming: |
|||
return self.proc.stdout |
|||
else: |
|||
return self |
|||
|
|||
@property |
|||
def proc(self): |
|||
if not self._proc: |
|||
if callable(self.source): |
|||
self.source = self.source(self) |
|||
|
|||
if isinstance(self.source, (tuple, list)): |
|||
self.source, self.metadata = self.source |
|||
|
|||
args = [ |
|||
self.command, |
|||
'-i', str(self.source), |
|||
'-f', 's16le', |
|||
'-ar', str(self.sampling_rate), |
|||
'-ac', str(self.channels), |
|||
'-loglevel', 'warning', |
|||
'pipe:1' |
|||
] |
|||
self._proc = subprocess.Popen(args, stdin=None, stdout=subprocess.PIPE) |
|||
return self._proc |
|||
|
|||
|
|||
class YoutubeDLInput(FFmpegInput): |
|||
def __init__(self, url=None, ie_info=None, *args, **kwargs): |
|||
super(YoutubeDLInput, self).__init__(None, *args, **kwargs) |
|||
self._url = url |
|||
self._ie_info = ie_info |
|||
self._info = None |
|||
self._info_lock = Semaphore() |
|||
|
|||
@property |
|||
def info(self): |
|||
with self._info_lock: |
|||
if not self._info: |
|||
import youtube_dl |
|||
ydl = youtube_dl.YoutubeDL({'format': 'webm[abr>0]/bestaudio/best'}) |
|||
|
|||
if self._url: |
|||
obj = ydl.extract_info(self._url, download=False, process=False) |
|||
if 'entries' in obj: |
|||
self._ie_info = obj['entries'][0] |
|||
else: |
|||
self._ie_info = obj |
|||
|
|||
self._info = ydl.process_ie_result(self._ie_info, download=False) |
|||
return self._info |
|||
|
|||
@property |
|||
def _metadata(self): |
|||
return self.info |
|||
|
|||
@classmethod |
|||
def many(cls, url, *args, **kwargs): |
|||
import youtube_dl |
|||
|
|||
ydl = youtube_dl.YoutubeDL({'format': 'webm[abr>0]/bestaudio/best'}) |
|||
info = ydl.extract_info(url, download=False, process=False) |
|||
|
|||
if 'entries' not in info: |
|||
yield cls(ie_info=info, *args, **kwargs) |
|||
raise StopIteration |
|||
|
|||
for item in info['entries']: |
|||
yield cls(ie_info=item, *args, **kwargs) |
|||
|
|||
@property |
|||
def source(self): |
|||
return self.info['url'] |
|||
|
|||
|
|||
class BufferedOpusEncoderPlayable(BasePlayable, OpusEncoder, AbstractOpus): |
|||
def __init__(self, source, *args, **kwargs): |
|||
self.source = source |
|||
self.frames = Queue(kwargs.pop('queue_size', 4096)) |
|||
|
|||
# Call the AbstractOpus constructor, as we need properties it sets |
|||
AbstractOpus.__init__(self, *args, **kwargs) |
|||
|
|||
# Then call the OpusEncoder constructor, which requires some properties |
|||
# that AbstractOpus sets up |
|||
OpusEncoder.__init__(self, self.sampling_rate, self.channels) |
|||
|
|||
# Spawn the encoder loop |
|||
gevent.spawn(self._encoder_loop) |
|||
|
|||
def _encoder_loop(self): |
|||
while self.source: |
|||
raw = self.source.read(self.frame_size) |
|||
if len(raw) < self.frame_size: |
|||
break |
|||
|
|||
self.frames.put(self.encode(raw, self.samples_per_frame)) |
|||
gevent.idle() |
|||
self.source = None |
|||
self.frames.put(None) |
|||
|
|||
def next_frame(self): |
|||
return self.frames.get() |
|||
|
|||
|
|||
class DCADOpusEncoderPlayable(BasePlayable, AbstractOpus, OpusEncoder): |
|||
def __init__(self, source, *args, **kwargs): |
|||
self.source = source |
|||
self.command = kwargs.pop('command', 'dcad') |
|||
super(DCADOpusEncoderPlayable, self).__init__(*args, **kwargs) |
|||
|
|||
self._done = False |
|||
self._proc = None |
|||
|
|||
@property |
|||
def proc(self): |
|||
if not self._proc: |
|||
source = obj = self.source.fileobj() |
|||
if not hasattr(obj, 'fileno'): |
|||
source = subprocess.PIPE |
|||
|
|||
self._proc = subprocess.Popen([ |
|||
self.command, |
|||
'--channels', str(self.channels), |
|||
'--rate', str(self.sampling_rate), |
|||
'--size', str(self.samples_per_frame), |
|||
'--bitrate', '128', |
|||
'--fec', |
|||
'--packet-loss-percent', '30', |
|||
'--input', 'pipe:0', |
|||
'--output', 'pipe:1', |
|||
], stdin=source, stdout=subprocess.PIPE) |
|||
|
|||
def writer(): |
|||
while True: |
|||
data = obj.read(2048) |
|||
if len(data) > 0: |
|||
self._proc.stdin.write(data) |
|||
if len(data) < 2048: |
|||
break |
|||
|
|||
if source == subprocess.PIPE: |
|||
gevent.spawn(writer) |
|||
return self._proc |
|||
|
|||
def next_frame(self): |
|||
if self._done: |
|||
return None |
|||
|
|||
header = self.proc.stdout.read(OPUS_HEADER_SIZE) |
|||
if len(header) < OPUS_HEADER_SIZE: |
|||
self._done = True |
|||
return |
|||
|
|||
size = struct.unpack('<h', header)[0] |
|||
|
|||
data = self.proc.stdout.read(size) |
|||
if len(data) < size: |
|||
self._done = True |
|||
return |
|||
|
|||
return data |
|||
|
|||
|
|||
class FileProxyPlayable(BasePlayable, AbstractOpus): |
|||
def __init__(self, other, output, *args, **kwargs): |
|||
self.flush = kwargs.pop('flush', False) |
|||
self.on_complete = kwargs.pop('on_complete', None) |
|||
super(FileProxyPlayable, self).__init__(*args, **kwargs) |
|||
self.other = other |
|||
self.output = output |
|||
|
|||
def next_frame(self): |
|||
frame = self.other.next_frame() |
|||
|
|||
if frame: |
|||
self.output.write(struct.pack('<h', len(frame))) |
|||
self.output.write(frame) |
|||
|
|||
if self.flush: |
|||
self.output.flush() |
|||
else: |
|||
self.output.flush() |
|||
self.on_complete() |
|||
self.output.close() |
|||
return frame |
|||
|
|||
|
|||
class PlaylistPlayable(BasePlayable, AbstractOpus): |
|||
def __init__(self, items, *args, **kwargs): |
|||
super(PlaylistPlayable, self).__init__(*args, **kwargs) |
|||
self.items = items |
|||
self.now_playing = None |
|||
|
|||
def _get_next(self): |
|||
if isinstance(self.items, types.GeneratorType): |
|||
return next(self.items, None) |
|||
return self.items.pop() |
|||
|
|||
def next_frame(self): |
|||
if not self.items: |
|||
return |
|||
|
|||
if not self.now_playing: |
|||
self.now_playing = self._get_next() |
|||
if not self.now_playing: |
|||
return |
|||
|
|||
frame = self.now_playing.next_frame() |
|||
if not frame: |
|||
return self.next_frame() |
|||
|
|||
return frame |
|||
|
|||
|
|||
class MemoryBufferedPlayable(BasePlayable, AbstractOpus): |
|||
def __init__(self, other, *args, **kwargs): |
|||
from gevent.queue import Queue |
|||
|
|||
super(MemoryBufferedPlayable, self).__init__(*args, **kwargs) |
|||
self.frames = Queue() |
|||
self.other = other |
|||
gevent.spawn(self._buffer) |
|||
|
|||
def _buffer(self): |
|||
while True: |
|||
frame = self.other.next_frame() |
|||
if not frame: |
|||
break |
|||
self.frames.put(frame) |
|||
self.frames.put(None) |
|||
|
|||
def next_frame(self): |
|||
return self.frames.get() |
@ -0,0 +1,126 @@ |
|||
import time |
|||
import gevent |
|||
|
|||
from six.moves import queue |
|||
from holster.enum import Enum |
|||
from holster.emitter import Emitter |
|||
|
|||
from disco.voice.client import VoiceState |
|||
|
|||
MAX_TIMESTAMP = 4294967295 |
|||
|
|||
|
|||
class Player(object): |
|||
Events = Enum( |
|||
'START_PLAY', |
|||
'STOP_PLAY', |
|||
'PAUSE_PLAY', |
|||
'RESUME_PLAY', |
|||
'DISCONNECT' |
|||
) |
|||
|
|||
def __init__(self, client): |
|||
self.client = client |
|||
|
|||
# Queue contains playable items |
|||
self.queue = queue.Queue() |
|||
|
|||
# Whether we're playing music (true for lifetime) |
|||
self.playing = True |
|||
|
|||
# Set to an event when playback is paused |
|||
self.paused = None |
|||
|
|||
# Current playing item |
|||
self.now_playing = None |
|||
|
|||
# Current play task |
|||
self.play_task = None |
|||
|
|||
# Core task |
|||
self.run_task = gevent.spawn(self.run) |
|||
|
|||
# Event triggered when playback is complete |
|||
self.complete = gevent.event.Event() |
|||
|
|||
# Event emitter for metadata |
|||
self.events = Emitter(gevent.spawn) |
|||
|
|||
def disconnect(self): |
|||
self.client.disconnect() |
|||
self.events.emit(self.Events.DISCONNECT) |
|||
|
|||
def skip(self): |
|||
self.play_task.kill() |
|||
|
|||
def pause(self): |
|||
if self.paused: |
|||
return |
|||
self.paused = gevent.event.Event() |
|||
self.events.emit(self.Events.PAUSE_PLAY) |
|||
|
|||
def resume(self): |
|||
self.paused.set() |
|||
self.paused = None |
|||
self.events.emit(self.Events.RESUME_PLAY) |
|||
|
|||
def play(self, item): |
|||
# Grab the first frame before we start anything else, sometimes playables |
|||
# can do some lengthy async tasks here to setup the playable and we |
|||
# don't want that lerp the first N frames of the playable into playing |
|||
# faster |
|||
frame = item.next_frame() |
|||
if frame is None: |
|||
return |
|||
|
|||
start = time.time() |
|||
loops = 0 |
|||
|
|||
while True: |
|||
loops += 1 |
|||
|
|||
if self.paused: |
|||
self.client.set_speaking(False) |
|||
self.paused.wait() |
|||
gevent.sleep(2) |
|||
self.client.set_speaking(True) |
|||
start = time.time() |
|||
loops = 0 |
|||
|
|||
if self.client.state == VoiceState.DISCONNECTED: |
|||
return |
|||
|
|||
if self.client.state != VoiceState.CONNECTED: |
|||
self.client.state_emitter.wait(VoiceState.CONNECTED) |
|||
|
|||
self.client.send_frame(frame) |
|||
self.client.timestamp += item.samples_per_frame |
|||
if self.client.timestamp > MAX_TIMESTAMP: |
|||
self.client.timestamp = 0 |
|||
|
|||
frame = item.next_frame() |
|||
if frame is None: |
|||
return |
|||
|
|||
next_time = start + 0.02 * loops |
|||
delay = max(0, 0.02 + (next_time - time.time())) |
|||
gevent.sleep(delay) |
|||
|
|||
def run(self): |
|||
self.client.set_speaking(True) |
|||
|
|||
while self.playing: |
|||
self.now_playing = self.queue.get() |
|||
|
|||
self.events.emit(self.Events.START_PLAY, self.now_playing) |
|||
self.play_task = gevent.spawn(self.play, self.now_playing) |
|||
self.play_task.join() |
|||
self.events.emit(self.Events.STOP_PLAY, self.now_playing) |
|||
|
|||
if self.client.state == VoiceState.DISCONNECTED: |
|||
self.playing = False |
|||
self.complete.set() |
|||
return |
|||
|
|||
self.client.set_speaking(False) |
|||
self.disconnect() |
@ -1,3 +0,0 @@ |
|||
# Message |
|||
|
|||
TODO |
@ -0,0 +1,52 @@ |
|||
from disco.bot import Plugin |
|||
from disco.bot.command import CommandError |
|||
from disco.voice.player import Player |
|||
from disco.voice.playable import YoutubeDLInput, BufferedOpusEncoderPlayable |
|||
from disco.voice.client import VoiceException |
|||
|
|||
|
|||
class MusicPlugin(Plugin): |
|||
def load(self, ctx): |
|||
super(MusicPlugin, self).load(ctx) |
|||
self.guilds = {} |
|||
|
|||
@Plugin.command('join') |
|||
def on_join(self, event): |
|||
if event.guild.id in self.guilds: |
|||
return event.msg.reply("I'm already playing music here.") |
|||
|
|||
state = event.guild.get_member(event.author).get_voice_state() |
|||
if not state: |
|||
return event.msg.reply('You must be connected to voice to use that command.') |
|||
|
|||
try: |
|||
client = state.channel.connect() |
|||
except VoiceException as e: |
|||
return event.msg.reply('Failed to connect to voice: `{}`'.format(e)) |
|||
|
|||
self.guilds[event.guild.id] = Player(client) |
|||
self.guilds[event.guild.id].complete.wait() |
|||
del self.guilds[event.guild.id] |
|||
|
|||
def get_player(self, guild_id): |
|||
if guild_id not in self.guilds: |
|||
raise CommandError("I'm not currently playing music here.") |
|||
return self.guilds.get(guild_id) |
|||
|
|||
@Plugin.command('leave') |
|||
def on_leave(self, event): |
|||
player = self.get_player(event.guild.id) |
|||
player.disconnect() |
|||
|
|||
@Plugin.command('play', '<url:str>') |
|||
def on_play(self, event, url): |
|||
item = YoutubeDLInput(url).pipe(BufferedOpusEncoderPlayable) |
|||
self.get_player(event.guild.id).queue.put(item) |
|||
|
|||
@Plugin.command('pause') |
|||
def on_pause(self, event): |
|||
self.get_player(event.guild.id).pause() |
|||
|
|||
@Plugin.command('resume') |
|||
def on_resume(self, event): |
|||
self.get_player(event.guild.id).resume() |
@ -0,0 +1,33 @@ |
|||
from disco.bot import Plugin |
|||
|
|||
|
|||
class BasicPlugin(Plugin): |
|||
def load(self, ctx): |
|||
super(BasicPlugin, self).load(ctx) |
|||
self.tags = self.storage.guild('tags') |
|||
|
|||
@Plugin.command('add', '<name:str> <value:str...>', group='tags') |
|||
def on_tags_add(self, event, name, value): |
|||
if name in self.tags: |
|||
return event.msg.reply('That tag already exists!') |
|||
|
|||
self.tags[name] = value |
|||
return event.msg.reply(u':ok_hand: created the tag {}'.format(name), sanitize=True) |
|||
|
|||
@Plugin.command('get', '<name:str>', group='tags') |
|||
def on_tags_get(self, event, name): |
|||
if name not in self.tags: |
|||
return event.msg.reply('That tag does not exist!') |
|||
|
|||
return event.msg.reply(self.tags[name], sanitize=True) |
|||
|
|||
@Plugin.command('delete', '<name:str>', group='tags', aliases=['del', 'rmv', 'remove']) |
|||
def on_tags_delete(self, event, name): |
|||
if name not in self.tags: |
|||
return event.msg.reply('That tag does not exist!') |
|||
|
|||
del self.tags[name] |
|||
|
|||
return event.msg.reply(u':ok_hand: I deleted the {} tag for you'.format( |
|||
name |
|||
), sanitize=True) |
@ -1,6 +1,6 @@ |
|||
gevent==1.1.2 |
|||
holster==1.0.11 |
|||
gevent==1.2.1 |
|||
holster==1.0.14 |
|||
inflection==0.3.1 |
|||
requests==2.11.1 |
|||
requests==2.13.0 |
|||
six==1.10.0 |
|||
websocket-client==0.37.0 |
|||
websocket-client==0.40.0 |
|||
|
@ -0,0 +1,47 @@ |
|||
from unittest import TestCase |
|||
|
|||
from disco.client import ClientConfig, Client |
|||
from disco.bot.bot import Bot |
|||
from disco.bot.command import Command |
|||
|
|||
|
|||
class MockBot(Bot): |
|||
@property |
|||
def commands(self): |
|||
return getattr(self, '_commands', []) |
|||
|
|||
|
|||
class TestBot(TestCase): |
|||
def setUp(self): |
|||
self.client = Client(ClientConfig( |
|||
{'config': 'TEST_TOKEN'} |
|||
)) |
|||
self.bot = MockBot(self.client) |
|||
|
|||
def test_command_abbreviation(self): |
|||
groups = ['config', 'copy', 'copez', 'copypasta'] |
|||
result = self.bot.compute_group_abbrev(groups) |
|||
self.assertDictEqual(result, { |
|||
'config': 'con', |
|||
'copypasta': 'copy', |
|||
'copez': 'cope', |
|||
}) |
|||
|
|||
def test_command_abbreivation_conflicting(self): |
|||
groups = ['cat', 'cap', 'caz', 'cas'] |
|||
result = self.bot.compute_group_abbrev(groups) |
|||
self.assertDictEqual(result, {}) |
|||
|
|||
def test_many_commands(self): |
|||
self.bot._commands = [ |
|||
Command(None, None, 'test{}'.format(i), '<test:str>') |
|||
for i in range(1000) |
|||
] |
|||
|
|||
self.bot.compute_command_matches_re() |
|||
match = self.bot.command_matches_re.match('test5 123') |
|||
self.assertNotEqual(match, None) |
|||
|
|||
match = self.bot._commands[0].compiled_regex.match('test0 123 456') |
|||
self.assertEqual(match.group(1).strip(), 'test0') |
|||
self.assertEqual(match.group(2).strip(), '123 456') |
@ -0,0 +1,21 @@ |
|||
from unittest import TestCase |
|||
|
|||
from disco.types.channel import Channel, ChannelType |
|||
|
|||
|
|||
class TestChannel(TestCase): |
|||
def test_nsfw_channel(self): |
|||
channel = Channel( |
|||
name='nsfw-testing', |
|||
type=ChannelType.GUILD_TEXT) |
|||
self.assertTrue(channel.is_nsfw) |
|||
|
|||
channel = Channel( |
|||
name='nsfw-testing', |
|||
type=ChannelType.GUILD_VOICE) |
|||
self.assertFalse(channel.is_nsfw) |
|||
|
|||
channel = Channel( |
|||
name='nsfw_testing', |
|||
type=ChannelType.GUILD_TEXT) |
|||
self.assertFalse(channel.is_nsfw) |
@ -0,0 +1,32 @@ |
|||
from unittest import TestCase |
|||
|
|||
from datetime import datetime |
|||
from disco.types.message import MessageEmbed |
|||
|
|||
|
|||
class TestEmbeds(TestCase): |
|||
def test_empty_embed(self): |
|||
embed = MessageEmbed() |
|||
self.assertDictEqual( |
|||
embed.to_dict(), |
|||
{ |
|||
'image': {}, |
|||
'author': {}, |
|||
'video': {}, |
|||
'thumbnail': {}, |
|||
'footer': {}, |
|||
'fields': [], |
|||
'type': 'rich', |
|||
}) |
|||
|
|||
def test_embed(self): |
|||
embed = MessageEmbed( |
|||
title='Test Title', |
|||
description='Test Description', |
|||
url='https://test.url/' |
|||
) |
|||
obj = embed.to_dict() |
|||
self.assertEqual(obj['title'], 'Test Title') |
|||
self.assertEqual(obj['description'], 'Test Description') |
|||
self.assertEqual(obj['url'], 'https://test.url/') |
|||
|
@ -0,0 +1,42 @@ |
|||
""" |
|||
This module tests that all of disco can be imported, mostly to help reduce issues |
|||
with untested code that will not even parse/run on Py2/3 |
|||
""" |
|||
from disco.api.client import * |
|||
from disco.api.http import * |
|||
from disco.api.ratelimit import * |
|||
from disco.bot.bot import * |
|||
from disco.bot.command import * |
|||
from disco.bot.parser import * |
|||
from disco.bot.plugin import * |
|||
from disco.bot.storage import * |
|||
from disco.gateway.client import * |
|||
from disco.gateway.events import * |
|||
from disco.gateway.ipc import * |
|||
from disco.gateway.packets import * |
|||
# Not imported, GIPC is required but not provided by default |
|||
# from disco.gateway.sharder import * |
|||
from disco.types.base import * |
|||
from disco.types.channel import * |
|||
from disco.types.guild import * |
|||
from disco.types.invite import * |
|||
from disco.types.message import * |
|||
from disco.types.permissions import * |
|||
from disco.types.user import * |
|||
from disco.types.voice import * |
|||
from disco.types.webhook import * |
|||
from disco.util.backdoor import * |
|||
from disco.util.config import * |
|||
from disco.util.functional import * |
|||
from disco.util.hashmap import * |
|||
from disco.util.limiter import * |
|||
from disco.util.logging import * |
|||
from disco.util.serializer import * |
|||
from disco.util.snowflake import * |
|||
from disco.util.token import * |
|||
from disco.util.websocket import * |
|||
from disco.voice.client import * |
|||
from disco.voice.opus import * |
|||
from disco.voice.packets import * |
|||
from disco.voice.playable import * |
|||
from disco.voice.player import * |
Loading…
Reference in new issue