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: re-arrange message models to handle images #33

Merged
merged 14 commits into from
Oct 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ ROOMS=QGIS,QField,Geotribu
# server rules
RULES="Free and open gischat instance, let's chat with your fellow GIS mates using this server, in a spirit of respect and tolerance"

MAX_IMAGE_SIZE=800

ENVIRONMENT=production

SENTRY_DSN=
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ repos:
rev: v5.0.0
hooks:
- id: check-added-large-files
args: ["--maxkb=500"]
args: ["--maxkb=750"]
- id: check-ast
- id: check-builtin-literals
- id: check-case-conflict
Expand Down
92 changes: 84 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,91 @@ Following instances are up and running :
- Rooms can be fetched using [the `/rooms` endpoint](https://gischat.geotribu.net/docs#/default/get_rooms_rooms_get)
- Rules can be fetched using [the `/rules` endpoint](https://gischat.geotribu.net/docs#/default/get_rules_rules_get)
- Number of connected users can be fetched using [the `/status` endpoint](https://gischat.geotribu.net/docs#/default/get_status_status_get)
- List of connected and registered users can be fetched using [the `/room/{room}/users` endpoint](https://gischat.geotribu.net/docs)
- 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}` -> 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"}`
- After connecting to the websocket, it is possible to register the user in the room by sending a `newcomer` message (see below)
- Messages passing through the websocket are strings with a JSON structure, they have a `type` key which represent which kind of message it is

### JSON message types

Those are the messages that might transit through the websocket.

Each of them has a `"type"` key based on which it is possible to parse them :

1. `"text"`: basic text message send by someone in the room, e.g.:

```json
{
"type": "text",
"author": "jane_doe",
"avatar": "mGeoPackage.svg",
"text": "Hi @all how are you doing ?"
}
```

> `"author"` value must be alphanumeric (or `_` or `-`) and have min / max length set by `MIN_AUTHOR_LENGTH` / `MAX_AUTHOR_LENGTH` environment variables

> `avatar` value is optional and usually points to [a QGIS resource icon](https://github.com/qgis/QGIS/blob/master/images/images.qrc) (see the ones [available in the QChat/QTribu plugin](https://github.com/geotribu/qtribu/blob/e07012628a6c03f2c4ee664025ece0bf7672d245/qtribu/constants.py#L200))

> `"text"` value must have max length set by `MAX_MESSAGE_LENGTH` environment variable


1. `"image"`: image message send by someone in the room, e.g.:

```json
{
"type": "image",
"author": "jane_doe",
"avatar": "mIconPostgis.svg",
"image_data": "utf-8 string of the image encoded in base64"
}
```

> The image will be resized by the backend before broadcast, using the `MAX_IMAGE_SIZE` environment variable value

1. `"nb_users"`: notifies about the number of users connected to the room, e.g.:

```json
{
"type": "nb_users",
"nb_users": 36
}
```

1. `"newcomer"`: someone has just registered in the room, e.g.:

```json
{
"type": "newcomer",
"newcomer": "jane_doe"
}
```

> After having connected to the websocket, it is possible to register a user by sending a `newcomer` message

1. `"exiter"`: someone registered has left the room, e.g.:

```json
{
"type": "exiter",
"exiter": "jane_doe"
}
```

1. `"like"`: someone has liked a message, e.g.:

```json
{
"type": "like",
"liker_author": "john_doe",
"liked_author": "jane_doe",
"message": "Hi @john_doe how are you doing ?"
}
```

means that `john_doe` liked `jane_doe`'s message (`"Hi @john_doe how are you doing ?"`)

> The messages of the `like` type are sent only to the liked author, if this user is registered. If this user is not registered, it won't be notified

## Deploy a self-hosted instance

Expand Down
1 change: 0 additions & 1 deletion gischat/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
INTERNAL_MESSAGE_AUTHOR = "internal"
127 changes: 68 additions & 59 deletions gischat/app.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import base64
import json
import logging
import os
import sys
from io import BytesIO

import colorlog
import sentry_sdk
from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.encoders import jsonable_encoder
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from PIL import Image
from pydantic import ValidationError
from starlette.websockets import WebSocketDisconnect

from gischat import INTERNAL_MESSAGE_AUTHOR
from gischat.models import (
InternalExiterMessageModel,
InternalLikeMessageModel,
InternalNbUsersMessageModel,
InternalNewcomerMessageModel,
MessageModel,
GischatExiterMessage,
GischatImageMessage,
GischatLikeMessage,
GischatMessageModel,
GischatMessageTypeEnum,
GischatNbUsersMessage,
GischatNewcomerMessage,
GischatTextMessage,
RulesModel,
StatusModel,
VersionModel,
Expand All @@ -42,7 +47,7 @@ def available_rooms() -> list[str]:
Returns list of available rooms
:return: list of available rooms
"""
return os.environ.get("ROOMS", "QGIS,QField,Geotribu").split(",")
return os.environ.get("ROOMS", "QGIS,Geotribu").split(",")


class WebsocketNotifier:
Expand All @@ -62,15 +67,6 @@ def __init__(self):
room: [] for room in available_rooms()
}
self.users = {}
self.generator = self.get_notification_generator()

async def get_notification_generator(self):
while True:
room, message = yield
await self.notify(room, message)

async def push(self, msg: str) -> None:
await self.generator.asend(msg)

async def connect(self, room: str, websocket: WebSocket) -> None:
"""
Expand All @@ -97,19 +93,19 @@ async def remove(self, room: str, websocket: WebSocket) -> None:
del self.users[websocket]
await self.notify_exiter(room, exiter)

async def notify(self, room: str, message: str) -> None:
async def notify_room(self, room: str, message: GischatMessageModel) -> None:
"""
Sends a message to a room
:param room: room to notify
:param message: message to send, should be stringified JSON
:param message: message to send
"""
living_connections = []
while len(self.connections[room]) > 0:
# Looping like this is necessary in case a disconnection is handled
# during await websocket.send_text(message)
# during await websocket.send_json(message)
websocket = self.connections[room].pop()
try:
await websocket.send_text(message)
await websocket.send_json(jsonable_encoder(message))
living_connections.append(websocket)
except WebSocketDisconnect:
logger.error("Can not send message to disconnected websocket")
Expand All @@ -128,32 +124,26 @@ 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)))
message = GischatNbUsersMessage(nb_users=self.get_nb_connected_users(room))
await self.notify_room(room, 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)))
message = GischatNewcomerMessage(newcomer=user)
await self.notify_room(room, 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)))
message = GischatExiterMessage(exiter=user)
await self.notify_room(room, message)

