Phase 1: Communications Module - Complete

Backend infrastructure:
- PostgreSQL models (users, channels, messages, DMs, files, artifacts)
- JWT authentication with password hashing
- Auth API (register, login, logout, get user)
- Channels API (create, list, join, leave)
- Messages API with @grimlock mention detection
- AI responds automatically when @mentioned
- Background task processing for AI responses

Database:
- SQLAlchemy ORM models
- Alembic ready for migrations
- PostgreSQL + Redis in docker-compose

Features working:
- User registration and login
- Create/join public channels
- Send messages in channels
- @grimlock triggers AI response with channel context
- Real-time ready (WebSocket next)

Next: WebSocket for real-time updates, frontend interface
This commit is contained in:
JA
2026-02-12 21:26:16 +00:00
parent 437336a1e4
commit 9f094b7a5d
10 changed files with 912 additions and 5 deletions

154
backend/api/auth.py Normal file
View File

@@ -0,0 +1,154 @@
"""
Authentication API Endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from core.database import get_db
from core.models import User, UserRole
from core.auth import verify_password, get_password_hash, create_access_token, decode_token
router = APIRouter()
security = HTTPBearer()
# Pydantic models
class UserRegister(BaseModel):
email: EmailStr
name: str
password: str
role: UserRole = UserRole.ENGINEER
class UserLogin(BaseModel):
email: EmailStr
password: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
id: int
email: str
name: str
role: UserRole
is_active: bool
is_online: bool
class Config:
from_attributes = True
# Dependency to get current user from token
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user"""
token = credentials.credentials
payload = decode_token(token)
if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled"
)
return user
@router.post("/register", response_model=UserResponse)
async def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""Register a new user"""
# Check if user exists
existing_user = db.query(User).filter(User.email == user_data.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
user = User(
email=user_data.email,
name=user_data.name,
password_hash=get_password_hash(user_data.password),
role=user_data.role
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=Token)
async def login(credentials: UserLogin, db: Session = Depends(get_db)):
"""Login and get access token"""
# Find user
user = db.query(User).filter(User.email == credentials.email).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
# Verify password
if not verify_password(credentials.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is disabled"
)
# Create access token
access_token = create_access_token(data={"sub": str(user.id)})
# Update user online status
user.is_online = True
db.commit()
return {"access_token": access_token}
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
"""Get current user info"""
return current_user
@router.post("/logout")
async def logout(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Logout user"""
current_user.is_online = False
db.commit()
return {"message": "Logged out successfully"}

188
backend/api/channels.py Normal file
View File

@@ -0,0 +1,188 @@
"""
Channels API - Channel management and operations
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
from core.database import get_db
from core.models import Channel, User, ChannelType
from api.auth import get_current_user
router = APIRouter()
# Pydantic models
class ChannelCreate(BaseModel):
name: str
description: Optional[str] = None
type: ChannelType = ChannelType.PUBLIC
class ChannelResponse(BaseModel):
id: int
name: str
description: Optional[str]
type: ChannelType
member_count: int
created_at: str
class Config:
from_attributes = True
class ChannelListResponse(BaseModel):
id: int
name: str
description: Optional[str]
type: ChannelType
unread_count: int = 0 # TODO: Implement actual unread counting
class Config:
from_attributes = True
@router.post("/", response_model=ChannelResponse)
async def create_channel(
channel_data: ChannelCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new channel"""
# Check if channel name already exists
existing = db.query(Channel).filter(Channel.name == channel_data.name).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Channel name already exists"
)
# Create channel
channel = Channel(
name=channel_data.name,
description=channel_data.description,
type=channel_data.type,
created_by=current_user.id
)
db.add(channel)
db.commit()
db.refresh(channel)
# Add creator as member
channel.members.append(current_user)
db.commit()
return {
**channel.__dict__,
"member_count": len(channel.members),
"created_at": channel.created_at.isoformat()
}
@router.get("/", response_model=List[ChannelListResponse])
async def list_channels(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List all channels user is a member of"""
channels = current_user.channels
return [
{
"id": c.id,
"name": c.name,
"description": c.description,
"type": c.type,
"unread_count": 0 # TODO: Implement
}
for c in channels
]
@router.get("/{channel_id}", response_model=ChannelResponse)
async def get_channel(
channel_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get channel details"""
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Check if user is member (for private channels)
if channel.type == ChannelType.PRIVATE:
if current_user not in channel.members:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this channel"
)
return {
**channel.__dict__,
"member_count": len(channel.members),
"created_at": channel.created_at.isoformat()
}
@router.post("/{channel_id}/join")
async def join_channel(
channel_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Join a channel"""
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Can't join private channels (must be invited)
if channel.type == ChannelType.PRIVATE:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot join private channel"
)
# Check if already a member
if current_user in channel.members:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Already a member"
)
channel.members.append(current_user)
db.commit()
return {"message": f"Joined channel #{channel.name}"}
@router.post("/{channel_id}/leave")
async def leave_channel(
channel_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Leave a channel"""
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
if current_user not in channel.members:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Not a member of this channel"
)
channel.members.remove(current_user)
db.commit()
return {"message": f"Left channel #{channel.name}"}

262
backend/api/messages.py Normal file
View File

