Skip to content

Commit

Permalink
Merge pull request #8 from mrgnr/0.2.0-release
Browse files Browse the repository at this point in the history
Release 0.2.0
  • Loading branch information
mrgnr authored Oct 20, 2020
2 parents 379e44d + aa73847 commit 341ed33
Show file tree
Hide file tree
Showing 22 changed files with 1,254 additions and 491 deletions.
7 changes: 4 additions & 3 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ Features
- Text chat
- Room management (public/private, password/no password, guest limits)

.. image:: screenshots/0.2.0.png

Demo
----

Expand All @@ -27,12 +29,11 @@ Running
Using Snap
~~~~~~~~~~

Make sure you have `snapd`_ installed. Install Camus (currently only an
unstable edge version is available):
Make sure you have `snapd`_ installed. Install Camus:

::

$ sudo snap install --edge camus
$ sudo snap install camus

Run Camus:

Expand Down
75 changes: 74 additions & 1 deletion camus/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,17 @@


def get_chat_manager():
"""Retrieve the global ChatManager object."""

global _chat_manager
if _chat_manager is None:
_chat_manager = ChatManager()
return _chat_manager


def generate_turn_creds(key, client_id):
"""Generate TURN server credentials for a client."""

expiration = int(time()) + 6 * 60 * 60 # creds expire after 6 hrs
username = '{}:{}'.format(expiration, client_id)
token = hmac.new(key.encode(), msg=username.encode(), digestmod='SHA1')
Expand All @@ -42,6 +46,11 @@ class ChatException(Exception):


class ChatRoom(AsyncIOEventEmitter):
"""
A class that tracks information for a specific room, including connected
clients and room settings.
"""

def __init__(self, name, password=None, guest_limit=None, admin_list=None, is_public=False):
super().__init__()

Expand All @@ -55,6 +64,11 @@ def __init__(self, name, password=None, guest_limit=None, admin_list=None, is_pu
self._last_active = time_ms()

async def check_expire():
"""Check whether the room has expired.
Rooms expire and are cleaned up after 60 minutes of inactivity.
"""

now = time_ms() / 1000
last_active = self.last_active / 1000

Expand All @@ -73,53 +87,80 @@ async def check_expire():

@property
def last_active(self):
"""A timestamp corresponding to when the room was last active."""

last_seen = [client.last_seen for client in self.clients.values()]
self._last_active = max([self._last_active, *last_seen])
return self._last_active

@property
def active_ago(self):
"""The number of minutes ago that the room was last active."""
return int((time_ms() - self.last_active) / 60000)

@property
def info(self):
"""
Information about the room, consisting of the room ID and a list of
connected clients.
"""

clients = [{'id': client.id, 'username': client.username}
for client in self.get_clients()]

return {'room_id': self.id, 'clients': clients}

def authenticate(self, password=None):
"""Attempt to authenticate access to the room."""

if password is None:
return self.password_hash is None

return check_password_hash(self.password_hash, password)

def is_full(self):
"""Check whether the room's guest limit has been reached.
Returns True if the guest limit has been reached, False otherwise.
"""

return self.guest_limit is not None and len(self.clients) == self.guest_limit

def add_client(self, client):
"""Add a client to the room.
Raises a ChatException if the room is already full.
"""

if self.is_full():
raise ChatException('Guest limit already reached')

self.clients[client.id] = client
client.room = self

def remove_client(self, client):
"""Remove a client from the room."""

logging.info('Removing client {} from room {}'.format(client.id, self.id))
self._last_active = max(self._last_active, client.last_seen)
client.room = None
self.clients.pop(client.id, None)
logging.info('{} clients remaining in room {}'.format(len(self.clients), self.id))

def get_clients(self):
"""Get the clients connected to the room."""
return self.clients.values()

def broadcast(self, message):
"""Send a message to all clients connected to the room."""

for client in self.get_clients():
message.receiver = client.id
client.send(message.json())

async def shutdown(self):
"""Shut down the room."""

if self._timer is not None:
self._timer.cancel()
self._timer = None
Expand All @@ -130,6 +171,8 @@ async def shutdown(self):


class ChatClient(AsyncIOEventEmitter):
"""A class that represents a connected client."""

def __init__(self, id, username=None, room=None, is_admin=False):
super().__init__()

Expand Down Expand Up @@ -157,6 +200,8 @@ async def _process_inbox(self):
self.emit('message', message)

def send(self, data):
"""Send a message to the client."""

try:
self.outbox.put_nowait(data)
message_type = json.loads(data)['type']
Expand All @@ -167,6 +212,8 @@ def send(self, data):
.format(self.id, e))

