diff --git a/anonstream/control/parse.py b/anonstream/control/parse.py index 78862fd..0f3c2fd 100644 --- a/anonstream/control/parse.py +++ b/anonstream/control/parse.py @@ -1,17 +1,17 @@ from anonstream.control.spec import NoParse, Ambiguous, Parsed from anonstream.control.spec.common import Str -#from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT +from anonstream.control.spec.methods.chat import SPEC as SPEC_CHAT from anonstream.control.spec.methods.exit import SPEC as SPEC_EXIT from anonstream.control.spec.methods.help import SPEC as SPEC_HELP -#from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE -#from anonstream.control.spec.methods.user import SPEC as SPEC_USER +from anonstream.control.spec.methods.title import SPEC as SPEC_TITLE +from anonstream.control.spec.methods.user import SPEC as SPEC_USER SPEC = Str({ 'help': SPEC_HELP, 'exit': SPEC_EXIT, - #'title': SPEC_TITLE, - #'chat': SPEC_CHAT, - #'user': SPEC_USER, + 'title': SPEC_TITLE, + 'chat': SPEC_CHAT, + 'user': SPEC_USER, }) async def parse(request): diff --git a/anonstream/control/server.py b/anonstream/control/server.py index 2c10bdc..df8671b 100644 --- a/anonstream/control/server.py +++ b/anonstream/control/server.py @@ -1,6 +1,6 @@ import asyncio -from anonstream.control.exceptions import Exit +from anonstream.control.exceptions import Exit, Fail from anonstream.control.parse import parse def start_control_server_at(address): @@ -15,6 +15,8 @@ async def serve_control_client(reader, writer): else: try: normal, response = await parse(request) + except Fail as e: + normal, response = None, e.args[0] + '\n' except Exit: writer.close() break diff --git a/anonstream/control/spec/common.py b/anonstream/control/spec/common.py index f0b339c..34ec014 100644 --- a/anonstream/control/spec/common.py +++ b/anonstream/control/spec/common.py @@ -1,3 +1,5 @@ +import json + from anonstream.control.spec import Spec, NoParse, Ambiguous, Parsed from anonstream.control.spec.utils import get_item, startswith @@ -18,15 +20,15 @@ class Str(Spec): reason = f'incomplete: expected one of {set(self.directives)}' else: reason = ( - f'bad word at position {index}: ' - f'expected one of {set(self.directives)}, found {word!r}' + f'bad word at position {index} {word!r}: ' + f'expected one of {set(self.directives)}' ) raise NoParse(reason) from e else: if len(candidates) > 1: raise Ambiguous( - f'bad word at position {index}: cannot unambiguously ' - f'match {word!r} against {set(self.directives)}' + f'bad word at position {index} {word!r}: ambiguous ' + f'abbreviation: {set(candidates)}' ) return self.directives[directive], 1, [] @@ -37,4 +39,65 @@ class End(Spec): def consume(self, words, index): if len(words) <= index: raise Parsed(self.fn) - raise NoParse(f'garbage at position {index}: {words[index:]!r}') + raise NoParse(f'garbage at position {index} {words[index:]!r}') + +class Args(Spec): + def __init__(self, spec): + self.spec = spec + +class ArgsInt(Args): + def consume(self, words, index): + try: + n_string = words[index] + except IndexError: + raise NoParse(f'incomplete: expected integer') + else: + try: + n = int(n_string) + except ValueError: + raise NoParse( + f'bad argument at position {index} {n_string!r}: ' + f'could not decode base-10 integer' + ) + return self.spec, 1, [n] + +class ArgsString(Args): + def consume(self, words, index): + try: + string = words[index] + except IndexError: + raise NoParse(f'incomplete: expected string') + return self.spec, 1, [string] + +class ArgsJson(Args): + def assert_obj(self, index, obj_json, obj): + pass + + def transform_obj(self, obj): + return obj + + def consume(self, words, index): + try: + obj_json = words[index] + except IndexError: + raise NoParse(f'incomplete: expected json') + else: + try: + obj = json.loads(obj_json) + except json.JSONDecodeError as e: + raise NoParse( + f'bad argument at position {index} {obj_json!r}: ' + f'could not decode json' + ) + else: + self.assert_obj(index, obj_json, obj) + obj = self.transform_obj(obj) + return self.spec, 1, [obj] + +class ArgsJsonString(ArgsJson): + def assert_obj(self, index, obj_json, obj): + if not isinstance(obj, str): + raise NoParse( + f'bad argument at position {index} {obj_json!r}: ' + f'could not decode json string' + ) diff --git a/anonstream/control/spec/methods/chat.py b/anonstream/control/spec/methods/chat.py new file mode 100644 index 0000000..a565602 --- /dev/null +++ b/anonstream/control/spec/methods/chat.py @@ -0,0 +1,52 @@ +import itertools + +from anonstream.chat import delete_chat_messages +from anonstream.control.spec import NoParse +from anonstream.control.spec.common import Str, End, Args +from anonstream.control.spec.utils import get_item, json_dumps_contiguous + +class ArgsSeqs(Args): + def consume(self, words, index): + seqs = [] + for i in itertools.count(): + seq_string = get_item(index + i, words) + try: + seq = int(seq_string) + except TypeError as e: + if not seqs: + raise NoParse('incomplete: expected SEQ') from e + else: + break + except ValueError as e: + raise NoParse( + 'could not decode {word!r} as base-10 integer' + ) from e + else: + seqs.append(seq) + return self.spec, i + 1, seqs + +async def cmd_chat_help(): + normal = ['chat', 'help'] + response = ( + 'Usage: chat {show [MESSAGES] | delete SEQS}\n' + 'Commands:\n' + #' chat show [MESSAGES]......show chat messages\n' + ' chat delete SEQS..........delete chat messages\n' + 'Definitions:\n' + #' MESSAGES..................undefined\n' + ' SEQS......................=SEQ [SEQ...]\n' + ' SEQ.......................a chat message\'s seq, base-10 integer\n' + ) + return normal, response + +async def cmd_chat_delete(*seqs): + delete_chat_messages(seqs) + normal = ['chat', 'delete', *map(str, seqs)] + response = '' + return normal, response + +SPEC = Str({ + None: End(cmd_chat_help), + 'help': End(cmd_chat_help), + 'delete': ArgsSeqs(End(cmd_chat_delete)), +}) diff --git a/anonstream/control/spec/methods/title.py b/anonstream/control/spec/methods/title.py new file mode 100644 index 0000000..754ad87 --- /dev/null +++ b/anonstream/control/spec/methods/title.py @@ -0,0 +1,40 @@ +import json + +from anonstream.control.exceptions import Fail +from anonstream.control.spec import Spec, NoParse +from anonstream.control.spec.common import Str, End, ArgsJsonString +from anonstream.control.spec.utils import get_item, json_dumps_contiguous +from anonstream.stream import get_stream_title, set_stream_title + +async def cmd_title_help(): + normal = ['title', 'help'] + response = ( + 'Usage: title [show | set TITLE]\n' + 'Commands:\n' + ' title [show].......show the stream title\n' + ' title set TITLE....set the stream title to TITLE\n' + 'Definitions:\n' + ' TITLE..............a json string, whitespace must be \\uXXXX-escaped\n' + ) + return normal, response + +async def cmd_title_show(): + normal = ['title', 'show'] + response = json.dumps(await get_stream_title()) + '\n' + return normal, response + +async def cmd_title_set(title): + try: + await set_stream_title(title) + except OSError as e: + raise Fail(f'could not set title: {e}') from e + normal = ['title', 'set', json_dumps_contiguous(title)] + response = '' + return normal, response + +SPEC = Str({ + None: End(cmd_title_show), + 'help': End(cmd_title_help), + 'show': End(cmd_title_show), + 'set': ArgsJsonString(End(cmd_title_set)), +}) diff --git a/anonstream/control/spec/methods/user.py b/anonstream/control/spec/methods/user.py new file mode 100644 index 0000000..edafb2c --- /dev/null +++ b/anonstream/control/spec/methods/user.py @@ -0,0 +1,135 @@ +import json + +from quart import current_app + +from anonstream.control.exceptions import Fail +from anonstream.control.spec import NoParse +from anonstream.control.spec.common import Str, End, ArgsInt, ArgsString, ArgsJson, ArgsJsonString +from anonstream.control.spec.utils import get_item, json_dumps_contiguous +from anonstream.utils.user import USER_WEBSOCKET_ATTRS + +USERS_BY_TOKEN = current_app.users_by_token +USERS = current_app.users +USERS_UPDATE_BUFFER = current_app.users_update_buffer + +class ArgsJsonTokenUser(ArgsJsonString): + def transform_obj(self, token): + try: + user = USERS_BY_TOKEN[token] + except KeyError: + raise NoParse(f'no user with token {token!r}') + return user + +def ArgsUser(spec): + return Str({ + 'token': ArgsJsonTokenUser(spec), + #'hash': ArgsJsonHashUser(spec), + }) + +async def cmd_user_help(): + normal = ['user', 'help'] + response = ( + 'Usage: user [show | attr USER | get USER ATTR | set USER ATTR VALUE]\n' + 'Commands:\n' + ' user [show].......................show all users\' tokens\n' + ' user attr USER....................show names of a user\'s attributes\n' + ' user get USER ATTR................show an attribute of a user\n' + ' user set USER ATTR................set an attribute of a user\n' + ' user eyes USER [show].............show a user\'s active video responses\n' + ' user eyes USER delete EYES_ID.....end a video response to a user\n' + 'Definitions:\n' + #' USER..............................={token TOKEN | hash HASH}\n' + ' USER..............................=token TOKEN\n' + ' TOKEN..............................a token, json string\n' + #' HASH..............................a token hash\n' + ' ATTR...............................a user attribute, re:[a-z0-9_]+\n' + ' EYES_ID............................a user\'s eyes_id, base 10 integer\n' + ) + return normal, response + +async def cmd_user_show(): + normal = ['user', 'show'] + response = json.dumps(tuple(USERS_BY_TOKEN)) + '\n' + return normal, response + +async def cmd_user_attr(user): + normal = ['user', 'attr', 'token', json_dumps_contiguous(user['token'])] + response = json.dumps(tuple(user.keys())) + '\n' + return normal, response + +async def cmd_user_get(user, attr): + try: + value = user[attr] + except KeyError as e: + raise Fail('user has no such attribute') from e + try: + value_json = json.dumps(value) + except (TypeError, ValueError) as e: + raise Fail('value is not representable in json') from e + normal = [ + 'user', + 'get', + 'token', + json_dumps_contiguous(user['token']), + attr, + ] + response = value_json + '\n' + return normal, response + +async def cmd_user_set(user, attr, value): + if attr not in user: + raise Fail(f'user has no attribute {attr!r}') + user[attr] = value + if attr in USER_WEBSOCKET_ATTRS: + USERS_UPDATE_BUFFER.add(user['token']) + normal = [ + 'user', + 'set', + 'token', + json_dumps_contiguous(user['token']), + attr, + json_dumps_contiguous(value), + ] + response = '' + return normal, response + +async def cmd_user_eyes_show(user): + normal = [ + 'user', + 'eyes', + 'token', + json_dumps_contiguous(user['token']), + 'show' + ] + response = json.dumps(user['eyes']['current']) + '\n' + return normal, response + +async def cmd_user_eyes_delete(user, eyes_id): + try: + user['eyes']['current'].pop(eyes_id) + except KeyError: + pass + normal = [ + 'user', + 'eyes', + 'token', + json_dumps_contiguous(user['token']), + 'delete', + str(eyes_id), + ] + response = '' + return normal, response + +SPEC = Str({ + None: End(cmd_user_show), + 'help': End(cmd_user_help), + 'show': End(cmd_user_show), + 'attr': ArgsUser(End(cmd_user_attr)), + 'get': ArgsUser(ArgsString(End(cmd_user_get))), + 'set': ArgsUser(ArgsString(ArgsJson(End(cmd_user_set)))), + 'eyes': ArgsUser(Str({ + None: End(cmd_user_eyes_show), + 'show': End(cmd_user_eyes_show), + 'delete': ArgsInt(End(cmd_user_eyes_delete)), + })), +}) diff --git a/anonstream/control/spec/utils.py b/anonstream/control/spec/utils.py index cc2f2c3..6d9468a 100644 --- a/anonstream/control/spec/utils.py +++ b/anonstream/control/spec/utils.py @@ -1,3 +1,5 @@ +import json + def get_item(index, words): try: word = words[index] @@ -8,6 +10,9 @@ def get_item(index, words): raise NoParse(f'empty word at position {index}') return word +def json_dumps_contiguous(obj, **kwargs): + return json.dumps(obj, **kwargs).replace(' ', r'\u0020') + def startswith(string, prefix): if string is None or prefix is None: return string is prefix diff --git a/anonstream/control/utils.py b/anonstream/control/utils.py deleted file mode 100644 index 75de212..0000000 --- a/anonstream/control/utils.py +++ /dev/null @@ -1,4 +0,0 @@ -import json - -def json_dumps_contiguous(obj, **kwargs): - return json.dumps(obj, **kwargs).replace(' ', r'\u0020')