from .common import ( MAX_HISTORY_MESSAGES, MAX_INCREMENTAL_MESSAGES, MAX_MESSAGE_LENGTH, now_iso, csrf_valid, rate_limited, require_user, require_writable_user, audit, safe_json, jsonify, request, get_db, build_message_payload, resolve_name_color, validate_dm_access, dm_payload, find_or_create_dm, get_dm_other_user, MESSAGE_SELECT, resolve_owned_attachment, resolve_owned_attachment_ids, normalize_username, list_dms_for_user, ) def register_dm_routes(app): @app.route("/api/dms") def api_list_dms(): user, error = require_user() if error: return error return jsonify({"dms": list_dms_for_user(user["id"])}) @app.route("/api/dms", methods=["POST"]) def api_create_or_open_dm(): if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 other_user_id = data.get("user_id") username = normalize_username(data.get("username")) with get_db() as db: target = None if other_user_id is not None: try: parsed_id = int(other_user_id) except (TypeError, ValueError): return jsonify({"error": "Invalid user_id."}), 400 target = db.execute( """ SELECT id, username, role, status FROM users WHERE id = ? """, (parsed_id,), ).fetchone() elif username: target = db.execute( """ SELECT id, username, role, status FROM users WHERE username = ? """, (username,), ).fetchone() if not target: return jsonify({"error": "User not found."}), 404 if int(target["id"]) == int(user["id"]): return jsonify({"error": "You cannot DM yourself."}), 400 if target["status"] in {"disabled", "blacklisted"}: return jsonify({"error": "User not available."}), 400 dm_id = find_or_create_dm(user["id"], target["id"], now_iso) audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"])) return jsonify({"dm": dm_payload(dm_id, user["id"], resolve_name_color)}), 201 @app.route("/api/dms//messages") def api_get_dm_messages(dm_id: int): user, error = require_user() if error: return error if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 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.direct_conversation_id = ? AND m.parent_message_id IS NULL AND m.id > ? ORDER BY m.id ASC LIMIT ? """, (dm_id, after_id, MAX_INCREMENTAL_MESSAGES), ).fetchall() else: rows = db.execute( f""" SELECT * FROM ( {MESSAGE_SELECT} WHERE m.direct_conversation_id = ? AND m.parent_message_id IS NULL ORDER BY m.id DESC LIMIT ? ) ORDER BY id ASC """, (dm_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( { "dm": dm_payload(dm_id, user["id"], resolve_name_color), "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, } ) @app.route("/api/dms//messages", methods=["POST"]) def api_send_dm_message(dm_id: int): limited = rate_limited("send") if limited: return limited if not csrf_valid(): return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 user, error = require_writable_user() if error: return error if not validate_dm_access(dm_id, user): return jsonify({"error": "DM not found."}), 404 try: data = safe_json() except ValueError as error: return jsonify({"error": str(error)}), 400 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_error = resolve_owned_attachment_ids( data.get("attachment_ids", []), user["id"], ) if attachment_error: return attachment_error if not attachment_ids and data.get("attachment_id") is not None: single_attachment_id, single_attachment_error = resolve_owned_attachment( data.get("attachment_id"), user["id"], ) if single_attachment_error: return single_attachment_error attachment_ids = [single_attachment_id] if single_attachment_id else [] attachment_id = attachment_ids[0] if attachment_ids else None 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, ?, ?, ?, ?, ?, NULL, ?) """, (dm_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("dm_message_sent", user["id"], "dm", str(dm_id), str(message_id)) return jsonify({"message": build_message_payload(row, user)}), 201