__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()) |