openhands openhands commited on
Commit
2299bb4
·
1 Parent(s): 39d39f4

feat: Add FastAPI backend + React frontend

Browse files

- FastAPI app.py with CORS and API routes
- Backend code (models, routes, services, system)
- React frontend (built static files)
- Requirements.txt for dependencies

Co-authored-by: openhands <openhands@all-hands.dev>

Files changed (41) hide show
  1. README.md +5 -3
  2. app.py +88 -0
  3. backend/app/config.py +51 -0
  4. backend/app/dependencies.py +49 -0
  5. backend/app/main.py +83 -0
  6. backend/app/models/__init__.py +10 -0
  7. backend/app/models/friend.py +40 -0
  8. backend/app/models/group.py +58 -0
  9. backend/app/models/message.py +49 -0
  10. backend/app/models/reaction.py +26 -0
  11. backend/app/models/user.py +39 -0
  12. backend/app/routes/__init__.py +10 -0
  13. backend/app/routes/auth.py +185 -0
  14. backend/app/routes/chat.py +255 -0
  15. backend/app/routes/group.py +399 -0
  16. backend/app/routes/system.py +119 -0
  17. backend/app/routes/user.py +183 -0
  18. backend/app/services/__init__.py +10 -0
  19. backend/app/services/auth_service.py +70 -0
  20. backend/app/services/chat_service.py +115 -0
  21. backend/app/services/file_service.py +137 -0
  22. backend/app/services/group_service.py +143 -0
  23. backend/app/services/user_service.py +148 -0
  24. backend/app/sockets/ws.py +89 -0
  25. backend/app/system/__init__.py +18 -0
  26. backend/app/system/batch.py +142 -0
  27. backend/app/system/controller.py +78 -0
  28. backend/app/system/monitor.py +74 -0
  29. backend/app/system/queue.py +93 -0
  30. backend/app/system/scheduler.py +88 -0
  31. backend/app/system/worker.py +100 -0
  32. backend/app/utils/__init__.py +16 -0
  33. backend/app/utils/avatar.py +54 -0
  34. backend/app/utils/compression.py +85 -0
  35. backend/app/utils/formatter.py +57 -0
  36. backend/app/utils/security.py +57 -0
  37. backend/app/utils/time.py +79 -0
  38. backend/requirements.txt +41 -0
  39. {assets → frontend}/index-CQxv4P1h.css +0 -0
  40. {assets → frontend}/index-D-0NzOas.js +0 -0
  41. requirements.txt +12 -0
README.md CHANGED
@@ -3,11 +3,13 @@ title: Chat App
3
  emoji: 💬
4
  colorFrom: blue
5
  colorTo: indigo
6
- sdk: static
7
- app_file: index.html
 
 
8
  pinned: false
9
  license: mit
10
- short_description: 'Modern chat application with Three.js background'
11
  ---
12
 
13
  # Chat-App 💬
 
3
  emoji: 💬
4
  colorFrom: blue
5
  colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 6.11.0
8
+ python_version: "3.11"
9
+ app_file: app.py
10
  pinned: false
11
  license: mit
12
+ short_description: 'Modern chat application with FastAPI + React'
13
  ---
14
 
15
  # Chat-App 💬