@@ -0,0 +1,262 @@
"""
Messages API - Send and receive messages with @mention support
"""
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import List, Optional
import re
from datetime import datetime
from core.database import get_db
from core.models import Message, Channel, User, ChannelType
from core.ai_client import AIClient
from core.context_manager import ContextManager
from api.auth import get_current_user
import main
router = APIRouter()
# Pydantic models
class MessageCreate(BaseModel):
content: str
reply_to: Optional[int] = None
class UserInfo(BaseModel):
id: int
name: str
email: str
role: str
is_online: bool
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
content: str
is_ai_message: bool
user: Optional[UserInfo]
reply_to_message_id: Optional[int]
created_at: str
edited_at: Optional[str]
class Config:
from_attributes = True
def detect_grimlock_mention(content: str) -> bool:
"""Detect if @grimlock is mentioned in message"""
return bool(re.search(r'@grimlock\b', content, re.IGNORECASE))
def extract_mention_query(content: str) -> str:
"""Extract the query after @grimlock mention"""
# Remove @grimlock and return the rest
return re.sub(r'@grimlock\s*', '', content, flags=re.IGNORECASE).strip()
async def handle_grimlock_mention(
message: Message,
channel: Channel,
db: Session,
context_manager: ContextManager,
ai_client: AIClient
):
"""Handle @grimlock mention - respond with AI"""
try:
# Extract query
query = extract_mention_query(message.content)
# Get channel history for context (last 10 messages)
recent_messages = db.query(Message)\
.filter(Message.channel_id == channel.id)\
.filter(Message.id < message.id)\
.order_by(Message.id.desc())\
.limit(10)\
.all()
# Build conversation history
conversation = []
for msg in reversed(recent_messages):
if msg.user:
conversation.append({
"role": "user",
"content": f"{msg.user.name}: {msg.content}"
})
elif msg.is_ai_message:
conversation.append({
"role": "assistant",
"content": msg.content
})
# Add current message
if message.user:
conversation.append({
"role": "user",
"content": f"{message.user.name}: {query}"
})
# Get context from context manager
context = context_manager.get_context_for_query(query)
system_prompt = context_manager.get_system_prompt()
# Add channel context
system_prompt += f"\n\nYou are responding in channel #{channel.name}."
if context:
system_prompt += f"\n\n# Company Context\n{context}"
# Get AI response
response = await ai_client.chat(
messages=conversation,
system_prompt=system_prompt
)
# Create AI message
ai_message = Message(
channel_id=channel.id,
user_id=None,
content=response,
is_ai_message=True,
reply_to_message_id=message.id
)
db.add(ai_message)
db.commit()
db.refresh(ai_message)
return ai_message
except Exception as e:
# Create error message
error_message = Message(
channel_id=channel.id,
user_id=None,
content=f"Sorry, I encountered an error: {str(e)}",
is_ai_message=True,
reply_to_message_id=message.id
)
db.add(error_message)
db.commit()
return error_message
@router.post("/{channel_id}/messages", response_model=MessageResponse)
async def send_message(
channel_id: int,
message_data: MessageCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
context_manager: ContextManager = Depends(main.get_context_manager),
ai_client: AIClient = Depends(main.get_ai_client)
):
"""Send a message to a channel"""
# Get channel
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Check if user is member
if current_user not in channel.members:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this channel"
)
# Create message
message = Message(
channel_id=channel_id,
user_id=current_user.id,
content=message_data.content,
reply_to_message_id=message_data.reply_to
)
db.add(message)
db.commit()
db.refresh(message)
# Check for @grimlock mention
if detect_grimlock_mention(message_data.content):
# Handle in background to not block response
background_tasks.add_task(
handle_grimlock_mention,
message,
channel,
db,
context_manager,
ai_client
)
return {
"id": message.id,
"content": message.content,
"is_ai_message": message.is_ai_message,
"user": {
"id": current_user.id,
"name": current_user.name,
"email": current_user.email,
"role": current_user.role.value,
"is_online": current_user.is_online
} if message.user else None,
"reply_to_message_id": message.reply_to_message_id,
"created_at": message.created_at.isoformat(),
"edited_at": message.edited_at.isoformat() if message.edited_at else None
}
@router.get("/{channel_id}/messages", response_model=List[MessageResponse])
async def get_messages(
channel_id: int,
limit: int = 50,
before: Optional[int] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get messages from a channel"""
# Get channel
channel = db.query(Channel).filter(Channel.id == channel_id).first()
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Check if user is member
if current_user not in channel.members:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not a member of this channel"
)
# Query messages
query = db.query(Message).filter(Message.channel_id == channel_id)
if before:
query = query.filter(Message.id < before)
messages = query.order_by(Message.id.desc()).limit(limit).all()
messages.reverse() # Return in chronological order
# Format response
result = []
for msg in messages:
result.append({
"id": msg.id,
"content": msg.content,
"is_ai_message": msg.is_ai_message,
"user": {
"id": msg.user.id,
"name": msg.user.name,
"email": msg.user.email,
"role": msg.user.role.value,
"is_online": msg.user.is_online
} if msg.user else None,
"reply_to_message_id": msg.reply_to_message_id,
"created_at": msg.created_at.isoformat(),
"edited_at": msg.edited_at.isoformat() if msg.edited_at else None
})
return result