Files
Lamont/api/main.py
Chelsea Lee fbdf33e894
Some checks failed
CI / test (push) Has been cancelled
CI / compose-smoke (push) Has been cancelled
Build reusable bot framework
2026-07-19 21:53:24 -05:00

309 lines
11 KiB
Python

"""Flask API and application factory for the reusable bot framework."""
import logging
import os
from pathlib import Path
import flask
from dotenv import load_dotenv
from api.security import jsonObject, requireService, requireUser
from core import api_keys as apiKeys
from core import auth, identity, outbox, postgres, users
from core.registry import discover_modules
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
def createApp():
app = flask.Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = int(
os.getenv("MAX_REQUEST_BYTES", 1024 * 1024)
)
app.config["SERVICE_KEY_READY"] = False
moduleRegistry = discover_modules()
_registerCoreRoutes(app)
for moduleName, registerRoutes in moduleRegistry.route_registrars:
logger.info("Registering routes for module %s", moduleName)
registerRoutes(app)
@app.before_request
def bootstrapConfiguredServiceKey():
if flask.request.path == "/health/live":
return None
if not app.config["SERVICE_KEY_READY"] and os.getenv("BOT_API_KEY"):
apiKeys.bootstrapServiceApiKey()
app.config["SERVICE_KEY_READY"] = True
return None
@app.errorhandler(404)
def notFound(_error):
return flask.jsonify({"error": "not found"}), 404
@app.errorhandler(405)
def methodNotAllowed(_error):
return flask.jsonify({"error": "method not allowed"}), 405
@app.errorhandler(413)
def requestTooLarge(_error):
return flask.jsonify({"error": "request too large"}), 413
@app.errorhandler(Exception)
def unexpectedError(error):
logger.exception("Unhandled API error", exc_info=error)
return flask.jsonify({"error": "internal server error"}), 500
return app
def _registerCoreRoutes(app):
@app.route("/api/register", methods=["POST"])
def api_register():
data = jsonObject()
if data is None:
return flask.jsonify({"error": "JSON object required"}), 400
try:
registered = users.registerUser(
data.get("username"), data.get("password"), data
)
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
if not registered:
return flask.jsonify({"error": "username taken"}), 409
return flask.jsonify({"success": True}), 201
@app.route("/api/login", methods=["POST"])
def api_login():
data = jsonObject()
if data is None:
return flask.jsonify({"error": "JSON object required"}), 400
token = auth.getLoginToken(data.get("username"), data.get("password"))
if not token:
return flask.jsonify({"error": "invalid credentials"}), 401
return flask.jsonify({"token": token}), 200
@app.route("/api/auth/discord/session", methods=["POST"])
@requireService("discord:session")
def api_discordSession():
data = jsonObject()
if data is None or not data.get("discord_id"):
return flask.jsonify({"error": "discord_id required"}), 400
try:
user = identity.getOrCreateDiscordUser(
data["discord_id"], displayName=data.get("display_name")
)
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
if not user:
return flask.jsonify({"error": "Discord user is not allowed"}), 403
token = auth.createLoginToken(
str(user["id"]),
name=data.get("display_name"),
extraClaims={"provider": "discord"},
)
return flask.jsonify(
{
"token": token,
"user_uuid": user["id"],
"timezone": user.get("timezone") or "UTC",
}
), 200
@app.route("/api/keys", methods=["GET"])
@requireUser(requireLogin=True)
def api_listKeys():
return flask.jsonify(
{"keys": apiKeys.listUserApiKeys(flask.g.user_uuid)}
), 200
@app.route("/api/keys", methods=["POST"])
@requireUser(requireLogin=True)
def api_createKey():
data = jsonObject()
if data is None:
return flask.jsonify({"error": "JSON object required"}), 400
try:
key = apiKeys.createUserApiKey(
flask.g.user_uuid,
data.get("name"),
expiresAt=data.get("expires_at"),
)
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
return flask.jsonify(key), 201
@app.route("/api/keys/<keyID>", methods=["DELETE"])
@requireUser(requireLogin=True)
def api_revokeKey(keyID):
if not apiKeys.revokeUserApiKey(flask.g.user_uuid, keyID):
return flask.jsonify({"error": "API key not found"}), 404
return flask.jsonify({"success": True}), 200
@app.route("/api/getUserUUID/<username>", methods=["GET"])
@requireUser()
def api_getUserUUID(username):
userUUID = users.getUserUUID(username)
if not userUUID:
return flask.jsonify({"error": "user not found"}), 404
if str(userUUID) != str(flask.g.user_uuid):
return flask.jsonify({"error": "unauthorized"}), 403
return flask.jsonify(userUUID), 200
@app.route("/api/user/<userUUID>", methods=["GET"])
@requireUser()
def api_getUser(userUUID):
if str(userUUID) != str(flask.g.user_uuid):
return flask.jsonify({"error": "unauthorized"}), 403
user = users.getUser(userUUID)
if not user:
return flask.jsonify({"error": "user not found"}), 404
user.pop("password_hashed", None)
return flask.jsonify(user), 200
@app.route("/api/user/<userUUID>", methods=["PUT"])
@requireUser()
def api_updateUser(userUUID):
if str(userUUID) != str(flask.g.user_uuid):
return flask.jsonify({"error": "unauthorized"}), 403
data = jsonObject()
if data is None:
return flask.jsonify({"error": "JSON object required"}), 400
try:
updated = users.updateUser(userUUID, data)
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
if not updated:
return flask.jsonify({"error": "no valid fields to update"}), 400
return flask.jsonify({"success": True}), 200
@app.route("/api/user/<userUUID>", methods=["DELETE"])
@requireUser(requireLogin=True)
def api_deleteUser(userUUID):
if str(userUUID) != str(flask.g.user_uuid):
return flask.jsonify({"error": "unauthorized"}), 403
data = jsonObject()
if data is None or not data.get("password"):
return flask.jsonify(
{"error": "password required for account deletion"}
), 400
if not auth.unregisterUser(userUUID, data["password"]):
return flask.jsonify({"error": "invalid password"}), 401
return flask.jsonify({"success": True}), 200
@app.route("/api/user/me/timezone", methods=["GET", "PUT"])
@requireUser()
def api_userTimezone():
if flask.request.method == "GET":
return flask.jsonify(
{"timezone": users.getUserTimezone(flask.g.user_uuid)}
), 200
data = jsonObject()
if data is None:
return flask.jsonify({"error": "JSON object required"}), 400
try:
timezoneName = users.normalizeTimezone(data.get("timezone"))
users.updateUser(flask.g.user_uuid, {"timezone": timezoneName})
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
return flask.jsonify({"timezone": timezoneName}), 200
@app.route("/api/internal/outbox/claim", methods=["POST"])
@requireService("outbox:claim")
def api_claimOutbox():
data = jsonObject() or {}
workerID = data.get("worker_id")
if not workerID:
return flask.jsonify({"error": "worker_id required"}), 400
try:
claimed = outbox.claim_messages(
workerID,
channel=data.get("channel", "discord_dm"),
limit=data.get("limit", 20),
)
except ValueError as error:
return flask.jsonify({"error": str(error)}), 400
messages = []
for message in claimed:
discordIdentity = _discordIdentityForUser(message["user_uuid"])
if not discordIdentity:
outbox.retry_message(
message["id"], workerID, "user has no Discord identity"
)
continue
payload = message.get("payload") or {}
messages.append(
{
"id": message["id"],
"provider_user_id": discordIdentity["provider_user_id"],
"content": payload.get("content", ""),
"attempts": message["attempts"],
"worker_id": workerID,
}
)
return flask.jsonify({"messages": messages}), 200
@app.route("/api/internal/outbox/<messageID>/result", methods=["POST"])
@requireService("outbox:deliver")
def api_outboxResult(messageID):
data = jsonObject()
if data is None or data.get("status") not in {"sent", "retry"}:
return flask.jsonify({"error": "valid status required"}), 400
message = outbox.get_message(messageID)
if not message or message.get("status") != "delivering":
return flask.jsonify({"error": "leased message not found"}), 404
workerID = message.get("leased_by")
if not data.get("worker_id") or data["worker_id"] != workerID:
return flask.jsonify({"error": "message lease is owned by another worker"}), 409
if data["status"] == "sent":
externalMessageID = data.get("external_message_id")
if externalMessageID is not None:
externalMessageID = str(externalMessageID)[:255]
updated = outbox.mark_delivered(
messageID,
workerID,
external_message_id=externalMessageID,
)
else:
updated = outbox.retry_message(
messageID, workerID, data.get("error", "delivery failed")
)
if not updated:
return flask.jsonify({"error": "message lease expired"}), 409
return flask.jsonify({"success": True, "message": updated}), 200
@app.route("/health/live", methods=["GET"])
def healthLive():
return flask.jsonify({"status": "ok"}), 200
@app.route("/health/ready", methods=["GET"])
def healthReady():
try:
postgres.execute("SELECT 1 AS ready")
except Exception:
return flask.jsonify({"status": "not ready"}), 503
return flask.jsonify({"status": "ready"}), 200
@app.route("/health", methods=["GET"])
def healthCompatibility():
return healthReady()
def _discordIdentityForUser(userUUID):
identities = identity.listUserIdentities(userUUID)
return next(
(item for item in identities if item.get("provider") == "discord"), None
)
app = createApp()
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)