def ping(self):
"""Send the client a ping message."""

message = ChatMessage()
message.sender = 'ground control'
message.receiver = self.id
Expand All @@ -175,6 +222,8 @@ def ping(self):
self.send(message.json())

async def shutdown(self):
"""Terminate the connection with the client."""

message = ChatMessage()
message.sender = 'ground control'
message.receiver = self.id
Expand All @@ -193,6 +242,8 @@ async def shutdown(self):
logging.info('Shut down client %s', self.id)

class ChatMessage:
"""A structured message that can be sent to a client."""

def __init__(self, message=None):
if isinstance(message, str):
_json = json.loads(message)
Expand All @@ -208,6 +259,7 @@ def __init__(self, message=None):
self.data = _json.get('data')

def json(self):
"""Get the JSON-encoded representation of the message."""
return json.dumps(self.__dict__)


Expand All @@ -219,6 +271,11 @@ def __init__(self):

@property
def clients(self):
"""
A dictionary containing a mapping of client IDs to ChatClient objects
for all clients in all rooms.
"""

return {client.id: client for room in self.rooms.values()
for client in room.clients.values()}

Expand Down Expand Up @@ -288,16 +345,21 @@ def _parse_message(self, message, client):
return chat_message

def broadcast_room_info(self, room):
"""Send a room-info message to all clients in a given room."""

message = ChatMessage()
message.sender = self._message_address
message.type = 'room-info'
message.data = room.info
room.broadcast(message)

def add_room(self, room):
"""Add a room."""
self.rooms[room.id] = room

async def remove_client(self, client):
"""Remove a client."""

room = client.room
if room:
room.remove_client(client)
Expand All @@ -315,13 +377,17 @@ async def _reap(self, client):
await self.remove_client(client)

def get_room(self, room_id):
"""Return a ChatRoom object given a room ID."""
return self.rooms.get(room_id)

def get_public_rooms(self):
"""Get a list of public rooms."""
return sorted([room for room in self.rooms.values() if room.is_public],
key=lambda room: room.active_ago)

def create_room(self, name, **kwargs):
"""Create a new room."""

room_id = slugify(name)
if room_id in self.rooms:
raise ChatException('Room {} already exists'.format(room_id))
Expand All @@ -336,11 +402,15 @@ async def on_expire():
return room

async def remove_room(self, room):
"""Remove a room."""

self.rooms.pop(room.id, None)
await room.shutdown()
logging.info('Removed room %s', room.id)

def create_client(self, client_id=None):
"""Create a new ChatClient."""

if client_id is None:
client_id = uuid.uuid4().hex

Expand Down Expand Up @@ -369,6 +439,8 @@ async def on_message(message):
return client

def get_ice_servers(self, client_id):
"""Get a list of configured ICE servers."""

stun_host = app.config['STUN_HOST']
stun_port = app.config['STUN_PORT']
stun_url = 'stun:{}:{}'.format(stun_host, stun_port)
Expand All @@ -389,6 +461,7 @@ def get_ice_servers(self, client_id):

def get_twilio_ice_servers(self):
"""Fetch a list of ICE servers provided by Twilio."""

account_sid = app.config['TWILIO_ACCOUNT_SID']
auth_token = app.config['TWILIO_AUTH_TOKEN']
key_sid = app.config['TWILIO_KEY_SID']
Expand All @@ -398,4 +471,4 @@ def get_twilio_ice_servers(self):
token = twilio.tokens.create()
return token.ice_servers
except (TwilioException, TwilioRestException):
return {}
return []
3 changes: 2 additions & 1 deletion camus/config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import os
import secrets
basedir = os.path.abspath(os.path.dirname(__file__))

class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or '420sixtyn9ne'
SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(32)
STUN_HOST = os.environ.get('STUN_HOST') or 'stun.l.google.com'
STUN_PORT = os.environ.get('STUN_PORT') or 19302
TURN_HOST = os.environ.get('TURN_HOST')
Expand Down
10 changes: 5 additions & 5 deletions camus/static/dist/chat.js

Large diffs are not rendered by default.

Binary file added camus/static/favicon.ico
Binary file not shown.
Loading

0 comments on commit 341ed33

Please sign in to comment.