from .common import ( MAX_HISTORY_MESSAGES, MAX_INCREMENTAL_MESSAGES, MAX_MESSAGE_EDIT_WINDOW_SECONDS, MAX_MESSAGE_LENGTH, MAX_SEARCH_QUERY_LENGTH, MAX_SEARCH_RESULTS, MAX_THREAD_REPLIES, READ_RECEIPT_BATCH_LIMIT, json, now_iso, parse_iso, utc_now, require_csrf, rate_limited, current_user, require_user, require_writable_user, require_moderator, audit, parse_json_request, jsonify, request, get_db, db_write, build_message_payload, build_feed_payload, build_local_stub_share_url, room_by_id, get_channel_by_id, get_channel_by_legacy_room_id, channel_payload, require_room_access, require_room_write, require_channel_access, require_channel_write, require_dm_access, validate_dm_access, dm_payload, resolve_name_color, MESSAGE_SELECT, attachment_list_for_message, reaction_summary_for_message, resolve_message_attachment_inputs, user_can_access_message_scope, ) def register_message_routes(app): @app.route("/api/rooms//messages") def get_room_messages(slug: str): user = current_user() room, error = require_room_access( slug, user, login_message="Login required for this private room.", forbidden_message="You do not have access to this room.", ) if error: return error after_raw = request.args.get("after_id", "").strip() after_id = 0 if after_raw: try: after_id = max(0, int(after_raw)) except ValueError: return jsonify({"error": "after_id must be a non-negative integer."}), 400 with get_db() as db: if after_raw: rows = db.execute( f""" {MESSAGE_SELECT} WHERE m.room_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL AND m.id > ? ORDER BY m.id ASC LIMIT ? """, (room["id"], after_id, MAX_INCREMENTAL_MESSAGES), ).fetchall() else: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.room_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (room["id"], MAX_HISTORY_MESSAGES), ).fetchall() messages = [build_message_payload(row, user) for row in rows] latest_id = messages[-1]["id"] if messages else after_id return jsonify( { "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, } ) @app.route("/api/rooms//messages", methods=["POST"]) def send_room_message(slug: str): limited = rate_limited("send") if limited: return limited csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_writable_user() if error: return error room, room_error = require_room_write(slug, user) if room_error: return room_error data, json_error = parse_json_request() if json_error: return json_error text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs( data, user["id"], ) if attachment_error: return attachment_error if not text and not attachment_id: return jsonify({"error": "Message cannot be empty."}), 400 channel = get_channel_by_legacy_room_id(room["id"]) channel_id = channel["id"] if channel else None with get_db() as db: db.execute("BEGIN IMMEDIATE") cursor = db.execute( """ INSERT INTO messages ( room_id, channel_id, direct_conversation_id, user_id, username, text, attachment_id, parent_message_id, created_at ) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?) """, ( room["id"], channel_id, user["id"], user["username"], text, attachment_id, now_iso(), ), ) message_id = cursor.lastrowid for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ INSERT OR IGNORE INTO message_attachments ( message_id, attachment_id, position ) VALUES (?, ?, ?) """, (message_id, extra_attachment_id, position), ) db.commit() with get_db() as db: row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() audit("room_message_sent", user["id"], "room", str(room["id"]), str(message_id)) return jsonify({"message": build_message_payload(row, user)}), 201 @app.route("/api/teams//channels//messages") def api_get_channel_messages(team_slug: str, channel_slug: str): user = current_user() channel, error = require_channel_access(team_slug, channel_slug, user) if error: return error after_raw = request.args.get("after_id", "").strip() after_id = 0 if after_raw: try: after_id = max(0, int(after_raw)) except ValueError: return jsonify({"error": "after_id must be a non-negative integer."}), 400 with get_db() as db: if after_raw: rows = db.execute( f""" {MESSAGE_SELECT} WHERE m.channel_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL AND m.id > ? ORDER BY m.id ASC LIMIT ? """, (channel["id"], after_id, MAX_INCREMENTAL_MESSAGES), ).fetchall() else: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.channel_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (channel["id"], MAX_HISTORY_MESSAGES), ).fetchall() messages = [build_message_payload(row, user) for row in rows] latest_id = messages[-1]["id"] if messages else after_id return jsonify( { "team": {"slug": channel["team_slug"], "name": channel["team_name"]}, "channel": channel_payload(channel, user), "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, } ) @app.route("/api/teams//channels//messages", methods=["POST"]) def api_send_channel_message(team_slug: str, channel_slug: str): limited = rate_limited("send") if limited: return limited csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_writable_user() if error: return error channel, channel_error = require_channel_write(team_slug, channel_slug, user) if channel_error: return channel_error data, json_error = parse_json_request() if json_error: return json_error text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Message must be text."}), 400 text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs( data, user["id"], ) if attachment_error: return attachment_error if not text and not attachment_id: return jsonify({"error": "Message cannot be empty."}), 400 with get_db() as db: db.execute("BEGIN IMMEDIATE") cursor = db.execute( """ INSERT INTO messages ( room_id, channel_id, direct_conversation_id, user_id, username, text, attachment_id, parent_message_id, created_at ) VALUES (?, ?, NULL, ?, ?, ?, ?, NULL, ?) """, ( channel["legacy_room_id"], channel["id"], user["id"], user["username"], text, attachment_id, now_iso(), ), ) message_id = cursor.lastrowid for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ INSERT OR IGNORE INTO message_attachments ( message_id, attachment_id, position ) VALUES (?, ?, ?) """, (message_id, extra_attachment_id, position), ) db.commit() with get_db() as db: row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() audit("channel_message_sent", user["id"], "channel", str(channel["id"]), str(message_id)) return jsonify({"message": build_message_payload(row, user)}), 201 @app.route("/api/messages/", methods=["PATCH"]) def edit_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error limited = rate_limited("send") if limited: return limited user, error = require_writable_user() if error: return error data, json_error = parse_json_request() if json_error: return json_error new_text = data.get("text", "") if not isinstance(new_text, str): return jsonify({"error": "Message must be text."}), 400 new_text = new_text.strip() attachment_ids, _, attachment_error = resolve_message_attachment_inputs( data, user["id"], ) if attachment_error: return attachment_error if len(new_text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Message is too long."}), 400 if not new_text and not attachment_ids: return jsonify({"error": "Message cannot be empty."}), 400 with get_db() as db: message = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() if not message: return jsonify({"error": "Message not found."}), 404 is_owner = message["user_id"] == user["id"] is_moderator = user["role"] in {"moderator", "admin"} if not is_owner and not is_moderator: return jsonify({"error": "You cannot edit this message.", "code": "forbidden"}), 403 created_at = parse_iso(message["created_at"]) if is_owner and created_at: age = (utc_now() - created_at).total_seconds() if age > MAX_MESSAGE_EDIT_WINDOW_SECONDS: return jsonify({"error": "Edit window has expired."}), 403 old_attachments = attachment_list_for_message(message_id) with get_db() as db: db.execute("BEGIN IMMEDIATE") db.execute( "UPDATE messages SET text = ?, attachment_id = ? WHERE id = ?", (new_text, attachment_ids[0] if attachment_ids else None, message_id), ) db.execute("DELETE FROM message_attachments WHERE message_id = ?", (message_id,)) for position, attachment_id in enumerate(attachment_ids): db.execute( """ INSERT INTO message_attachments ( message_id, attachment_id, position ) VALUES (?, ?, ?) """, (message_id, attachment_id, position), ) db.execute( """ INSERT INTO message_edits ( message_id, edited_by_user_id, previous_text, new_text, previous_attachments_json, new_attachments_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( message_id, user["id"], message["text"], new_text, json.dumps(old_attachments), json.dumps(attachment_ids), now_iso(), ), ) db.commit() with get_db() as db: updated = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() audit("message_edited", user["id"], "message", str(message_id)) return jsonify({"message": build_message_payload(updated, user)}) @app.route("/api/messages/", methods=["DELETE"]) def delete_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error moderator, error = require_moderator() if error: return error limited = rate_limited("admin") if limited: return limited cursor = db_write("DELETE FROM messages WHERE id = ?", (message_id,)) if cursor.rowcount == 0: return jsonify({"error": "Message not found."}), 404 audit("message_deleted", moderator["id"], "message", str(message_id)) return jsonify({"status": "deleted"}) @app.route("/api/messages/history") def api_message_history(): user = current_user() kind = str(request.args.get("kind", "")).strip() slug = str(request.args.get("slug", "")).strip() team_slug = str(request.args.get("team_slug", "")).strip() before_id_raw = str(request.args.get("before_id", "")).strip() try: before_id = int(before_id_raw) except ValueError: return jsonify({"error": "before_id is required."}), 400 if kind == "room": room, error = require_room_access(slug, user) if error: return error with get_db() as db: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.room_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL AND m.id < ? ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (room["id"], before_id, MAX_HISTORY_MESSAGES), ).fetchall() elif kind == "channel": channel, error = require_channel_access(team_slug, slug, user) if error: return error with get_db() as db: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.channel_id = ? AND m.parent_message_id IS NULL AND m.direct_conversation_id IS NULL AND m.id < ? ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (channel["id"], before_id, MAX_HISTORY_MESSAGES), ).fetchall() elif kind == "dm": dm_id_raw = str(request.args.get("dm_id", "")).strip() try: dm_id = int(dm_id_raw) except ValueError: return jsonify({"error": "dm_id is required for DM history."}), 400 _, error = require_dm_access(dm_id, user) if error: return error with get_db() as db: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.direct_conversation_id = ? AND m.parent_message_id IS NULL AND m.id < ? ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (dm_id, before_id, MAX_HISTORY_MESSAGES), ).fetchall() else: return jsonify({"error": "Invalid history kind."}), 400 messages = [build_message_payload(row, user) for row in rows] return jsonify({"messages": messages, "has_more": len(messages) == MAX_HISTORY_MESSAGES}) @app.route("/api/messages//thread") def api_get_message_thread(message_id: int): user = current_user() with get_db() as db: root = db.execute( f""" {MESSAGE_SELECT} WHERE m.id = ? AND m.parent_message_id IS NULL """, (message_id,), ).fetchone() if not root: return jsonify({"error": "Thread root message not found."}), 404 if root["direct_conversation_id"]: if not validate_dm_access(root["direct_conversation_id"], user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403 else: channel = None if root["channel_id"]: channel = get_channel_by_id(root["channel_id"]) if not channel and root["room_id"]: channel = get_channel_by_legacy_room_id(root["room_id"]) if not channel or not require_channel_access(channel["team_slug"], channel["slug"], user)[0]: if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403 with get_db() as db: replies = db.execute( f""" {MESSAGE_SELECT} WHERE m.parent_message_id = ? ORDER BY m.id ASC LIMIT ? """, (message_id, MAX_THREAD_REPLIES), ).fetchall() payload = { "root": build_message_payload(root, user), "replies": [build_message_payload(reply, user) for reply in replies], } if root["direct_conversation_id"]: payload["dm"] = dm_payload(root["direct_conversation_id"], user["id"], resolve_name_color) else: channel = ( get_channel_by_id(root["channel_id"]) if root["channel_id"] else get_channel_by_legacy_room_id(root["room_id"]) ) payload["team"] = {"slug": channel["team_slug"], "name": channel["team_name"]} payload["channel"] = channel_payload(channel, user) return jsonify(payload) @app.route("/api/messages//thread", methods=["POST"]) def api_reply_to_thread(message_id: int): limited = rate_limited("send") if limited: return limited csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_writable_user() if error: return error with get_db() as db: root = db.execute( """ SELECT id, room_id, channel_id, direct_conversation_id, parent_message_id FROM messages WHERE id = ? """, (message_id,), ).fetchone() if not root or root["parent_message_id"] is not None: return jsonify({"error": "Thread root message not found."}), 404 if root["direct_conversation_id"]: if not validate_dm_access(root["direct_conversation_id"], user): return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 else: channel = None if root["channel_id"]: channel = get_channel_by_id(root["channel_id"]) if not channel and root["room_id"]: channel = get_channel_by_legacy_room_id(root["room_id"]) if not channel or not user_can_access_message_scope(root, user) or not channel: return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 if not channel or channel["id"] != root["channel_id"] and root["channel_id"] is not None: return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 data, json_error = parse_json_request() if json_error: return json_error text = data.get("text", "") if not isinstance(text, str): return jsonify({"error": "Reply must be text."}), 400 text = text.strip() if len(text) > MAX_MESSAGE_LENGTH: return jsonify({"error": "Reply is too long."}), 400 attachment_ids, attachment_id, attachment_error = resolve_message_attachment_inputs( data, user["id"], ) if attachment_error: return attachment_error if not text and not attachment_id: return jsonify({"error": "Reply cannot be empty."}), 400 with get_db() as db: db.execute("BEGIN IMMEDIATE") cursor = db.execute( """ INSERT INTO messages ( room_id, channel_id, direct_conversation_id, user_id, username, text, attachment_id, parent_message_id, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( root["room_id"], root["channel_id"], root["direct_conversation_id"], user["id"], user["username"], text, attachment_id, root["id"], now_iso(), ), ) reply_id = cursor.lastrowid for position, extra_attachment_id in enumerate(attachment_ids): db.execute( """ INSERT OR IGNORE INTO message_attachments ( message_id, attachment_id, position ) VALUES (?, ?, ?) """, (reply_id, extra_attachment_id, position), ) db.commit() with get_db() as db: reply = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (reply_id,)).fetchone() audit("thread_reply_sent", user["id"], "message", str(message_id), str(reply_id)) return jsonify({"reply": build_message_payload(reply, user)}), 201 @app.route("/api/search") def api_search(): user = current_user() query = str(request.args.get("q", "")).strip() if not query: return jsonify({"results": [], "query": ""}) if len(query) > MAX_SEARCH_QUERY_LENGTH: return jsonify({"error": "Search query is too long."}), 400 pattern = f"%{query.lower()}%" with get_db() as db: rows = db.execute( """ SELECT m.id, m.room_id, m.channel_id, m.direct_conversation_id, m.user_id, m.username, m.text, m.created_at, m.attachment_id, m.parent_message_id, ( SELECT COUNT(*) FROM messages AS thread_reply WHERE thread_reply.parent_message_id = m.id ) AS thread_reply_count, r.slug AS room_slug, r.name AS room_name, r.visibility AS room_visibility, c.slug AS channel_slug, c.name AS channel_name, t.slug AS team_slug, t.name AS team_name, a.original_name AS attachment_original_name, a.mime_type AS attachment_mime_type, a.size_bytes AS attachment_size_bytes, u.role AS user_role, us.name_color AS user_name_color FROM messages AS m JOIN users AS u ON u.id = m.user_id LEFT JOIN user_settings AS us ON us.user_id = u.id LEFT JOIN rooms AS r ON r.id = m.room_id LEFT JOIN channels AS c ON c.id = m.channel_id LEFT JOIN teams AS t ON t.id = c.team_id LEFT JOIN attachments AS a ON a.id = m.attachment_id WHERE LOWER(m.text) LIKE ? ORDER BY m.id DESC LIMIT ? """, (pattern, MAX_SEARCH_RESULTS), ).fetchall() visible = [build_feed_payload(row, user) for row in rows if user_can_access_message_scope(row, user)] return jsonify({"query": query, "results": visible}) @app.route("/api/read-receipts", methods=["POST"]) def api_mark_read_receipts(): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error data, json_error = parse_json_request() if json_error: return json_error ids = data.get("message_ids", []) if not isinstance(ids, list): return jsonify({"error": "message_ids must be an array."}), 400 parsed_ids = [] for item in ids[:READ_RECEIPT_BATCH_LIMIT]: try: parsed_ids.append(int(item)) except (TypeError, ValueError): continue if not parsed_ids: return jsonify({"status": "ok", "updated": 0}) updated = 0 with get_db() as db: db.execute("BEGIN IMMEDIATE") for message_id in parsed_ids: row = db.execute( """ SELECT id, room_id, channel_id, direct_conversation_id FROM messages WHERE id = ? """, (message_id,), ).fetchone() if not row: continue if not user_can_access_message_scope(row, user): continue db.execute( """ INSERT INTO message_receipts ( message_id, user_id, seen_at ) VALUES (?, ?, ?) ON CONFLICT(message_id, user_id) DO UPDATE SET seen_at = excluded.seen_at """, (message_id, user["id"], now_iso()), ) updated += 1 db.commit() return jsonify({"status": "ok", "updated": updated}) @app.route("/api/messages//receipts") def api_get_message_receipts(message_id: int): user, error = require_user() if error: return error with get_db() as db: message = db.execute( """ SELECT id, room_id, channel_id, direct_conversation_id, user_id FROM messages WHERE id = ? """, (message_id,), ).fetchone() if not message: return jsonify({"error": "Message not found."}), 404 if not user_can_access_message_scope(message, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 with get_db() as db: rows = db.execute( """ SELECT u.id, u.username, mr.seen_at FROM message_receipts AS mr JOIN users AS u ON u.id = mr.user_id WHERE mr.message_id = ? ORDER BY mr.seen_at DESC, u.username COLLATE NOCASE """, (message_id,), ).fetchall() return jsonify( { "message_id": message_id, "receipts": [ { "user_id": row["id"], "username": row["username"], "seen_at": row["seen_at"], } for row in rows ], } ) @app.route("/api/messages//reactions") def api_get_message_reactions(message_id: int): user = current_user() with get_db() as db: row = db.execute( """ SELECT id, room_id, channel_id, direct_conversation_id FROM messages WHERE id = ? """, (message_id,), ).fetchone() if not row: return jsonify({"error": "Message not found."}), 404 if not user_can_access_message_scope(row, user): if not user: return jsonify({"error": "Login required.", "code": "login_required"}), 401 return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 with get_db() as db: detail_rows = db.execute( """ SELECT mr.emoji, u.id AS user_id, u.username, mr.created_at FROM message_reactions AS mr JOIN users AS u ON u.id = mr.user_id WHERE mr.message_id = ? ORDER BY mr.emoji ASC, u.username COLLATE NOCASE """, (message_id,), ).fetchall() grouped = {} for item in detail_rows: grouped.setdefault(item["emoji"], []).append( { "user_id": item["user_id"], "username": item["username"], "created_at": item["created_at"], } ) summary = [] for emoji, users in grouped.items(): summary.append( { "emoji": emoji, "count": len(users), "reacted": bool(user and any(x["user_id"] == user["id"] for x in users)), "users": users, } ) summary.sort(key=lambda x: (-x["count"], x["emoji"])) return jsonify({"message_id": message_id, "reactions": summary}) @app.route("/api/messages//reactions", methods=["POST"]) def api_add_reaction(message_id: int): limited = rate_limited("reaction") if limited: return limited csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_writable_user() if error: return error data, json_error = parse_json_request() if json_error: return json_error emoji = str(data.get("emoji", "")).strip() if not emoji or len(emoji) > 16: return jsonify({"error": "Invalid reaction."}), 400 with get_db() as db: row = db.execute( """ SELECT id, room_id, channel_id, direct_conversation_id FROM messages WHERE id = ? """, (message_id,), ).fetchone() if not row: return jsonify({"error": "Message not found."}), 404 if not user_can_access_message_scope(row, user): return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 db_write( """ INSERT OR IGNORE INTO message_reactions ( message_id, user_id, emoji, created_at ) VALUES (?, ?, ?, ?) """, (message_id, user["id"], emoji, now_iso()), ) audit("message_reaction_added", user["id"], "message", str(message_id), emoji) return jsonify( { "message_id": message_id, "reactions": reaction_summary_for_message(message_id, user), } ), 201 @app.route("/api/messages//reactions/", methods=["DELETE"]) def api_remove_reaction(message_id: int, emoji: str): limited = rate_limited("reaction") if limited: return limited csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_writable_user() if error: return error db_write( """ DELETE FROM message_reactions WHERE message_id = ? AND user_id = ? AND emoji = ? """, (message_id, user["id"], emoji), ) audit("message_reaction_removed", user["id"], "message", str(message_id), emoji) return jsonify( { "message_id": message_id, "reactions": reaction_summary_for_message(message_id, user), } ) @app.route("/api/messages//bookmark", methods=["POST"]) def api_bookmark_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify({"status": "stubbed", "message_id": message_id}) @app.route("/api/messages//bookmark", methods=["DELETE"]) def api_unbookmark_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify({"status": "stubbed", "message_id": message_id}) @app.route("/api/bookmarks") def api_list_bookmarks(): user, error = require_user() if error: return error return jsonify({"messages": []}) @app.route("/api/messages//pin", methods=["POST"]) def api_pin_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify({"status": "stubbed", "message_id": message_id}) @app.route("/api/messages//pin", methods=["DELETE"]) def api_unpin_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify({"status": "stubbed", "message_id": message_id}) @app.route("/api/pins") def api_list_pins(): user, error = require_user() if error: return error return jsonify({"messages": []}) @app.route("/api/messages//share", methods=["POST"]) def api_share_message(message_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify( { "share": { "url": build_local_stub_share_url("message", message_id) } } ) @app.route("/api/attachments//share", methods=["POST"]) def api_share_attachment(attachment_id: int): csrf_error = require_csrf() if csrf_error: return csrf_error user, error = require_user() if error: return error return jsonify( { "share": { "url": build_local_stub_share_url("attachment", attachment_id) } } )