Initial commit

This commit is contained in:
2026-02-12 17:48:00 -06:00
parent 903df52206
commit 11c4ff5cb7
17 changed files with 1369 additions and 0 deletions

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "api/main.py"]

15
ai/ai_config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"model": "qwen/qwen3-next-80b-a3b-thinking:nitro",
"max_tokens": 8192,
"prompts": {
"command_parser": {
"system": "You are a helpful AI assistant that parses user commands into structured JSON. Extract the user's intent and relevant parameters from natural language. Return ONLY valid JSON, no explanations.\n\nBe flexible with language - handle typos, slang, and casual phrasing. Consider conversation context when available.\n\nIf unclear, ask for clarification in the 'needs_clarification' field with confidence < 0.8.",
"user_template": "Parse this command into structured JSON.\n\nCurrent conversation context (if any):\n{history_context}\n\nUser message: \"{user_input}\"\n\nReturn JSON with:\n{\n \"interaction_type\": \"string\",\n \"action\": \"string\",\n \"confidence\": number (0-1),\n \"needs_clarification\": \"string\" (if confidence < 0.8),\n ... other extracted fields ...\n}\n\nIf unclear, ask for clarification in the needs_clarification field."
}
},
"validation": {
"max_retries": 3,
"timeout_seconds": 15,
"validators": {}
}
}

151
ai/parser.py Normal file
View File

