Skip to content

Commit

Permalink
feature: handle users registration (#30)
Browse files Browse the repository at this point in the history
* feature: handle users registration

* refactor: refactor newcomer notification

* tests: add users route test

* tests: add newcomer registration tests

* fix: set newcomer field validators

* feature: notify room when a user has left

* feature: notify room when a user has left in web page

* doc: update README.md with new message types

* doc: add docstrings
  • Loading branch information
gounux authored Oct 25, 2024
1 parent 2c6fb8b commit aca9c13
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 26 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ Following instances are up and running :
- Number of connected users can be fetched using [the `/status` endpoint](https://gischat.geotribu.net/docs#/default/get_status_status_get)
- New users must connect a websocket to the `/room/{room_name}/ws` endpoint
- Messages passing through the websocket are simple JSON dicts like this: `{"message": "hello", "author": "Hans Hibbel", "avatar": "mGeoPackage.svg"}`
- :warning: Messages having the `"internal"` author are internal messages and should not be printed, they contain technical information: `{"author": "internal", "nb_users": 36}`
- :warning: Messages having the `"internal"` author are internal messages and should not be printed, they contain technical information:
- `{"author": "internal", "nb_users": 36}` -> there are now 36 users in the room
- `{"author": "internal", "newcomer": "Jane"}` -> Jane has joined the room
- `{"author": "internal", "exiter": "Jane"}` -> Jane has left the room
- `"author"` value must be alphanumeric (or `_` or `-`) and have min / max length set by `MIN_AUTHOR_LENGTH` / `MAX_AUTHOR_LENGTH` environment variables
- `"message"` value must have max length set by `MAX_MESSAGE_LENGTH` environment variable
- Once the websocket is connected, it might be polite to send a registration message like : `{"author": "internal", "newcomer": "Jane"}`

## Deploy a self-hosted instance

Expand Down
156 changes: 137 additions & 19 deletions gischat/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@

from gischat import INTERNAL_MESSAGE_AUTHOR
from gischat.models import (
InternalMessageModel,
InternalExiterMessageModel,
InternalNbUsersMessageModel,
InternalNewcomerMessageModel,
MessageModel,
RulesModel,
StatusModel,
Expand All @@ -35,6 +37,10 @@


def available_rooms() -> list[str]:
"""
Returns list of available rooms
:return: list of available rooms
"""
return os.environ.get("ROOMS", "QGIS,QField,Geotribu").split(",")


Expand All @@ -43,13 +49,18 @@ class WebsocketNotifier:
Class used to broadcast messages to registered websockets
"""

# list of websockets connection associated to room
connections: dict[str, list[WebSocket]]

# list of user nicknames associated to their websocket
users: dict[WebSocket, str]

def __init__(self):
# registered websockets for rooms
self.connections: dict[str, list[WebSocket]] = {
room: [] for room in available_rooms()
}
self.users = {}
self.generator = self.get_notification_generator()

async def get_notification_generator(self):
Expand All @@ -61,14 +72,36 @@ async def push(self, msg: str) -> None:
await self.generator.asend(msg)

async def connect(self, room: str, websocket: WebSocket) -> None:
"""
Connects a new user to a room
:param room: room to connect the websocket to
:param websocket: new user's websocket connection
"""
await websocket.accept()
self.connections[room].append(websocket)

def remove(self, room: str, websocket: WebSocket) -> None:
async def remove(self, room: str, websocket: WebSocket) -> None:
"""
Removes a user from a room
Should be called when a websocket is disconnected
:param room: room to disconnect user from
:param websocket: user's websocket connection
"""
# remove websocket from connections
if websocket in self.connections[room]:
self.connections[room].remove(websocket)
# unregister user
if websocket in self.users.keys():
exiter = self.users[websocket]
del self.users[websocket]
await self.notify_exiter(room, exiter)

async def notify(self, room: str, message: str) -> None:
"""
Sends a message to a room
:param room: room to notify
:param message: message to send, should be stringified JSON
"""
living_connections = []
while len(self.connections[room]) > 0:
# Looping like this is necessary in case a disconnection is handled
Expand All @@ -81,19 +114,90 @@ async def notify(self, room: str, message: str) -> None:
logger.error("Can not send message to disconnected websocket")
self.connections[room] = living_connections

def get_nb_users(self, room: str) -> int:
def get_nb_connected_users(self, room: str) -> int:
"""
Returns the number of connected users in a room
:param room: room to check
:return: number of connected users in a room
"""
return len(self.connections[room])

async def notify_internal(self, room: str) -> None:
message = InternalMessageModel(
author=INTERNAL_MESSAGE_AUTHOR, nb_users=self.get_nb_users(room)
async def notify_nb_users(self, room: str) -> None:
"""
Notifies connected users in a room with the number of connected users
:param room: room to notify
"""
message = InternalNbUsersMessageModel(
author=INTERNAL_MESSAGE_AUTHOR, nb_users=self.get_nb_connected_users(room)
)
await self.notify(room, json.dumps(jsonable_encoder(message)))

async def notify_newcomer(self, room: str, user: str) -> None:
"""
Notifies a room that a newcomer has joined
:param room: room to notify
:param user: nickname of the newcomer
"""
message = InternalNewcomerMessageModel(
author=INTERNAL_MESSAGE_AUTHOR, newcomer=user
)
await self.notify(room, json.dumps(jsonable_encoder(message)))

async def notify_exiter(self, room: str, user: str) -> None:
"""
Notifies a room that a user has left the room
:param room: room to notify
:param user: nickname of the exiter
"""
message = InternalExiterMessageModel(
author=INTERNAL_MESSAGE_AUTHOR, exiter=user
)
await self.notify(room, json.dumps(jsonable_encoder(message)))

def register_user(self, websocket: WebSocket, user: str) -> None:
"""
Registers a user assigned to a websocket
:param websocket: user's websocket
:param user: user's nickname
"""
self.users[websocket] = user

def get_registered_users(self, room: str) -> list[str]:
"""
Returns the nicknames of users registered in a room
:param room: room to check
:return: List of user names
"""
users = []
for ws in self.connections[room]:
# use try/except instead of list comprehension
# in case a user didn't register itself
try:
users.append(self.users[ws])
except KeyError:
continue
return users

def is_user_present(self, room: str, user: str) -> bool:
"""
Checks if a user given by the nickname is registered in a room
:param room: room to check
:param user: user to check
:return: True if present, False otherwise
"""
for ws in self.connections[room]:
try:
if self.users[ws] == user:
return True
except KeyError:
continue
return False


notifier = WebsocketNotifier()


# initialize sentry
if "SENTRY_DSN" in os.environ and os.environ.get("SENTRY_DSN"):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),
Expand All @@ -105,7 +209,7 @@ async def notify_internal(self, room: str) -> None:

app = FastAPI(
title="gischat API",
summary="Chat with your GIS tribe in QGIS, QField and other clients !",
summary="Chat with your GIS tribe in QGIS, GIS mobile apps and other clients !",
version=get_poetry_version(),
)
templates = Jinja2Templates(directory="gischat/templates")
Expand Down Expand Up @@ -152,6 +256,13 @@ async def get_rules() -> RulesModel:
)


@app.get("/room/{room}/users")
async def get_connected_users(room: str) -> list[str]:
if room not in notifier.connections.keys():
raise HTTPException(status_code=404, detail=f"Room '{room}' not registered")
return sorted(notifier.get_registered_users(room), key=str.casefold)


@app.put(
"/room/{room}/message",
response_model=MessageModel,
Expand All @@ -172,23 +283,30 @@ async def websocket_endpoint(websocket: WebSocket, room: str) -> None:
raise HTTPException(status_code=404, detail=f"Room '{room}' not registered")

await notifier.connect(room, websocket)
await notifier.notify_internal(room)
await notifier.notify_nb_users(room)
logger.info(f"New websocket connected in room '{room}'")

try:
while True:
data = await websocket.receive_text()
try:
message = MessageModel(**json.loads(data))
except ValidationError:
logger.error("Invalid message in websocket")
continue
logger.info(f"Message in room '{room}': {message}")
try:
payload = json.loads(data)
if "author" in payload and payload["author"] == "internal":
if "newcomer" in payload:
newcomer = payload["newcomer"]
notifier.register_user(websocket, newcomer)
logger.info(f"Newcomer in room {room}: {newcomer}")
await notifier.notify_newcomer(room, newcomer)

else:
try:
message = MessageModel(**payload)
logger.info(f"Message in room '{room}': {message}")
except ValidationError:
logger.error("Invalid message in websocket")
continue
await notifier.notify(room, json.dumps(jsonable_encoder(message)))
except WebSocketDisconnect:
logger.error("Can not send message to disconnected websocket")

except WebSocketDisconnect:
notifier.remove(room, websocket)
await notifier.notify_internal(room)
await notifier.remove(room, websocket)
await notifier.notify_nb_users(room)
logger.info(f"Websocket disconnected from room '{room}'")
22 changes: 21 additions & 1 deletion gischat/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ def __str__(self) -> str:
return f"[{self.author}]: '{self.message}'"


class InternalMessageModel(BaseModel):
class InternalNbUsersMessageModel(BaseModel):
author: str
nb_users: int


class InternalNewcomerMessageModel(BaseModel):
author: str
newcomer: str = Field(
None,
min_length=int(os.environ.get("MIN_AUTHOR_LENGTH", 3)),
max_length=int(os.environ.get("MAX_AUTHOR_LENGTH", 32)),
pattern=r"^[a-z-A-Z-0-9-_]+$",
)


class InternalExiterMessageModel(BaseModel):
author: str
exiter: str = Field(
None,
min_length=int(os.environ.get("MIN_AUTHOR_LENGTH", 3)),
max_length=int(os.environ.get("MAX_AUTHOR_LENGTH", 32)),
pattern=r"^[a-z-A-Z-0-9-_]+$",
)
21 changes: 16 additions & 5 deletions gischat/templates/ws-page.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ <h1>gischat web client</h1>
</form>
<br>
<form>
<label>Author: <input type="text" id="authorId" class="text-input" autocomplete="off" value="Geotribu" disabled/></label>
<label>Author: <input type="text" id="authorId" class="text-input" autocomplete="off" value="Geotribu"/></label>
<label>Avatar: <input type="text" id="avatarId" class="text-input" autocomplete="off" value="mGeoPackage.svg" disabled/></label>
</form>
<br>
Expand Down Expand Up @@ -86,7 +86,6 @@ <h1>gischat web client</h1>
instance.disabled = enabled;
ssl.disabled = enabled;
document.getElementById("roomId").disabled = enabled;
document.getElementById("authorId").disabled = !enabled;
document.getElementById("avatarId").disabled = !enabled;
document.getElementById("messageText").disabled = !enabled;
document.getElementById("sendButton").disabled = !enabled;
Expand All @@ -112,13 +111,25 @@ <h1>gischat web client</h1>
displayMessage(`Connected to websocket in room ${room.value}`);
connected = true;
setFormEnabled(true);
websocket.send(JSON.stringify({author: "internal", newcomer: document.getElementById("authorId").value}));
}
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
const author = data.author;
const log = author === "internal" ?
`${data.nb_users} user${data.nb_users > 1 ? "s" : ""} connected in room` :
`[${author}] (${new Date().toLocaleTimeString()}): ${data.message}`;
let log;
if (author === "internal") {
if (data.nb_users) {
log = `${data.nb_users} user${data.nb_users > 1 ? "s" : ""} connected in room`;
}
if (data.newcomer) {
log = `${data.newcomer} has joined the room`;
}
if (data.exiter) {
log = `${data.exiter} has left the room`;
}
} else {
log = `[${author}] (${new Date().toLocaleTimeString()}): ${data.message}`
}
displayMessage(log);
};
websocket.onerror = (error) => {
Expand Down
7 changes: 7 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ def test_get_rules(client: TestClient):
assert response.json()["max_message_length"] == int(MAX_MESSAGE_LENGTH)


@pytest.mark.parametrize("room", test_rooms())
def test_get_users(client: TestClient, room: str):
response = client.get(f"/room/{room}/users")
assert response.status_code == 200
assert response.json() == []


@pytest.mark.parametrize("room", test_rooms())
def test_put_message(client: TestClient, room: str):
response = client.put(
Expand Down
Loading

0 comments on commit aca9c13

Please sign in to comment.