Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
4279410
✨ Use `electro`with websockets (#5)
samonaisi Mar 7, 2025
aab490b
:sparkles: Update electro-migrations repository
samonaisi Mar 7, 2025
4c42463
use ssh url for gitmodule
samonaisi Mar 7, 2025
9c9ddb5
Removed submodule
samonaisi Mar 7, 2025
10fda47
recreate submodule
samonaisi Mar 7, 2025
37dac4f
add create_dm method for user
samonaisi Mar 7, 2025
b564b51
add create_dm method to user type
samonaisi Mar 7, 2025
06582f6
better with one app
samonaisi Mar 12, 2025
05c6d12
cleanup
samonaisi Mar 12, 2025
9bdd646
⚗️ Use `contextvars` to store sent messages in `APIInterface`
mykolasolodukha Mar 24, 2025
48baaf9
✨ Remove discord dependencies (#9)
samonaisi Apr 8, 2025
d207854
add user dm_channel field
samonaisi Apr 8, 2025
88b54eb
fix dm_channel setup in manager
samonaisi Apr 8, 2025
d779881
handle api connection
samonaisi Apr 9, 2025
7ce2003
handle api key auth
samonaisi Apr 9, 2025
630b4f8
fix linter issues
samonaisi Apr 10, 2025
8cc8ca1
solve more linter issues
samonaisi Apr 10, 2025
3ebb00d
fix error
samonaisi Apr 10, 2025
82ce4b6
handle no database url
samonaisi Apr 11, 2025
d62f823
feat: handle message breaks
samonaisi Apr 29, 2025
7c7cf3a
Temporary comment api key validation
samonaisi May 22, 2025
d0aa1f1
New message model
samonaisi May 22, 2025
8384225
add authentication
samonaisi May 23, 2025
485326f
more flexible authentication
samonaisi May 26, 2025
754061e
Update authentication
samonaisi May 26, 2025
4b5eb52
send command messages in history
samonaisi May 26, 2025
d7929a0
rename storage service methods
samonaisi May 27, 2025
ca12518
handle files and unique response format
samonaisi May 28, 2025
14240e6
do not allow bytes as message files
samonaisi May 28, 2025
831c483
remove test fields in files response
samonaisi Jun 2, 2025
6d178f3
feat: add cookie setting endpoint
JulienDroulez Jun 2, 2025
69cec32
Update i18n setup
samonaisi Jun 3, 2025
e069513
Update messages history response
samonaisi Jun 3, 2025
59a45c2
Decorators
samonaisi Jun 4, 2025
a0da321
handle cors policy with env var
samonaisi Jun 5, 2025
43cb30b
temporary turn off auth
samonaisi Jun 6, 2025
a847e8c
reactivate authentication
samonaisi Jun 10, 2025
56c6523
Remove duplicate constant_typing
samonaisi Jun 11, 2025
cf3685b
Send images with last message chunk if splited
samonaisi Jun 11, 2025
87cb02e
Use Redis to store flow state and data
samonaisi Jun 17, 2025
d7c83b8
feat: Flow specific chat history
samonaisi Jul 22, 2025
f229a8a
:sparkles: Send flow finished signal
samonaisi Jul 29, 2025
4fe6b1c
:adhesive_bandage: Use unique name for auth cookie
samonaisi Aug 25, 2025
4637e15
🌐 Dynamic language selection (#18)
samonaisi Sep 2, 2025
fdb03df
:sparkles: Use class for authentication
samonaisi Sep 2, 2025
6200d11
Button already clicked message
samonaisi Sep 8, 2025
736abb8
:adhesive_bandage: Fix images language selection
samonaisi Sep 9, 2025
9ddc19a
:adhesive_bandage: Fix older messages url in messages history
samonaisi Sep 16, 2025
ffe0c94
:wrench: Remove Poeditor dependencies
samonaisi Oct 14, 2025
fcd7703
handle filters for storage buckets
samonaisi Oct 21, 2025
65e9905
♻️ Remove last discord legacy models
samonaisi Oct 24, 2025
c1b502f
use settings for i18n
samonaisi Dec 4, 2025
66f891f
add admin auth
samonaisi Dec 9, 2025
b428ad2
fix: Add fallback for locales
samonaisi Dec 17, 2025
5a64350
⬆️ Bump FastAPI to fix starlette DoS vulnerabilities
mykolasolodukha Jan 5, 2026
041edc9
✨ Add `make_public` parameter to file storage `upload_file`
mykolasolodukha Feb 2, 2026
01e7e01
fix: platform default value in auth
samonaisi Feb 3, 2026
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Icon
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.bash_history

# Directories potentially created on remote AFP share
.AppleDB
Expand Down Expand Up @@ -150,6 +151,7 @@ celerybeat.pid

# Environments
examples/.env
.env
.venv
env/
venv/
Expand Down
3 changes: 0 additions & 3 deletions .gitmodules

This file was deleted.

16 changes: 16 additions & 0 deletions .pylintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[MAIN]

max-line-length=120

disable=
C0114, # Missing module docstring
C0115, # Missing class docstring
C0116, # Missing function or method docstring
C0302, # Too many lines in module
E0401, # Import error: Ignored because imports actually work
R0801, # Duplicate code
R0902, # Too many instance attributes
R0903, # Too few public methods
R0913, # Too many arguments
R0917, # Too many positional arguments
W0511, # TODO
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"python.analysis.extraPaths": ["./electro"],
"python.defaultInterpreterPath": ".venv/bin/python",
}
35 changes: 35 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
FROM python:3.12.6-slim AS builder

ARG EXPORT_FLAG="--with dev"

RUN pip install --upgrade pip poetry poetry-plugin-export

COPY pyproject.toml poetry.lock ./

RUN poetry export -f requirements.txt $EXPORT_FLAG --without-hashes --output /tmp/requirements.txt


FROM python:3.12.6-slim

WORKDIR /app

RUN groupadd -g 10000 app && \
useradd -g app -d /app -u 10000 app && \
chown app:app /app && \
apt-get update && \
apt upgrade -y && \
apt-get install nano && \
apt-get install -y git && \
pip install --upgrade pip

COPY --from=builder /tmp/requirements.txt .

RUN pip install -r requirements.txt
RUN pip install watchdog

COPY . .

USER app
ENV PYTHONPATH="/app"

CMD ["watchmedo", "auto-restart", "--pattern=*.py", "--recursive", "--", "python", "examples/test_flow.py"]
1 change: 0 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ style:
poetry run black $(SOURCES_DIR)
poetry run isort $(SOURCES_DIR)
poetry run pylint $(SOURCES_DIR)
poetry run pydocstyle $(SOURCES_DIR)

.PHONY: docs
docs:
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ A framework for building bots, made for humans.
```shell
cp .env.example .env
# vi .env

4. Extract and compile translations:
```shell
make upload-locales
make update-locales
```

4. Run the `TestFlow`:
5. Run the `TestFlow`:
```shell
poetry run python ./test_flow.py
```

5. Check the API server @ http://localhost:8000/docs.
6. Use one of the clients to connect the platforms: [Discord](https://github.com/CyberCRI/ikigai-discord-client).
6. Check the API server @ http://localhost:8000/docs.
7. Use one of the clients to connect the platforms: [Whatsapp](https://github.com/CyberCRI/ikigai-whatsapp-bot).
8 changes: 0 additions & 8 deletions docs/source/electro.toolkit.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,6 @@ electro.toolkit.buttons module
:show-inheritance:
:undoc-members:

electro.toolkit.discord\_tweeks module
--------------------------------------

.. automodule:: electro.toolkit.discord_tweeks
:members:
:show-inheritance:
:undoc-members:

electro.toolkit.loguru\_logging module
--------------------------------------

Expand Down
194 changes: 184 additions & 10 deletions electro/app.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
"""The API server that works as an endpoint for all the Electro Interfaces."""

from fastapi import FastAPI
import asyncio
from typing import Any, Dict, Optional

from fastapi import Depends, FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.websockets import WebSocketState
from tortoise.contrib.fastapi import register_tortoise

from . import types_ as types
from .flow_manager import global_flow_manager
from .authentication import ElectroAuthentication
from .interfaces import APIInterface, WebSocketInterface
from .models import Message, PlatformId, User
from .schemas import CookieToken
from .settings import settings
from .toolkit.tortoise_orm import get_tortoise_config
from .utils import format_historical_message, limit_from_id_paginate_response

app = FastAPI(
title="Electro API",
Expand All @@ -16,17 +25,182 @@
)


@app.post("/message")
async def process_message(message: types.Message) -> list[types.MessageToSend] | None:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ALLOW_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)


@app.patch("/api/platform/{platform}/user/{user_id}")
async def update_user(
platform: str,
user_id: str,
data: Dict[str, Any],
request_user: Optional[User] = Depends(ElectroAuthentication.authenticate_user),
):
"""
Update the user information.

Arguments:
platform: The platform where the user is registered.
user_id: The ID of the user on the platform.
username: Optional username to set for the user.
"""
platform_id = await PlatformId.get_or_none(
platform_id=user_id, platform=platform, type=PlatformId.PlatformIdTypes.USER
)
if not platform_id:
raise HTTPException(status_code=404, detail="User not found.")
user: User = await platform_id.user
if request_user == user:
for field in ["username", "locale"]:
if field in data:
setattr(user, field, data[field])
await user.save()
return {
"id": user.id,
"username": user.username,
"locale": user.locale,
"platform_ids": [
{
"platform": platform.platform,
"platform_id": platform.platform_id,
"type": platform.type,
}
for platform in await user.platform_ids.all()
],
}
raise HTTPException(status_code=403, detail="You are not authorized to update this user's information.")


@app.get("/api/platform/{platform}/user/{user_id}")
async def get_user(
platform: str, user_id: str, request_user: Optional[User] = Depends(ElectroAuthentication.authenticate_user)
):
"""
Test the API endpoint.
"""
platform_id = await PlatformId.get_or_none(
platform_id=user_id, platform=platform, type=PlatformId.PlatformIdTypes.USER
)
if not platform_id:
raise HTTPException(status_code=404, detail="User not found.")
user: User = await platform_id.user
# TODO: create a permission check to allow access to other users
if request_user == user:
return {
"id": user.id,
"username": user.username,
"locale": user.locale,
"platform_ids": [
{
"platform": platform.platform,
"platform_id": platform.platform_id,
"type": platform.type,
}
for platform in await user.platform_ids.all()
],
}
raise HTTPException(status_code=403, detail="You are not authorized to access this user's information.")


@app.get("/api/platform/{platform}/user/{user_id}/flow/{flow_code}/messages")
async def get_user_messages(
platform: str,
user_id: str,
flow_code: str,
request_user: Optional[User] = Depends(ElectroAuthentication.authenticate_user),
limit: int = 20,
from_id: Optional[int] = None,
):
"""
Get the message history for a user.

Arguments:
user: The user whose message history is to be retrieved.
limit: The maximum number of messages to retrieve.
offset: The number of messages to skip before retrieving the history.
from_id: If provided, this will override the offset to start from the latest message ID.
"""
platform_id = await PlatformId.get_or_none(
platform_id=user_id, platform=platform, type=PlatformId.PlatformIdTypes.USER
)
if not platform_id:
raise HTTPException(status_code=404, detail="User not found.")
user = await platform_id.user
if request_user == user:
messages = Message.filter(user=user, flow_code=flow_code, is_temporary=False).order_by("-date_added")
return await limit_from_id_paginate_response(
messages,
format_historical_message,
limit=limit,
from_id=from_id,
url=f"/api/platform/{platform}/user/{user_id}/flow/{flow_code}/messages",
)
raise HTTPException(status_code=403, detail="You are not authorized to access this user's message history.")


@app.post("/api/platform/{platform}/user/{user_id}/flow/{flow_code}/messages")
async def process_message(
platform: str,
user_id: str,
flow_code: str,
data: Dict[str, Any],
request_user: Optional[User] = Depends(ElectroAuthentication.authenticate_user),
):
"""Process the message."""
platform_id = await PlatformId.get_or_none(
platform_id=user_id, platform=platform, type=PlatformId.PlatformIdTypes.USER
)
if not platform_id:
raise HTTPException(status_code=404, detail="User not found.")
user = await platform_id.user
if request_user == user:
interface = APIInterface(flow_code=flow_code)
await interface.handle_incoming_action(user, platform, flow_code, data)
return interface.messages.get()
raise HTTPException(status_code=403, detail="You are not authorized to send messages on behalf of this user.")

return await global_flow_manager.on_message(message)

@app.websocket("/websocket/platform/{platform}/user/{user_id}/flow/{flow_code}")
async def websocket_endpoint(
websocket: WebSocket,
platform: str,
user_id: str,
flow_code: str,
request_user: Optional[User] = Depends(ElectroAuthentication.authenticate_user),
):
"""Handle the websocket connection."""
platform_id = await PlatformId.get_or_none(
platform_id=user_id, platform=platform, type=PlatformId.PlatformIdTypes.USER
)
if not platform_id:
raise HTTPException(status_code=404, detail="User not found.")
user = await platform_id.user
if request_user == user:
interface = WebSocketInterface(flow_code=flow_code)
await interface.connect(websocket)
try:
while websocket.application_state == WebSocketState.CONNECTED:
data = await websocket.receive_json()
asyncio.create_task(interface.handle_incoming_action(user, platform, flow_code, data))
except WebSocketDisconnect:
del interface
else:
raise HTTPException(status_code=403, detail="You are not authorized to send messages on behalf of this user.")


@app.post("/api/cookies")
async def set_cookie(data: CookieToken, response: Response):
cookie_value = "" if data.token is None else data.token
response.set_cookie(key="IKIGAI_AUTHORIZATION", value=cookie_value)
return {"status": "ok"}


# region Register Tortoise
register_tortoise(
app,
config=get_tortoise_config(),
)
register_tortoise(app, config=get_tortoise_config())

# endregion
Loading