app.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chat App - FastAPI + React (HuggingFace Space)
2
+
3
+ from fastapi import FastAPI, Request
4
+ from fastapi.responses import HTMLResponse, JSONResponse
5
+ from fastapi.staticfiles import StaticFiles
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+ import uvicorn
8
+ import os
9
+
10
+ app = FastAPI(title="Chat App", version="1.0.0")
11
+
12
+ # Enable CORS
13
+ app.add_middleware(
14
+ CORSMiddleware,
15
+ allow_origins=["*"],
16
+ allow_credentials=True,
17
+ allow_methods=["*"],
18
+ allow_headers=["*"],
19
+ )
20
+
21
+ # Get the directory where this file is located
22
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
23
+
24
+ # Mount frontend static files
25
+ frontend_path = os.path.join(BASE_DIR, "frontend")
26
+ if os.path.exists(frontend_path):
27
+ app.mount("/frontend", StaticFiles(directory=frontend_path), name="frontend")
28
+
29
+ # API Routes
30
+ @app.get("/")
31
+ async def root(request: Request):
32
+ """Serve the frontend index.html"""
33
+ index_path = os.path.join(BASE_DIR, "index.html")
34
+ if os.path.exists(index_path):
35
+ with open(index_path, "r") as f:
36
+ content = f.read()
37
+ return HTMLResponse(content=content)
38
+ return {"message": "Chat App API", "status": "running"}
39
+
40
+ @app.get("/api/health")
41
+ async def health():
42
+ """Health check endpoint"""
43
+ return {"status": "healthy", "service": "chat-app"}
44
+
45
+ @app.get("/api/v1")
46
+ async def api_info():
47
+ """API info endpoint"""
48
+ return {
49
+ "name": "Chat App API",
50
+ "version": "1.0.0",
51
+ "endpoints": {
52
+ "auth": "/api/v1/auth",
53
+ "users": "/api/v1/users",
54
+ "chat": "/api/v1/chat",
55
+ "groups": "/api/v1/groups"
56
+ }
57
+ }
58
+
59
+ # Auth endpoints
60
+ @app.post("/api/v1/auth/register")
61
+ async def register(data: dict):
62
+ return {"message": "Register endpoint - requires database"}
63
+
64
+ @app.post("/api/v1/auth/login")
65
+ async def login(data: dict):
66
+ return {"message": "Login endpoint - requires database"}
67
+
68
+ # Chat endpoints
69
+ @app.get("/api/v1/chat/conversations")
70
+ async def get_conversations():
71
+ return {"conversations": [], "message": "Requires database setup"}
72
+
73
+ @app.post("/api/v1/chat/send")
74
+ async def send_message(data: dict):
75
+ return {"message": "Send message - requires database"}
76
+
77
+ # Group endpoints
78
+ @app.get("/api/v1/groups")
79
+ async def get_groups():
80
+ return {"groups": [], "message": "Requires database setup"}
81
+
82
+ @app.post("/api/v1/groups/create")
83
+ async def create_group(data: dict):
84
+ return {"message": "Create group - requires database"}
85
+
86
+ if __name__ == "__main__":
87
+ port = int(os.environ.get("PORT", 7860))
88
+ uvicorn.run(app, host="0.0.0.0", port=port)
backend/app/config.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration module - loads environment variables and secrets.
3
+ DO NOT commit actual secrets to version control.
4
+ """
5
+ from pydantic_settings import BaseSettings
6
+ from typing import Optional
7
+ import os
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ """Application settings loaded from environment variables."""
12
+
13
+ # App
14
+ APP_NAME: str = "ChatApp"
15
+ APP_VERSION: str = "1.0.0"
16
+ DEBUG: bool = False
17
+ SECRET_KEY: str = "change-me-in-production" # TODO: Set via env
18
+
19
+ # Database
20
+ DATABASE_URL: str = "postgresql+asyncpg://user:pass@localhost:5432/chatapp"
21
+
22
+ # JWT
23
+ JWT_SECRET: str = "jwt-secret-change-me"
24
+ JWT_ALGORITHM: str = "HS256"
25
+ JWT_EXPIRATION_MINUTES: int = 60 * 24 * 7 # 7 days
26
+
27
+ # Redis (for async queue)
28
+ REDIS_URL: str = "redis://localhost:6379/0"
29
+
30
+ # GitHub (for storage backup)
31
+ GITHUB_TOKEN: Optional[str] = None
32
+ GITHUB_REPO: Optional[str] = None # e.g., "username/chat-storage"
33
+
34
+ # HuggingFace (optional)
35
+ HF_TOKEN: Optional[str] = None
36
+ HF_SPACE_ID: Optional[str] = None
37
+
38
+ # Compression settings
39
+ COMPRESSION_ENABLED: bool = True
40
+ MAX_BATCH_SIZE_MB: int = 10
41
+ BATCH_INTERVAL_MINUTES: int = 60
42
+
43
+ # Storage
44
+ STORAGE_PATH: str = "./storage/data"
45
+
46
+ class Config:
47
+ env_file = ".env"
48
+ case_sensitive = True
49
+
50
+
51
+ settings = Settings()
backend/app/dependencies.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dependencies - shared FastAPI dependencies (auth, database, etc).
3
+ """
4
+ from typing import Optional
5
+ from fastapi import Depends, HTTPException, status
6
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy import create_async_engine, select
9
+ from sqlalchemy.orm import sessionmaker, declarative_base
10
+
11
+ from .config import settings
12
+
13
+ # Security scheme
14
+ security = HTTPBearer()
15
+
16
+ # Database setup
17
+ engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG)
18
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
19
+
20
+ Base = declarative_base()
21
+
22
+
23
+ async def get_db() -> AsyncSession:
24
+ """Database session dependency."""
25
+ async with async_session() as session:
26
+ yield session
27
+
28
+
29
+ async def get_current_user(
30
+ credentials: HTTPAuthorizationCredentials = Depends(security)
31
+ ) -> dict:
32
+ """
33
+ Validate JWT token and return current user.
34
+ In production, decode JWT and fetch user from DB.
35
+ """
36
+ # TODO: Implement actual JWT validation
37
+ # For now, return a placeholder user
38
+ token = credentials.credentials
39
+ # This would decode the token and verify it
40
+ return {"id": "user_placeholder", "username": "test_user"}
41
+
42
+
43
+ async def get_optional_user(
44
+ credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
45
+ ) -> Optional[dict]:
46
+ """Optional auth - returns None if not authenticated."""
47
+ if not credentials:
48
+ return None
49
+ return await get_current_user(credentials)
backend/app/main.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main FastAPI application entry point.
3
+ """
4
+ from fastapi import FastAPI, WebSocket
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from contextlib import asynccontextmanager
7
+
8
+ from .config import settings
9
+ from .routes import auth_router, user_router, chat_router, group_router, system_router
10
+ from .sockets.ws import websocket_endpoint
11
+
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(app: FastAPI):
15
+ """Application lifespan - startup and shutdown."""
16
+ # Startup
17
+ print(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
18
+
19
+ # TODO: Start worker and scheduler
20
+ # from .system import worker, batch_scheduler
21
+ # await worker.start()
22
+ # await batch_scheduler.start()
23
+
24
+ yield
25
+
26
+ # Shutdown
27
+ print("Shutting down...")
28
+ # await worker.stop()
29
+ # await batch_scheduler.stop()
30
+
31
+
32
+ # Create FastAPI app
33
+ app = FastAPI(
34
+ title=settings.APP_NAME,
35
+ version=settings.APP_VERSION,
36
+ description="Chat application with async message processing",
37
+ lifespan=lifespan
38
+ )
39
+
40
+ # CORS middleware
41
+ app.add_middleware(
42
+ CORSMiddleware,
43
+ allow_origins=["*"], # Configure appropriately for production
44
+ allow_credentials=True,
45
+ allow_methods=["*"],
46
+ allow_headers=["*"],
47
+ )
48
+
49
+
50
+ # Include routers
51
+ app.include_router(auth_router, prefix="/api")
52
+ app.include_router(user_router, prefix="/api")
53
+ app.include_router(chat_router, prefix="/api")
54
+ app.include_router(group_router, prefix="/api")
55
+ app.include_router(system_router, prefix="/api")
56
+
57
+
58
+ # WebSocket endpoint
59
+ @app.websocket("/ws/{user_id}")
60
+ async def websocket_route(websocket: WebSocket, user_id: str):
61
+ """WebSocket endpoint for real-time notifications."""
62
+ await websocket_endpoint(websocket, user_id)
63
+
64
+
65
+ # Root endpoint
66
+ @app.get("/")
67
+ async def root():
68
+ """Root endpoint."""
69
+ return {
70
+ "name": settings.APP_NAME,
71
+ "version": settings.APP_VERSION,
72
+ "status": "running"
73
+ }
74
+
75
+
76
+ @app.get("/api/health")
77
+ async def health():
78
+ """Health check endpoint."""
79
+ return {"status": "healthy"}
80
+
81
+
82
+ # Import all routes to ensure they're registered
83
+ __all__ = ["app"]
backend/app/models/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Models package - all database models.
3
+ """
4
+ from .user import User
5
+ from .message import Message
6
+ from .group import Group, GroupMember
7
+ from .friend import Friend
8
+ from .reaction import Reaction
9
+
10
+ __all__ = ["User", "Message", "Group", "GroupMember", "Friend", "Reaction"]
backend/app/models/friend.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Friend model - represents friend relationships between users.
3
+ """
4
+ from sqlalchemy import Column, String, DateTime, ForeignKey, Boolean, Enum as SQLEnum
5
+ from sqlalchemy.sql import func
6
+ from ..dependencies import Base
7
+ import uuid
8
+ import enum
9
+
10
+
11
+ class FriendStatus(enum.Enum):
12
+ """Friend request status."""
13
+ PENDING = "pending"
14
+ ACCEPTED = "accepted"
15
+ BLOCKED = "blocked"
16
+
17
+
18
+ class Friend(Base):
19
+ """Friend relationship model."""
20
+
21
+ __tablename__ = "friends"
22
+
23
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
24
+
25
+ # User who initiated the friend request
26
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
27
+
28
+ # User who received the request
29
+ friend_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
30
+
31
+ # Status: pending, accepted, blocked
32
+ status = Column(String(20), default="pending")
33
+
34
+ # Timestamps
35
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
36
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
37
+ accepted_at = Column(DateTime(timezone=True), nullable=True)
38
+
39
+ def __repr__(self):
40
+ return f"<Friend {self.user_id} <-> {self.friend_id}>"
backend/app/models/group.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Group model - represents chat groups.
3
+ """
4
+ from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Integer
5
+ from sqlalchemy.sql import func
6
+ from ..dependencies import Base
7
+ import uuid
8
+
9
+
10
+ class Group(Base):
11
+ """Chat group model."""
12
+
13
+ __tablename__ = "groups"
14
+
15
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
16
+ name = Column(String(100), nullable=False)
17
+ description = Column(Text, nullable=True)
18
+ avatar = Column(Text, nullable=True)
19
+
20
+ # Admin (owner)
21
+ owner_id = Column(String, ForeignKey("users.id"), nullable=False)
22
+
23
+ # Settings
24
+ is_private = Column(Boolean, default=False)
25
+ max_members = Column(Integer, default=100)
26
+
27
+ # Timestamps
28
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
29
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
30
+
31
+ def __repr__(self):
32
+ return f"<Group {self.name}>"
33
+
34
+
35
+ class GroupMember(Base):
36
+ """Group member with roles."""
37
+
38
+ __tablename__ = "group_members"
39
+
40
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
41
+ group_id = Column(String, ForeignKey("groups.id"), nullable=False, index=True)
42
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
43
+
44
+ # Role: 'admin', 'co_admin', 'member'
45
+ role = Column(String(20), default="member")
46
+
47
+ # Custom nickname in group
48
+ nickname = Column(String(50), nullable=True)
49
+
50
+ # Mute notifications?
51
+ is_muted = Column(Boolean, default=False)
52
+ is_banned = Column(Boolean, default=False)
53
+
54
+ # Timestamps
55
+ joined_at = Column(DateTime(timezone=True), server_default=func.now())
56
+
57
+ def __repr__(self):
58
+ return f"<GroupMember {self.user_id} in {self.group_id}>"
backend/app/models/message.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Message model - represents chat messages.
3
+ """
4
+ from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Integer
5
+ from sqlalchemy.sql import func
6
+ from sqlalchemy.orm import relationship
7
+ from ..dependencies import Base
8
+ import uuid
9
+
10
+
11
+ class Message(Base):
12
+ """Chat message model."""
13
+
14
+ __tablename__ = "messages"
15
+
16
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
17
+ sender_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
18
+
19
+ # Message type: 'text', 'image', 'file', 'system'
20
+ type = Column(String(20), default="text")
21
+
22
+ # Content (text or file URL)
23
+ content = Column(Text, nullable=False)
24
+
25
+ # For private chat (user-to-user)
26
+ receiver_id = Column(String, ForeignKey("users.id"), nullable=True, index=True)
27
+
28
+ # For group chat
29
+ group_id = Column(String, ForeignKey("groups.id"), nullable=True, index=True)
30
+
31
+ # Message status
32
+ is_deleted = Column(Boolean, default=False)
33
+ is_archived = Column(Boolean, default=False)
34
+
35
+ # Batch processing info
36
+ batch_id = Column(String, nullable=True, index=True)
37
+ compressed = Column(Boolean, default=False)
38
+
39
+ # Timestamps
40
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
41
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
42
+ deleted_at = Column(DateTime(timezone=True), nullable=True)
43
+
44
+ # Relationships
45
+ # sender = relationship("User", back_populates="messages")
46
+ # reactions = relationship("Reaction", back_populates="message")
47
+
48
+ def __repr__(self):
49
+ return f"<Message {self.id[:8]} from {self.sender_id[:8]}>"
backend/app/models/reaction.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reaction model - emoji/like reactions on messages.
3
+ """
4
+ from sqlalchemy import Column, String, DateTime, ForeignKey
5
+ from sqlalchemy.sql import func
6
+ from ..dependencies import Base
7
+ import uuid
8
+
9
+
10
+ class Reaction(Base):
11
+ """Message reaction (emoji) model."""
12
+
13
+ __tablename__ = "reactions"
14
+
15
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
16
+ message_id = Column(String, ForeignKey("messages.id"), nullable=False, index=True)
17
+ user_id = Column(String, ForeignKey("users.id"), nullable=False, index=True)
18
+
19
+ # Emoji: '👍', '❤️', '😂', '😢', '😮', '😡', etc.
20
+ emoji = Column(String(10), nullable=False)
21
+
22
+ # Timestamps
23
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
24
+
25
+ def __repr__(self):
26
+ return f"<Reaction {self.emoji} on {self.message_id[:8]}>"
backend/app/models/user.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User model - represents a user in the chat system.
3
+ """
4
+ from sqlalchemy import Column, String, DateTime, Boolean, Text
5
+ from sqlalchemy.sql import func
6
+ from ..dependencies import Base
7
+ import uuid
8
+
9
+
10
+ class User(Base):
11
+ """User account model."""
12
+
13
+ __tablename__ = "users"
14
+
15
+ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
16
+ username = Column(String(50), unique=True, nullable=False, index=True)
17
+ email = Column(String(255), unique=True, nullable=False, index=True)
18
+ password_hash = Column(String(255), nullable=False)
19
+
20
+ # Profile
21
+ display_name = Column(String(100), nullable=True)
22
+ avatar = Column(Text, nullable=True) # URL or base64
23
+ bio = Column(Text, nullable=True)
24
+ status = Column(String(20), default="offline") # online, offline, away
25
+
26
+ # Settings
27
+ is_public = Column(Boolean, default=True) # Public profile?
28
+ allow_friends = Column(Boolean, default=True)
29
+
30
+ # Timestamps
31
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
32
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
33
+ last_seen = Column(DateTime(timezone=True), nullable=True)
34
+
35
+ # Blocked users (relationship defined elsewhere)
36
+ # blocked_users = relationship("BlockedUser", back_populates="user")
37
+
38
+ def __repr__(self):
39
+ return f"<User {self.username}>"
backend/app/routes/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Routes package - API endpoints.
3
+ """
4
+ from .auth import router as auth_router
5
+ from .user import router as user_router
6
+ from .chat import router as chat_router
7
+ from .group import router as group_router
8
+ from .system import router as system_router
9
+
10
+ __all__ = ["auth_router", "user_router", "chat_router", "group_router", "system_router"]
backend/app/routes/auth.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication routes - register, login, logout, token refresh.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from fastapi.security import HTTPBearer
6
+ from pydantic import BaseModel, EmailStr
7
+ from sqlalchemy.ext.asyncio import AsyncSession
8
+ from sqlalchemy import select
9
+ from typing import Optional
10
+
11
+ from ..dependencies import get_db, get_current_user
12
+ from ..models import User
13
+ from ..utils.security import hash_password, verify_password, create_access_token
14
+ from ..utils.avatar import generate_avatar
15
+
16
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
17
+ security = HTTPBearer()
18
+
19
+
20
+ # --- Schemas ---
21
+ class RegisterRequest(BaseModel):
22
+ username: str
23
+ email: EmailStr
24
+ password: str
25
+
26
+
27
+ class LoginRequest(BaseModel):
28
+ email: EmailStr
29
+ password: str
30
+
31
+
32
+ class AuthResponse(BaseModel):
33
+ access_token: str
34
+ token_type: str = "bearer"
35
+ user: dict
36
+
37
+
38
+ class UserResponse(BaseModel):
39
+ id: str
40
+ username: str
41
+ email: str
42
+ display_name: Optional[str] = None
43
+ avatar: Optional[str] = None
44
+ status: str = "offline"
45
+
46
+
47
+ # --- Routes ---
48
+ @router.post("/register", response_model=AuthResponse, status_code=status.HTTP_201_CREATED)
49
+ async def register(
50
+ request: RegisterRequest,
51
+ db: AsyncSession = Depends(get_db)
52
+ ):
53
+ """Register a new user account."""
54
+ # Check if email already exists
55
+ result = await db.execute(select(User).where(User.email == request.email))
56
+ if result.scalar_one_or_none():
57
+ raise HTTPException(
58
+ status_code=status.HTTP_400_BAD_REQUEST,
59
+ detail="Email already registered"
60
+ )
61
+
62
+ # Check if username exists
63
+ result = await db.execute(select(User).where(User.username == request.username))
64
+ if result.scalar_one_or_none():
65
+ raise HTTPException(
66
+ status_code=status.HTTP_400_BAD_REQUEST,
67
+ detail="Username already taken"
68
+ )
69
+
70
+ # Create user
71
+ password_hash = hash_password(request.password)
72
+ avatar_url = await generate_avatar(request.username, request.username)
73
+
74
+ user = User(
75
+ username=request.username,
76
+ email=request.email,
77
+ password_hash=password_hash,
78
+ display_name=request.username,
79
+ avatar=avatar_url,
80
+ status="offline"
81
+ )
82
+
83
+ db.add(user)
84
+ await db.commit()
85
+ await db.refresh(user)
86
+
87
+ # Generate token
88
+ access_token = create_access_token({"sub": user.id, "username": user.username})
89
+
90
+ return AuthResponse(
91
+ access_token=access_token,
92
+ user={
93
+ "id": user.id,
94
+ "username": user.username,
95
+ "email": user.email,
96
+ "display_name": user.display_name,
97
+ "avatar": user.avatar,
98
+ "status": user.status
99
+ }
100
+ )
101
+
102
+
103
+ @router.post("/login", response_model=AuthResponse)
104
+ async def login(
105
+ request: LoginRequest,
106
+ db: AsyncSession = Depends(get_db)
107
+ ):
108
+ """Login with email and password."""
109
+ # Find user
110
+ result = await db.execute(select(User).where(User.email == request.email))
111
+ user = result.scalar_one_or_none()
112
+
113
+ if not user or not verify_password(request.password, user.password_hash):
114
+ raise HTTPException(
115
+ status_code=status.HTTP_401_UNAUTHORIZED,
116
+ detail="Invalid email or password"
117
+ )
118
+
119
+ # Update status
120
+ user.status = "online"
121
+ await db.commit()
122
+
123
+ # Generate token
124
+ access_token = create_access_token({"sub": user.id, "username": user.username})
125
+
126
+ return AuthResponse(
127
+ access_token=access_token,
128
+ user={
129
+ "id": user.id,
130
+ "username": user.username,
131
+ "email": user.email,
132
+ "display_name": user.display_name,
133
+ "avatar": user.avatar,
134
+ "status": user.status
135
+ }
136
+ )
137
+
138
+
139
+ @router.post("/logout")
140
+ async def logout(
141
+ current_user: dict = Depends(get_current_user),
142
+ db: AsyncSession = Depends(get_db)
143
+ ):
144
+ """Logout and update user status."""
145
+ result = await db.execute(select(User).where(User.id == current_user["id"]))
146
+ user = result.scalar_one_or_none()
147
+
148
+ if user:
149
+ user.status = "offline"
150
+ await db.commit()
151
+
152
+ return {"message": "Logged out successfully"}
153
+
154
+
155
+ @router.get("/me", response_model=UserResponse)
156
+ async def get_me(
157
+ current_user: dict = Depends(get_current_user),
158
+ db: AsyncSession = Depends(get_db)
159
+ ):
160
+ """Get current authenticated user profile."""
161
+ result = await db.execute(select(User).where(User.id == current_user["id"]))
162
+ user = result.scalar_one_or_none()
163
+
164
+ if not user:
165
+ raise HTTPException(status_code=404, detail="User not found")
166
+
167
+ return UserResponse(
168
+ id=user.id,
169
+ username=user.username,
170
+ email=user.email,
171
+ display_name=user.display_name,
172
+ avatar=user.avatar,
173
+ status=user.status
174
+ )
175
+
176
+
177
+ @router.post("/refresh")
178
+ async def refresh_token(
179
+ current_user: dict = Depends(get_current_user)
180
+ ):
181
+ """Refresh access token."""
182
+ access_token = create_access_token(
183
+ {"sub": current_user["id"], "username": current_user.get("username", "user")}
184
+ )
185
+ return {"access_token": access_token, "token_type": "bearer"}
backend/app/routes/chat.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat routes - send messages, delete, reactions.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from pydantic import BaseModel
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy import select, and_, or_
8
+ from typing import Optional, List
9
+ from datetime import datetime
10
+ import uuid
11
+
12
+ from ..dependencies import get_db, get_current_user
13
+ from ..models import Message, Reaction, User
14
+ from ..utils.formatter import clean_message
15
+
16
+ router = APIRouter(prefix="/chat", tags=["Chat"])
17
+
18
+
19
+ # --- Schemas ---
20
+ class SendMessageRequest(BaseModel):
21
+ receiver_id: Optional[str] = None
22
+ group_id: Optional[str] = None
23
+ content: str
24
+ type: str = "text" # text, image, file
25
+
26
+
27
+ class MessageResponse(BaseModel):
28
+ id: str
29
+ sender_id: str
30
+ receiver_id: Optional[str] = None
31
+ group_id: Optional[str] = None
32
+ type: str
33
+ content: str
34
+ is_deleted: bool
35
+ created_at: datetime
36
+
37
+
38
+ class ReactionRequest(BaseModel):
39
+ message_id: str
40
+ emoji: str
41
+
42
+
43
+ class ReactionResponse(BaseModel):
44
+ id: str
45
+ message_id: str
46
+ user_id: str
47
+ emoji: str
48
+ created_at: datetime
49
+
50
+
51
+ # --- Routes ---
52
+ @router.post("/send", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
53
+ async def send_message(
54
+ request: SendMessageRequest,
55
+ current_user: dict = Depends(get_current_user),
56
+ db: AsyncSession = Depends(get_db)
57
+ ):
58
+ """Send a message to a user or group."""
59
+ # Validate - must have either receiver_id or group_id
60
+ if not request.receiver_id and not request.group_id:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_400_BAD_REQUEST,
63
+ detail="Must specify receiver_id or group_id"
64
+ )
65
+
66
+ if request.receiver_id and request.group_id:
67
+ raise HTTPException(
68
+ status_code=status.HTTP_400_BAD_REQUEST,
69
+ detail="Cannot send to both receiver and group"
70
+ )
71
+
72
+ # Clean message content
73
+ content = clean_message(request.content)
74
+
75
+ # Create message
76
+ message = Message(
77
+ sender_id=current_user["id"],
78
+ receiver_id=request.receiver_id,
79
+ group_id=request.group_id,
80
+ content=content,
81
+ type=request.type,
82
+ compressed=False
83
+ )
84
+
85
+ db.add(message)
86
+ await db.commit()
87
+ await db.refresh(message)
88
+
89
+ # TODO: Push to async queue for batch processing
90
+ # await queue.push_message(message)
91
+
92
+ return MessageResponse(
93
+ id=message.id,
94
+ sender_id=message.sender_id,
95
+ receiver_id=message.receiver_id,
96
+ group_id=message.group_id,
97
+ type=message.type,
98
+ content=message.content,
99
+ is_deleted=message.is_deleted,
100
+ created_at=message.created_at
101
+ )
102
+
103
+
104
+ @router.get("/messages/{user_id}")
105
+ async def get_private_messages(
106
+ user_id: str,
107
+ limit: int = 50,
108
+ offset: int = 0,
109
+ current_user: dict = Depends(get_current_user),
110
+ db: AsyncSession = Depends(get_db)
111
+ ):
112
+ """Get private messages between current user and another user."""
113
+ result = await db.execute(
114
+ select(Message).where(
115
+ and_(
116
+ or_(
117
+ and_(Message.sender_id == current_user["id"], Message.receiver_id == user_id),
118
+ and_(Message.sender_id == user_id, Message.receiver_id == current_user["id"])
119
+ ),
120
+ Message.group_id.is_(None),
121
+ Message.is_deleted == False
122
+ )
123
+ ).order_by(Message.created_at.desc()).limit(limit).offset(offset)
124
+ )
125
+ messages = result.scalars().all()
126
+
127
+ return [
128
+ {
129
+ "id": m.id,
130
+ "sender_id": m.sender_id,
131
+ "receiver_id": m.receiver_id,
132
+ "content": m.content,
133
+ "type": m.type,
134
+ "created_at": m.created_at.isoformat()
135
+ }
136
+ for m in messages
137
+ ]
138
+
139
+
140
+ @router.delete("/messages/{message_id}")
141
+ async def delete_message(
142
+ message_id: str,
143
+ current_user: dict = Depends(get_current_user),
144
+ db: AsyncSession = Depends(get_db)
145
+ ):
146
+ """Delete a message (soft delete)."""
147
+ result = await db.execute(
148
+ select(Message).where(
149
+ and_(Message.id == message_id, Message.sender_id == current_user["id"])
150
+ )
151
+ )
152
+ message = result.scalar_one_or_none()
153
+
154
+ if not message:
155
+ raise HTTPException(status_code=404, detail="Message not found or not authorized")
156
+
157
+ message.is_deleted = True
158
+ message.deleted_at = datetime.utcnow()
159
+ await db.commit()
160
+
161
+ return {"message": "Message deleted"}
162
+
163
+
164
+ @router.post("/reactions", response_model=ReactionResponse)
165
+ async def add_reaction(
166
+ request: ReactionRequest,
167
+ current_user: dict = Depends(get_current_user),
168
+ db: AsyncSession = Depends(get_db)
169
+ ):
170
+ """Add an emoji reaction to a message."""
171
+ # Verify message exists
172
+ result = await db.execute(select(Message).where(Message.id == request.message_id))
173
+ message = result.scalar_one_or_none()
174
+
175
+ if not message:
176
+ raise HTTPException(status_code=404, detail="Message not found")
177
+
178
+ # Check if user already reacted with this emoji
179
+ result = await db.execute(
180
+ select(Reaction).where(
181
+ and_(
182
+ Reaction.message_id == request.message_id,
183
+ Reaction.user_id == current_user["id"],
184
+ Reaction.emoji == request.emoji
185
+ )
186
+ )
187
+ )
188
+ existing = result.scalar_one_or_none()
189
+
190
+ if existing:
191
+ raise HTTPException(status_code=400, detail="Reaction already exists")
192
+
193
+ # Create reaction
194
+ reaction = Reaction(
195
+ message_id=request.message_id,
196
+ user_id=current_user["id"],
197
+ emoji=request.emoji
198
+ )
199
+
200
+ db.add(reaction)
201
+ await db.commit()
202
+ await db.refresh(reaction)
203
+
204
+ return ReactionResponse(
205
+ id=reaction.id,
206
+ message_id=reaction.message_id,
207
+ user_id=reaction.user_id,
208
+ emoji=reaction.emoji,
209
+ created_at=reaction.created_at
210
+ )
211
+
212
+
213
+ @router.delete("/reactions/{reaction_id}")
214
+ async def remove_reaction(
215
+ reaction_id: str,
216
+ current_user: dict = Depends(get_current_user),
217
+ db: AsyncSession = Depends(get_db)
218
+ ):
219
+ """Remove a reaction."""
220
+ result = await db.execute(
221
+ select(Reaction).where(
222
+ and_(Reaction.id == reaction_id, Reaction.user_id == current_user["id"])
223
+ )
224
+ )
225
+ reaction = result.scalar_one_or_none()
226
+
227
+ if not reaction:
228
+ raise HTTPException(status_code=404, detail="Reaction not found")
229
+
230
+ await db.delete(reaction)
231
+ await db.commit()
232
+
233
+ return {"message": "Reaction removed"}
234
+
235
+
236
+ @router.get("/messages/{message_id}/reactions")
237
+ async def get_message_reactions(
238
+ message_id: str,
239
+ db: AsyncSession = Depends(get_db)
240
+ ):
241
+ """Get all reactions on a message."""
242
+ result = await db.execute(
243
+ select(Reaction).where(Reaction.message_id == message_id)
244
+ )
245
+ reactions = result.scalars().all()
246
+
247
+ # Group by emoji
248
+ emoji_counts = {}
249
+ for r in reactions:
250
+ if r.emoji not in emoji_counts:
251
+ emoji_counts[r.emoji] = {"count": 0, "users": []}
252
+ emoji_counts[r.emoji]["count"] += 1
253
+ emoji_counts[r.emoji]["users"].append(r.user_id)
254
+
255
+ return emoji_counts
backend/app/routes/group.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Group routes - create groups, manage members, admin controls.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from pydantic import BaseModel
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy import select, and_, or_
8
+ from typing import Optional, List
9
+ from datetime import datetime
10
+ import uuid
11
+
12
+ from ..dependencies import get_db, get_current_user
13
+ from ..models import Group, GroupMember, User, Message
14
+
15
+ router = APIRouter(prefix="/groups", tags=["Group"])
16
+
17
+
18
+ # --- Schemas ---
19
+ class CreateGroupRequest(BaseModel):
20
+ name: str
21
+ description: Optional[str] = None
22
+ is_private: bool = False
23
+ max_members: int = 100
24
+
25
+
26
+ class UpdateGroupRequest(BaseModel):
27
+ name: Optional[str] = None
28
+ description: Optional[str] = None
29
+ avatar: Optional[str] = None
30
+
31
+
32
+ class GroupResponse(BaseModel):
33
+ id: str
34
+ name: str
35
+ description: Optional[str]
36
+ avatar: Optional[str]
37
+ owner_id: str
38
+ is_private: bool
39
+ max_members: int
40
+ created_at: datetime
41
+
42
+
43
+ class AddMemberRequest(BaseModel):
44
+ user_id: str
45
+ role: str = "member" # member, co_admin
46
+
47
+
48
+ class UpdateMemberRequest(BaseModel):
49
+ nickname: Optional[str] = None
50
+ role: Optional[str] = None
51
+ is_muted: Optional[bool] = None
52
+
53
+
54
+ class MemberResponse(BaseModel):
55
+ id: str
56
+ user_id: str
57
+ group_id: str
58
+ role: str
59
+ nickname: Optional[str]
60
+ is_muted: bool
61
+ joined_at: datetime
62
+
63
+
64
+ # --- Routes ---
65
+ @router.post("/", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
66
+ async def create_group(
67
+ request: CreateGroupRequest,
68
+ current_user: dict = Depends(get_current_user),
69
+ db: AsyncSession = Depends(get_db)
70
+ ):
71
+ """Create a new chat group."""
72
+ group = Group(
73
+ name=request.name,
74
+ description=request.description,
75
+ owner_id=current_user["id"],
76
+ is_private=request.is_private,
77
+ max_members=request.max_members
78
+ )
79
+
80
+ db.add(group)
81
+ await db.commit()
82
+ await db.refresh(group)
83
+
84
+ # Add creator as admin
85
+ member = GroupMember(
86
+ group_id=group.id,
87
+ user_id=current_user["id"],
88
+ role="admin"
89
+ )
90
+ db.add(member)
91
+ await db.commit()
92
+
93
+ return GroupResponse(
94
+ id=group.id,
95
+ name=group.name,
96
+ description=group.description,
97
+ avatar=group.avatar,
98
+ owner_id=group.owner_id,
99
+ is_private=group.is_private,
100
+ max_members=group.max_members,
101
+ created_at=group.created_at
102
+ )
103
+
104
+
105
+ @router.get("/{group_id}", response_model=GroupResponse)
106
+ async def get_group(
107
+ group_id: str,
108
+ current_user: dict = Depends(get_current_user),
109
+ db: AsyncSession = Depends(get_db)
110
+ ):
111
+ """Get group details."""
112
+ result = await db.execute(select(Group).where(Group.id == group_id))
113
+ group = result.scalar_one_or_none()
114
+
115
+ if not group:
116
+ raise HTTPException(status_code=404, detail="Group not found")
117
+
118
+ return GroupResponse(
119
+ id=group.id,
120
+ name=group.name,
121
+ description=group.description,
122
+ avatar=group.avatar,
123
+ owner_id=group.owner_id,
124
+ is_private=group.is_private,
125
+ max_members=group.max_members,
126
+ created_at=group.created_at
127
+ )
128
+
129
+
130
+ @router.put("/{group_id}", response_model=GroupResponse)
131
+ async def update_group(
132
+ group_id: str,
133
+ request: UpdateGroupRequest,
134
+ current_user: dict = Depends(get_current_user),
135
+ db: AsyncSession = Depends(get_db)
136
+ ):
137
+ """Update group (admin only)."""
138
+ # Check if user is admin
139
+ result = await db.execute(
140
+ select(GroupMember).where(
141
+ and_(
142
+ GroupMember.group_id == group_id,
143
+ GroupMember.user_id == current_user["id"],
144
+ GroupMember.role.in_(["admin", "co_admin"])
145
+ )
146
+ )
147
+ )
148
+ member = result.scalar_one_or_none()
149
+
150
+ if not member:
151
+ raise HTTPException(status_code=403, detail="Not authorized")
152
+
153
+ result = await db.execute(select(Group).where(Group.id == group_id))
154
+ group = result.scalar_one_or_none()
155
+
156
+ if request.name:
157
+ group.name = request.name
158
+ if request.description is not None:
159
+ group.description = request.description
160
+ if request.avatar is not None:
161
+ group.avatar = request.avatar
162
+
163
+ await db.commit()
164
+ await db.refresh(group)
165
+
166
+ return GroupResponse(
167
+ id=group.id,
168
+ name=group.name,
169
+ description=group.description,
170
+ avatar=group.avatar,
171
+ owner_id=group.owner_id,
172
+ is_private=group.is_private,
173
+ max_members=group.max_members,
174
+ created_at=group.created_at
175
+ )
176
+
177
+
178
+ @router.delete("/{group_id}")
179
+ async def delete_group(
180
+ group_id: str,
181
+ current_user: dict = Depends(get_current_user),
182
+ db: AsyncSession = Depends(get_db)
183
+ ):
184
+ """Delete group (owner only)."""
185
+ result = await db.execute(select(Group).where(Group.id == group_id))
186
+ group = result.scalar_one_or_none()
187
+
188
+ if not group:
189
+ raise HTTPException(status_code=404, detail="Group not found")
190
+
191
+ if group.owner_id != current_user["id"]:
192
+ raise HTTPException(status_code=403, detail="Only owner can delete group")
193
+
194
+ await db.delete(group)
195
+ await db.commit()
196
+
197
+ return {"message": "Group deleted"}
198
+
199
+
200
+ @router.get("/{group_id}/members")
201
+ async def get_group_members(
202
+ group_id: str,
203
+ current_user: dict = Depends(get_current_user),
204
+ db: AsyncSession = Depends(get_db)
205
+ ):
206
+ """Get all group members."""
207
+ result = await db.execute(
208
+ select(GroupMember, User).join(User, GroupMember.user_id == User.id).where(
209
+ GroupMember.group_id == group_id,
210
+ GroupMember.is_banned == False
211
+ )
212
+ )
213
+ members = result.all()
214
+
215
+ return [
216
+ {
217
+ "id": m.GroupMember.id,
218
+ "user_id": m.GroupMember.user_id,
219
+ "username": m.User.username,
220
+ "display_name": m.User.display_name,
221
+ "avatar": m.User.avatar,
222
+ "role": m.GroupMember.role,
223
+ "nickname": m.GroupMember.nickname,
224
+ "is_muted": m.GroupMember.is_muted,
225
+ "joined_at": m.GroupMember.joined_at.isoformat()
226
+ }
227
+ for m in members
228
+ ]
229
+
230
+
231
+ @router.post("/{group_id}/members", status_code=status.HTTP_201_CREATED)
232
+ async def add_member(
233
+ group_id: str,
234
+ request: AddMemberRequest,
235
+ current_user: dict = Depends(get_current_user),
236
+ db: AsyncSession = Depends(get_db)
237
+ ):
238
+ """Add a member to group (admin only)."""
239
+ # Check admin
240
+ result = await db.execute(
241
+ select(GroupMember).where(
242
+ and_(
243
+ GroupMember.group_id == group_id,
244
+ GroupMember.user_id == current_user["id"],
245
+ GroupMember.role.in_(["admin", "co_admin"])
246
+ )
247
+ )
248
+ )
249
+ if not result.scalar_one_or_none():
250
+ raise HTTPException(status_code=403, detail="Not authorized")
251
+
252
+ # Check group exists
253
+ result = await db.execute(select(Group).where(Group.id == group_id))
254
+ group = result.scalar_one_or_none()
255
+ if not group:
256
+ raise HTTPException(status_code=404, detail="Group not found")
257
+
258
+ # Check not already member
259
+ result = await db.execute(
260
+ select(GroupMember).where(
261
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == request.user_id)
262
+ )
263
+ )
264
+ if result.scalar_one_or_none():
265
+ raise HTTPException(status_code=400, detail="Already a member")
266
+
267
+ # Add member
268
+ member = GroupMember(
269
+ group_id=group_id,
270
+ user_id=request.user_id,
271
+ role=request.role
272
+ )
273
+ db.add(member)
274
+ await db.commit()
275
+
276
+ return {"message": "Member added"}
277
+
278
+
279
+ @router.delete("/{group_id}/members/{user_id}")
280
+ async def remove_member(
281
+ group_id: str,
282
+ user_id: str,
283
+ current_user: dict = Depends(get_current_user),
284
+ db: AsyncSession = Depends(get_db)
285
+ ):
286
+ """Remove a member from group (admin only)."""
287
+ # Check admin or self-removal
288
+ result = await db.execute(
289
+ select(GroupMember).where(
290
+ and_(
291
+ GroupMember.group_id == group_id,
292
+ GroupMember.user_id == current_user["id"],
293
+ GroupMember.role.in_(["admin", "co_admin"])
294
+ )
295
+ )
296
+ )
297
+ is_admin = result.scalar_one_or_none() is not None
298
+
299
+ if not is_admin and user_id != current_user["id"]:
300
+ raise HTTPException(status_code=403, detail="Not authorized")
301
+
302
+ # Find and remove member
303
+ result = await db.execute(
304
+ select(GroupMember).where(
305
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
306
+ )
307
+ )
308
+ member = result.scalar_one_or_none()
309
+
310
+ if not member:
311
+ raise HTTPException(status_code=404, detail="Member not found")
312
+
313
+ await db.delete(member)
314
+ await db.commit()
315
+
316
+ return {"message": "Member removed"}
317
+
318
+
319
+ @router.put("/{group_id}/members/{user_id}", response_model=MemberResponse)
320
+ async def update_member(
321
+ group_id: str,
322
+ user_id: str,
323
+ request: UpdateMemberRequest,
324
+ current_user: dict = Depends(get_current_user),
325
+ db: AsyncSession = Depends(get_db)
326
+ ):
327
+ """Update member (nickname, role, mute) - admin only."""
328
+ # Check admin
329
+ result = await db.execute(
330
+ select(GroupMember).where(
331
+ and_(
332
+ GroupMember.group_id == group_id,
333
+ GroupMember.user_id == current_user["id"],
334
+ GroupMember.role == "admin"
335
+ )
336
+ )
337
+ )
338
+ if not result.scalar_one_or_none():
339
+ raise HTTPException(status_code=403, detail="Not authorized")
340
+
341
+ result = await db.execute(
342
+ select(GroupMember).where(
343
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
344
+ )
345
+ )
346
+ member = result.scalar_one_or_none()
347
+
348
+ if not member:
349
+ raise HTTPException(status_code=404, detail="Member not found")
350
+
351
+ if request.nickname is not None:
352
+ member.nickname = request.nickname
353
+ if request.role is not None:
354
+ member.role = request.role
355
+ if request.is_muted is not None:
356
+ member.is_muted = request.is_muted
357
+
358
+ await db.commit()
359
+
360
+ return MemberResponse(
361
+ id=member.id,
362
+ user_id=member.user_id,
363
+ group_id=member.group_id,
364
+ role=member.role,
365
+ nickname=member.nickname,
366
+ is_muted=member.is_muted,
367
+ joined_at=member.joined_at
368
+ )
369
+
370
+
371
+ @router.get("/{group_id}/messages")
372
+ async def get_group_messages(
373
+ group_id: str,
374
+ limit: int = 50,
375
+ offset: int = 0,
376
+ current_user: dict = Depends(get_current_user),
377
+ db: AsyncSession = Depends(get_db)
378
+ ):
379
+ """Get group messages."""
380
+ result = await db.execute(
381
+ select(Message).where(
382
+ and_(
383
+ Message.group_id == group_id,
384
+ Message.is_deleted == False
385
+ )
386
+ ).order_by(Message.created_at.desc()).limit(limit).offset(offset)
387
+ )
388
+ messages = result.scalars().all()
389
+
390
+ return [
391
+ {
392
+ "id": m.id,
393
+ "sender_id": m.sender_id,
394
+ "content": m.content,
395
+ "type": m.type,
396
+ "created_at": m.created_at.isoformat()
397
+ }
398
+ for m in messages
399
+ ]
backend/app/routes/system.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System routes - batch triggers, queue control, monitoring.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from pydantic import BaseModel
6
+ from typing import Optional, List, Dict
7
+ from datetime import datetime, timedelta
8
+
9
+ from ..dependencies import get_current_user
10
+
11
+ router = APIRouter(prefix="/system", tags=["System"])
12
+
13
+
14
+ # --- Schemas ---
15
+ class BatchStatusResponse(BaseModel):
16
+ last_batch_time: Optional[datetime]
17
+ next_batch_time: Optional[datetime]
18
+ queue_size: int
19
+ processed_today: int
20
+
21
+
22
+ class TriggerBatchRequest(BaseModel):
23
+ user_id: Optional[str] = None
24
+ group_id: Optional[str] = None
25
+
26
+
27
+ class QueueStatsResponse(BaseModel):
28
+ size: int
29
+ oldest_message_age_seconds: int
30
+ avg_processing_time_ms: int
31
+
32
+
33
+ # --- Routes ---
34
+ @router.get("/health")
35
+ async def health_check():
36
+ """Health check endpoint."""
37
+ return {
38
+ "status": "healthy",
39
+ "timestamp": datetime.utcnow().isoformat(),
40
+ "version": "1.0.0"
41
+ }
42
+
43
+
44
+ @router.get("/batch/status", response_model=BatchStatusResponse)
45
+ async def get_batch_status(current_user: dict = Depends(get_current_user)):
46
+ """Get batch processing status."""
47
+ # TODO: Pull actual stats from Redis/monitor
48
+ return BatchStatusResponse(
49
+ last_batch_time=datetime.utcnow() - timedelta(hours=1),
50
+ next_batch_time=datetime.utcnow() + timedelta(minutes=30),
51
+ queue_size=42,
52
+ processed_today=156
53
+ )
54
+
55
+
56
+ @router.post("/batch/trigger", status_code=status.HTTP_202_ACCEPTED)
57
+ async def trigger_batch(
58
+ request: TriggerBatchRequest,
59
+ current_user: dict = Depends(get_current_user)
60
+ ):
61
+ """
62
+ Manually trigger batch processing.
63
+ Can target specific user or group, or process all.
64
+ """
65
+ # TODO: Call controller to trigger batch
66
+ target = request.user_id or request.group_id or "all"
67
+ return {
68
+ "message": f"Batch processing triggered for {target}",
69
+ "status": "queued"
70
+ }
71
+
72
+
73
+ @router.get("/queue/stats", response_model=QueueStatsResponse)
74
+ async def get_queue_stats():
75
+ """Get queue statistics."""
76
+ # TODO: Pull from Redis
77
+ return QueueStatsResponse(
78
+ size=42,
79
+ oldest_message_age_seconds=300,
80
+ avg_processing_time_ms=15
81
+ )
82
+
83
+
84
+ @router.get("/storage/stats")
85
+ async def get_storage_stats():
86
+ """Get storage statistics."""
87
+ # TODO: Calculate from storage directory
88
+ return {
89
+ "total_files": 1250,
90
+ "total_size_mb": 45.2,
91
+ "compression_ratio": 68.5
92
+ }
93
+
94
+
95
+ @router.post("/cleanup/old-messages")
96
+ async def cleanup_old_messages(
97
+ days: int = 30,
98
+ current_user: dict = Depends(get_current_user)
99
+ ):
100
+ """Clean up messages older than specified days."""
101
+ # TODO: Implement cleanup
102
+ return {
103
+ "message": f"Cleanup scheduled for messages older than {days} days"
104
+ }
105
+
106
+
107
+ @router.get("/logs")
108
+ async def get_logs(
109
+ limit: int = 100,
110
+ level: Optional[str] = None
111
+ ):
112
+ """Get recent system logs."""
113
+ # TODO: Pull from log system
114
+ return {
115
+ "logs": [
116
+ {"timestamp": datetime.utcnow().isoformat(), "level": "INFO", "message": "System running"}
117
+ ],
118
+ "count": 1
119
+ }
backend/app/routes/user.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User routes - profile, avatar, block user.
3
+ """
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from pydantic import BaseModel, EmailStr
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy import select, or_
8
+ from typing import Optional, List
9
+ from datetime import datetime
10
+
11
+ from ..dependencies import get_db, get_current_user
12
+ from ..models import User, Friend
13
+ from ..utils.avatar import generate_avatar
14
+
15
+ router = APIRouter(prefix="/users", tags=["User"])
16
+
17
+
18
+ # --- Schemas ---
19
+ class UserProfileResponse(BaseModel):
20
+ id: str
21
+ username: str
22
+ email: str
23
+ display_name: Optional[str] = None
24
+ avatar: Optional[str] = None
25
+ bio: Optional[str] = None
26
+ status: str
27
+ is_public: bool = True
28
+ created_at: datetime
29
+
30
+
31
+ class UpdateProfileRequest(BaseModel):
32
+ display_name: Optional[str] = None
33
+ bio: Optional[str] = None
34
+ is_public: Optional[bool] = None
35
+ allow_friends: Optional[bool] = None
36
+
37
+
38
+ class BlockUserRequest(BaseModel):
39
+ user_id: str
40
+
41
+
42
+ class FriendRequest(BaseModel):
43
+ user_id: str
44
+
45
+
46
+ # --- Routes ---
47
+ @router.get("/{user_id}", response_model=UserProfileResponse)
48
+ async def get_user_profile(
49
+ user_id: str,
50
+ current_user: dict = Depends(get_current_user),
51
+ db: AsyncSession = Depends(get_db)
52
+ ):
53
+ """Get a user's public profile."""
54
+ result = await db.execute(select(User).where(User.id == user_id))
55
+ user = result.scalar_one_or_none()
56
+
57
+ if not user:
58
+ raise HTTPException(status_code=404, detail="User not found")
59
+
60
+ # Check if user allows public profile
61
+ if not user.is_public and user.id != current_user["id"]:
62
+ raise HTTPException(status_code=403, detail="This profile is private")
63
+
64
+ return UserProfileResponse(
65
+ id=user.id,
66
+ username=user.username,
67
+ email=user.email,
68
+ display_name=user.display_name,
69
+ avatar=user.avatar,
70
+ bio=user.bio,
71
+ status=user.status,
72
+ is_public=user.is_public,
73
+ created_at=user.created_at
74
+ )
75
+
76
+
77
+ @router.put("/profile", response_model=UserProfileResponse)
78
+ async def update_profile(
79
+ request: UpdateProfileRequest,
80
+ current_user: dict = Depends(get_current_user),
81
+ db: AsyncSession = Depends(get_db)
82
+ ):
83
+ """Update current user's profile."""
84
+ result = await db.execute(select(User).where(User.id == current_user["id"]))
85
+ user = result.scalar_one_or_none()
86
+
87
+ if not user:
88
+ raise HTTPException(status_code=404, detail="User not found")
89
+
90
+ # Update fields
91
+ if request.display_name is not None:
92
+ user.display_name = request.display_name
93
+ if request.bio is not None:
94
+ user.bio = request.bio
95
+ if request.is_public is not None:
96
+ user.is_public = request.is_public
97
+ if request.allow_friends is not None:
98
+ user.allow_friends = request.allow_friends
99
+
100
+ await db.commit()
101
+ await db.refresh(user)
102
+
103
+ return UserProfileResponse(
104
+ id=user.id,
105
+ username=user.username,
106
+ email=user.email,
107
+ display_name=user.display_name,
108
+ avatar=user.avatar,
109
+ bio=user.bio,
110
+ status=user.status,
111
+ is_public=user.is_public,
112
+ created_at=user.created_at
113
+ )
114
+
115
+
116
+ @router.put("/avatar")
117
+ async def update_avatar(
118
+ current_user: dict = Depends(get_current_user),
119
+ db: AsyncSession = Depends(get_db)
120
+ ):
121
+ """Update avatar (auto-generate or custom URL)."""
122
+ result = await db.execute(select(User).where(User.id == current_user["id"]))
123
+ user = result.scalar_one_or_none()
124
+
125
+ if not user:
126
+ raise HTTPException(status_code=404, detail="User not found")
127
+
128
+ # Generate new avatar based on username
129
+ user.avatar = await generate_avatar(user.id, user.username)
130
+ await db.commit()
131
+
132
+ return {"avatar": user.avatar}
133
+
134
+
135
+ @router.post("/block")
136
+ async def block_user(
137
+ request: BlockUserRequest,
138
+ current_user: dict = Depends(get_current_user),
139
+ db: AsyncSession = Depends(get_db)
140
+ ):
141
+ """Block a user (add to blocked list)."""
142
+ # In a real implementation, this would create a BlockedUser record
143
+ # For now, return success
144
+ return {"message": f"User {request.user_id} blocked"}
145
+
146
+
147
+ @router.post("/unblock")
148
+ async def unblock_user(
149
+ request: BlockUserRequest,
150
+ current_user: dict = Depends(get_current_user),
151
+ db: AsyncSession = Depends(get_db)
152
+ ):
153
+ """Unblock a user."""
154
+ return {"message": f"User {request.user_id} unblocked"}
155
+
156
+
157
+ @router.get("/search/{query}")
158
+ async def search_users(
159
+ query: str,
160
+ current_user: dict = Depends(get_current_user),
161
+ db: AsyncSession = Depends(get_db)
162
+ ):
163
+ """Search users by username or display name."""
164
+ result = await db.execute(
165
+ select(User).where(
166
+ or_(
167
+ User.username.ilike(f"%{query}%"),
168
+ User.display_name.ilike(f"%{query}%")
169
+ )
170
+ ).limit(20)
171
+ )
172
+ users = result.scalars().all()
173
+
174
+ return [
175
+ {
176
+ "id": u.id,
177
+ "username": u.username,
178
+ "display_name": u.display_name,
179
+ "avatar": u.avatar,
180
+ "status": u.status
181
+ }
182
+ for u in users
183
+ ]
backend/app/services/__init__.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Services package - business logic layer.
3
+ """
4
+ from .auth_service import AuthService
5
+ from .chat_service import ChatService
6
+ from .group_service import GroupService
7
+ from .user_service import UserService
8
+ from .file_service import FileService
9
+
10
+ __all__ = ["AuthService", "ChatService", "GroupService", "UserService", "FileService"]
backend/app/services/auth_service.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Auth service - authentication business logic.
3
+ """
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select
6
+ from typing import Optional
7
+
8
+ from ..models import User
9
+ from ..utils.security import hash_password, verify_password, create_access_token
10
+ from ..utils.avatar import generate_avatar
11
+
12
+
13
+ class AuthService:
14
+ """Authentication business logic."""
15
+
16
+ @staticmethod
17
+ async def register_user(db: AsyncSession, username: str, email: str, password: str) -> User:
18
+ """Register a new user."""
19
+ # Check existing
20
+ result = await db.execute(select(User).where(User.email == email))
21
+ if result.scalar_one_or_none():
22
+ raise ValueError("Email already registered")
23
+
24
+ result = await db.execute(select(User).where(User.username == username))
25
+ if result.scalar_one_or_none():
26
+ raise ValueError("Username taken")
27
+
28
+ # Create
29
+ user = User(
30
+ username=username,
31
+ email=email,
32
+ password_hash=hash_password(password),
33
+ display_name=username,
34
+ avatar=await generate_avatar(username, username),
35
+ status="offline"
36
+ )
37
+ db.add(user)
38
+ await db.commit()
39
+ await db.refresh(user)
40
+ return user
41
+
42
+ @staticmethod
43
+ async def login_user(db: AsyncSession, email: str, password: str) -> Optional[User]:
44
+ """Login user by email/password."""
45
+ result = await db.execute(select(User).where(User.email == email))
46
+ user = result.scalar_one_or_none()
47
+
48
+ if not user or not verify_password(password, user.password_hash):
49
+ return None
50
+
51
+ user.status = "online"
52
+ await db.commit()
53
+ return user
54
+
55
+ @staticmethod
56
+ def generate_token(user: User) -> str:
57
+ """Generate JWT token for user."""
58
+ return create_access_token({"sub": user.id, "username": user.username})
59
+
60
+ @staticmethod
61
+ async def logout_user(db: AsyncSession, user_id: str) -> bool:
62
+ """Logout user."""
63
+ result = await db.execute(select(User).where(User.id == user_id))
64
+ user = result.scalar_one_or_none()
65
+
66
+ if user:
67
+ user.status = "offline"
68
+ await db.commit()
69
+ return True
70
+ return False
backend/app/services/chat_service.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat service - message handling and queue push.
3
+ """
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select, and_, or_
6
+ from typing import Optional, List
7
+ from datetime import datetime
8
+
9
+ from ..models import Message, User
10
+ from ..utils.formatter import clean_message
11
+
12
+
13
+ class ChatService:
14
+ """Chat business logic."""
15
+
16
+ @staticmethod
17
+ async def send_message(
18
+ db: AsyncSession,
19
+ sender_id: str,
20
+ content: str,
21
+ receiver_id: Optional[str] = None,
22
+ group_id: Optional[str] = None,
23
+ message_type: str = "text"
24
+ ) -> Message:
25
+ """Send a message (queued for batch processing)."""
26
+ # Clean content
27
+ content = clean_message(content)
28
+
29
+ # Create message (initially uncompressed)
30
+ message = Message(
31
+ sender_id=sender_id,
32
+ receiver_id=receiver_id,
33
+ group_id=group_id,
34
+ content=content,
35
+ type=message_type,
36
+ compressed=False
37
+ )
38
+
39
+ db.add(message)
40
+ await db.commit()
41
+ await db.refresh(message)
42
+
43
+ # TODO: Push to async queue for batch processing
44
+ # from ..system.queue import message_queue
45
+ # await message_queue.push(message)
46
+
47
+ return message
48
+
49
+ @staticmethod
50
+ async def get_private_messages(
51
+ db: AsyncSession,
52
+ user_id: str,
53
+ other_user_id: str,
54
+ limit: int = 50,
55
+ offset: int = 0
56
+ ) -> List[Message]:
57
+ """Get private messages between two users."""
58
+ result = await db.execute(
59
+ select(Message).where(
60
+ and_(
61
+ or_(
62
+ and_(Message.sender_id == user_id, Message.receiver_id == other_user_id),
63
+ and_(Message.sender_id == other_user_id, Message.receiver_id == user_id)
64
+ ),
65
+ Message.group_id.is_(None),
66
+ Message.is_deleted == False
67
+ )
68
+ ).order_by(Message.created_at.desc()).limit(limit).offset(offset)
69
+ )
70
+ return result.scalars().all()
71
+
72
+ @staticmethod
73
+ async def delete_message(db: AsyncSession, message_id: str, user_id: str) -> bool:
74
+ """Delete a message (soft delete)."""
75
+ result = await db.execute(
76
+ select(Message).where(
77
+ and_(Message.id == message_id, Message.sender_id == user_id)
78
+ )
79
+ )
80
+ message = result.scalar_one_or_none()
81
+
82
+ if not message:
83
+ return False
84
+
85
+ message.is_deleted = True
86
+ message.deleted_at = datetime.utcnow()
87
+ await db.commit()
88
+ return True
89
+
90
+ @staticmethod
91
+ async def get_conversations(db: AsyncSession, user_id: str) -> List[dict]:
92
+ """Get list of conversations for a user."""
93
+ # Get recent private conversations
94
+ result = await db.execute(
95
+ select(Message).where(
96
+ or_(
97
+ Message.sender_id == user_id,
98
+ Message.receiver_id == user_id
99
+ )
100
+ ).order_by(Message.created_at.desc()).limit(100)
101
+ )
102
+ messages = result.scalars().all()
103
+
104
+ # Group by conversation partner
105
+ conversations = {}
106
+ for msg in messages:
107
+ partner_id = msg.receiver_id if msg.sender_id == user_id else msg.sender_id
108
+ if partner_id and partner_id not in conversations:
109
+ conversations[partner_id] = {
110
+ "user_id": partner_id,
111
+ "last_message": msg.content[:50],
112
+ "last_time": msg.created_at
113
+ }
114
+
115
+ return list(conversations.values())[:20]
backend/app/services/file_service.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File service - GitHub upload logic.
3
+ """
4
+ from typing import Optional
5
+ from pathlib import Path
6
+ import asyncio
7
+ from github import Github
8
+ from ..config import settings
9
+
10
+
11
+ class FileService:
12
+ """File storage and GitHub upload logic."""
13
+
14
+ def __init__(self):
15
+ self.github = None
16
+ if settings.GITHUB_TOKEN:
17
+ self.github = Github(settings.GITHUB_TOKEN)
18
+
19
+ async def upload_to_github(
20
+ self,
21
+ file_path: Path,
22
+ repo_path: str,
23
+ branch: str = "main"
24
+ ) -> Optional[str]:
25
+ """
26
+ Upload a file to GitHub repository.
27
+
28
+ Args:
29
+ file_path: Local file path to upload
30
+ repo_path: GitHub repo (e.g., "username/repo")
31
+ branch: Target branch
32
+
33
+ Returns:
34
+ GitHub file URL or None on failure
35
+ """
36
+ if not self.github:
37
+ print("GitHub token not configured")
38
+ return None
39
+
40
+ try:
41
+ repo = self.github.get_repo(repo_path)
42
+
43
+ with open(file_path, "rb") as f:
44
+ content = f.read()
45
+
46
+ # Get file path in repo
47
+ repo_path_str = str(file_path).replace("./", "")
48
+
49
+ # Check if file exists
50
+ try:
51
+ existing_file = repo.get_contents(repo_path_str, ref=branch)
52
+ # Update existing file
53
+ repo.update_file(
54
+ path=repo_path_str,
55
+ message=f"Update {file_path.name}",
56
+ content=content,
57
+ sha=existing_file.sha,
58
+ branch=branch
59
+ )
60
+ except Exception:
61
+ # Create new file
62
+ repo.create_file(
63
+ path=repo_path_str,
64
+ message=f"Upload {file_path.name}",
65
+ content=content,
66
+ branch=branch
67
+ )
68
+
69
+ return f"https://github.com/{repo_path}/blob/{branch}/{repo_path_str}"
70
+
71
+ except Exception as e:
72
+ print(f"GitHub upload error: {e}")
73
+ return None
74
+
75
+ async def download_from_github(
76
+ self,
77
+ repo_path: str,
78
+ file_path: str,
79
+ branch: str = "main"
80
+ ) -> Optional[bytes]:
81
+ """Download a file from GitHub."""
82
+ if not self.github:
83
+ return None
84
+
85
+ try:
86
+ repo = self.github.get_repo(repo_path)
87
+ contents = repo.get_contents(file_path, ref=branch)
88
+ return contents.decoded_content
89
+ except Exception as e:
90
+ print(f"GitHub download error: {e}")
91
+ return None
92
+
93
+ def get_local_storage_path(self, user_id: str, project_id: str, date_path: str) -> Path:
94
+ """Get local storage path for a user's project data."""
95
+ base = Path(settings.STORAGE_PATH) / "users" / user_id / "projects" / project_id / date_path
96
+ base.mkdir(parents=True, exist_ok=True)
97
+ return base
98
+
99
+ async def save_batch_file(
100
+ self,
101
+ user_id: str,
102
+ project_id: str,
103
+ filename: str,
104
+ content: bytes
105
+ ) -> Path:
106
+ """Save a batch file to local storage."""
107
+ from ..utils.time import get_date_path
108
+ date_path = get_date_path()
109
+ storage_dir = self.get_local_storage_path(user_id, project_id, date_path)
110
+
111
+ file_path = storage_dir / filename
112
+ with open(file_path, "wb") as f:
113
+ f.write(content)
114
+
115
+ return file_path
116
+
117
+ async def upload_batch(
118
+ self,
119
+ user_id: str,
120
+ project_id: str,
121
+ batch_dir: Path
122
+ ) -> bool:
123
+ """Upload all files in a batch directory to GitHub."""
124
+ if not settings.GITHUB_REPO:
125
+ return False
126
+
127
+ success = True
128
+ for file_path in batch_dir.glob("*"):
129
+ if file_path.is_file():
130
+ result = await self.upload_to_github(
131
+ file_path,
132
+ settings.GITHUB_REPO
133
+ )
134
+ if not result:
135
+ success = False
136
+
137
+ return success
backend/app/services/group_service.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Group service - group management business logic.
3
+ """
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select, and_
6
+ from typing import Optional, List
7
+
8
+ from ..models import Group, GroupMember, User
9
+
10
+
11
+ class GroupService:
12
+ """Group business logic."""
13
+
14
+ @staticmethod
15
+ async def create_group(
16
+ db: AsyncSession,
17
+ name: str,
18
+ owner_id: str,
19
+ description: Optional[str] = None,
20
+ is_private: bool = False
21
+ ) -> Group:
22
+ """Create a new group."""
23
+ group = Group(
24
+ name=name,
25
+ owner_id=owner_id,
26
+ description=description,
27
+ is_private=is_private
28
+ )
29
+ db.add(group)
30
+ await db.commit()
31
+ await db.refresh(group)
32
+
33
+ # Add owner as admin
34
+ member = GroupMember(
35
+ group_id=group.id,
36
+ user_id=owner_id,
37
+ role="admin"
38
+ )
39
+ db.add(member)
40
+ await db.commit()
41
+
42
+ return group
43
+
44
+ @staticmethod
45
+ async def get_group(db: AsyncSession, group_id: str) -> Optional[Group]:
46
+ """Get group by ID."""
47
+ result = await db.execute(select(Group).where(Group.id == group_id))
48
+ return result.scalar_one_or_none()
49
+
50
+ @staticmethod
51
+ async def add_member(
52
+ db: AsyncSession,
53
+ group_id: str,
54
+ user_id: str,
55
+ role: str = "member"
56
+ ) -> GroupMember:
57
+ """Add a member to group."""
58
+ # Check not already member
59
+ result = await db.execute(
60
+ select(GroupMember).where(
61
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
62
+ )
63
+ )
64
+ if result.scalar_one_or_none():
65
+ raise ValueError("Already a member")
66
+
67
+ member = GroupMember(
68
+ group_id=group_id,
69
+ user_id=user_id,
70
+ role=role
71
+ )
72
+ db.add(member)
73
+ await db.commit()
74
+ await db.refresh(member)
75
+ return member
76
+
77
+ @staticmethod
78
+ async def remove_member(db: AsyncSession, group_id: str, user_id: str) -> bool:
79
+ """Remove a member from group."""
80
+ result = await db.execute(
81
+ select(GroupMember).where(
82
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
83
+ )
84
+ )
85
+ member = result.scalar_one_or_none()
86
+
87
+ if not member:
88
+ return False
89
+
90
+ await db.delete(member)
91
+ await db.commit()
92
+ return True
93
+
94
+ @staticmethod
95
+ async def get_members(db: AsyncSession, group_id: str) -> List[GroupMember]:
96
+ """Get all group members."""
97
+ result = await db.execute(
98
+ select(GroupMember).where(GroupMember.group_id == group_id)
99
+ )
100
+ return result.scalars().all()
101
+
102
+ @staticmethod
103
+ async def is_admin(db: AsyncSession, group_id: str, user_id: str) -> bool:
104
+ """Check if user is admin or co_admin."""
105
+ result = await db.execute(
106
+ select(GroupMember).where(
107
+ and_(
108
+ GroupMember.group_id == group_id,
109
+ GroupMember.user_id == user_id,
110
+ GroupMember.role.in_(["admin", "co_admin"])
111
+ )
112
+ )
113
+ )
114
+ return result.scalar_one_or_none() is not None
115
+
116
+ @staticmethod
117
+ async def is_owner(db: AsyncSession, group_id: str, user_id: str) -> bool:
118
+ """Check if user is owner."""
119
+ result = await db.execute(select(Group).where(Group.id == group_id))
120
+ group = result.scalar_one_or_none()
121
+ return group and group.owner_id == user_id
122
+
123
+ @staticmethod
124
+ async def update_member_role(
125
+ db: AsyncSession,
126
+ group_id: str,
127
+ user_id: str,
128
+ new_role: str
129
+ ) -> bool:
130
+ """Update member role (admin only)."""
131
+ result = await db.execute(
132
+ select(GroupMember).where(
133
+ and_(GroupMember.group_id == group_id, GroupMember.user_id == user_id)
134
+ )
135
+ )
136
+ member = result.scalar_one_or_none()
137
+
138
+ if not member:
139
+ return False
140
+
141
+ member.role = new_role
142
+ await db.commit()
143
+ return True
backend/app/services/user_service.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ User service - user profile management.
3
+ """
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlalchemy import select, and_, or_
6
+ from typing import Optional, List
7
+
8
+ from ..models import User, Friend
9
+ from ..utils.avatar import generate_avatar
10
+
11
+
12
+ class UserService:
13
+ """User business logic."""
14
+
15
+ @staticmethod
16
+ async def get_user(db: AsyncSession, user_id: str) -> Optional[User]:
17
+ """Get user by ID."""
18
+ result = await db.execute(select(User).where(User.id == user_id))
19
+ return result.scalar_one_or_none()
20
+
21
+ @staticmethod
22
+ async def get_by_email(db: AsyncSession, email: str) -> Optional[User]:
23
+ """Get user by email."""
24
+ result = await db.execute(select(User).where(User.email == email))
25
+ return result.scalar_one_or_none()
26
+
27
+ @staticmethod
28
+ async def get_by_username(db: AsyncSession, username: str) -> Optional[User]:
29
+ """Get user by username."""
30
+ result = await db.execute(select(User).where(User.username == username))
31
+ return result.scalar_one_or_none()
32
+
33
+ @staticmethod
34
+ async def update_profile(
35
+ db: AsyncSession,
36
+ user_id: str,
37
+ display_name: Optional[str] = None,
38
+ bio: Optional[str] = None,
39
+ is_public: Optional[bool] = None
40
+ ) -> Optional[User]:
41
+ """Update user profile."""
42
+ result = await db.execute(select(User).where(User.id == user_id))
43
+ user = result.scalar_one_or_none()
44
+
45
+ if not user:
46
+ return None
47
+
48
+ if display_name is not None:
49
+ user.display_name = display_name
50
+ if bio is not None:
51
+ user.bio = bio
52
+ if is_public is not None:
53
+ user.is_public = is_public
54
+
55
+ await db.commit()
56
+ await db.refresh(user)
57
+ return user
58
+
59
+ @staticmethod
60
+ async def update_avatar(db: AsyncSession, user_id: str) -> Optional[str]:
61
+ """Generate and update avatar."""
62
+ result = await db.execute(select(User).where(User.id == user_id))
63
+ user = result.scalar_one_or_none()
64
+
65
+ if not user:
66
+ return None
67
+
68
+ user.avatar = await generate_avatar(user.id, user.username)
69
+ await db.commit()
70
+ return user.avatar
71
+
72
+ @staticmethod
73
+ async def search_users(db: AsyncSession, query: str, limit: int = 20) -> List[User]:
74
+ """Search users by username or display name."""
75
+ result = await db.execute(
76
+ select(User).where(
77
+ or_(
78
+ User.username.ilike(f"%{query}%"),
79
+ User.display_name.ilike(f"%{query}%")
80
+ )
81
+ ).limit(limit)
82
+ )
83
+ return result.scalars().all()
84
+
85
+ @staticmethod
86
+ async def add_friend(db: AsyncSession, user_id: str, friend_id: str) -> Friend:
87
+ """Send friend request."""
88
+ # Check not already friends
89
+ result = await db.execute(
90
+ select(Friend).where(
91
+ or_(
92
+ and_(Friend.user_id == user_id, Friend.friend_id == friend_id),
93
+ and_(Friend.user_id == friend_id, Friend.friend_id == user_id)
94
+ )
95
+ )
96
+ )
97
+ if result.scalar_one_or_none():
98
+ raise ValueError("Already friends or request pending")
99
+
100
+ friend = Friend(
101
+ user_id=user_id,
102
+ friend_id=friend_id,
103
+ status="pending"
104
+ )
105
+ db.add(friend)
106
+ await db.commit()
107
+ await db.refresh(friend)
108
+ return friend
109
+
110
+ @staticmethod
111
+ async def accept_friend(db: AsyncSession, user_id: str, friend_id: str) -> bool:
112
+ """Accept friend request."""
113
+ result = await db.execute(
114
+ select(Friend).where(
115
+ and_(Friend.user_id == friend_id, Friend.friend_id == user_id, Friend.status == "pending")
116
+ )
117
+ )
118
+ friend = result.scalar_one_or_none()
119
+
120
+ if not friend:
121
+ return False
122
+
123
+ friend.status = "accepted"
124
+ await db.commit()
125
+ return True
126
+
127
+ @staticmethod
128
+ async def get_friends(db: AsyncSession, user_id: str) -> List[User]:
129
+ """Get user's friends."""
130
+ result = await db.execute(
131
+ select(Friend).where(
132
+ or_(
133
+ and_(Friend.user_id == user_id, Friend.status == "accepted"),
134
+ and_(Friend.friend_id == user_id, Friend.status == "accepted")
135
+ )
136
+ )
137
+ )
138
+ friends = result.scalars().all()
139
+
140
+ friend_ids = []
141
+ for f in friends:
142
+ if f.user_id == user_id:
143
+ friend_ids.append(f.friend_id)
144
+ else:
145
+ friend_ids.append(f.user_id)
146
+
147
+ result = await db.execute(select(User).where(User.id.in_(friend_ids)))
148
+ return result.scalars().all()
backend/app/sockets/ws.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WebSocket handler - real-time notifications (optional).
3
+ """
4
+ from fastapi import WebSocket, WebSocketDisconnect
5
+ from typing import List, Dict
6
+ import json
7
+ import asyncio
8
+
9
+
10
+ class ConnectionManager:
11
+ """Manage WebSocket connections."""
12
+
13
+ def __init__(self):
14
+ # user_id -> WebSocket
15
+ self.active_connections: Dict[str, WebSocket] = {}
16
+
17
+ async def connect(self, user_id: str, websocket: WebSocket):
18
+ """Accept and store a WebSocket connection."""
19
+ await websocket.accept()
20
+ self.active_connections[user_id] = websocket
21
+
22
+ def disconnect(self, user_id: str):
23
+ """Remove a connection."""
24
+ if user_id in self.active_connections:
25
+ del self.active_connections[user_id]
26
+
27
+ async def send_personal(self, user_id: str, message: dict):
28
+ """Send message to specific user."""
29
+ if user_id in self.active_connections:
30
+ try:
31
+ await self.active_connections[user_id].send_text(json.dumps(message))
32
+ except Exception:
33
+ self.disconnect(user_id)
34
+
35
+ async def broadcast(self, message: dict):
36
+ """Broadcast to all connected users."""
37
+ for ws in list(self.active_connections.values()):
38
+ try:
39
+ await ws.send_text(json.dumps(message))
40
+ except Exception:
41
+ pass
42
+
43
+
44
+ manager = ConnectionManager()
45
+
46
+
47
+ async def websocket_endpoint(websocket: WebSocket, user_id: str):
48
+ """
49
+ WebSocket endpoint for real-time notifications.
50
+
51
+ Events:
52
+ - new_message: New message received
53
+ - message_deleted: Message was deleted
54
+ - friend_request: New friend request
55
+ - group_invite: Group invitation
56
+ """
57
+ await manager.connect(user_id, websocket)
58
+ try:
59
+ while True:
60
+ # Keep connection alive, listen for client messages
61
+ data = await websocket.receive_text()
62
+ # Could handle client acknowledgments here
63
+ pass
64
+ except WebSocketDisconnect:
65
+ manager.disconnect(user_id)
66
+
67
+
68
+ async def notify_new_message(receiver_id: str, message: dict):
69
+ """Notify user of new message."""
70
+ await manager.send_personal(receiver_id, {
71
+ "type": "new_message",
72
+ "data": message
73
+ })
74
+
75
+
76
+ async def notify_friend_request(user_id: str, request: dict):
77
+ """Notify user of friend request."""
78
+ await manager.send_personal(user_id, {
79
+ "type": "friend_request",
80
+ "data": request
81
+ })
82
+
83
+
84
+ async def notify_group_invite(user_id: str, invite: dict):
85
+ """Notify user of group invite."""
86
+ await manager.send_personal(user_id, {
87
+ "type": "group_invite",
88
+ "data": invite
89
+ })
backend/app/system/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System package - async batch processing core.
3
+ """
4
+ from .controller import SystemController, controller
5
+ from .queue import MessageQueue, message_queue
6
+ from .worker import Worker, worker
7
+ from .batch import BatchProcessor, batch_processor
8
+ from .scheduler import BatchScheduler, batch_scheduler
9
+ from .monitor import SystemMonitor, system_monitor
10
+
11
+ __all__ = [
12
+ "SystemController", "controller",
13
+ "MessageQueue", "message_queue",
14
+ "Worker", "worker",
15
+ "BatchProcessor", "batch_processor",
16
+ "BatchScheduler", "batch_scheduler",
17
+ "SystemMonitor", "system_monitor"
18
+ ]
backend/app/system/batch.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Batch builder - groups messages and compresses them.
3
+ """
4
+ import json
5
+ from typing import List, Optional
6
+ from pathlib import Path
7
+ from datetime import datetime
8
+ import uuid
9
+
10
+ from ..config import settings
11
+ from ..utils.compression import compress_zstd
12
+ from ..utils.time import get_date_path, utc_timestamp
13
+ from ..services.file_service import FileService
14
+
15
+
16
+ class BatchProcessor:
17
+ """
18
+ Batch processor - groups messages by user/project/time and compresses.
19
+ """
20
+
21
+ def __init__(self, max_size_mb: int = 10):
22
+ self._max_size_bytes = max_size_mb * 1024 * 1024
23
+ self._current_batch: List[dict] = []
24
+ self._current_size = 0
25
+ self._batch_id = str(uuid.uuid4())[:8]
26
+ self._file_service = FileService()
27
+
28
+ async def add_message(self, message: dict) -> bool:
29
+ """
30
+ Add a message to the current batch.
31
+
32
+ Args:
33
+ message: Cleaned message dict
34
+
35
+ Returns:
36
+ True if added, False if batch is full
37
+ """
38
+ msg_size = len(json.dumps(message).encode())
39
+
40
+ # Check if adding would exceed limit
41
+ if self._current_size + msg_size > self._max_size_bytes:
42
+ # Compress current batch
43
+ await self.compress_and_save()
44
+ self._start_new_batch()
45
+
46
+ self._current_batch.append(message)
47
+ self._current_size += msg_size
48
+ return True
49
+
50
+ def is_ready(self) -> bool:
51
+ """Check if batch is ready for compression."""
52
+ # Ready if has messages and reaches size threshold (1MB)
53
+ return len(self._current_batch) > 0 and self._current_size > 1024 * 1024
54
+
55
+ async def compress_and_save(self) -> Optional[Path]:
56
+ """
57
+ Compress current batch and save to storage.
58
+
59
+ Returns:
60
+ Path to saved file or None on failure
61
+ """
62
+ if not self._current_batch:
63
+ return None
64
+
65
+ try:
66
+ # Prepare batch data
67
+ batch_data = {
68
+ "batch_id": self._batch_id,
69
+ "created_at": datetime.utcnow().isoformat(),
70
+ "message_count": len(self._current_batch),
71
+ "messages": self._current_batch
72
+ }
73
+
74
+ # Serialize and compress
75
+ json_data = json.dumps(batch_data, ensure_ascii=False)
76
+ compressed = compress_zstd(json_data.encode())
77
+
78
+ # Generate filename
79
+ timestamp = utc_timestamp()
80
+ filename = f"{timestamp}_{self._batch_id}.zst"
81
+
82
+ # Save to local storage
83
+ # In production, use user_id from messages
84
+ user_id = self._current_batch[0].get("sender_id", "default")
85
+ project_id = "chat_messages"
86
+ date_path = get_date_path()
87
+
88
+ storage_path = self._file_service.get_local_storage_path(user_id, project_id, date_path)
89
+ file_path = storage_path / filename
90
+
91
+ with open(file_path, "wb") as f:
92
+ f.write(compressed)
93
+
94
+ # Create index file
95
+ await self._save_index(storage_path, filename, batch_data)
96
+
97
+ # Optionally upload to GitHub
98
+ if settings.GITHUB_REPO:
99
+ await self._file_service.upload_to_github(file_path, settings.GITHUB_REPO)
100
+
101
+ self._start_new_batch()
102
+ return file_path
103
+
104
+ except Exception as e:
105
+ print(f"Batch save error: {e}")
106
+ return None
107
+
108
+ async def _save_index(self, storage_path: Path, filename: str, batch_data: dict):
109
+ """Save or update index.json for the directory."""
110
+ index_path = storage_path / "index.json"
111
+
112
+ index_data = []
113
+ if index_path.exists():
114
+ with open(index_path, "r") as f:
115
+ index_data = json.load(f)
116
+
117
+ index_data.append({
118
+ "filename": filename,
119
+ "batch_id": self._batch_id,
120
+ "created_at": batch_data["created_at"],
121
+ "message_count": batch_data["message_count"],
122
+ "compressed_size": len(compress_zstd(json.dumps(batch_data).encode()))
123
+ })
124
+
125
+ with open(index_path, "w") as f:
126
+ json.dump(index_data, f, indent=2)
127
+
128
+ def _start_new_batch(self):
129
+ """Start a new batch."""
130
+ self._current_batch = []
131
+ self._current_size = 0
132
+ self._batch_id = str(uuid.uuid4())[:8]
133
+
134
+ async def force_compress(self) -> Optional[Path]:
135
+ """Force compress current batch even if not full."""
136
+ if self._current_batch:
137
+ return await self.compress_and_save()
138
+ return None
139
+
140
+
141
+ # Global batch processor
142
+ batch_processor = BatchProcessor()
backend/app/system/controller.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ System controller - main entry point for async batch processing.
3
+ Receives messages and routes to appropriate handlers.
4
+ """
5
+ from typing import Optional
6
+ from ..models import Message
7
+ from .queue import MessageQueue
8
+ from .batch import BatchProcessor
9
+ from .scheduler import BatchScheduler
10
+ from .monitor import SystemMonitor
11
+
12
+
13
+ class SystemController:
14
+ """
15
+ Main controller for async message processing.
16
+ Coordinates queue, batch, scheduler, and monitoring.
17
+ """
18
+
19
+ def __init__(self):
20
+ self.queue = MessageQueue()
21
+ self.batch_processor = BatchProcessor()
22
+ self.scheduler = BatchScheduler()
23
+ self.monitor = SystemMonitor()
24
+
25
+ async def receive_message(self, message: Message) -> bool:
26
+ """
27
+ Receive a message and add it to the processing queue.
28
+
29
+ Args:
30
+ message: Message to process
31
+
32
+ Returns:
33
+ True if queued successfully
34
+ """
35
+ try:
36
+ await self.queue.enqueue(message)
37
+ self.monitor.increment_queue_size()
38
+ return True
39
+ except Exception as e:
40
+ self.monitor.log_error(f"Failed to queue message: {e}")
41
+ return False
42
+
43
+ async def trigger_batch(
44
+ self,
45
+ user_id: Optional[str] = None,
46
+ group_id: Optional[str] = None
47
+ ) -> dict:
48
+ """
49
+ Manually trigger batch processing.
50
+
51
+ Args:
52
+ user_id: Optional specific user to process
53
+ group_id: Optional specific group to process
54
+
55
+ Returns:
56
+ Status dict with processing results
57
+ """
58
+ return await self.batch_processor.process_batch(user_id, group_id)
59
+
60
+ async def get_status(self) -> dict:
61
+ """Get system status."""
62
+ return {
63
+ "queue_size": self.queue.size(),
64
+ "monitor": self.monitor.get_stats(),
65
+ "scheduler_next": self.scheduler.get_next_run()
66
+ }
67
+
68
+ async def start_scheduler(self):
69
+ """Start the batch scheduler."""
70
+ await self.scheduler.start()
71
+
72
+ async def stop_scheduler(self):
73
+ """Stop the batch scheduler."""
74
+ await self.scheduler.stop()
75
+
76
+
77
+ # Global controller instance
78
+ controller = SystemController()
backend/app/system/monitor.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Monitor - logs, queue size, errors tracking.
3
+ """
4
+ from typing import Dict, List
5
+ from datetime import datetime
6
+ from collections import deque
7
+
8
+
9
+ class SystemMonitor:
10
+ """
11
+ System monitor - tracks metrics, logs, and errors.
12
+ """
13
+
14
+ def __init__(self, max_log_entries: int = 1000):
15
+ self._queue_size = 0
16
+ self._processed_count = 0
17
+ self._batch_count = 0
18
+ self._error_count = 0
19
+ self._errors: deque = deque(maxlen=100)
20
+ self._max_log = max_log_entries
21
+
22
+ def increment_queue_size(self, delta: int = 1):
23
+ """Increment queue size counter."""
24
+ self._queue_size += delta
25
+
26
+ def decrement_queue_size(self, delta: int = 1):
27
+ """Decrement queue size counter."""
28
+ self._queue_size = max(0, self._queue_size - delta)
29
+
30
+ def increment_processed(self, delta: int = 1):
31
+ """Increment processed messages counter."""
32
+ self._processed_count += delta
33
+
34
+ def increment_batches(self, delta: int = 1):
35
+ """Increment batch count."""
36
+ self._batch_count += delta
37
+
38
+ def increment_errors(self, delta: int = 1):
39
+ """Increment error counter."""
40
+ self._error_count += delta
41
+
42
+ def log_error(self, message: str):
43
+ """Log an error message."""
44
+ self._errors.append({
45
+ "timestamp": datetime.utcnow().isoformat(),
46
+ "message": message
47
+ })
48
+ self.increment_errors()
49
+
50
+ def get_stats(self) -> Dict:
51
+ """Get current statistics."""
52
+ return {
53
+ "queue_size": self._queue_size,
54
+ "processed_total": self._processed_count,
55
+ "batches_processed": self._batch_count,
56
+ "error_count": self._error_count,
57
+ "error_rate": self._error_count / max(1, self._processed_count)
58
+ }
59
+
60
+ def get_recent_errors(self, limit: int = 10) -> List[Dict]:
61
+ """Get recent error entries."""
62
+ return list(self._errors)[-limit:]
63
+
64
+ def reset(self):
65
+ """Reset all counters."""
66
+ self._queue_size = 0
67
+ self._processed_count = 0
68
+ self._batch_count = 0
69
+ self._error_count = 0
70
+ self._errors.clear()
71
+
72
+
73
+ # Global monitor instance
74
+ system_monitor = SystemMonitor()
backend/app/system/queue.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Message queue - buffer system for async message processing.
3
+ """
4
+ from typing import Optional, List
5
+ from collections import deque
6
+ import asyncio
7
+ import json
8
+ from datetime import datetime
9
+
10
+
11
+ class MessageQueue:
12
+ """
13
+ In-memory message queue with async support.
14
+ In production, use Redis for distributed processing.
15
+ """
16
+
17
+ def __init__(self, max_size: int = 10000):
18
+ self._queue = deque()
19
+ self._max_size = max_size
20
+ self._lock = asyncio.Lock()
21
+
22
+ async def enqueue(self, message) -> bool:
23
+ """
24
+ Add a message to the queue.
25
+
26
+ Args:
27
+ message: Message object to queue
28
+
29
+ Returns:
30
+ True if enqueued successfully
31
+ """
32
+ async with self._lock:
33
+ if len(self._queue) >= self._max_size:
34
+ return False
35
+
36
+ # Serialize message for storage
37
+ msg_data = {
38
+ "id": message.id,
39
+ "sender_id": message.sender_id,
40
+ "receiver_id": message.receiver_id,
41
+ "group_id": message.group_id,
42
+ "content": message.content,
43
+ "type": message.type,
44
+ "created_at": message.created_at.isoformat() if message.created_at else None
45
+ }
46
+ self._queue.append(msg_data)
47
+ return True
48
+
49
+ async def dequeue(self) -> Optional[dict]:
50
+ """
51
+ Remove and return the oldest message from queue.
52
+
53
+ Returns:
54
+ Message data dict or None if empty
55
+ """
56
+ async with self._lock:
57
+ if not self._queue:
58
+ return None
59
+ return self._queue.popleft()
60
+
61
+ async def peek(self) -> Optional[dict]:
62
+ """View the oldest message without removing it."""
63
+ async with self._lock:
64
+ if not self._queue:
65
+ return None
66
+ return self._queue[0]
67
+
68
+ async def get_batch(self, size: int = 100) -> List[dict]:
69
+ """Get multiple messages at once for batch processing."""
70
+ async with self._lock:
71
+ batch = []
72
+ for _ in range(min(size, len(self._queue))):
73
+ if self._queue:
74
+ batch.append(self._queue.popleft())
75
+ return batch
76
+
77
+ async def clear(self):
78
+ """Clear all messages from queue."""
79
+ async with self._lock:
80
+ self._queue.clear()
81
+
82
+ def size(self) -> int:
83
+ """Get current queue size (non-async)."""
84
+ return len(self._queue)
85
+
86
+ async def is_empty(self) -> bool:
87
+ """Check if queue is empty."""
88
+ async with self._lock:
89
+ return len(self._queue) == 0
90
+
91
+
92
+ # Global queue instance
93
+ message_queue = MessageQueue()
backend/app/system/scheduler.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Scheduler - time-based trigger for batch processing (daily/hourly).
3
+ """
4
+ import asyncio
5
+ from datetime import datetime, timedelta
6
+ from typing import Optional, Callable
7
+ from ..config import settings
8
+
9
+
10
+ class BatchScheduler:
11
+ """
12
+ Scheduler for automatic batch processing.
13
+ Triggers compression at configured intervals.
14
+ """
15
+
16
+ def __init__(self):
17
+ self._running = False
18
+ self._task: Optional[asyncio.Task] = None
19
+ self._interval_minutes = settings.BATCH_INTERVAL_MINUTES
20
+ self._next_run: Optional[datetime] = None
21
+ self._last_run: Optional[datetime] = None
22
+
23
+ async def start(self):
24
+ """Start the scheduler."""
25
+ self._running = True
26
+ self._calculate_next_run()
27
+ self._task = asyncio.create_task(self._run_loop())
28
+
29
+ async def stop(self):
30
+ """Stop the scheduler."""
31
+ self._running = False
32
+ if self._task:
33
+ self._task.cancel()
34
+ try:
35
+ await self._task
36
+ except asyncio.CancelledError:
37
+ pass
38
+
39
+ async def _run_loop(self):
40
+ """Main scheduler loop."""
41
+ while self._running:
42
+ try:
43
+ if self._should_run():
44
+ await self._trigger_batch()
45
+ except Exception as e:
46
+ print(f"Scheduler error: {e}")
47
+
48
+ # Check every minute
49
+ await asyncio.sleep(60)
50
+
51
+ def _should_run(self) -> bool:
52
+ """Check if it's time to run."""
53
+ if not self._next_run:
54
+ return False
55
+ return datetime.utcnow() >= self._next_run
56
+
57
+ def _calculate_next_run(self):
58
+ """Calculate next run time."""
59
+ self._next_run = datetime.utcnow() + timedelta(minutes=self._interval_minutes)
60
+
61
+ async def _trigger_batch(self):
62
+ """Trigger batch processing."""
63
+ from .batch import batch_processor
64
+ from .monitor import system_monitor
65
+
66
+ try:
67
+ await batch_processor.force_compress()
68
+ self._last_run = datetime.utcnow()
69
+ self._calculate_next_run()
70
+ system_monitor.increment_batches()
71
+ except Exception as e:
72
+ print(f"Scheduled batch error: {e}")
73
+
74
+ def get_next_run(self) -> Optional[datetime]:
75
+ """Get next scheduled run time."""
76
+ return self._next_run
77
+
78
+ def get_last_run(self) -> Optional[datetime]:
79
+ """Get last run time."""
80
+ return self._last_run
81
+
82
+ async def run_now(self):
83
+ """Manually trigger a batch immediately."""
84
+ await self._trigger_batch()
85
+
86
+
87
+ # Global scheduler
88
+ batch_scheduler = BatchScheduler()
backend/app/system/worker.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Worker - process messages from queue (clean, group, compress).
3
+ """
4
+ import asyncio
5
+ from typing import Optional, List
6
+ from datetime import datetime
7
+
8
+ from .queue import message_queue
9
+ from .batch import BatchProcessor
10
+ from .monitor import SystemMonitor
11
+
12
+
13
+ class Worker:
14
+ """
15
+ Background worker that processes messages from queue.
16
+ Handles cleaning, grouping, and preparing for compression.
17
+ """
18
+
19
+ def __init__(self):
20
+ self.batch_processor = BatchProcessor()
21
+ self.monitor = SystemMonitor()
22
+ self._running = False
23
+ self._task: Optional[asyncio.Task] = None
24
+
25
+ async def start(self, interval_seconds: int = 5):
26
+ """
27
+ Start the worker process.
28
+
29
+ Args:
30
+ interval_seconds: How often to check queue
31
+ """
32
+ self._running = True
33
+ self._task = asyncio.create_task(self._run_loop(interval_seconds))
34
+
35
+ async def stop(self):
36
+ """Stop the worker."""
37
+ self._running = False
38
+ if self._task:
39
+ self._task.cancel()
40
+ try:
41
+ await self._task
42
+ except asyncio.CancelledError:
43
+ pass
44
+
45
+ async def _run_loop(self, interval: int):
46
+ """Main worker loop."""
47
+ while self._running:
48
+ try:
49
+ await self._process_queue()
50
+ except Exception as e:
51
+ self.monitor.log_error(f"Worker error: {e}")
52
+
53
+ await asyncio.sleep(interval)
54
+
55
+ async def _process_queue(self):
56
+ """Process pending messages in queue."""
57
+ # Get batch of messages
58
+ messages = await message_queue.get_batch(size=100)
59
+
60
+ if not messages:
61
+ return
62
+
63
+ # Process each message
64
+ for msg_data in messages:
65
+ try:
66
+ # Clean and normalize message
67
+ cleaned = await self._clean_message(msg_data)
68
+
69
+ # Add to batch processor
70
+ await self.batch_processor.add_message(cleaned)
71
+
72
+ self.monitor.increment_processed()
73
+ except Exception as e:
74
+ self.monitor.log_error(f"Message processing error: {e}")
75
+
76
+ # Check if batch is ready to compress
77
+ await self._check_batch_ready()
78
+
79
+ async def _clean_message(self, msg_data: dict) -> dict:
80
+ """Clean and normalize message data."""
81
+ # In production, use formatter.clean_message
82
+ content = msg_data.get("content", "")
83
+
84
+ # Basic cleaning
85
+ cleaned_content = content.strip()
86
+
87
+ return {
88
+ **msg_data,
89
+ "content": cleaned_content,
90
+ "processed_at": datetime.utcnow().isoformat()
91
+ }
92
+
93
+ async def _check_batch_ready(self):
94
+ """Check if batch is ready for compression."""
95
+ if self.batch_processor.is_ready():
96
+ await self.batch_processor.compress_and_save()
97
+
98
+
99
+ # Global worker instance
100
+ worker = Worker()
backend/app/utils/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utils package - helper utilities.
3
+ """
4
+ from .security import hash_password, verify_password, create_access_token, decode_access_token
5
+ from .avatar import generate_avatar, get_avatar_initials
6
+ from .formatter import clean_message, normalize_content
7
+ from .time import utc_now, utc_timestamp, format_date
8
+ from .compression import compress_zstd, decompress_zstd
9
+
10
+ __all__ = [
11
+ "hash_password", "verify_password", "create_access_token", "decode_access_token",
12
+ "generate_avatar", "get_avatar_initials",
13
+ "clean_message", "normalize_content",
14
+ "utc_now", "utc_timestamp", "format_date",
15
+ "compress_zstd", "decompress_zstd"
16
+ ]
backend/app/utils/avatar.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Avatar utilities - auto-generate avatars for users.
3
+ """
4
+ import hashlib
5
+ import base64
6
+ from typing import Optional
7
+ from io import BytesIO
8
+ import json
9
+
10
+
11
+ def generate_avatar_seed(user_id: str) -> str:
12
+ """Generate a deterministic seed from user ID for avatar."""
13
+ return hashlib.md5(user_id.encode()).hexdigest()[:16]
14
+
15
+
16
+ def get_avatar_color(user_id: str) -> tuple:
17
+ """Generate a consistent color from user ID (RGB)."""
18
+ seed = generate_avatar_seed(user_id)
19
+ # Generate consistent color based on seed
20
+ r = int(seed[0:2], 16) % 200 + 30 # Avoid too dark
21
+ g = int(seed[2:4], 16) % 200 + 30
22
+ b = int(seed[4:6], 16) % 200 + 30
23
+ return (r, g, b)
24
+
25
+
26
+ def get_avatar_initials(username: str) -> str:
27
+ """Get initials from username for avatar display."""
28
+ if not username:
29
+ return "?"
30
+ parts = username.split()
31
+ if len(parts) >= 2:
32
+ return (parts[0][0] + parts[1][0]).upper()
33
+ return username[:2].upper()
34
+
35
+
36
+ def generate_identicon(user_id: str) -> str:
37
+ """
38
+ Generate a simple identicon (base64 encoded).
39
+ In production, use a proper library or service.
40
+ """
41
+ # Simplified placeholder - returns a colored background URL
42
+ color = get_avatar_color(user_id)
43
+ # In production: generate actual identicon or use a service like DiceBear
44
+ return f"https://api.dicebear.com/7.x/initials/svg?seed={user_id}"
45
+
46
+
47
+ async def generate_avatar(user_id: str, username: str) -> str:
48
+ """
49
+ Generate an avatar URL for a user.
50
+ Can be customized to use different styles or services.
51
+ """
52
+ # Using DiceBear API for automatic avatar generation
53
+ # Options: 'initials', 'identicon', 'bottts', 'avataaars', etc.
54
+ return f"https://api.dicebear.com/7.x/initials/svg?seed={username}&backgroundColor={get_avatar_color(user_id)}"
backend/app/utils/compression.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Compression utilities - gzip and zstd compression for batch storage.
3
+ """
4
+ import gzip
5
+ import zstandard as zstd
6
+ from typing import bytes
7
+ from pathlib import Path
8
+ import json
9
+
10
+
11
+ def compress_gzip(data: bytes) -> bytes:
12
+ """Compress data using gzip."""
13
+ return gzip.compress(data, compresslevel=6)
14
+
15
+
16
+ def decompress_gzip(data: bytes) -> bytes:
17
+ """Decompress gzip data."""
18
+ return gzip.decompress(data)
19
+
20
+
21
+ def compress_zstd(data: bytes, level: int = 3) -> bytes:
22
+ """Compress data using zstd (better compression ratio)."""
23
+ cctx = zstd.ZstdCompressor(level=level)
24
+ return cctx.compress(data)
25
+
26
+
27
+ def decompress_zstd(data: bytes) -> bytes:
28
+ """Decompress zstd data."""
29
+ dctx = zstd.ZstdDecompressor()
30
+ return dctx.decompress(data)
31
+
32
+
33
+ async def compress_file(input_path: Path, output_path: Path, method: str = "zstd") -> bool:
34
+ """
35
+ Compress a file and save to output path.
36
+
37
+ Args:
38
+ input_path: Source file path
39
+ output_path: Destination file path
40
+ method: 'zstd' or 'gzip'
41
+ """
42
+ try:
43
+ with open(input_path, "rb") as f:
44
+ data = f.read()
45
+
46
+ if method == "zstd":
47
+ compressed = compress_zstd(data)
48
+ else:
49
+ compressed = compress_gzip(data)
50
+
51
+ output_path.parent.mkdir(parents=True, exist_ok=True)
52
+ with open(output_path, "wb") as f:
53
+ f.write(compressed)
54
+
55
+ return True
56
+ except Exception as e:
57
+ print(f"Compression error: {e}")
58
+ return False
59
+
60
+
61
+ async def decompress_file(input_path: Path, output_path: Path, method: str = "zstd") -> bool:
62
+ """Decompress a file."""
63
+ try:
64
+ with open(input_path, "rb") as f:
65
+ data = f.read()
66
+
67
+ if method == "zstd":
68
+ decompressed = decompress_zstd(data)
69
+ else:
70
+ decompressed = decompress_gzip(data)
71
+
72
+ with open(output_path, "wb") as f:
73
+ f.write(decompressed)
74
+
75
+ return True
76
+ except Exception as e:
77
+ print(f"Decompression error: {e}")
78
+ return False
79
+
80
+
81
+ def get_compression_ratio(original_size: int, compressed_size: int) -> float:
82
+ """Calculate compression ratio."""
83
+ if original_size == 0:
84
+ return 0.0
85
+ return round((1 - compressed_size / original_size) * 100, 2)
backend/app/utils/formatter.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Formatter utilities - clean and normalize messages.
3
+ """
4
+ import re
5
+ from typing import Optional
6
+ from html import escape
7
+
8
+
9
+ def clean_message(text: str, max_length: int = 4000) -> str:
10
+ """
11
+ Clean and sanitize message text.
12
+ - Remove excessive whitespace
13
+ - Limit length
14
+ - Escape HTML
15
+ """
16
+ if not text:
17
+ return ""
18
+
19
+ # Strip leading/trailing whitespace
20
+ text = text.strip()
21
+
22
+ # Remove excessive internal whitespace (more than 2 spaces)
23
+ text = re.sub(r' {2,}', ' ', text)
24
+
25
+ # Remove excessive newlines (more than 2 consecutive)
26
+ text = re.sub(r'\n{3,}', '\n\n', text)
27
+
28
+ # Limit length
29
+ if len(text) > max_length:
30
+ text = text[:max_length]
31
+
32
+ # Escape HTML to prevent XSS
33
+ text = escape(text)
34
+
35
+ return text
36
+
37
+
38
+ def normalize_content(content: str) -> str:
39
+ """Normalize message content for storage/search."""
40
+ # Lowercase for search indexing (optional)
41
+ # Remove special characters for search (optional)
42
+ return content.strip()
43
+
44
+
45
+ def format_timestamp(timestamp) -> str:
46
+ """Format a timestamp for display."""
47
+ from datetime import datetime
48
+ if isinstance(timestamp, datetime):
49
+ return timestamp.strftime("%Y-%m-%d %H:%M")
50
+ return str(timestamp)
51
+
52
+
53
+ def truncate_text(text: str, max_len: int = 50) -> str:
54
+ """Truncate text for previews."""
55
+ if len(text) <= max_len:
56
+ return text
57
+ return text[:max_len - 3] + "..."
backend/app/utils/security.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Security utilities - password hashing, JWT token handling.
3
+ """
4
+ from passlib.context import CryptContext
5
+ from jose import JWTError, jwt
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional
8
+ from ..config import settings
9
+
10
+ # Password hashing
11
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
12
+
13
+
14
+ def hash_password(password: str) -> str:
15
+ """Hash a plain text password."""
16
+ return pwd_context.hash(password)
17
+
18
+
19
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
20
+ """Verify a password against its hash."""
21
+ return pwd_context.verify(plain_password, hashed_password)
22
+
23
+
24
+ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
25
+ """Create a JWT access token."""
26
+ to_encode = data.copy()
27
+ if expires_delta:
28
+ expire = datetime.utcnow() + expires_delta
29
+ else:
30
+ expire = datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRATION_MINUTES)
31
+
32
+ to_encode.update({"exp": expire})
33
+ encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
34
+ return encoded_jwt
35
+
36
+
37
+ def decode_access_token(token: str) -> Optional[dict]:
38
+ """Decode and verify a JWT token."""
39
+ try:
40
+ payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
41
+ return payload
42
+ except JWTError:
43
+ return None
44
+
45
+
46
+ def create_password_reset_token(email: str) -> str:
47
+ """Create a password reset token (time-limited)."""
48
+ delta = timedelta(hours=1)
49
+ return create_access_token({"sub": email, "type": "password_reset"}, delta)
50
+
51
+
52
+ def verify_password_reset_token(token: str) -> Optional[str]:
53
+ """Verify password reset token and return email."""
54
+ payload = decode_access_token(token)
55
+ if payload and payload.get("type") == "password_reset":
56
+ return payload.get("sub")
57
+ return None
backend/app/utils/time.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Time utilities - timestamp helpers.
3
+ """
4
+ from datetime import datetime, timezone, timedelta
5
+ from typing import Optional
6
+ import time
7
+
8
+
9
+ def utc_now() -> datetime:
10
+ """Get current UTC time as timezone-aware datetime."""
11
+ return datetime.now(timezone.utc)
12
+
13
+
14
+ def utc_timestamp() -> int:
15
+ """Get current UTC timestamp in seconds."""
16
+ return int(time.time())
17
+
18
+
19
+ def format_date(dt: datetime, fmt: str = "%Y-%m-%d") -> str:
20
+ """Format datetime to string."""
21
+ if dt is None:
22
+ return ""
23
+ return dt.strftime(fmt)
24
+
25
+
26
+ def format_datetime(dt: datetime) -> str:
27
+ """Format datetime for display."""
28
+ if dt is None:
29
+ return ""
30
+ return dt.strftime("%Y-%m-%d %H:%M:%S")
31
+
32
+
33
+ def parse_date(date_str: str) -> Optional[datetime]:
34
+ """Parse date string to datetime."""
35
+ try:
36
+ return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
37
+ except (ValueError, AttributeError):
38
+ return None
39
+
40
+
41
+ def time_ago(dt: datetime) -> str:
42
+ """Get human-readable time ago string."""
43
+ if dt is None:
44
+ return "unknown"
45
+
46
+ now = utc_now()
47
+ if dt.tzinfo is None:
48
+ dt = dt.replace(tzinfo=timezone.utc)
49
+
50
+ diff = now - dt
51
+
52
+ seconds = diff.total_seconds()
53
+ if seconds < 60:
54
+ return "just now"
55
+ elif seconds < 3600:
56
+ minutes = int(seconds / 60)
57
+ return f"{minutes}m ago"
58
+ elif seconds < 86400:
59
+ hours = int(seconds / 3600)
60
+ return f"{hours}h ago"
61
+ elif seconds < 604800:
62
+ days = int(seconds / 86400)
63
+ return f"{days}d ago"
64
+ else:
65
+ return format_date(dt)
66
+
67
+
68
+ def get_date_path(dt: Optional[datetime] = None) -> str:
69
+ """Get date path for storage (YYYY/MM/DD)."""
70
+ if dt is None:
71
+ dt = utc_now()
72
+ return f"{dt.year}/{dt.month:02d}/{dt.day:02d}"
73
+
74
+
75
+ def get_hour_bucket(dt: Optional[datetime] = None) -> int:
76
+ """Get hour bucket (0-23) for batching."""
77
+ if dt is None:
78
+ dt = utc_now()
79
+ return dt.hour
backend/requirements.txt ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI and async
2
+ fastapi==0.109.0
3
+ uvicorn[standard]==0.27.0
4
+ python-multipart==0.0.6
5
+ pydantic==2.5.3
6
+ pydantic-settings==2.1.0
7
+
8
+ # Database
9
+ sqlalchemy==2.0.25
10
+ asyncpg==0.29.0
11
+ alembic==1.13.1
12
+
13
+ # Auth
14
+ python-jose[cryptography]==3.3.0
15
+ passlib[bcrypt]==1.7.4
16
+ bcrypt==4.1.2
17
+
18
+ # Async queue and workers
19
+ aioredis==2.0.1
20
+ celery==5.3.6
21
+ httpx==0.26.0
22
+
23
+ # Compression
24
+ zstandard==0.22.0
25
+
26
+ # Utils
27
+ python-dotenv==1.0.0
28
+ aiofiles==23.2.1
29
+ python-dateutil==2.8.2
30
+
31
+ # WebSocket
32
+ websockets==12.0
33
+
34
+ # GitHub API
35
+ pygithub==2.2.0
36
+
37
+ # Logging
38
+ loguru==0.7.2
39
+
40
+ # Optional: HuggingFace dataset upload
41
+ # huggingface-hub==0.20.0
{assets → frontend}/index-CQxv4P1h.css RENAMED
File without changes
{assets → frontend}/index-D-0NzOas.js RENAMED
File without changes
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HuggingFace Space Dependencies
2
+ fastapi>=0.109.0
3
+ uvicorn[standard]>=0.27.0
4
+ python-multipart>=0.0.6
5
+ python-jose[cryptography]>=3.3.0
6
+ passlib[bcrypt]>=1.7.4
7
+ sqlalchemy>=2.0.0
8
+ asyncpg>=0.29.0
9
+ redis>=5.0.0
10
+ python-dotenv>=1.0.0
11
+ pydantic>=2.5.0
12
+ pydantic-settings>=2.1.0