Roderic Day pirms 5 gadiem
revīzija
890095b8f1
8 mainītis faili ar 454 papildinājumiem un 0 dzēšanām
  1. +2
    -0
      .gitignore
  2. +19
    -0
      makefile
  3. +42
    -0
      pico.css
  4. +9
    -0
      pico.html
  5. +107
    -0
      pico.js
  6. +106
    -0
      pico.py
  7. +1
    -0
      requirements.txt
  8. +168
    -0
      test.py

+ 2
- 0
.gitignore Parādīt failu

@@ -0,0 +1,2 @@
__pycache__
venv/

+ 19
- 0
makefile Parādīt failu

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

+ 42
- 0
pico.css Parādīt failu

@@ -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);
}

+ 9
- 0
pico.html Parādīt failu

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

+ 107
- 0
pico.js Parādīt failu

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

+ 106
- 0
pico.py Parādīt failu

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

+ 1
- 0
requirements.txt Parādīt failu

@@ -0,0 +1 @@
websockets

+ 168
- 0
test.py Parādīt failu

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

Notiek ielāde…
Atcelt
Saglabāt