Build reusable bot framework
This commit is contained in:
308
api/main.py
Normal file
308
api/main.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user