diff --git a/.gitignore b/.gitignore index b23420e..adf4417 100644 --- a/.gitignore +++ b/.gitignore @@ -129,6 +129,7 @@ celerybeat.pid # Environments .env +.envrc .venv env/ venv/ @@ -187,4 +188,4 @@ cython_debug/ .cursorignore .cursorindexingignore -*.db \ No newline at end of file +*.db* diff --git a/pyproject.toml b/pyproject.toml index cda48e5..ca4a340 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,11 @@ dependencies = [ "fastapi~=0.115.12", "uvicorn[standard]~=0.34.2", "click~=8.2.1", - "sqlmodel~=0.0.24", - "argon2-cffi~=23.1.0" + "argon2-cffi~=23.1.0", + "tortoise-orm[accel]~=0.25.0", + "slack-bolt~=1.23.0", + "python-dotenv==1.1.0", + "aiohttp~=3.12.11 " ] requires-python = ">=3.12" @@ -26,3 +29,6 @@ allow-direct-references = true include = [ "src/random_access" ] +exclude = [ + "*.db*" +] \ No newline at end of file diff --git a/src/random_access/db.py b/src/random_access/db.py deleted file mode 100644 index 51a91ea..0000000 --- a/src/random_access/db.py +++ /dev/null @@ -1,15 +0,0 @@ -from sqlmodel import SQLModel, create_engine, Session - -DATABASE_URL = "sqlite:////home/micha/Documents/random-access/database.db" # sync SQLite URL - -# connect_args needed for SQLite to allow multithreaded access -connect_args = {"check_same_thread": False} - -engine = create_engine(DATABASE_URL, echo=True, connect_args=connect_args) - -def init_db(): - SQLModel.metadata.create_all(engine) - -def get_session(): - with Session(engine) as session: - yield session diff --git a/src/random_access/main.py b/src/random_access/main.py index 0596689..3c45233 100644 --- a/src/random_access/main.py +++ b/src/random_access/main.py @@ -1,60 +1,187 @@ +from logging import Logger +import os +import secrets from contextlib import asynccontextmanager -from typing import Annotated -from fastapi import FastAPI, Depends, HTTPException, Header -from sqlmodel import select, Session -from .db import get_session, init_db -from .models import Player, World -from secrets import token_urlsafe -from argon2 import PasswordHasher +from urllib.parse import urlencode +from typing import Annotated, Any + + +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, Header, HTTPException, Request +from fastapi.responses import RedirectResponse +from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler +from slack_bolt.async_app import AsyncAck, AsyncApp, AsyncRespond, AsyncSay +from slack_bolt.response import BoltResponse +from tortoise import Tortoise + +from .models import User, UserCreate, UserResponse, UserUpdate + + +if not load_dotenv(): + raise FileNotFoundError("Environment secrets not found!") -hasher = PasswordHasher() @asynccontextmanager async def lifespan(_: FastAPI): - init_db() + await Tortoise.init( + db_url="sqlite://random_access.db", modules={"models": ["random_access.models"]} + ) + await Tortoise.generate_schemas() yield + await Tortoise.close_connections() -app = FastAPI(lifespan=lifespan) +slack = AsyncApp( + signing_secret=os.getenv("SLACK_SIGNING_SECRET") +) +slack_handler = AsyncSlackRequestHandler(slack) -@app.post("/players/") -def create_player(player: Player, session: Session = Depends(get_session)) -> Player: - session.add(player) - session.commit() - session.refresh(player) - return player +@slack.event("app_mention") # pyright:ignore[reportUnknownMemberType] +async def handle_app_mentions(body: BoltResponse, say: AsyncSay, logger: Logger): + logger.info(body) + _ = await say("What's up?") -@app.get("/players/location") -def read_player_location( - api_token: Annotated[str | None, Header()], session: Session = Depends(get_session) +@slack.event("message") # pyright:ignore[reportUnknownMemberType] +async def handle_message(): + pass + +@slack.command("/random-access") # pyright:ignore[reportUnknownMemberType] +async def handle_command(ack: AsyncAck, body: BoltResponse, respond: AsyncRespond): + await ack() + subcommand = dict(body).get('text') # type: ignore + print(subcommand) + + await respond("hewowo") + +app = FastAPI(title="Random Access API", version="0.1.0", lifespan=lifespan) + + +@app.post("/slack/events") +async def endpoint(req: Request): + return await slack_handler.handle(req) + + +# Authentication dependency +async def get_current_user(x_api_key: Annotated[str, Header()]) -> User: + if not x_api_key: + raise HTTPException(status_code=401, detail="API key required") + + user = await User.get_or_none(api_key=x_api_key) + if not user: + raise HTTPException(status_code=401, detail="Invalid API key") + + return user + + +# Public endpoints (no auth required) +@app.get("/") +async def root(): + return {"message": "Random Access API is running"} + +@app.get("/auth/start") +async def auth_start(game_id: str): + url = "https://slack.com/openid/connect/authorize/?" + params = {"response_type": "code", "scope": "openid profile email", "client_id": os.environ.get("SLACK_CLIENT_ID"), "state": game_id, "redirect_uri": "https://random-access.prox.mra.sh/auth/callback"} + return RedirectResponse(url + urlencode(params)) + +@app.get("/auth/callback") +async def auth_callback(code: str, state: str): + print(code, state) + return "yay!" + +@app.post("/register", response_model=UserResponse) +async def register_user(user_data: UserCreate): + # Check if username or email already exists + existing_user = await User.get_or_none(slack_id=user_data.slack_id) + if existing_user: + raise HTTPException(status_code=400, detail="Username already exists") + + existing_email = await User.get_or_none(email=user_data.email) + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + # Generate API key + api_key = secrets.token_urlsafe(32) + + # Create user + user = await User.create( + slack_id=user_data.slack_id, + email=user_data.email, + display_name=user_data.display_name, + api_key=api_key, + ) + + return UserResponse( + id=user.id, + slack_id=user.slack_id, + email=user.email, + display_name=user.display_name, + api_key=user.api_key, + created_at=user.created_at.isoformat(), + ) + + +# Protected endpoints (auth required) +@app.get("/profile", response_model=UserResponse) +async def get_profile(current_user: User = Depends(get_current_user)): # pyright:ignore[reportCallInDefaultInitializer] + return UserResponse( + id=current_user.id, + slack_id=current_user.slack_id, + email=current_user.email, + display_name=current_user.display_name, + api_key=current_user.api_key, + created_at=current_user.created_at.isoformat(), + ) + + +@app.put("/profile", response_model=UserResponse) +async def update_profile( + user_update: UserUpdate, current_user: User = Depends(get_current_user) # pyright:ignore[reportCallInDefaultInitializer] + ): - if not api_token: - raise HTTPException(status_code=401, detail="No API token provided") - hashed_token = hasher.hash(api_token) - results = session.exec(select(Player).where(Player.api_token == hashed_token)) - if len(results.all()) != 1: - raise HTTPException(status_code=500, detail="Critical error, please report this!") + # Update fields if provided + if user_update.email is not None: + # Check if email is already taken by another user + existing_email = await User.get_or_none(email=user_update.email) + if existing_email and existing_email.id != current_user.id: + raise HTTPException(status_code=400, detail="Email already exists") + current_user.email = user_update.email + + if user_update.display_name is not None: + current_user.display_name = user_update.display_name + + await current_user.save() + + return UserResponse( + id=current_user.id, + slack_id=current_user.slack_id, + email=current_user.email, + display_name=current_user.display_name, + api_key=current_user.api_key, + created_at=current_user.created_at.isoformat(), + ) +@app.get("/profile/data") +async def get_profile_data(current_user: User = Depends(get_current_user)) -> dict[str, int | str | bool | float | dict[str, Any]]: # pyright:ignore[reportCallInDefaultInitializer,reportExplicitAny] + + """Get flexible profile data (for future passport features)""" -@app.get("/players/") -def read_players(session: Session = Depends(get_session)) -> list[dict[str, int | str]]: - players = [ - { - "id": player.id, - "username": player.in_game_name, - "current_world_id": player.current_world_id, - "level": player.level, - } - for player in list(session.exec(select(Player)).all()) - ] - return players + return {"profile_data": current_user.profile_data} # pyright:ignore[reportUnknownMemberType] -@app.get("/worlds") -def read_worlds(session: Session = Depends(get_session)): - worlds = list(session.exec(select(World)).all()) - return worlds +@app.put("/profile/data") +async def update_profile_data( + data: dict[str, Any], current_user: User = Depends(get_current_user) # pyright:ignore[reportCallInDefaultInitializer,reportExplicitAny] + +): + """Update flexible profile data (for future passport features)""" + current_user.profile_data = data + await current_user.save() + return { + "message": "Profile data updated", + "profile_data": current_user.profile_data, + } diff --git a/src/random_access/models.py b/src/random_access/models.py index 5ccc574..06fa7bf 100644 --- a/src/random_access/models.py +++ b/src/random_access/models.py @@ -1,73 +1,81 @@ -import datetime -from decimal import Decimal -from enum import Enum -from typing import Optional, List -from sqlmodel import Field, Relationship, SQLModel -from sqlalchemy import Column, Integer +from enum import Enum, IntEnum +from typing import Optional +from pydantic import BaseModel +from tortoise import Model, fields -class ItemCategory(str, Enum): - ranged_weapon = "ranged_weapon" - melee_weapon = "melee_weapon" - armor_head = "armor_head" - armor_torso = "armor_torso" - armor_legs = "armor_legs" - armor_shoes = "armor_shoes" - potion = "potion" - throwable_potion = "throwable_potion" - special = "special" - shield = "shield" - resource = "resource" +class ItemCategory(IntEnum): + special = 0 + armor = 1 + melee_weapon = 2 + ranged_weapon = 3 + defensive = 4 + consumable = 5 + augment = 6 + resource = 7 - -class World(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - name: str - - creator_id: Optional[int] = Field(default=None, foreign_key="player.id") - creator: Optional["Player"] = Relationship( - back_populates="personal_world", sa_relationship_kwargs={"foreign_keys": "[World.creator_id]"} - ) - - inhabitants: List["Player"] = Relationship( - back_populates="current_world", sa_relationship_kwargs={"foreign_keys": "[Player.current_world_id]"} - ) - - -class Player(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - name: str - in_game_name: Optional[str] - slack_id: str = Field(index=True) - api_token: str = Field(index=True) - token_last_updated: datetime.datetime - - pos_x: int = Field(default=0) - pos_y: int = Field(default=0) - - inventory: List["Item"] = Relationship(back_populates="player") +class SlackSubCommands(Enum): + STATS = 'stats' - level: int = Field(default=1, le=100) - xp: int = Field(default=0, lt=1000) + +class User(Model): + id = fields.BigIntField(pk=True) + email = fields.CharField(max_length=100, unique=True) + display_name = fields.CharField(max_length=100, null=True) + api_key = fields.CharField(max_length=64, unique=True) + created_at = fields.DatetimeField(auto_now_add=True) + slack_id = fields.CharField(max_length=16, unique=True, db_index=True) + + games: fields.ReverseRelation["Game"] # pyright:ignore[reportUninitializedInstanceVariable] + # Flexible JSON field for future passport data + profile_data = fields.JSONField(default=dict) + + class Meta: # pyright:ignore[reportIncompatibleVariableOverride] + table = "users" + + +class UserCreate(BaseModel): + email: str + display_name: Optional[str] = None + slack_id: str + +class UserResponse(BaseModel): + id: int + slack_id: str + email: str + display_name: Optional[str] + api_key: str + created_at: str - personal_world: Optional["World"] = Relationship( - back_populates="creator", sa_relationship_kwargs={"foreign_keys": "[World.creator_id]"} + class Config: + from_attributes = True + +class UserUpdate(BaseModel): + email: Optional[str] = None + display_name: Optional[str] = None + +class Game(Model): + id = fields.BigIntField(pk=True) + owner: fields.ForeignKeyRelation[User] = fields.ForeignKeyField( + "models.User", related_name="games", to_field="id" ) + items: fields.ReverseRelation["BaseItem"] - current_world_id: Optional[int] = Field(default=None, foreign_key="world.id") - current_world: Optional["World"] = Relationship( - back_populates="inhabitants", sa_relationship_kwargs={"foreign_keys": "[Player.current_world_id]"} + +class BaseItem(Model): + id = fields.BigIntField(pk=True) + game: fields.ForeignKeyRelation[Game] = fields.ForeignKeyField( + "models.Game", related_name="items", to_field="id" ) + name = fields.CharField(max_length=32) + category = fields.IntEnumField(ItemCategory) + instances: fields.ReverseRelation["ItemInstance"] + damage_from_use = fields.IntField() -class Item(SQLModel, table=True): - id: Optional[int] = Field(default=None, primary_key=True) - name: str - category: ItemCategory - cost: Optional[Decimal] = None - can_be_sold: bool = Field(default=False) - is_breakable: bool = Field(default=False) - hitpoints_remaining: Optional[Decimal] = None - - player_id: Optional[int] = Field(default=None, foreign_key="player.id") - player: Optional[Player] = Relationship(back_populates="inventory") +class ItemInstance(Model): + id = fields.BigIntField(pk=True) + base: fields.ForeignKeyRelation[BaseItem] = fields.ForeignKeyField( + "models.BaseItem", related_name="instances", to_field="id" + ) + hitpoints = fields.IntField(constraints={"ge": 0, "le": 100})