"""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/", 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/", 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/", 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/", 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/", 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//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)