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