| @@ -0,0 +1,2 @@ | |||
| __pycache__ | |||
| venv/ | |||
| @@ -0,0 +1,19 @@ | |||
| default: test | |||
| test: venv/ | |||
| venv/bin/python -u test.py | |||
| run: venv/ | |||
| pkill Python || true | |||
| make test | |||
| venv/bin/python -u pico.py | |||
| deploy: | |||
| rsync --archive --exclude-from=.gitignore . root@roderic.ca:/home/pico.chat/ | |||
| ssh root@roderic.ca "cd /home/pico.chat/ && service pico.chat restart" | |||
| venv/: requirements.txt | |||
| rm -rf venv | |||
| python3 -m venv venv | |||
| venv/bin/pip install websockets | |||
| touch requirements.txt venv | |||
| @@ -0,0 +1,42 @@ | |||
| body { | |||
| margin: 0; | |||
| padding: 0; | |||
| } | |||
| .chat { | |||
| display: grid; | |||
| grid-template-areas: | |||
| 'posts users' | |||
| 'actions users' | |||
| ; | |||
| grid-template-columns: 1fr auto; | |||
| grid-template-rows: 1fr auto auto; | |||
| height: 150px; | |||
| position: fixed; | |||
| top: 0; | |||
| --pad: 3px; | |||
| padding: var(--pad); | |||
| width: calc(100vw - 2 * var(--pad)); | |||
| } | |||
| .ts { | |||
| color: rgba(0, 0, 0, 0.4); | |||
| font-family: monospace; | |||
| } | |||
| .source { | |||
| font-weight: bold; | |||
| } | |||
| .users { | |||
| grid-area: users; | |||
| } | |||
| .posts { | |||
| grid-area: posts; | |||
| overflow-y: scroll; | |||
| } | |||
| .actions { | |||
| grid-area: actions; | |||
| } | |||
| .post > div { | |||
| display: inline; | |||
| padding-left: var(--pad); | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| <!DOCTYPE html> | |||
| <html> | |||
| <title>PicoChat!</title> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | |||
| <script src="https://unpkg.com/mithril/mithril.min.js"></script> | |||
| <script src="/pico.js" defer></script> | |||
| <link rel="stylesheet" href="/pico.css" /> | |||
| <body>PicoChat requires JS</body> | |||
| </html> | |||
| @@ -0,0 +1,107 @@ | |||
| const State = { | |||
| websocket: null, | |||
| posts: [], | |||
| users: [], | |||
| } | |||
| const autoFocus = (vnode) => { | |||
| vnode.dom.focus() | |||
| } | |||
| const scrollIntoView = (vnode) => { | |||
| vnode.dom.scrollIntoView() | |||
| } | |||
| const prettyTime = (ts) => { | |||
| return ts.slice(11, 19) | |||
| } | |||
| const Login = { | |||
| sendLogin: (e) => { | |||
| e.preventDefault() | |||
| const username = e.target.username.value | |||
| localStorage.username = username | |||
| connect(username) | |||
| }, | |||
| sendLogout: (e) => { | |||
| localStorage.removeItem('username') | |||
| State.websocket.send(JSON.stringify({action: 'logout'})) | |||
| State.posts = [] | |||
| }, | |||
| view() { | |||
| return m('.login', | |||
| m('form', {onsubmit: Login.sendLogin}, | |||
| m('input', {oncreate: autoFocus, name: 'username', autocomplete: 'off'}), | |||
| m('button', 'Login'), | |||
| ), | |||
| State.kind === 'error' && m('.error', State.info), | |||
| ) | |||
| }, | |||
| } | |||
| const Chat = { | |||
| sendPost: (e) => { | |||
| e.preventDefault() | |||
| const field = e.target.text | |||
| State.websocket.send(JSON.stringify({action: 'post', text: field.value})) | |||
| field.value = '' | |||
| }, | |||
| view() { | |||
| return m('.chat', | |||
| m('.posts', | |||
| State.posts.map(post => m('.post', {oncreate: scrollIntoView}, | |||
| m('.ts', prettyTime(post.ts)), | |||
| m('.source', post.source), | |||
| m('.text', post.text), | |||
| )), | |||
| ), | |||
| m('form.actions', {onsubmit: Chat.sendPost}, | |||
| m('input', {oncreate: autoFocus, name: 'text', autocomplete: 'off'}), | |||
| m('button', 'Send'), | |||
| ), | |||
| m('.users', | |||
| m('button', {onclick: Login.sendLogout}, 'Logout'), | |||
| m('ul.user-list', State.users.map(username => m('li', username))) | |||
| ), | |||
| ) | |||
| }, | |||
| } | |||
| const Main = { | |||
| view() { | |||
| const connected = State.websocket && State.websocket.readyState === 1 | |||
| return connected ? m(Chat) : m(Login) | |||
| }, | |||
| } | |||
| m.mount(document.body, Main) | |||
| const connect = (username) => { | |||
| State.websocket = new WebSocket(wsUrl) | |||
| State.websocket.onopen = (e) => { | |||
| State.websocket.send(JSON.stringify({action: 'login', username})) | |||
| } | |||
| State.websocket.onmessage = (e) => { | |||
| const message = JSON.parse(e.data) | |||
| if(message.kind === 'post') { | |||
| State.posts.push(message) | |||
| } | |||
| else if(message.kind === 'update') { | |||
| Object.assign(State, message) | |||
| if(message.info) { | |||
| State.posts.push({ts: message.ts, source: '~', text: message.info}) | |||
| } | |||
| } | |||
| else if(message.kind === 'error') { | |||
| Object.assign(State, message) | |||
| Login.sendLogout() | |||
| } | |||
| else { | |||
| console.log(message) | |||
| } | |||
| m.redraw() | |||
| } | |||
| State.websocket.onclose = (e) => { | |||
| if(!e.wasClean) { | |||
| setTimeout(connect, 100, username) | |||
| } | |||
| m.redraw() | |||
| } | |||
| } | |||
| const wsUrl = location.toString().replace('http', 'ws') | |||
| if(localStorage.username) { | |||
| connect(localStorage.username) | |||
| } | |||
| @@ -0,0 +1,106 @@ | |||
| import asyncio | |||
| import collections | |||
| import datetime | |||
| import http | |||
| import json | |||
| import functools | |||
| from pathlib import Path | |||
| import websockets | |||
| rooms = collections.defaultdict(dict) | |||
| async def send_json_many(targets, **data): | |||
| for websocket in list(targets): | |||
| await send_json(websocket, **data) | |||
| async def send_json(websocket, **data): | |||
| try: | |||
| await websocket.send(json.dumps(data)) | |||
| except websockets.exceptions.ConnectionClosed: | |||
| pass | |||
| async def recv_json(websocket): | |||
| try: | |||
| return json.loads(await websocket.recv()) | |||
| except websockets.exceptions.ConnectionClosed: | |||
| return {'action': 'logout'} | |||
| except json.decoder.JSONDecodeError: | |||
| return {} | |||
| async def core(websocket, path, server_name): | |||
| sockets = rooms[path] | |||
| while True: | |||
| data = await recv_json(websocket) | |||
| ts = datetime.datetime.now().isoformat() | |||
| reply = functools.partial(send_json, websocket=websocket, ts=ts) | |||
| error = functools.partial(reply, kind='error') | |||
| broadcast = functools.partial(send_json_many, targets=sockets, ts=ts) | |||
| if 'action' not in data: | |||
| await error(info='Message without action is invalid') | |||
| elif websocket not in sockets and data['action'] not in {'login', 'logout'}: | |||
| await error(info='Not logged in') | |||
| elif data['action'] == 'login': | |||
| username = data['username'] | |||
| if not username: | |||
| await error(info='Invalid username') | |||
| elif username in sockets.values(): | |||
| await error(info='Username taken') | |||
| else: | |||
| sockets[websocket] = username | |||
| await reply(kind='update', username=username) | |||
| await broadcast(kind='update', users=list(sockets.values()), info=f'{username} joined') | |||
| elif data['action'] == 'post': | |||
| text = data['text'] | |||
| if not text: | |||
| continue | |||
| await broadcast(kind='post', source=sockets[websocket], text=text) | |||
| elif data['action'] == 'logout': | |||
| if websocket in sockets: | |||
| username = sockets.pop(websocket) | |||
| await broadcast(kind='update', users=list(sockets.values()), info=f'{username} left') | |||
| break | |||
| async def serve_html(path, request_headers): | |||
| if request_headers.get('Upgrade') != 'websocket': | |||
| document = Path(__file__, '..', Path(path).name).resolve() | |||
| if not document.is_file(): | |||
| document = Path(__file__, '..', 'pico.html').resolve() | |||
| content_type = 'text/html; charset=utf-8' | |||
| elif path.endswith('.js'): | |||
| content_type = 'application/javascript; charset=utf-8' | |||
| elif path.endswith('.css'): | |||
| content_type = 'text/css; charset=utf-8' | |||
| else: | |||
| content_type = 'text/plain; charset=utf-8' | |||
| return ( | |||
| http.HTTPStatus.OK, | |||
| [('Content-Type', content_type)], | |||
| document.read_bytes(), | |||
| ) | |||
| async def start_server(host, port, server_name): | |||
| bound_core = functools.partial(core, server_name=server_name) | |||
| return await websockets.serve(bound_core, host, port, process_request=serve_html) | |||
| if __name__ == '__main__': | |||
| host, port = 'localhost', 9753 | |||
| loop = asyncio.get_event_loop() | |||
| loop.run_until_complete(start_server(host, port, 'PicoChat')) | |||
| print(f'Running on {host}:{port}') | |||
| loop.run_forever() | |||
| @@ -0,0 +1 @@ | |||
| websockets | |||
| @@ -0,0 +1,168 @@ | |||
| import asyncio | |||
| import itertools | |||
| from pico import start_server, send_json, recv_json | |||
| import websockets | |||
| class Script: | |||
| def __init__(self): | |||
| self.events = [] | |||
| def __add__(self, event): | |||
| self.events.append(('send', event)) | |||
| return self | |||
| def __sub__(self, event): | |||
| self.events.append(('recv', event)) | |||
| return self | |||
| async def _make_client(path, timeout, script): | |||
| state = {} | |||
| error = False | |||
| await asyncio.sleep(timeout) | |||
| async with websockets.connect(path) as websocket: | |||
| for kind, message in script.events: | |||
| if kind == 'recv': | |||
| A, B = message, await recv_json(websocket) | |||
| B.pop('ts') | |||
| state.update(message) | |||
| if A != B: | |||
| error = True | |||
| print('-', A) | |||
| print('+', B) | |||
| print() | |||
| elif kind == 'send': | |||
| await send_json(websocket, **message) | |||
| while True: | |||
| if state['users'][0] == state['username']: | |||
| break | |||
| message = await recv_json(websocket) | |||
| message.pop('ts') | |||
| error = True | |||
| print('+', message) | |||
| print() | |||
| state.update(message) | |||
| if error: | |||
| print('error') | |||
| exit() | |||
| async def _test(*clients): | |||
| try: | |||
| gather = asyncio.gather( | |||
| start_server('localhost', 8642, 'TestServer'), | |||
| *clients, | |||
| ) | |||
| server, *_ = await asyncio.wait_for(gather, timeout=2) | |||
| server.close() | |||
| except asyncio.TimeoutError: | |||
| return | |||
| def test_happy_path(): | |||
| client = _make_client('ws://localhost:8642/', 0.1, Script() | |||
| + {'action': 'login', 'username': 'TestUser'} | |||
| - {'kind': 'update', 'username': 'TestUser'} | |||
| - {'kind': 'update', 'users': ['TestUser'], 'info': 'TestUser joined'} | |||
| + {'action': 'post', 'text': 'Hello World!'} | |||
| - {'kind': 'post', 'source': 'TestUser', 'text': 'Hello World!'} | |||
| ) | |||
| return _test(client) | |||
| def test_post_before_login(): | |||
| client = _make_client('ws://localhost:8642/', 0.1, Script() | |||
| + {} | |||
| - {'kind': 'error', 'info': 'Message without action is invalid'} | |||
| + {'action': 'post', 'text': ''} | |||
| - {'kind': 'error', 'info': 'Not logged in'} | |||
| + {'action': 'login', 'username': ''} | |||
| - {'kind': 'error', 'info': 'Invalid username'} | |||
| + {'action': 'login', 'username': 'Joe'} | |||
| - {'kind': 'update', 'username': 'Joe'} | |||
| - {'kind': 'update', 'users': ['Joe'], 'info': 'Joe joined'} | |||
| ) | |||
| return _test(client) | |||
| def test_interact(): | |||
| client1 = _make_client('ws://localhost:8642/', 0.10, Script() | |||
| + {'action': 'login', 'username': 'Alice'} | |||
| - {'kind': 'update', 'username': 'Alice'} | |||
| - {'kind': 'update', 'users': ['Alice'], 'info': 'Alice joined'} | |||
| - {'kind': 'update', 'users': ['Alice', 'Bob'], 'info': 'Bob joined'} | |||
| + {'action': 'post', 'text': 'Hey Bob!'} | |||
| - {'kind': 'post', 'source': 'Alice', 'text': 'Hey Bob!'} | |||
| - {'kind': 'post', 'source': 'Bob', 'text': 'Howdy!'} | |||
| ) | |||
| client2 = _make_client('ws://localhost:8642/', 0.11, Script() | |||
| + {'action': 'login', 'username': 'Bob'} | |||
| - {'kind': 'update', 'username': 'Bob'} | |||
| - {'kind': 'update', 'users': ['Alice', 'Bob'], 'info': 'Bob joined'} | |||
| - {'kind': 'post', 'source': 'Alice', 'text': 'Hey Bob!'} | |||
| + {'action': 'post', 'text': 'Howdy!'} | |||
| - {'kind': 'post', 'source': 'Bob', 'text': 'Howdy!'} | |||
| + {'action': 'post', 'text': ''} | |||
| - {'kind': 'update', 'users': ['Bob'], 'info': 'Alice left'} | |||
| ) | |||
| return _test(client1, client2) | |||
| def test_party(): | |||
| client1 = _make_client('ws://localhost:8642/', 0.10, Script() | |||
| + {'action': 'login', 'username': 'Norman'} | |||
| - {'kind': 'update', 'username': 'Norman'} | |||
| - {'kind': 'update', 'users': ['Norman'], 'info': 'Norman joined'} | |||
| - {'kind': 'update', 'users': ['Norman', 'Ray'], 'info': 'Ray joined'} | |||
| - {'kind': 'update', 'users': ['Norman', 'Ray', 'Emma'], 'info': 'Emma joined'} | |||
| + {'action': 'post', 'text': 'なに?'} | |||
| - {'kind': 'post', 'source': 'Norman', 'text': 'なに?'} | |||
| ) | |||
| client2 = _make_client('ws://localhost:8642/', 0.11, Script() | |||
| + {'action': 'login', 'username': 'Ray'} | |||
| - {'kind': 'update', 'username': 'Ray'} | |||
| - {'kind': 'update', 'users': ['Norman', 'Ray'], 'info': 'Ray joined'} | |||
| - {'kind': 'update', 'users': ['Norman', 'Ray', 'Emma'], 'info': 'Emma joined'} | |||
| - {'kind': 'post', 'source': 'Norman', 'text': 'なに?'} | |||
| - {'kind': 'update', 'users': ['Ray', 'Emma'], 'info': 'Norman left'} | |||
| ) | |||
| client3 = _make_client('ws://localhost:8642/', 0.12, Script() | |||
| + {'action': 'login', 'username': 'Emma'} | |||
| - {'kind': 'update', 'username': 'Emma'} | |||
| - {'kind': 'update', 'users': ['Norman', 'Ray', 'Emma'], 'info': 'Emma joined'} | |||
| - {'kind': 'post', 'source': 'Norman', 'text': 'なに?'} | |||
| - {'kind': 'update', 'users': ['Ray', 'Emma'], 'info': 'Norman left'} | |||
| - {'kind': 'update', 'users': ['Emma'], 'info': 'Ray left'} | |||
| ) | |||
| return _test(client1, client2, client3) | |||
| def test_rooms(): | |||
| client1 = _make_client('ws://localhost:8642/A', 0.10, Script() | |||
| + {'action': 'login', 'username': 'Dandy'} | |||
| - {'kind': 'update', 'username': 'Dandy'} | |||
| - {'kind': 'update', 'users': ['Dandy'], 'info': 'Dandy joined'} | |||
| + {'action': 'post', 'text': 'Hi'} | |||
| - {'kind': 'post', 'source': 'Dandy', 'text': 'Hi'} | |||
| ) | |||
| client2 = _make_client('ws://localhost:8642/B', 0.10, Script() | |||
| + {'action': 'login', 'username': 'Dandy'} | |||
| - {'kind': 'update', 'username': 'Dandy'} | |||
| - {'kind': 'update', 'users': ['Dandy'], 'info': 'Dandy joined'} | |||
| + {'action': 'post', 'text': 'Howdy'} | |||
| - {'kind': 'post', 'source': 'Dandy', 'text': 'Howdy'} | |||
| ) | |||
| return _test(client1, client2) | |||
| if __name__ == '__main__': | |||
| loop = asyncio.get_event_loop() | |||
| for fn_name, fn in list(locals().items()): | |||
| if fn_name.startswith('test_'): | |||
| loop.run_until_complete(fn()) | |||