Update features.py

This commit is contained in:
admin 2026-10-08 00:44:16 +00:00
parent 1efe0817b2
commit 73f10ab2cb

View File

@ -1,18 +1,15 @@
# features.py # features.py
import json import json
import mimetypes
import re import re
import secrets
import time import time
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from flask import abort, jsonify, send_from_directory from flask import abort, jsonify, send_from_directory
import markdown import markdown
from sanitizer import sanitize_html_fragment
from sanitizer import sanitize_html_fragment
IMAGE_MIME_PREFIX = "image/" IMAGE_MIME_PREFIX = "image/"
AUDIO_MIME_PREFIX = "audio/" AUDIO_MIME_PREFIX = "audio/"
@ -33,9 +30,12 @@ THEME_MODES = {"dark", "light"}
PAGE_VISIBILITIES = {"team", "public"} PAGE_VISIBILITIES = {"team", "public"}
PAGE_TYPES = {"project", "document", "update", "resource"} PAGE_TYPES = {"project", "document", "update", "resource"}
TEAM_MEMBER_ROLES = {"owner", "admin", "member"} TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
PROFILE_VISIBILITIES = {"public", "members", "private"}
LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$") REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
def valid_hex_color(value: object) -> bool: def valid_hex_color(value: object) -> bool:
@ -65,16 +65,27 @@ def render_markdown_to_safe_html(text: str) -> str:
def safe_profile_bio_html(html_module, bio: str, bio_mode: str) -> str: def safe_profile_bio_html(html_module, bio: str, bio_mode: str) -> str:
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt" mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
if mode == "html": if mode == "html":
return sanitize_html_fragment(bio or "") return sanitize_html_fragment(bio or "")
if mode == "md": if mode == "md":
return render_markdown_to_safe_html(bio or "") return render_markdown_to_safe_html(bio or "")
return "<p>" + html_module.escape(bio or "").replace("\n", "<br>") + "</p>" return "<p>" + html_module.escape(bio or "").replace("\n", "<br>") + "</p>"
def extract_page_reference_slugs(markdown_text: str) -> list[str]:
text = str(markdown_text or "")
matches = PAGE_LINK_RE.findall(text)
seen = set()
slugs = []
for slug in matches:
normalized = slug.lower().strip()
if not normalized or normalized in seen:
continue
seen.add(normalized)
slugs.append(normalized)
return slugs
def attachment_kind_from_name_and_mime( def attachment_kind_from_name_and_mime(
original_name: Optional[str], original_name: Optional[str],
mime_type: Optional[str], mime_type: Optional[str],
@ -82,7 +93,6 @@ def attachment_kind_from_name_and_mime(
mime_type = str(mime_type or "").lower() mime_type = str(mime_type or "").lower()
name = str(original_name or "") name = str(original_name or "")
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "" ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS: if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
return "image" return "image"
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS: if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
@ -90,7 +100,224 @@ def attachment_kind_from_name_and_mime(
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS: if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
return "video" return "video"
return "file" return "file"
def load_page_children(get_db, page_id: int) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
title,
updated_at
FROM pages
WHERE parent_page_id = ?
AND is_active = 1
ORDER BY title COLLATE NOCASE, id ASC
""",
(page_id,),
).fetchall()
return [dict(row) for row in rows]
def load_page_backlinks(get_db, page_id: int, page_slug: str, team_id: int) -> list[dict]:
pattern_1 = f"%]({page_slug})%"
pattern_2 = f"%/pages/{page_slug})%"
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
slug,
title,
updated_at
FROM pages
WHERE team_id = ?
AND is_active = 1
AND id != ?
AND (body LIKE ? OR body LIKE ?)
ORDER BY updated_at DESC, id DESC
""",
(team_id, page_id, pattern_1, pattern_2),
).fetchall()
return [dict(row) for row in rows]
def load_page_comments(get_db, page_id: int) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
pc.id,
pc.page_id,
pc.user_id,
pc.username,
pc.body,
pc.created_at,
pc.updated_at,
u.role AS user_role,
us.name_color AS user_name_color
FROM page_comments AS pc
JOIN users AS u
ON u.id = pc.user_id
LEFT JOIN user_settings AS us
ON us.user_id = u.id
WHERE pc.page_id = ?
AND pc.deleted_at IS NULL
ORDER BY pc.created_at ASC, pc.id ASC
""",
(page_id,),
).fetchall()
return [
{
"id": row["id"],
"page_id": row["page_id"],
"user_id": row["user_id"],
"username": row["username"],
"body": row["body"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"user_role": row["user_role"],
"user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
}
for row in rows
]
def next_page_version_number(get_db, page_id: int) -> int:
with get_db() as db:
row = db.execute(
"""
SELECT COALESCE(MAX(version_number), 0) AS value
FROM page_versions
WHERE page_id = ?
""",
(page_id,),
).fetchone()
return int(row["value"] or 0) + 1
def snapshot_page_version(
get_db,
page_id: int,
title: str,
body: str,
visibility: str,
page_type: str,
channel_id: Optional[int],
parent_page_id: Optional[int],
created_by_user_id: int,
created_at: str,
) -> int:
version_number = next_page_version_number(get_db, page_id)
with get_db() as db:
db.execute("BEGIN IMMEDIATE")
cursor = db.execute(
"""
INSERT INTO page_versions (
page_id,
version_number,
title,
body,
visibility,
page_type,
channel_id,
parent_page_id,
created_by_user_id,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
page_id,
version_number,
title,
body,
visibility,
page_type,
channel_id,
parent_page_id,
created_by_user_id,
created_at,
),
)
db.commit()
return cursor.lastrowid
def load_page_versions(get_db, page_id: int) -> list[dict]:
with get_db() as db:
rows = db.execute(
"""
SELECT
pv.id,
pv.page_id,
pv.version_number,
pv.title,
pv.visibility,
pv.page_type,
pv.channel_id,
pv.parent_page_id,
pv.created_by_user_id,
pv.created_at,
u.username AS created_by_username
FROM page_versions AS pv
JOIN users AS u
ON u.id = pv.created_by_user_id
WHERE pv.page_id = ?
ORDER BY pv.version_number DESC
""",
(page_id,),
).fetchall()
return [dict(row) for row in rows]
def load_page_version_detail(get_db, version_id: int):
with get_db() as db:
row = db.execute(
"""
SELECT
pv.id,
pv.page_id,
pv.version_number,
pv.title,
pv.body,
pv.visibility,
pv.page_type,
pv.channel_id,
pv.parent_page_id,
pv.created_by_user_id,
pv.created_at,
u.username AS created_by_username
FROM page_versions AS pv
JOIN users AS u
ON u.id = pv.created_by_user_id
WHERE pv.id = ?
""",
(version_id,),
).fetchone()
return row
def user_can_see_last_active(settings_row, viewer: Optional[dict], target_user_id: int, is_team_member_func=None) -> bool:
visibility = (settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone") or "everyone"
if visibility == "nobody":
return False
if visibility == "everyone":
return True
if visibility == "members":
if not viewer:
return False
if viewer["id"] == target_user_id:
return True
if viewer["role"] in {"moderator", "admin"}:
return True
if is_team_member_func:
return True
return False
return False
def page_payload(app_module, page, user: Optional[dict]) -> dict: def page_payload(app_module, page, user: Optional[dict]) -> dict:
can_read = bool( can_read = bool(
page["visibility"] == "public" page["visibility"] == "public"
@ -98,13 +325,11 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
user user
and ( and (
user["role"] in {"moderator", "admin"} user["role"] in {"moderator", "admin"}
or app_module.is_team_member(page["team_id"], user["id"]) or app_module["is_team_member"](page["team_id"], user["id"])
) )
) )
) )
rendered_html = render_markdown_to_safe_html(page["body"] or "")
rendered_html = app_module.render_markdown_to_safe_html(page["body"] or "")
return { return {
"id": page["id"], "id": page["id"],
"team_id": page["team_id"], "team_id": page["team_id"],
@ -113,6 +338,9 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
"channel_id": page["channel_id"], "channel_id": page["channel_id"],
"channel_slug": page["channel_slug"], "channel_slug": page["channel_slug"],
"channel_name": page["channel_name"], "channel_name": page["channel_name"],
"parent_page_id": page["parent_page_id"] if "parent_page_id" in page.keys() else None,
"parent_page_slug": page["parent_page_slug"] if "parent_page_slug" in page.keys() else None,
"parent_page_title": page["parent_page_title"] if "parent_page_title" in page.keys() else None,
"slug": page["slug"], "slug": page["slug"],
"title": page["title"], "title": page["title"],
"body": page["body"], "body": page["body"],
@ -128,20 +356,28 @@ def page_payload(app_module, page, user: Optional[dict]) -> dict:
user user
and ( and (
user["role"] in {"moderator", "admin"} user["role"] in {"moderator", "admin"}
or app_module.is_team_admin(page["team_id"], user) or app_module["is_team_admin"](page["team_id"], user)
) )
), ),
"children": load_page_children(app_module["get_db"], page["id"]),
"backlinks": load_page_backlinks(
app_module["get_db"],
page["id"],
page["slug"],
page["team_id"],
),
"references": extract_page_reference_slugs(page["body"] or ""),
"comments": load_page_comments(app_module["get_db"], page["id"]),
} }
def attachment_payload_from_row(row) -> Optional[dict]: def attachment_payload_from_row(row) -> Optional[dict]:
if not row or not row["id"]: if not row or not row["id"]:
return None return None
kind = attachment_kind_from_name_and_mime( kind = attachment_kind_from_name_and_mime(
row["original_name"], row["original_name"],
row["mime_type"], row["mime_type"],
) )
return { return {
"id": row["id"], "id": row["id"],
"original_name": row["original_name"], "original_name": row["original_name"],
@ -155,12 +391,10 @@ def attachment_payload_from_row(row) -> Optional[dict]:
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]: def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
if not row["attachment_id"]: if not row["attachment_id"]:
return None return None
kind = attachment_kind_from_name_and_mime( kind = attachment_kind_from_name_and_mime(
row["attachment_original_name"], row["attachment_original_name"],
row["attachment_mime_type"], row["attachment_mime_type"],
) )
return { return {
"id": row["attachment_id"], "id": row["attachment_id"],
"original_name": row["attachment_original_name"], "original_name": row["attachment_original_name"],
@ -188,7 +422,6 @@ def attachment_list_for_message(get_db, message_id: int) -> list[dict]:
""", """,
(message_id,), (message_id,),
).fetchall() ).fetchall()
return [attachment_payload_from_row(row) for row in rows] return [attachment_payload_from_row(row) for row in rows]
@ -200,7 +433,6 @@ def cleanup_orphaned_attachments(
app_logger, app_logger,
) -> None: ) -> None:
cutoff = (utc_now() - timedelta(hours=orphan_ttl_hours)).isoformat() cutoff = (utc_now() - timedelta(hours=orphan_ttl_hours)).isoformat()
with get_db() as db: with get_db() as db:
orphaned = db.execute( orphaned = db.execute(
""" """
@ -222,7 +454,6 @@ def cleanup_orphaned_attachments(
""", """,
(cutoff,), (cutoff,),
).fetchall() ).fetchall()
for attachment in orphaned: for attachment in orphaned:
db.execute( db.execute(
""" """
@ -231,7 +462,6 @@ def cleanup_orphaned_attachments(
""", """,
(attachment["id"],), (attachment["id"],),
) )
db.commit() db.commit()
for attachment in orphaned: for attachment in orphaned:
@ -252,12 +482,9 @@ def maybe_cleanup_orphaned_attachments(
) -> None: ) -> None:
now = time.time() now = time.time()
last = state.get("last_attachment_cleanup", 0.0) last = state.get("last_attachment_cleanup", 0.0)
if now - last < cleanup_interval_seconds: if now - last < cleanup_interval_seconds:
return return
state["last_attachment_cleanup"] = now state["last_attachment_cleanup"] = now
try: try:
cleanup_func() cleanup_func()
except Exception: except Exception:
@ -271,23 +498,19 @@ def resolve_owned_attachment_ids(
) -> tuple[Optional[list[int]], Optional[tuple]]: ) -> tuple[Optional[list[int]], Optional[tuple]]:
if attachment_ids is None: if attachment_ids is None:
return [], None return [], None
if not isinstance(attachment_ids, list): if not isinstance(attachment_ids, list):
return None, (jsonify({"error": "attachment_ids must be an array."}), 400) return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
parsed = [] parsed = []
seen = set() seen = set()
for item in attachment_ids[:10]: for item in attachment_ids[:10]:
try: try:
attachment_id = int(item) attachment_id = int(item)
except (TypeError, ValueError): except (TypeError, ValueError):
return None, (jsonify({"error": "Invalid attachment."}), 400) return None, (jsonify({"error": "Invalid attachment."}), 400)
if attachment_id in seen: if attachment_id in seen:
continue continue
seen.add(attachment_id) seen.add(attachment_id)
with get_db() as db: with get_db() as db:
row = db.execute( row = db.execute(
""" """
@ -297,18 +520,14 @@ def resolve_owned_attachment_ids(
""", """,
(attachment_id,), (attachment_id,),
).fetchone() ).fetchone()
if not row: if not row:
return None, (jsonify({"error": "Attachment not found."}), 404) return None, (jsonify({"error": "Attachment not found."}), 404)
if row["uploader_user_id"] != user_id: if row["uploader_user_id"] != user_id:
return None, ( return None, (
jsonify({"error": "You can only attach files you uploaded."}), jsonify({"error": "You can only attach files you uploaded."}),
403, 403,
) )
parsed.append(attachment_id) parsed.append(attachment_id)
return parsed, None return parsed, None
@ -319,7 +538,6 @@ def resolve_owned_attachment(
) -> tuple[Optional[int], Optional[tuple]]: ) -> tuple[Optional[int], Optional[tuple]]:
if attachment_id is None: if attachment_id is None:
return None, None return None, None
try: try:
parsed_id = int(attachment_id) parsed_id = int(attachment_id)
except (TypeError, ValueError): except (TypeError, ValueError):
@ -336,16 +554,13 @@ def resolve_owned_attachment(
""", """,
(parsed_id,), (parsed_id,),
).fetchone() ).fetchone()
if not attachment: if not attachment:
return None, (jsonify({"error": "Attachment not found."}), 404) return None, (jsonify({"error": "Attachment not found."}), 404)
if attachment["uploader_user_id"] != user_id: if attachment["uploader_user_id"] != user_id:
return None, ( return None, (
jsonify({"error": "You can only attach files you uploaded."}), jsonify({"error": "You can only attach files you uploaded."}),
403, 403,
) )
return parsed_id, None return parsed_id, None
@ -359,7 +574,6 @@ def can_download_attachment(
) -> bool: ) -> bool:
if not user: if not user:
return False return False
with get_db() as db: with get_db() as db:
room_rows = db.execute( room_rows = db.execute(
""" """
@ -381,7 +595,6 @@ def can_download_attachment(
""", """,
(attachment_id,), (attachment_id,),
).fetchall() ).fetchall()
channel_rows = db.execute( channel_rows = db.execute(
""" """
SELECT DISTINCT SELECT DISTINCT
@ -394,7 +607,6 @@ def can_download_attachment(
""", """,
(attachment_id,), (attachment_id,),
).fetchall() ).fetchall()
dm_rows = db.execute( dm_rows = db.execute(
""" """
SELECT DISTINCT dc.id SELECT DISTINCT dc.id
@ -413,15 +625,12 @@ def can_download_attachment(
if dm_rows: if dm_rows:
return True return True
if any(can_access_room(room, user) for room in room_rows): if any(can_access_room(room, user) for room in room_rows):
return True return True
for item in channel_rows: for item in channel_rows:
channel = get_channel_by_id(item["channel_id"]) channel = get_channel_by_id(item["channel_id"])
if channel and can_access_channel(channel, user): if channel and can_access_channel(channel, user):
return True return True
return False return False
@ -447,7 +656,6 @@ def send_attachment_file(
""", """,
(attachment_id,), (attachment_id,),
).fetchone() ).fetchone()
if not attachment: if not attachment:
abort(404) abort(404)
@ -493,7 +701,6 @@ def reaction_summary_for_message(get_db, message_id: int, user: Optional[dict])
""", """,
(user["id"] if user else -1, message_id), (user["id"] if user else -1, message_id),
).fetchall() ).fetchall()
return [ return [
{ {
"emoji": row["emoji"], "emoji": row["emoji"],
@ -532,6 +739,7 @@ def richer_message_payload(
).fetchone()["count"] ).fetchone()["count"]
seen_by_user = False seen_by_user = False
is_bookmarked = False
if current_user_id: if current_user_id:
seen_by_user = bool( seen_by_user = bool(
db.execute( db.execute(
@ -544,10 +752,29 @@ def richer_message_payload(
(row["id"], current_user_id), (row["id"], current_user_id),
).fetchone() ).fetchone()
) )
is_bookmarked = bool(
db.execute(
"""
SELECT 1
FROM message_bookmarks
WHERE message_id = ?
AND user_id = ?
""",
(row["id"], current_user_id),
).fetchone()
)
pin_row = db.execute(
"""
SELECT pinned_at, pinned_by_user_id
FROM message_pins
WHERE message_id = ?
""",
(row["id"],),
).fetchone()
primary_attachment = attachment_payload_legacy_from_message_select_row(row) primary_attachment = attachment_payload_legacy_from_message_select_row(row)
attachments = attachment_list_for_message(get_db, row["id"]) attachments = attachment_list_for_message(get_db, row["id"])
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments): if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
attachments.insert(0, primary_attachment) attachments.insert(0, primary_attachment)
@ -576,6 +803,10 @@ def richer_message_payload(
"seen_by_current_user": seen_by_user, "seen_by_current_user": seen_by_user,
"reactions": reaction_summary_for_message(get_db, row["id"], user), "reactions": reaction_summary_for_message(get_db, row["id"], user),
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]), "is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
"is_pinned": bool(pin_row),
"pinned_at": pin_row["pinned_at"] if pin_row else None,
"pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
"is_bookmarked": is_bookmarked,
} }
@ -590,7 +821,6 @@ def feed_message_payload(
user, user,
user["id"] if user else None, user["id"] if user else None,
) )
payload.update( payload.update(
{ {
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None, "room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
@ -618,19 +848,42 @@ def profile_payload(
viewer: Optional[dict], viewer: Optional[dict],
) -> dict: ) -> dict:
is_self = bool(viewer and viewer["id"] == user_row["id"]) is_self = bool(viewer and viewer["id"] == user_row["id"])
status_payload = user_payload_func(user_row) status_payload = user_payload_func(user_row, settings_row)
bio = profile_row["bio"] or "" bio = profile_row["bio"] or ""
bio_mode = profile_row["bio_mode"] or "txt" bio_mode = profile_row["bio_mode"] or "txt"
explicit_name_color = settings_row["name_color"] if settings_row else None explicit_name_color = settings_row["name_color"] if settings_row else None
resolved = resolve_name_color(user_row["role"], explicit_name_color) resolved = resolve_name_color(user_row["role"], explicit_name_color)
profile_visibility = settings_row["profile_visibility"] if settings_row else "public"
if profile_visibility not in PROFILE_VISIBILITIES:
profile_visibility = "public"
last_active_visibility = settings_row["last_active_visibility"] if settings_row else "everyone"
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
last_active_visibility = "everyone"
presence_mode = settings_row["presence_mode"] if settings_row else "online"
if presence_mode not in PRESENCE_MODES:
presence_mode = "online"
last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
can_show_last_active = False
if last_active_visibility == "everyone":
can_show_last_active = True
elif last_active_visibility == "members":
can_show_last_active = bool(viewer and (viewer["id"] == user_row["id"] or viewer["role"] in {"moderator", "admin"}))
elif last_active_visibility == "nobody":
can_show_last_active = bool(viewer and viewer["id"] == user_row["id"])
return { return {
"id": user_row["id"], "id": user_row["id"],
"username": user_row["username"], "username": user_row["username"],
"role": user_row["role"], "role": user_row["role"],
"status": status_payload["status"], "status": status_payload["status"],
"presence_mode": presence_mode,
"created_at": user_row["created_at"], "created_at": user_row["created_at"],
"last_active_at": last_active_at if can_show_last_active else None,
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"], "display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "", "badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
"bio": bio, "bio": bio,
@ -638,10 +891,17 @@ def profile_payload(
"bio_html": safe_profile_bio_html(html_module, bio, bio_mode), "bio_html": safe_profile_bio_html(html_module, bio, bio_mode),
"website": profile_row["website"] or "", "website": profile_row["website"] or "",
"location": profile_row["location"] or "", "location": profile_row["location"] or "",
"status_message": profile_row["status_message"] or "",
"banner_attachment_id": profile_row["banner_attachment_id"],
"banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if profile_row["banner_attachment_id"] else "",
"updated_at": profile_row["updated_at"], "updated_at": profile_row["updated_at"],
"is_self": is_self, "is_self": is_self,
"name_color": explicit_name_color, "name_color": explicit_name_color,
"resolved_name_color": resolved, "resolved_name_color": resolved,
"profile_background_color": settings_row["profile_background_color"] if settings_row else None,
"profile_banner_color": settings_row["profile_banner_color"] if settings_row else None,
"profile_visibility": profile_visibility,
"last_active_visibility": last_active_visibility,
} }
@ -657,6 +917,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
other.role AS other_role, other.role AS other_role,
us.name_color AS other_name_color, us.name_color AS other_name_color,
up.display_name AS other_display_name, up.display_name AS other_display_name,
up.status_message AS other_status_message,
us.presence_mode AS other_presence_mode,
( (
SELECT m.text SELECT m.text
FROM messages AS m FROM messages AS m
@ -709,6 +971,8 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
"role": row["other_role"], "role": row["other_role"],
"name_color": row["other_name_color"], "name_color": row["other_name_color"],
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]), "resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
"status_message": row["other_status_message"] or "",
"presence_mode": row["other_presence_mode"] or "online",
}, },
"last_message_text": row["last_message_text"], "last_message_text": row["last_message_text"],
"last_message_created_at": row["last_message_created_at"], "last_message_created_at": row["last_message_created_at"],
@ -720,7 +984,6 @@ def list_dms_for_user(get_db, user_id: int) -> list[dict]:
def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> int: def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> int:
low_id, high_id = sorted([int(user_a_id), int(user_b_id)]) low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
with get_db() as db: with get_db() as db:
existing = db.execute( existing = db.execute(
""" """
@ -735,7 +998,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
""", """,
(low_id, high_id, f"{low_id}:{high_id}"), (low_id, high_id, f"{low_id}:{high_id}"),
).fetchone() ).fetchone()
if existing: if existing:
return existing["id"] return existing["id"]
@ -750,7 +1012,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
""", """,
(f"{low_id}:{high_id}",), (f"{low_id}:{high_id}",),
).fetchone() ).fetchone()
if existing: if existing:
db.commit() db.commit()
return existing["id"] return existing["id"]
@ -765,7 +1026,6 @@ def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int)
(f"{low_id}:{high_id}", now_iso()), (f"{low_id}:{high_id}", now_iso()),
) )
dm_id = cursor.lastrowid dm_id = cursor.lastrowid
db.execute( db.execute(
""" """
INSERT INTO direct_conversation_members ( INSERT INTO direct_conversation_members (
@ -817,7 +1077,9 @@ def get_dm_other_user(get_db, dm_id: int, user_id: int):
u.username, u.username,
u.role, u.role,
up.display_name, up.display_name,
us.name_color up.status_message,
us.name_color,
us.presence_mode
FROM direct_conversation_members AS dcm FROM direct_conversation_members AS dcm
JOIN users AS u JOIN users AS u
ON u.id = dcm.user_id ON u.id = dcm.user_id
@ -853,6 +1115,8 @@ def dm_payload(get_db, dm_id: int, user_id: int) -> dict:
"role": other["role"], "role": other["role"],
"name_color": other["name_color"], "name_color": other["name_color"],
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]), "resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
"status_message": other["status_message"] or "",
"presence_mode": other["presence_mode"] or "online",
}, },
} }
@ -1071,3 +1335,89 @@ def call_payload(get_db, call_id: int) -> Optional[dict]:
"created_at": row["created_at"], "created_at": row["created_at"],
"ended_at": row["ended_at"], "ended_at": row["ended_at"],
} }
def dispatch_scheduled_messages(get_db, now_iso_func, app_logger) -> None:
now_value = now_iso_func()
with get_db() as db:
rows = db.execute(
"""
SELECT
id,
room_id,
channel_id,
direct_conversation_id,
user_id,
username,
text,
attachment_ids_json
FROM scheduled_messages
WHERE sent_at IS NULL
AND cancelled_at IS NULL
AND deliver_at <= ?
ORDER BY deliver_at ASC, id ASC
LIMIT 100
""",
(now_value,),
).fetchall()
if not rows:
return
db.execute("BEGIN IMMEDIATE")
for row in rows:
try:
attachment_ids = json.loads(row["attachment_ids_json"] or "[]")
except json.JSONDecodeError:
attachment_ids = []
attachment_id = attachment_ids[0] if attachment_ids else None
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, ?)
""",
(
row["room_id"],
row["channel_id"],
row["direct_conversation_id"],
row["user_id"],
row["username"],
row["text"],
attachment_id,
now_value,
),
)
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.execute(
"""
UPDATE scheduled_messages
SET sent_at = ?
WHERE id = ?
""",
(now_value, row["id"]),
)
db.commit()