changes :(

This commit is contained in:
Micha R. Albert 2025-06-25 11:49:01 -04:00
parent 96f95b5cd1
commit 62da10b69c
Signed by: mra
SSH key fingerprint: SHA256:2JB0fGfy7m2HQXAzvSXXKm7wPTj9Z60MOjFOQGM2Y/E
5 changed files with 249 additions and 122 deletions

3
.gitignore vendored
View file

@ -129,6 +129,7 @@ celerybeat.pid
# Environments
.env
.envrc
.venv
env/
venv/
@ -187,4 +188,4 @@ cython_debug/
.cursorignore
.cursorindexingignore
*.db
*.db*

View file

@ -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*"
]

View file

@ -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

View file

@ -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]
@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,
"""Get flexible profile data (for future passport features)"""
return {"profile_data": current_user.profile_data} # pyright:ignore[reportUnknownMemberType]
@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,
}
for player in list(session.exec(select(Player)).all())
]
return players
@app.get("/worlds")
def read_worlds(session: Session = Depends(get_session)):
worlds = list(session.exec(select(World)).all())
return worlds

View file

@ -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 SlackSubCommands(Enum):
STATS = 'stats'
class World(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
name: str
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)
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]"}
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
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"]
inhabitants: List["Player"] = Relationship(
back_populates="current_world", 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 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")
level: int = Field(default=1, le=100)
xp: int = Field(default=0, lt=1000)
personal_world: Optional["World"] = Relationship(
back_populates="creator", sa_relationship_kwargs={"foreign_keys": "[World.creator_id]"}
class ItemInstance(Model):
id = fields.BigIntField(pk=True)
base: fields.ForeignKeyRelation[BaseItem] = fields.ForeignKeyField(
"models.BaseItem", related_name="instances", to_field="id"
)
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 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")
hitpoints = fields.IntField(constraints={"ge": 0, "le": 100})