glo/routes/messages.py
2026-11-03 00:22:48 +00:00

1180 lines
39 KiB
Python

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/<string:slug>/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/<string:slug>/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/<string:team_slug>/channels/<string:channel_slug>/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/<string:team_slug>/channels/<string:channel_slug>/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/<int:message_id>", 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/<int:message_id>", 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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/reactions/<path:emoji>", 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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:message_id>/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/<int:attachment_id>/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)
}
}
)