| __pycache__ | |||||
| venv/ |
| 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 |
| 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); | |||||
| } |
| <!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> |
| 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) | |||||
| } |
| 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() |
| websockets |
| 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()) |