def register_user(self, websocket: WebSocket, user: str) -> None:
"""
Expand Down Expand Up @@ -194,7 +184,9 @@ def is_user_present(self, room: str, user: str) -> bool:
continue
return False

async def notify_user(self, room: str, user: str, message: str) -> None:
async def notify_user(
self, room: str, user: str, message: GischatMessageModel
) -> None:
"""
Notifies a user in a room with a "private" message
Private means only this user is notified of the message
Expand All @@ -206,7 +198,7 @@ async def notify_user(self, room: str, user: str, message: str) -> None:
try:
if self.users[ws] == user:
try:
await ws.send_text(message)
await ws.send_json(jsonable_encoder(message))
except WebSocketDisconnect:
logger.error("Can not send message to disconnected websocket")
except KeyError:
Expand Down Expand Up @@ -283,14 +275,16 @@ async def get_connected_users(room: str) -> list[str]:


@app.put(
"/room/{room}/message",
response_model=MessageModel,
"/room/{room}/text",
response_model=GischatTextMessage,
)
async def put_message(room: str, message: MessageModel) -> MessageModel:
async def put_text_message(
room: str, message: GischatTextMessage
) -> GischatTextMessage:
if room not in notifier.connections.keys():
raise HTTPException(status_code=404, detail=f"Room '{room}' not registered")
await notifier.notify_room(room, message)
logger.info(f"Message in room '{room}': {message}")
await notifier.notify(room, json.dumps(jsonable_encoder(message)))
return message


Expand All @@ -310,35 +304,50 @@ async def websocket_endpoint(websocket: WebSocket, room: str) -> None:
data = await websocket.receive_text()
payload = json.loads(data)

# handle internal messages
if "author" in payload and payload["author"] == "internal":

# registration messages
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)
try:
message = GischatMessageModel(**payload)

# like messages
if "liked_author" in payload and "liker_author" in payload:
message = InternalLikeMessageModel(**payload)
# text message
if message.type == GischatMessageTypeEnum.TEXT:
message = GischatTextMessage(**payload)
logger.info(f"Message in room '{room}': {message}")
await notifier.notify_room(room, message)

# image message
if message.type == GischatMessageTypeEnum.IMAGE:
message = GischatImageMessage(**payload)
logger.info(f"Message (image) in room '{room}' by {message.author}")
# resize image if needed using MAX_IMAGE_SIZE env var
image = Image.open(BytesIO(base64.b64decode(message.image_data)))
size = int(os.environ.get("MAX_IMAGE_SIZE", 800))
image.thumbnail((size, size), Image.Resampling.LANCZOS)
img_byte_arr = BytesIO()
image.save(img_byte_arr, format="PNG")
message.image_data = base64.b64encode(
img_byte_arr.getvalue()
).decode("utf-8")
await notifier.notify_room(room, message)

# newcomer message
if message.type == GischatMessageTypeEnum.NEWCOMER:
message = GischatNewcomerMessage(**payload)
notifier.register_user(websocket, message.newcomer)
logger.info(f"Newcomer in room {room}: {message.newcomer}")
await notifier.notify_newcomer(room, message.newcomer)

# like message
if message.type == GischatMessageTypeEnum.LIKE:
message = GischatLikeMessage(**payload)
logger.info(
f"{message.liker_author} liked {message.liked_author}'s message ({message.message})"
)
await notifier.notify_user(
room,
message.liked_author,
json.dumps(jsonable_encoder(message)),
message,
)
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 ValidationError as e:
logger.error(f"Uncompliant message: {e}")

except WebSocketDisconnect:
await notifier.remove(room, websocket)
Expand Down
Loading