Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: handle users registration #30

Merged
merged 11 commits into from
Oct 25, 2024
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