diff --git a/api/__init__.py b/api/__init__.py index 5e4b337..33bd802 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -9,6 +9,7 @@ from .pins import pins_bp from .stream import stream_bp from .calls import calls_bp +from .webhooks import webhooks_bp from .utils import process_cors_headers, cleaner from threading import Thread @@ -29,5 +30,6 @@ def add_cors_headers(resp): api_bp.register_blueprint(pins_bp) api_bp.register_blueprint(stream_bp) api_bp.register_blueprint(calls_bp) +api_bp.register_blueprint(webhooks_bp) -Thread(target=cleaner, daemon=True).start() \ No newline at end of file +Thread(target=cleaner, daemon=True).start() diff --git a/api/auth.py b/api/auth.py index 08c9bc6..0bff801 100644 --- a/api/auth.py +++ b/api/auth.py @@ -21,7 +21,7 @@ def index(): return {"running": "Parley", "version": version, "disable_channel_creation": config["instance"]["disable_channel_creation"], "disable_channel_deletion": config["instance"]["disable_channel_deletion"], "max_channels": config["max_members"]["max_channels"], "password_protected": bool(config["instance"]["password"]), - "calls": config["calls"], + "calls": config["calls"], "webhooks": config["webhooks"], **({"dev": True} if dev_mode else {})}, 200 def join_invite(db, id, invite_code): diff --git a/api/channels.py b/api/channels.py index 31722d6..7e76577 100644 --- a/api/channels.py +++ b/api/channels.py @@ -48,12 +48,12 @@ def channels(db:SQLite, id): 'signature', last_msg.signature, 'signed_timestamp', last_msg.signed_timestamp, 'nonce', last_msg.nonce, - 'user', - json_object( - 'username', last_msg_user.username, - 'display', last_msg_user.display_name, - 'pfp', last_msg_user.pfp - ), + 'user', + json_object( + 'username', CASE WHEN last_msg.user_id='0' THEN NULL ELSE last_msg_user.username END, + 'display', CASE WHEN last_msg.user_id='0' THEN last_msg.webhook_name ELSE last_msg_user.display_name END, + 'pfp', CASE WHEN last_msg.user_id='0' THEN last_msg.webhook_pfp ELSE last_msg_user.pfp END + ), 'attachments', ( SELECT json_group_array(json_object( 'id', am.file_id, diff --git a/api/messages.py b/api/messages.py index 34c568d..dc79a16 100644 --- a/api/messages.py +++ b/api/messages.py @@ -49,7 +49,7 @@ def channel_messages(db:SQLite, id, channel_id): if before_messages>100: before_messages=100 if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, ", "NULL AS user, ", "NULL AS signature, ", "NULL AS signed_timestamp, ", @@ -68,11 +68,11 @@ def channel_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.nonce, m.webhook_id, ", "json_object(", - " 'username', u.username, ", - " 'display', u.display_name, ", - " 'pfp', u.pfp", + " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", + " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", + " 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END", ") AS user, ", "m.signature, ", "m.signed_timestamp, ", @@ -92,7 +92,7 @@ def channel_messages(db:SQLite, id, channel_id): ] params=[channel_id, member_message_seq] if "user_id" in request.args: - if len(request.args["user_id"])!=20: return make_json_error(400, "Invalid user_id parameter, error: length") + if request.args["user_id"]!="0" and len(request.args["user_id"])!=20: return make_json_error(400, "Invalid user_id parameter, error: length") sql_parts.append("AND m.user_id=?") params.append(request.args["user_id"]) if "before" in request.args and "after" in request.args: @@ -272,6 +272,7 @@ def message_management(db:SQLite, id, channel_id, message_id): signature=request.form["signature"] current_time=timestamp() if abs(current_time-signed_timestamp)>config["messages"]["signature_timestamp_window"]: return make_json_error(400, "Timestamp is invalid") + if content==data["content"] and (data["type"]==3 or request.form.get("iv")==data["iv"]): return jsonify({"success": True}) update_fields={"content": content, "edited_at": timestamp(True), "signature": signature, "signed_timestamp": signed_timestamp} @@ -284,8 +285,8 @@ def message_management(db:SQLite, id, channel_id, message_id): # Get updated message data for emit updated_message=db.execute_raw_sql(""" - SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, - json_object('username', u.username, 'display', u.display_name, 'pfp', u.pfp) as user, + SELECT m.id, m.content, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, + json_object('username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END) as user, (SELECT json_group_array(json_object('id', am.file_id, 'filename', f.filename, 'size', f.size, 'mimetype', f.mimetype, 'encrypted', am.encrypted, 'iv', am.iv)) FROM attachment_message am JOIN files f ON am.file_id = f.id WHERE am.message_id = m.id) as attachments FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id=? diff --git a/api/pins.py b/api/pins.py index c0b6965..33c45e0 100644 --- a/api/pins.py +++ b/api/pins.py @@ -33,7 +33,7 @@ def get_pinned_messages(db:SQLite, id, channel_id): page_size, offset = pagination["page_size"], pagination["offset"] if hide_author: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, ", "NULL AS user, ", "(SELECT json_group_array(json_object(", " 'id', am.file_id, ", @@ -51,11 +51,11 @@ def get_pinned_messages(db:SQLite, id, channel_id): ] else: sql_parts=[ - "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, ", + "SELECT m.content, m.id, m.key, m.iv, m.timestamp, m.edited_at, m.replied_to, m.signature, m.signed_timestamp, m.nonce, m.webhook_id, ", "json_object(", - " 'username', u.username, ", - " 'display', u.display_name, ", - " 'pfp', u.pfp", + " 'username', CASE WHEN m.user_id='0' THEN NULL ELSE u.username END, ", + " 'display', CASE WHEN m.user_id='0' THEN m.webhook_name ELSE u.display_name END, ", + " 'pfp', CASE WHEN m.user_id='0' THEN m.webhook_pfp ELSE u.pfp END", ") AS user, ", "(SELECT json_group_array(json_object(", " 'id', am.file_id, ", @@ -113,4 +113,4 @@ def unpin_message(db:SQLite, id, channel_id, message_id): if channel_data["type"]!=1 and not has_permission(user_permissions, perm.manage_messages, channel_data["permissions"]): return make_json_error(403, "You don't have manage messages permission") if not db.exists("messages", {"id": message_id, "channel_id": channel_id}): return make_json_error(404, "Message not found") if db.delete_data("message_pins", {"id": message_id})==0: return make_json_error(409, "Message is not pinned") - return jsonify({"success": True}) \ No newline at end of file + return jsonify({"success": True}) diff --git a/api/webhooks.py b/api/webhooks.py new file mode 100644 index 0000000..c8ca17a --- /dev/null +++ b/api/webhooks.py @@ -0,0 +1,167 @@ +from flask import Blueprint, request, jsonify +import json +import re +from .utils import make_json_error, logged_in, sliding_window_rate_limiter, timestamp, perm, has_permission +from .stream import message_sent +from utils import generate, config +from db import SQLite + +webhooks_bp=Blueprint("webhooks", __name__) +data_uri_regex=re.compile(r"^data:image\/(png|jpeg|jpg|webp|gif|x-icon);base64,[A-Za-z0-9+/=]+$") + +def _get_manageable_channel(db, id, channel_id): + member_channel_data=db.execute_raw_sql(""" + SELECT m.permissions, c.type, c.permissions as channel_permissions + FROM members m + JOIN channels c ON m.channel_id=c.id + WHERE m.user_id=? AND m.channel_id=? + """, (id, channel_id)) + if not member_channel_data: return None, make_json_error(404, "Channel not found") + data=member_channel_data[0] + if data["type"]!=3: return None, make_json_error(400, "Webhooks are only supported in broadcast channels") + if not has_permission(data["permissions"], perm.manage_channel, data["channel_permissions"]): return None, make_json_error(403, "Channel management privileges required") + return data, None + +def _build_webhook_path(channel_id, webhook_id, service=None, token=None): + base=f"{request.host_url.rstrip('/')}{request.script_root}" + path=f"/{config['uri_prefix']}/api/v1/channel/{channel_id}/webhooks/{webhook_id}" if config["uri_prefix"] else f"/api/v1/channel/{channel_id}/webhooks/{webhook_id}" + if service: path+=f"/{service}" + if token: path+=f"?token={token}" + return f"{base}{path}" + +def _get_webhook_data(db, channel_id, webhook_id, token): + webhook_data=db.execute_raw_sql(""" + SELECT w.id, w.channel_id, w.name, w.pfp, w.token, c.type + FROM webhooks w + JOIN channels c ON c.id=w.channel_id + WHERE w.id=? AND w.channel_id=? + """, (webhook_id, channel_id)) + if not webhook_data: return None, make_json_error(404, "Webhook not found") + if webhook_data[0]["token"]!=token: return None, make_json_error(401, "Invalid webhook token") + if webhook_data[0]["type"]!=3: return None, make_json_error(400, "Webhooks are only supported in broadcast channels") + return webhook_data[0], None + +def _truncate_webhook_content(content): + return content[:config["messages"]["max_message_length"]] + +def _validate_webhook_pfp(pfp): + if pfp is None or pfp=="": return None, None + pfp=pfp.strip() + if len(pfp)>131072: return None, make_json_error(400, "Webhook pfp is too large") + if not data_uri_regex.fullmatch(pfp): return None, make_json_error(400, "Webhook pfp must be an image data URI") + return pfp, None + +def _github_message(payload): + event=request.headers.get("X-GitHub-Event", "github") + repository=(payload.get("repository") or {}).get("full_name") if isinstance(payload, dict) else None + if event=="ping": return f"GitHub webhook ping{f' from {repository}' if repository else ''}" + if event=="push": + ref=(payload.get("ref") or "").split("/")[-1] + pusher=(payload.get("pusher") or {}).get("name") or (payload.get("sender") or {}).get("login") or "unknown" + commits=len(payload.get("commits") or []) + return _truncate_webhook_content(f"[{repository or 'GitHub'}] {pusher} pushed {commits} commit{'s' if commits!=1 else ''} to {ref or 'unknown'}") + if event=="pull_request": + action=payload.get("action") or "updated" + pr=payload.get("pull_request") or {} + title=pr.get("title") or "pull request" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] pull request {action}: {title}") + if event=="issues": + action=payload.get("action") or "updated" + issue=payload.get("issue") or {} + title=issue.get("title") or "issue" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] issue {action}: {title}") + if event=="issue_comment": + action=payload.get("action") or "created" + issue=payload.get("issue") or {} + title=issue.get("title") or "issue" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] comment {action} on: {title}") + if event=="release": + action=payload.get("action") or "published" + release=payload.get("release") or {} + name=release.get("name") or release.get("tag_name") or "release" + return _truncate_webhook_content(f"[{repository or 'GitHub'}] release {action}: {name}") + return _truncate_webhook_content(f"GitHub event received: {event}{f' in {repository}' if repository else ''}") + +def _get_webhook_content(service): + payload=request.get_json(force=True, silent=True) + if service is not None: service=service.lower() + if service not in [None, "discord", "github"]: return None, make_json_error(400, "Unsupported webhook compatibility mode") + if not isinstance(payload, dict): return None, make_json_error(400, "Webhook payload must be JSON") + if service=="github": + return {"content": _github_message(payload), "name": None, "pfp": None}, None + content=(payload.get("content") or "").strip() + if not content and service=="discord" and payload.get("embeds"): content=json.dumps(payload["embeds"], ensure_ascii=True) + if not content and payload: content=json.dumps(payload, ensure_ascii=True) + if not content: return None, make_json_error(400, "content is required") + webhook_name=(payload.get("username") or payload.get("name") or "").strip() + webhook_pfp=payload.get("avatar_url") if service=="discord" else payload.get("pfp") + webhook_pfp, error_resp=_validate_webhook_pfp(webhook_pfp) + if error_resp: return None, error_resp + if webhook_name and len(webhook_name)>50: return None, make_json_error(400, "Invalid webhook username parameter, error: length") + return {"content": _truncate_webhook_content(content), "name": webhook_name or None, "pfp": webhook_pfp}, None + +@webhooks_bp.route("/channel//webhooks", methods=["GET"]) +@logged_in() +@sliding_window_rate_limiter(limit=100, window=60, user_limit=50) +def list_webhooks(db:SQLite, id, channel_id): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + _, error_resp=_get_manageable_channel(db, id, channel_id) + if error_resp: return error_resp + webhooks=db.execute_raw_sql(""" + SELECT w.id, w.name, w.pfp, w.token, w.created_at, w.last_used_at, u.username as created_by_username, u.display_name as created_by_display + FROM webhooks w + LEFT JOIN users u ON u.id=w.created_by + WHERE w.channel_id=? + ORDER BY w.created_at DESC + """, (channel_id,)) + for webhook in webhooks: + webhook["url"]=_build_webhook_path(channel_id, webhook["id"]) + return jsonify(webhooks) + +@webhooks_bp.route("/channel//webhooks", methods=["POST"]) +@logged_in() +@sliding_window_rate_limiter(limit=20, window=300, user_limit=10) +def create_webhook(db:SQLite, id, channel_id): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + _, error_resp=_get_manageable_channel(db, id, channel_id) + if error_resp: return error_resp + data=request.get_json(force=True, silent=True) + if not isinstance(data, dict): return make_json_error(400, "Webhook payload must be JSON") + name=(data.get("name") or "").strip() + if len(name)<1 or len(name)>50: return make_json_error(400, "Invalid name parameter, error: length") + pfp, error_resp=_validate_webhook_pfp(data.get("pfp")) + if error_resp: return error_resp + webhook_id=generate() + webhook_token=generate(32) + now=timestamp(True) + db.insert_data("webhooks", {"id": webhook_id, "channel_id": channel_id, "name": name, "pfp": pfp, "token": webhook_token, "created_by": id, "created_at": now, "last_used_at": None}) + webhook={"id": webhook_id, "name": name, "pfp": pfp, "created_at": now, "last_used_at": None, "url": _build_webhook_path(channel_id, webhook_id)} + return jsonify({"webhook": webhook, "token": webhook_token, "send_url": _build_webhook_path(channel_id, webhook_id, token=webhook_token), "success": True}), 201 + +@webhooks_bp.route("/channel//webhooks/", methods=["POST", "DELETE"]) +@webhooks_bp.route("/channel//webhooks//", methods=["POST"]) +@sliding_window_rate_limiter(limit=100, window=60) +def webhook_action(channel_id, webhook_id, service=None): + if not config["webhooks"]["enabled"]: return make_json_error(403, "Webhooks are disabled") + db=SQLite() + try: + token=request.args.get("token") + if not token: return make_json_error(401, "Webhook token is required") + webhook_data, error_resp=_get_webhook_data(db, channel_id, webhook_id, token) + if error_resp: return error_resp + if request.method=="DELETE": + db.delete_data("webhooks", {"id": webhook_id, "channel_id": channel_id}) + return jsonify({"success": True}) + payload, error_resp=_get_webhook_content(service) + if error_resp: return error_resp + sent_at=timestamp(True) + message_id=generate() + webhook_name=payload["name"] or webhook_data["name"] + webhook_pfp=payload["pfp"] or webhook_data["pfp"] + db.insert_data("messages", {"id": message_id, "channel_id": channel_id, "user_id": "0", "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "replied_to": None, "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id, "webhook_name": webhook_name, "webhook_pfp": webhook_pfp}) + db.update_data("webhooks", {"last_used_at": sent_at}, {"id": webhook_id}) + message_data={"id": message_id, "content": payload["content"], "key": None, "iv": None, "timestamp": sent_at, "edited_at": None, "replied_to": None, "user": {"username": None, "display": webhook_name, "pfp": webhook_pfp}, "attachments": [], "signature": None, "signed_timestamp": None, "nonce": None, "webhook_id": webhook_id} + message_sent(channel_id, message_data, "0", db) + return jsonify({"message_id": message_id, "success": True}), 201 + finally: + db.close() diff --git a/default_config.toml b/default_config.toml index 5ec1ead..ed309b9 100644 --- a/default_config.toml +++ b/default_config.toml @@ -1,6 +1,6 @@ # DO NOT EDIT THIS FILE, RUN THE PROGRAM AND EDIT config.toml INSTEAD -version=6 # DO NOT TOUCH IF YOU DON'T KNOW WHAT YOU'RE DOING +version=7 # DO NOT TOUCH IF YOU DON'T KNOW WHAT YOU'RE DOING uri_prefix="$URI_PREFIX" # URI Prefix, you must include it when connecting to the server (https://example.com/uri_prefix/) if present [server] @@ -39,3 +39,5 @@ uri_prefix="$URI_PREFIX" # URI Prefix, you must include it when connecting to th turn_servers=["turn:openrelay.metered.ca:80", "turn:openrelay.metered.ca:443"] # Optional TURN servers for relaying (format: ["turns:turn.example.com:5349"]) turn_username="openrelayproject" # TURN server username turn_password="openrelayproject" # TURN server password +[webhooks] + enabled=true # Enable or disable webhooks feature diff --git a/main.py b/main.py index a3a1e2e..dc0b1d9 100644 --- a/main.py +++ b/main.py @@ -20,7 +20,7 @@ db.create_table("session", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "user": "TEXT NOT NULL", "token_hash": "TEXT UNIQUE NOT NULL", "id": "TEXT UNIQUE NOT NULL", "device": "TEXT", "browser": "TEXT", "logged_in_at": "INTEGER NOT NULL", "next_challenge": "INTEGER", "FOREIGN KEY (user)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("channels", {"id": "TEXT PRIMARY KEY", "name": "TEXT", "pfp": "TEXT", "type": "INTEGER NOT NULL CHECK (type IN (1, 2, 3))", "permissions": "INTEGER NOT NULL DEFAULT 0", "dm": "TEXT", "invite_code": "TEXT UNIQUE", "created_at": "INTEGER NOT NULL", "FOREIGN KEY (pfp)": "REFERENCES files (id) ON DELETE SET NULL"}) db.create_table("members", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "user_id": "TEXT", "channel_id": "TEXT", "joined_at": "INTEGER NOT NULL", "permissions": "INTEGER", "message_seq": "INTEGER DEFAULT 0", "hidden": "INTEGER CHECK (hidden IS NULL OR hidden = 1)", "UNIQUE": "(user_id, channel_id)", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE"}) - db.create_table("messages", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "content": "TEXT NOT NULL", "key": "TEXT", "iv": "TEXT", "timestamp": "INTEGER NOT NULL", "edited_at": "INTEGER", "replied_to": "TEXT", "signature": "TEXT", "signed_timestamp": "INTEGER", "nonce": "TEXT", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) + db.create_table("messages", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "content": "TEXT NOT NULL", "key": "TEXT", "iv": "TEXT", "timestamp": "INTEGER NOT NULL", "edited_at": "INTEGER", "replied_to": "TEXT", "signature": "TEXT", "signed_timestamp": "INTEGER", "nonce": "TEXT", "webhook_id": "TEXT", "webhook_name": "TEXT", "webhook_pfp": "TEXT", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("message_pins", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "id": "TEXT UNIQUE NOT NULL", "FOREIGN KEY (id)": "REFERENCES messages (id) ON DELETE CASCADE"}) db.create_table("files", {"id": "TEXT PRIMARY KEY", "filename": "TEXT", "hash": "TEXT NOT NULL", "size": "INTEGER NOT NULL", "mimetype": "TEXT", "file_type": "TEXT NOT NULL CHECK (file_type IN ('attachment', 'pfp'))", "UNIQUE": "(hash, file_type)"}) db.create_table("attachment_message", {"file_id": "TEXT NOT NULL", "message_id": "TEXT NOT NULL", "encrypted": "INTEGER NOT NULL DEFAULT 0", "iv": "TEXT", "PRIMARY KEY": "(file_id, message_id)", "FOREIGN KEY (file_id)": "REFERENCES files (id) ON DELETE CASCADE", "FOREIGN KEY (message_id)": "REFERENCES messages (id) ON DELETE CASCADE"}) @@ -31,6 +31,7 @@ db.create_table("blocks", {"seq": "INTEGER PRIMARY KEY AUTOINCREMENT", "blocker_id": "TEXT NOT NULL", "blocked_id": "TEXT NOT NULL", "blocked_at": "INTEGER NOT NULL", "UNIQUE": "(blocker_id, blocked_id)", "FOREIGN KEY (blocker_id)": "REFERENCES users (id) ON DELETE CASCADE", "FOREIGN KEY (blocked_id)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("calls", {"channel_id": "TEXT PRIMARY KEY", "started_by": "TEXT NOT NULL", "started_at": "INTEGER NOT NULL", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (started_by)": "REFERENCES users (id) ON DELETE CASCADE"}) db.create_table("call_participants", {"channel_id": "TEXT NOT NULL", "user_id": "TEXT NOT NULL", "joined_at": "INTEGER NOT NULL", "left_at": "INTEGER", "PRIMARY KEY": "(channel_id, user_id)", "FOREIGN KEY (channel_id)": "REFERENCES calls (channel_id) ON DELETE CASCADE", "FOREIGN KEY (user_id)": "REFERENCES users (id) ON DELETE CASCADE"}) + db.create_table("webhooks", {"id": "TEXT PRIMARY KEY", "channel_id": "TEXT NOT NULL", "name": "TEXT NOT NULL", "pfp": "TEXT", "token": "TEXT NOT NULL", "created_by": "TEXT", "created_at": "INTEGER NOT NULL", "last_used_at": "INTEGER", "FOREIGN KEY (channel_id)": "REFERENCES channels (id) ON DELETE CASCADE", "FOREIGN KEY (created_by)": "REFERENCES users (id) ON DELETE SET NULL"}) db.create_index("session", "user") db.create_index("members", "channel_id") db.create_index("members", "message_seq") @@ -47,6 +48,9 @@ db.create_index("message_reads", "channel_id") db.create_index("call_participants", "channel_id") db.create_index("call_participants", "user_id") + db.create_index("webhooks", "channel_id") + db.create_index("webhooks", "token", unique=True) + if not db.exists("users", {"id": "0"}): db.insert_data("users", {"id": "0", "username": "__parley_webhooks_system_account_do_not_use__", "display_name": "System", "pfp": None, "passkey": "system", "public_key": "system", "created_at": 0}) if db.execute_raw_sql("PRAGMA user_version;")[0]["user_version"]!=db_version: db.execute_raw_sql(f"PRAGMA user_version={db_version};") uri_prefix="/"+config["uri_prefix"] if config["uri_prefix"] else "" diff --git a/migrations/8.sql b/migrations/8.sql new file mode 100644 index 0000000..83510e7 --- /dev/null +++ b/migrations/8.sql @@ -0,0 +1,18 @@ +CREATE TABLE webhooks ( + id TEXT PRIMARY KEY, + channel_id TEXT NOT NULL, + name TEXT NOT NULL, + pfp TEXT, + token TEXT NOT NULL, + created_by TEXT, + created_at INTEGER NOT NULL, + last_used_at INTEGER, + FOREIGN KEY (channel_id) REFERENCES channels (id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL +); +ALTER TABLE messages ADD COLUMN webhook_id TEXT; +ALTER TABLE messages ADD COLUMN webhook_name TEXT; +ALTER TABLE messages ADD COLUMN webhook_pfp TEXT; +INSERT OR IGNORE INTO users (id, username, display_name, pfp, passkey, public_key, created_at) VALUES ('0', '__parley_webhooks_system_account_do_not_use__', 'System', NULL, 'system', 'system', 0); +CREATE INDEX idx_webhooks_channel_id ON webhooks (channel_id); +CREATE UNIQUE INDEX idx_webhooks_token ON webhooks (token); diff --git a/version.toml b/version.toml index 1319e2c..de90ee7 100644 --- a/version.toml +++ b/version.toml @@ -1,4 +1,4 @@ # DO NOT TOUCH THESE IF YOU DON'T KNOW WHAT YOU'RE DOING -version="0.6.0" # app version -db=7 # database schema version -config=5 # config file version \ No newline at end of file +version="0.7.0" # app version +db=8 # database schema version +config=7 # config file version