@@ -0,0 +1,151 @@
"""
parser.py - LLM-powered JSON parser with retry and validation
Config-driven via ai_config.json. Supports:
- Any OpenAI-compatible API (OpenRouter, local, etc.)
- Reasoning models that output in reasoning field
- Schema validation with automatic retry
- Conversation context for multi-turn interactions
"""
import json
import os
import re
from openai import OpenAI
CONFIG_PATH = os.environ.get(
"AI_CONFIG_PATH", os.path.join(os.path.dirname(__file__), "ai_config.json")
)
with open(CONFIG_PATH, "r") as f:
AI_CONFIG = json.load(f)
client = OpenAI(
api_key=os.getenv("OPENROUTER_API_KEY"),
base_url=os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"),
)
def _extract_json_from_text(text):
"""Pull the first JSON object out of a block of text (for reasoning models)."""
match = re.search(r"```json\s*(\{.*?\})\s*```", text, re.DOTALL)
if match:
return match.group(1)
match = re.search(r"(\{[^{}]*\})", text, re.DOTALL)
if match:
return match.group(1)
return None
def _call_llm(system_prompt, user_prompt):
"""Call OpenAI-compatible API and return the response text."""
try:
response = client.chat.completions.create(
model=AI_CONFIG["model"],
max_tokens=AI_CONFIG.get("max_tokens", 8192),
timeout=AI_CONFIG["validation"]["timeout_seconds"],
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
msg = response.choices[0].message
text = msg.content.strip() if msg.content else ""
if text:
return text
reasoning = getattr(msg, "reasoning", None)
if reasoning:
extracted = _extract_json_from_text(reasoning)
if extracted:
return extracted
return None
except Exception as e:
print(f"LLM error: {type(e).__name__}: {e}", flush=True)
return None
def parse(user_input, interaction_type, retry_count=0, errors=None, history=None):
"""
Parse user input into structured JSON using LLM.
Args:
user_input: The raw user message
interaction_type: Key in ai_config.json prompts (e.g., 'command_parser')
retry_count: Internal retry counter
errors: Previous validation errors for retry
history: List of (user_msg, parsed_result) tuples for context
Returns:
dict: Parsed JSON or error dict
"""
if retry_count >= AI_CONFIG["validation"]["max_retries"]:
return {
"error": f"Failed to parse after {retry_count} retries",
"user_input": user_input,
}
prompt_config = AI_CONFIG["prompts"].get(interaction_type)
if not prompt_config:
return {
"error": f"Unknown interaction type: {interaction_type}",
"user_input": user_input,
}
history_context = "No previous context"
if history and len(history) > 0:
history_lines = []
for i, (msg, result) in enumerate(history[-3:]):
history_lines.append(f"{i + 1}. User: {msg}")
if isinstance(result, dict) and not result.get("error"):
history_lines.append(f" Parsed: {json.dumps(result)}")
else:
history_lines.append(f" Parsed: {result}")
history_context = "\n".join(history_lines)
user_prompt = prompt_config["user_template"].format(
user_input=user_input, history_context=history_context
)
if errors:
user_prompt += (
f"\n\nPrevious attempt had errors: {errors}\nPlease fix and try again."
)
response_text = _call_llm(prompt_config["system"], user_prompt)
if not response_text:
return {"error": "AI service unavailable", "user_input": user_input}
try:
parsed = json.loads(response_text)
except json.JSONDecodeError:
return parse(
user_input,
interaction_type,
retry_count + 1,
["Response was not valid JSON"],
history=history,
)
if "error" in parsed:
return parsed
validator = AI_CONFIG["validation"].get("validators", {}).get(interaction_type)
if validator:
validation_errors = validator(parsed)
if validation_errors:
return parse(
user_input,
interaction_type,
retry_count + 1,
validation_errors,
history=history,
)
return parsed
def register_validator(interaction_type, validator_fn):
"""Register a custom validation function for an interaction type."""
if "validators" not in AI_CONFIG["validation"]:
AI_CONFIG["validation"]["validators"] = {}
AI_CONFIG["validation"]["validators"][interaction_type] = validator_fn

137
api/main.py Normal file
View File

@@ -0,0 +1,137 @@
"""
main.py - Flask API with auth routes and module registry
Domain routes are registered via the routes registry.
"""
import os
import flask
import core.auth as auth
import core.users as users
import core.postgres as postgres
app = flask.Flask(__name__)
ROUTE_MODULES = []
def register_routes(module):
"""Register a routes module. Module should have a register(app) function."""
ROUTE_MODULES.append(module)
# ── Auth Routes ────────────────────────────────────────────────────
@app.route("/api/register", methods=["POST"])
def api_register():
data = flask.request.get_json()
username = data.get("username")
password = data.get("password")
if not username or not password:
return flask.jsonify({"error": "username and password required"}), 400
result = users.registerUser(username, password, data)
if result:
return flask.jsonify({"success": True}), 201
else:
return flask.jsonify({"error": "username taken"}), 409
@app.route("/api/login", methods=["POST"])
def api_login():
data = flask.request.get_json()
username = data.get("username")
password = data.get("password")
if not username or not password:
return flask.jsonify({"error": "username and password required"}), 400
token = auth.getLoginToken(username, password)
if token:
return flask.jsonify({"token": token}), 200
else:
return flask.jsonify({"error": "invalid credentials"}), 401
# ── User Routes ────────────────────────────────────────────────────
@app.route("/api/getUserUUID/<username>", methods=["GET"])
def api_getUserUUID(username):
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
if auth.verifyLoginToken(token, username):
return flask.jsonify(users.getUserUUID(username)), 200
else:
return flask.jsonify({"error": "unauthorized"}), 401
@app.route("/api/user/<userUUID>", methods=["GET"])
def api_getUser(userUUID):
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
if auth.verifyLoginToken(token, userUUID=userUUID):
user = postgres.select_one("users", {"id": userUUID})
if user:
user.pop("password_hashed", None)
return flask.jsonify(user), 200
else:
return flask.jsonify({"error": "user not found"}), 404
else:
return flask.jsonify({"error": "unauthorized"}), 401
@app.route("/api/user/<userUUID>", methods=["PUT"])
def api_updateUser(userUUID):
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
if auth.verifyLoginToken(token, userUUID=userUUID):
data = flask.request.get_json()
result = users.updateUser(userUUID, data)
if result:
return flask.jsonify({"success": True}), 200
else:
return flask.jsonify({"error": "no valid fields to update"}), 400
else:
return flask.jsonify({"error": "unauthorized"}), 401
@app.route("/api/user/<userUUID>", methods=["DELETE"])
def api_deleteUser(userUUID):
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
if auth.verifyLoginToken(token, userUUID=userUUID):
data = flask.request.get_json()
password = data.get("password")
if not password:
return flask.jsonify(
{"error": "password required for account deletion"}
), 400
result = auth.unregisterUser(userUUID, password)
if result:
return flask.jsonify({"success": True}), 200
else:
return flask.jsonify({"error": "invalid password"}), 401
else:
return flask.jsonify({"error": "unauthorized"}), 401
# ── Health Check ───────────────────────────────────────────────────
@app.route("/health", methods=["GET"])
def health_check():
return flask.jsonify({"status": "ok"}), 200
if __name__ == "__main__":
for module in ROUTE_MODULES:
if hasattr(module, "register"):
module.register(app)
app.run(host="0.0.0.0", port=5000)

50
api/routes/example.py Normal file
View File

@@ -0,0 +1,50 @@
"""
Example route module - Copy this pattern for your domain.
This module demonstrates:
1. Registering routes with Flask app
2. Using auth validation
3. Making database calls via postgres module
"""
import flask
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import core.auth as auth
import core.postgres as postgres
def register(app):
"""Register routes with the Flask app."""
@app.route("/api/example", methods=["GET"])
def api_listExamples():
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
decoded = auth.verifyLoginToken(token, userUUID=True)
if decoded != True:
return flask.jsonify({"error": "unauthorized"}), 401
items = postgres.select("examples")
return flask.jsonify(items), 200
@app.route("/api/example", methods=["POST"])
def api_addExample():
header = flask.request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return flask.jsonify({"error": "missing token"}), 401
token = header[7:]
decoded = auth.verifyLoginToken(token, userUUID=True)
if decoded != True:
return flask.jsonify({"error": "unauthorized"}), 401
data = flask.request.get_json()
item = postgres.insert("examples", data)
return flask.jsonify(item), 201

272
bot/bot.py Normal file
View File

@@ -0,0 +1,272 @@
"""
bot.py - Discord bot client with session management and command routing
Features:
- Login flow with username/password
- Session management with JWT tokens
- AI-powered command parsing via registry
- Background task loop for polling
"""
import discord
from discord.ext import tasks
import os
import sys
import json
import base64
import requests
import bcrypt
import pickle
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bot.command_registry import get_handler, list_registered
import ai.parser as ai_parser
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
API_URL = os.getenv("API_URL", "http://app:5000")
user_sessions = {}
login_state = {}
message_history = {}
user_cache = {}
CACHE_FILE = "/app/user_cache.pkl"
intents = discord.Intents.default()
intents.message_content = True
client = discord.Client(intents=intents)
def decodeJwtPayload(token):
payload = token.split(".")[1]
payload += "=" * (4 - len(payload) % 4)
return json.loads(base64.urlsafe_b64decode(payload))
def apiRequest(method, endpoint, token=None, data=None):
url = f"{API_URL}{endpoint}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
try:
resp = getattr(requests, method)(url, headers=headers, json=data, timeout=10)
try:
return resp.json(), resp.status_code
except ValueError:
return {}, resp.status_code
except requests.RequestException:
return {"error": "API unavailable"}, 503
def loadCache():
try:
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "rb") as f:
global user_cache
user_cache = pickle.load(f)
print(f"Loaded cache for {len(user_cache)} users")
except Exception as e:
print(f"Error loading cache: {e}")
def saveCache():
try:
with open(CACHE_FILE, "wb") as f:
pickle.dump(user_cache, f)
except Exception as e:
print(f"Error saving cache: {e}")
def hashPassword(password):
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verifyPassword(password, hashed):
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
def getCachedUser(discord_id):
return user_cache.get(discord_id)
def setCachedUser(discord_id, user_data):
user_cache[discord_id] = user_data
saveCache()
def negotiateToken(discord_id, username, password):
cached = getCachedUser(discord_id)
if (
cached
and cached.get("username") == username
and verifyPassword(password, cached.get("hashed_password"))
):
result, status = apiRequest(
"post", "/api/login", data={"username": username, "password": password}
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
setCachedUser(
discord_id,
{
"hashed_password": cached["hashed_password"],
"user_uuid": user_uuid,
"username": username,
},
)
return token, user_uuid
return None, None
result, status = apiRequest(
"post", "/api/login", data={"username": username, "password": password}
)
if status == 200 and "token" in result:
token = result["token"]
payload = decodeJwtPayload(token)
user_uuid = payload["sub"]
setCachedUser(
discord_id,
{
"hashed_password": hashPassword(password),
"user_uuid": user_uuid,
"username": username,
},
)
return token, user_uuid
return None, None
async def handleAuthFailure(message):
discord_id = message.author.id
user_sessions.pop(discord_id, None)
await message.channel.send(
"Your session has expired. Send any message to log in again."
)
async def handleLoginStep(message):
discord_id = message.author.id
state = login_state[discord_id]
if state["step"] == "username":
state["username"] = message.content.strip()
state["step"] = "password"
await message.channel.send("Password?")
elif state["step"] == "password":
username = state["username"]
password = message.content.strip()
del login_state[discord_id]
token, user_uuid = negotiateToken(discord_id, username, password)
if token and user_uuid:
user_sessions[discord_id] = {
"token": token,
"user_uuid": user_uuid,
"username": username,
}
registered = ", ".join(list_registered()) or "none"
await message.channel.send(
f"Welcome back **{username}**!\n\n"
f"Registered modules: {registered}\n\n"
f"Send 'help' for available commands."
)
else:
await message.channel.send(
"Invalid credentials. Send any message to try again."
)
async def sendHelpMessage(message):
registered = list_registered()
help_msg = f"**Available Modules:**\n{chr(10).join(f'- {m}' for m in registered) if registered else '- No modules registered'}\n\nJust talk naturally and I'll help you out!"
await message.channel.send(help_msg)
async def routeCommand(message):
discord_id = message.author.id
session = user_sessions[discord_id]
user_input = message.content.lower()
if "help" in user_input or "what can i say" in user_input:
await sendHelpMessage(message)
return
async with message.channel.typing():
history = message_history.get(discord_id, [])
parsed = ai_parser.parse(message.content, "command_parser", history=history)
if discord_id not in message_history:
message_history[discord_id] = []
message_history[discord_id].append((message.content, parsed))
message_history[discord_id] = message_history[discord_id][-5:]
if "needs_clarification" in parsed:
await message.channel.send(
f"I'm not quite sure what you mean. {parsed['needs_clarification']}"
)
return
if "error" in parsed:
await message.channel.send(
f"I had trouble understanding that: {parsed['error']}"
)
return
interaction_type = parsed.get("interaction_type")
handler = get_handler(interaction_type)
if handler:
await handler(message, session, parsed)
else:
registered = ", ".join(list_registered()) or "none"
await message.channel.send(
f"Unknown command type '{interaction_type}'. Registered modules: {registered}"
)
@client.event
async def on_ready():
print(f"Bot logged in as {client.user}")
loadCache()
backgroundLoop.start()
@client.event
async def on_message(message):
if message.author == client.user:
return
if not isinstance(message.channel, discord.DMChannel):
return
discord_id = message.author.id
if discord_id in login_state:
await handleLoginStep(message)
return
if discord_id not in user_sessions:
login_state[discord_id] = {"step": "username"}
await message.channel.send("Welcome! Send your username to log in.")
return
await routeCommand(message)
@tasks.loop(seconds=60)
async def backgroundLoop():
"""Override this in your domain module or extend as needed."""
pass
@backgroundLoop.before_loop
async def beforeBackgroundLoop():
await client.wait_until_ready()
if __name__ == "__main__":
client.run(DISCORD_BOT_TOKEN)

35
bot/command_registry.py Normal file
View File

@@ -0,0 +1,35 @@
"""
command_registry.py - Module registration for bot commands
Register domain-specific handlers for different interaction types.
"""
COMMAND_MODULES = {}
def register_module(interaction_type, handler):
"""
Register a handler for an interaction type.
Args:
interaction_type: String key (e.g., 'med', 'habit', 'task')
handler: Async function(message, session, parsed) -> None
Example:
async def handle_med(message, session, parsed):
action = parsed['action']
# ... handle medication logic ...
register_module('med', handle_med)
"""
COMMAND_MODULES[interaction_type] = handler
def get_handler(interaction_type):
"""Get the registered handler for an interaction type."""
return COMMAND_MODULES.get(interaction_type)
def list_registered():
"""List all registered interaction types."""
return list(COMMAND_MODULES.keys())

68
bot/commands/example.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Example command module - Copy this pattern for your domain.
This module demonstrates:
1. Registering a handler with the command registry
2. Using the AI parser with custom prompts
3. Making API calls
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from bot.command_registry import register_module
import ai.parser as ai_parser
async def handle_example(message, session, parsed):
"""
Handler for 'example' interaction type.
Args:
message: Discord message object
session: {token, user_uuid, username}
parsed: Parsed JSON from AI parser
"""
action = parsed.get("action", "unknown")
token = session["token"]
user_uuid = session["user_uuid"]
if action == "check":
await message.channel.send(
f"Checking example items for {session['username']}..."
)
elif action == "add":
item_name = parsed.get("item_name", "unnamed")
await message.channel.send(f"Adding example item: **{item_name}**")
else:
await message.channel.send(f"Unknown example action: {action}")
def validate_example_json(data):
"""Validate parsed JSON for example commands. Return list of errors."""
errors = []
if not isinstance(data, dict):
return ["Response must be a JSON object"]
if "error" in data:
return []
if "action" not in data:
errors.append("Missing required field: action")
action = data.get("action")
if action == "add" and "item_name" not in data:
errors.append("Missing required field for add: item_name")
return errors
# Register the module
register_module("example", handle_example)
# Register the validator
ai_parser.register_validator("example", validate_example_json)

20
config/.env.example Normal file
View File

@@ -0,0 +1,20 @@
# Discord Bot
DISCORD_BOT_TOKEN=your_discord_bot_token_here
# API
API_URL=http://app:5000
# Database
DB_HOST=db
DB_PORT=5432
DB_NAME=app
DB_USER=app
DB_PASS=your_db_password_here
# JWT
JWT_SECRET=your_jwt_secret_here
# AI / OpenRouter
OPENROUTER_API_KEY=your_openrouter_api_key_here
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
AI_CONFIG_PATH=/app/ai/ai_config.json

30
config/schema.sql Normal file
View File

@@ -0,0 +1,30 @@
-- Users table (minimal)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY,
username VARCHAR(255) UNIQUE NOT NULL,
password_hashed BYTEA NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Notifications table
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY,
user_uuid UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE,
discord_webhook VARCHAR(500),
discord_enabled BOOLEAN DEFAULT FALSE,
ntfy_topic VARCHAR(255),
ntfy_enabled BOOLEAN DEFAULT FALSE,
last_message_sent TIMESTAMP,
current_notification_status VARCHAR(50) DEFAULT 'inactive',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Add your domain tables below
-- Example:
-- CREATE TABLE IF NOT EXISTS examples (
-- id UUID PRIMARY KEY,
-- user_uuid UUID REFERENCES users(id) ON DELETE CASCADE,
-- name VARCHAR(255) NOT NULL,
-- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-- );

58
core/auth.py Normal file
View File

@@ -0,0 +1,58 @@
import users
import postgres
import bcrypt
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
import datetime
import os
def verifyLoginToken(login_token, username=False, userUUID=False):
if username:
userUUID = users.getUserUUID(username)
if userUUID:
try:
decoded_token = jwt.decode(
login_token, os.getenv("JWT_SECRET"), algorithms=["HS256"]
)
if decoded_token.get("sub") == str(userUUID):
return True
return False
except:
return 401
return 401
def getUserpasswordHash(userUUID):
user = postgres.select_one("users", {"id": userUUID})
if user:
pw_hash = user.get("password_hashed")
if isinstance(pw_hash, memoryview):
return bytes(pw_hash)
return pw_hash
return None
def getLoginToken(username, password):
userUUID = users.getUserUUID(username)
if userUUID:
formatted_pass = password.encode("utf-8")
users_hashed_pw = getUserpasswordHash(userUUID)
if bcrypt.checkpw(formatted_pass, users_hashed_pw):
payload = {
"sub": userUUID,
"name": users.getUserFirstName(userUUID),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
}
return jwt.encode(payload, os.getenv("JWT_SECRET"), algorithm="HS256")
return False
def unregisterUser(userUUID, password):
pw_hash = getUserpasswordHash(userUUID)
if not pw_hash:
return False
if bcrypt.checkpw(password.encode("utf-8"), pw_hash):
return users.deleteUser(userUUID)
return False

74
core/notifications.py Normal file
View File

@@ -0,0 +1,74 @@
"""
notifications.py - Multi-channel notification routing
Supported channels: Discord webhook, ntfy
"""
import postgres
import uuid
import requests
import time
def _sendToEnabledChannels(notif_settings, message):
"""Send message to all enabled channels. Returns True if at least one succeeded."""
sent = False
if notif_settings.get("discord_enabled") and notif_settings.get("discord_webhook"):
if discord.send(notif_settings["discord_webhook"], message):
sent = True
if notif_settings.get("ntfy_enabled") and notif_settings.get("ntfy_topic"):
if ntfy.send(notif_settings["ntfy_topic"], message):
sent = True
return sent
def getNotificationSettings(userUUID):
settings = postgres.select_one("notifications", {"user_uuid": userUUID})
if not settings:
return False
return settings
def setNotificationSettings(userUUID, data_dict):
existing = postgres.select_one("notifications", {"user_uuid": userUUID})
allowed = [
"discord_webhook",
"discord_enabled",
"ntfy_topic",
"ntfy_enabled",
]
updates = {k: v for k, v in data_dict.items() if k in allowed}
if not updates:
return False
if existing:
postgres.update("notifications", updates, {"user_uuid": userUUID})
else:
updates["id"] = str(uuid.uuid4())
updates["user_uuid"] = userUUID
postgres.insert("notifications", updates)
return True
class discord:
@staticmethod
def send(webhook_url, message):
try:
response = requests.post(webhook_url, json={"content": message})
return response.status_code == 204
except:
return False
class ntfy:
@staticmethod
def send(topic, message):
try:
response = requests.post(
f"https://ntfy.sh/{topic}", data=message.encode("utf-8")
)
return response.status_code == 200
except:
return False

264
core/postgres.py Normal file
View File

@@ -0,0 +1,264 @@
"""
postgres.py - Generic PostgreSQL CRUD module
Requires: pip install psycopg2-binary
Connection config from environment:
DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
"""
import os
import re
import psycopg2
import psycopg2.extras
from contextlib import contextmanager
def _get_config():
return {
"host": os.environ.get("DB_HOST", "localhost"),
"port": int(os.environ.get("DB_PORT", 5432)),
"dbname": os.environ.get("DB_NAME", "app"),
"user": os.environ.get("DB_USER", "app"),
"password": os.environ.get("DB_PASS", ""),
}
def _safe_id(name):
if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name):
raise ValueError(f"Invalid SQL identifier: {name}")
return f'"{name}"'
def _build_where(where, prefix=""):
clauses = []
params = {}
for i, (col, val) in enumerate(where.items()):
param_name = f"{prefix}{col}_{i}"
safe_col = _safe_id(col)
if isinstance(val, tuple) and len(val) == 2:
op, operand = val
op = op.upper()
allowed = {
"=",
"!=",
"<",
">",
"<=",
">=",
"LIKE",
"ILIKE",
"IN",
"IS",
"IS NOT",
}
if op not in allowed:
raise ValueError(f"Unsupported operator: {op}")
if op == "IN":
ph = ", ".join(f"%({param_name}_{j})s" for j in range(len(operand)))
clauses.append(f"{safe_col} IN ({ph})")
for j, item in enumerate(operand):
params[f"{param_name}_{j}"] = item
elif op in ("IS", "IS NOT"):
clauses.append(f"{safe_col} {op} NULL")
else:
clauses.append(f"{safe_col} {op} %({param_name})s")
params[param_name] = operand
elif val is None:
clauses.append(f"{safe_col} IS NULL")
else:
clauses.append(f"{safe_col} = %({param_name})s")
params[param_name] = val
return " AND ".join(clauses), params
@contextmanager
def get_connection():
conn = psycopg2.connect(**_get_config())
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@contextmanager
def get_cursor(dict_cursor=True):
with get_connection() as conn:
factory = psycopg2.extras.RealDictCursor if dict_cursor else None
cur = conn.cursor(cursor_factory=factory)
try:
yield cur
finally:
cur.close()
def insert(table, data):
columns = list(data.keys())
placeholders = [f"%({col})s" for col in columns]
safe_cols = [_safe_id(c) for c in columns]
query = f"""
INSERT INTO {_safe_id(table)}
({", ".join(safe_cols)})
VALUES ({", ".join(placeholders)})
RETURNING *
"""
with get_cursor() as cur:
cur.execute(query, data)
return dict(cur.fetchone()) if cur.rowcount else None
def select(table, where=None, order_by=None, limit=None, offset=None):
query = f"SELECT * FROM {_safe_id(table)}"
params = {}
if where:
clauses, params = _build_where(where)
query += f" WHERE {clauses}"
if order_by:
if isinstance(order_by, list):
order_by = ", ".join(order_by)
query += f" ORDER BY {order_by}"
if limit is not None:
query += f" LIMIT {int(limit)}"
if offset is not None:
query += f" OFFSET {int(offset)}"
with get_cursor() as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def select_one(table, where):
results = select(table, where=where, limit=1)
return results[0] if results else None
def update(table, data, where):
set_columns = list(data.keys())
set_clause = ", ".join(f"{_safe_id(col)} = %(set_{col})s" for col in set_columns)
params = {f"set_{col}": val for col, val in data.items()}
where_clause, where_params = _build_where(where, prefix="where_")
params.update(where_params)
query = f"""
UPDATE {_safe_id(table)}
SET {set_clause}
WHERE {where_clause}
RETURNING *
"""
with get_cursor() as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def delete(table, where):
where_clause, params = _build_where(where)
query = f"""
DELETE FROM {_safe_id(table)}
WHERE {where_clause}
RETURNING *
"""
with get_cursor() as cur:
cur.execute(query, params)
return [dict(row) for row in cur.fetchall()]
def count(table, where=None):
query = f"SELECT COUNT(*) as count FROM {_safe_id(table)}"
params = {}
if where:
clauses, params = _build_where(where)
query += f" WHERE {clauses}"
with get_cursor() as cur:
cur.execute(query, params)
return cur.fetchone()["count"]
def exists(table, where):
return count(table, where) > 0
def upsert(table, data, conflict_columns):
columns = list(data.keys())
placeholders = [f"%({col})s" for col in columns]
safe_cols = [_safe_id(c) for c in columns]
conflict_cols = [_safe_id(c) for c in conflict_columns]
update_cols = [c for c in columns if c not in conflict_columns]
update_clause = ", ".join(
f"{_safe_id(c)} = EXCLUDED.{_safe_id(c)}" for c in update_cols
)
query = f"""
INSERT INTO {_safe_id(table)}
({", ".join(safe_cols)})
VALUES ({", ".join(placeholders)})
ON CONFLICT ({", ".join(conflict_cols)})
DO UPDATE SET {update_clause}
RETURNING *
"""
with get_cursor() as cur:
cur.execute(query, data)
return dict(cur.fetchone()) if cur.rowcount else None
def insert_many(table, rows):
if not rows:
return 0
columns = list(rows[0].keys())
safe_cols = [_safe_id(c) for c in columns]
query = f"""
INSERT INTO {_safe_id(table)}
({", ".join(safe_cols)})
VALUES %s
"""
template = f"({', '.join(f'%({col})s' for col in columns)})"
with get_cursor() as cur:
psycopg2.extras.execute_values(
cur, query, rows, template=template, page_size=100
)
return cur.rowcount
def execute(query, params=None):
with get_cursor() as cur:
cur.execute(query, params or {})
if cur.description:
return [dict(row) for row in cur.fetchall()]
return cur.rowcount
def table_exists(table):
with get_cursor() as cur:
cur.execute(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %(table)s
)
""",
{"table": table},
)
return cur.fetchone()["exists"]
def get_columns(table):
with get_cursor() as cur:
cur.execute(
"""
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %(table)s
ORDER BY ordinal_position
""",
{"table": table},
)
return [dict(row) for row in cur.fetchall()]

96
core/users.py Normal file
View File

@@ -0,0 +1,96 @@
import uuid
import postgres
import bcrypt
def getUserUUID(username):
userRecord = postgres.select_one("users", {"username": username})
if userRecord:
return userRecord["id"]
return False
def getUserFirstName(userUUID):
userRecord = postgres.select_one("users", {"id": userUUID})
if userRecord:
return userRecord.get("username")
return None
def isUsernameAvailable(username):
return not postgres.exists("users", {"username": username})
def doesUserUUIDExist(userUUID):
return postgres.exists("users", {"id": userUUID})
def registerUser(username, password, data=None):
if isUsernameAvailable(username):
hashed_pass = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
user_data = {
"id": str(uuid.uuid4()),
"username": username,
"password_hashed": hashed_pass,
}
if data:
user_data.update(data)
createUser(user_data)
return True
return False
def updateUser(userUUID, data_dict):
user = postgres.select_one("users", {"id": userUUID})
if not user:
return False
blocked = {"id", "password_hashed", "created_at"}
allowed = set(user.keys()) - blocked
updates = {k: v for k, v in data_dict.items() if k in allowed}
if not updates:
return False
postgres.update("users", updates, {"id": userUUID})
return True
def changePassword(userUUID, new_password):
user = postgres.select_one("users", {"id": userUUID})
if not user:
return False
hashed = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt())
postgres.update("users", {"password_hashed": hashed}, {"id": userUUID})
return True
def deleteUser(userUUID):
user = postgres.select_one("users", {"id": userUUID})
if not user:
return False
postgres.delete("users", {"id": userUUID})
return True
def createUser(data_dict):
user_schema = {
"id": None,
"username": None,
"password_hashed": None,
"created_at": None,
}
for key in user_schema:
if key in data_dict:
user_schema[key] = data_dict[key]
is_valid, errors = validateUser(user_schema)
if not is_valid:
raise ValueError(f"Invalid user data: {', '.join(errors)}")
postgres.insert("users", user_schema)
def validateUser(user):
required = ["id", "username", "password_hashed"]
missing = [f for f in required if f not in user or user[f] is None]
if missing:
return False, missing
return True, []

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASS}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./config/schema.sql:/docker-entrypoint-initdb.d/schema.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
ports:
- "5000:5000"
env_file: config/.env
depends_on:
db:
condition: service_healthy
scheduler:
build: .
command: ["python", "scheduler/daemon.py"]
env_file: config/.env
depends_on:
db:
condition: service_healthy
bot:
build: .
command: ["python", "bot/bot.py"]
env_file: config/.env
depends_on:
app:
condition: service_started
volumes:
pgdata:

7
requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
flask>=3.0.0
psycopg2-binary>=2.9.0
bcrypt>=4.1.0
PyJWT>=2.8.0
discord.py>=2.3.0
openai>=1.0.0
requests>=2.31.0

37
scheduler/daemon.py Normal file
View File

@@ -0,0 +1,37 @@
"""
daemon.py - Background polling loop for scheduled tasks
Override poll_callback() with your domain-specific logic.
"""
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", 60))
def poll_callback():
"""
Override this function with your domain logic.
Called every POLL_INTERVAL seconds.
"""
pass
def daemon_loop():
logger.info("Scheduler daemon starting")
while True:
try:
poll_callback()
except Exception as e:
logger.error(f"Poll callback error: {e}")
time.sleep(POLL_INTERVAL)
if __name__ == "__main__":
import os
daemon_loop()