changes :(
This commit is contained in:
parent
96f95b5cd1
commit
62da10b69c
5 changed files with 249 additions and 122 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -129,6 +129,7 @@ celerybeat.pid
|
|||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
|
@ -187,4 +188,4 @@ cython_debug/
|
|||
.cursorignore
|
||||
.cursorindexingignore
|
||||
|
||||
*.db
|
||||
*.db*
|
||||
|
|
|
|||
|
|
@ -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*"
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue