Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -216,10 +216,12 @@ async def init_database():
|
|
| 216 |
if col not in cm_cols:
|
| 217 |
await _ensure_column(db, "conversation_members", col, ddl)
|
| 218 |
|
| 219 |
-
# Existing memberships predate invites/roles:
|
|
|
|
|
|
|
| 220 |
await db.execute(
|
| 221 |
"UPDATE conversation_members SET status = 'accepted' "
|
| 222 |
-
"WHERE status IS NULL OR status = ''
|
| 223 |
)
|
| 224 |
await db.execute(
|
| 225 |
"UPDATE conversation_members SET role = 'member' "
|
|
@@ -586,7 +588,11 @@ def _preview_for(content: str, file_type: str, file_name: str) -> str:
|
|
| 586 |
|
| 587 |
|
| 588 |
async def _peer_for(db: aiosqlite.Connection, cid: int, uid: int) -> Optional[dict]:
|
| 589 |
-
"""The other person inside a DM conversation.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
cursor = await db.execute(
|
| 591 |
"""SELECT u.id, u.username, u.display_name, u.avatar_path, u.status, u.last_seen
|
| 592 |
FROM conversation_members cm JOIN users u ON u.id = cm.user_id
|
|
@@ -594,6 +600,23 @@ async def _peer_for(db: aiosqlite.Connection, cid: int, uid: int) -> Optional[di
|
|
| 594 |
(cid, uid)
|
| 595 |
)
|
| 596 |
row = await cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
return dict(row) if row else None
|
| 598 |
|
| 599 |
|
|
@@ -646,6 +669,7 @@ async def _build_conversation_summary(
|
|
| 646 |
"created_at": conv["created_at"],
|
| 647 |
"peer": None,
|
| 648 |
"peer_online": False,
|
|
|
|
| 649 |
"dm_number": dm_number,
|
| 650 |
"status": membership.get("status", "accepted"),
|
| 651 |
"role": membership.get("role", "member"),
|
|
@@ -692,11 +716,13 @@ async def _build_conversation_summary(
|
|
| 692 |
"last_seen": peer["last_seen"],
|
| 693 |
}
|
| 694 |
summary["peer_online"] = peer["id"] in online_ids
|
|
|
|
|
|
|
| 695 |
summary["dm_number"] = await _dm_number(db, cid)
|
| 696 |
if not custom_name:
|
| 697 |
title = peer["display_name"] or peer["username"]
|
| 698 |
summary["title"] = title
|
| 699 |
-
|
| 700 |
|
| 701 |
# Newest visible message (encrypted in DB - decrypt only what we preview)
|
| 702 |
cursor = await db.execute(
|
|
@@ -1142,9 +1168,14 @@ _dm_create_lock = asyncio.Lock()
|
|
| 1142 |
|
| 1143 |
async def _create_dm(db: aiosqlite.Connection, uid: int, target_id: int,
|
| 1144 |
manager_ref=None) -> Optional[dict]:
|
| 1145 |
-
"""Create a private chat. Returns summary or None if invalid
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1146 |
|
| 1147 |
Up to MAX_PRIVATE_CHATS_PER_PAIR private chats are allowed per pair of users.
|
|
|
|
| 1148 |
Blocked users cannot create or be added to a private chat; the global room
|
| 1149 |
remains the only shared conversation.
|
| 1150 |
"""
|
|
@@ -1163,31 +1194,80 @@ async def _create_dm(db: aiosqlite.Connection, uid: int, target_id: int,
|
|
| 1163 |
|
| 1164 |
low, high = sorted([uid, target_id])
|
| 1165 |
cursor = await db.execute(
|
| 1166 |
-
"""SELECT
|
| 1167 |
-
WHERE type = 'dm' AND is_group = 0 AND user_low_id = ? AND user_high_id = ?
|
|
|
|
| 1168 |
(low, high)
|
| 1169 |
)
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
raise HTTPException(
|
| 1174 |
400,
|
| 1175 |
f"You already have {MAX_PRIVATE_CHATS_PER_PAIR} private chats with "
|
| 1176 |
f"{target['username']} - delete one to start another"
|
| 1177 |
)
|
| 1178 |
|
| 1179 |
-
cursor = await db.execute(
|
| 1180 |
-
"""INSERT INTO conversations (type, title, created_by, user_low_id, user_high_id, is_group)
|
| 1181 |
-
VALUES ('dm', '', ?, ?, ?, 0)""",
|
| 1182 |
-
(uid, low, high)
|
| 1183 |
-
)
|
| 1184 |
-
cid = cursor.lastrowid
|
| 1185 |
now = int(time.time())
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1191 |
await db.commit()
|
| 1192 |
schedule_db_sync()
|
| 1193 |
|
|
@@ -1208,13 +1288,15 @@ async def create_dm_rest(
|
|
| 1208 |
summary = await _create_dm(db, user['id'], user_id, manager)
|
| 1209 |
if summary is None:
|
| 1210 |
raise HTTPException(400, "Could not create private chat")
|
| 1211 |
-
# Live-push the new conversation
|
|
|
|
| 1212 |
online = {u["id"] for u in manager.get_online_users()}
|
| 1213 |
await manager.send_to_user(user['id'], {"type": "dm_created", "conversation": summary})
|
| 1214 |
peer = summary.get("peer") or {}
|
| 1215 |
if peer.get("id"):
|
| 1216 |
peer_summary = await _build_conversation_summary(db, summary["id"], peer["id"], online)
|
| 1217 |
-
await manager.send_to_user(peer["id"], {"type": "
|
|
|
|
| 1218 |
return {"conversation": summary}
|
| 1219 |
finally:
|
| 1220 |
await db.close()
|
|
@@ -1308,7 +1390,7 @@ async def create_group_rest(
|
|
| 1308 |
async def _accept_conversation_invite(db: aiosqlite.Connection, uid: int, cid: int) -> dict:
|
| 1309 |
member = await _membership(db, cid, uid)
|
| 1310 |
if not member or member.get("status") != "pending":
|
| 1311 |
-
raise HTTPException(404, "You don't have a pending invite to this
|
| 1312 |
if await _conversation_blocked(db, cid, uid):
|
| 1313 |
raise HTTPException(403, "This conversation is hidden because of a block")
|
| 1314 |
await db.execute(
|
|
@@ -1323,11 +1405,21 @@ async def _accept_conversation_invite(db: aiosqlite.Connection, uid: int, cid: i
|
|
| 1323 |
async def _reject_conversation_invite(db: aiosqlite.Connection, uid: int, cid: int) -> bool:
|
| 1324 |
member = await _membership(db, cid, uid)
|
| 1325 |
if not member or member.get("status") != "pending":
|
| 1326 |
-
raise HTTPException(404, "You don't have a pending invite to this
|
| 1327 |
await db.execute("DELETE FROM conversation_members WHERE conversation_id = ? AND user_id = ?",
|
| 1328 |
(cid, uid))
|
| 1329 |
await db.commit()
|
| 1330 |
schedule_db_sync()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
return True
|
| 1332 |
|
| 1333 |
|
|
@@ -1467,6 +1559,77 @@ async def rename_conversation_rest(
|
|
| 1467 |
await db.close()
|
| 1468 |
|
| 1469 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1470 |
@app.delete("/api/conversations/{conversation_id}")
|
| 1471 |
async def delete_or_leave_conversation_rest(
|
| 1472 |
conversation_id: int,
|
|
@@ -1482,19 +1645,19 @@ async def delete_or_leave_conversation_rest(
|
|
| 1482 |
conv = await cursor.fetchone()
|
| 1483 |
if not conv:
|
| 1484 |
raise HTTPException(404, "Conversation not found")
|
|
|
|
|
|
|
|
|
|
| 1485 |
if conv["is_group"] != 1:
|
| 1486 |
-
|
|
|
|
| 1487 |
if not member or member.get("status") != "accepted":
|
| 1488 |
raise HTTPException(403, "You are not a member of this group")
|
| 1489 |
|
| 1490 |
notify_ids = await _group_update_recipient_ids(db, conversation_id)
|
| 1491 |
# Owner deletes the group permanently; members leave the group.
|
| 1492 |
if member and member.get("role") == "owner" and member.get("status") == "accepted":
|
| 1493 |
-
await
|
| 1494 |
-
"(SELECT id FROM messages WHERE conversation_id = ?)", (conversation_id,))
|
| 1495 |
-
await db.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
|
| 1496 |
-
await db.execute("DELETE FROM conversation_members WHERE conversation_id = ?", (conversation_id,))
|
| 1497 |
-
await db.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
|
| 1498 |
await db.commit()
|
| 1499 |
schedule_db_sync()
|
| 1500 |
for mid in notify_ids:
|
|
@@ -2210,14 +2373,15 @@ async def ws_endpoint(ws: WebSocket, token: str = Query(...)):
|
|
| 2210 |
})
|
| 2211 |
peer = summary.get("peer") or {}
|
| 2212 |
online = {u["id"] for u in manager.get_online_users()}
|
| 2213 |
-
#
|
|
|
|
| 2214 |
if peer.get("id"):
|
| 2215 |
db2 = await get_db()
|
| 2216 |
try:
|
| 2217 |
peer_summary = await _build_conversation_summary(
|
| 2218 |
db2, summary["id"], peer["id"], online)
|
| 2219 |
await manager.send_to_user(peer["id"], {
|
| 2220 |
-
"type": "
|
| 2221 |
"conversation": peer_summary,
|
| 2222 |
})
|
| 2223 |
finally:
|
|
|
|
| 216 |
if col not in cm_cols:
|
| 217 |
await _ensure_column(db, "conversation_members", col, ddl)
|
| 218 |
|
| 219 |
+
# Existing memberships predate invites/roles: only null/empty get the
|
| 220 |
+
# default. A real 'pending' invite keeps its pending state across
|
| 221 |
+
# restarts so DMs/groups still require the invitee to accept.
|
| 222 |
await db.execute(
|
| 223 |
"UPDATE conversation_members SET status = 'accepted' "
|
| 224 |
+
"WHERE status IS NULL OR status = ''"
|
| 225 |
)
|
| 226 |
await db.execute(
|
| 227 |
"UPDATE conversation_members SET role = 'member' "
|
|
|
|
| 588 |
|
| 589 |
|
| 590 |
async def _peer_for(db: aiosqlite.Connection, cid: int, uid: int) -> Optional[dict]:
|
| 591 |
+
"""The other person inside a DM conversation.
|
| 592 |
+
|
| 593 |
+
Falls back to the conversation's pair columns so the initiator still sees
|
| 594 |
+
the intended recipient's name even after that recipient rejected or left.
|
| 595 |
+
"""
|
| 596 |
cursor = await db.execute(
|
| 597 |
"""SELECT u.id, u.username, u.display_name, u.avatar_path, u.status, u.last_seen
|
| 598 |
FROM conversation_members cm JOIN users u ON u.id = cm.user_id
|
|
|
|
| 600 |
(cid, uid)
|
| 601 |
)
|
| 602 |
row = await cursor.fetchone()
|
| 603 |
+
if row:
|
| 604 |
+
return dict(row)
|
| 605 |
+
cursor = await db.execute(
|
| 606 |
+
"SELECT user_low_id, user_high_id FROM conversations WHERE id = ?", (cid,)
|
| 607 |
+
)
|
| 608 |
+
conv = await cursor.fetchone()
|
| 609 |
+
if not conv:
|
| 610 |
+
return None
|
| 611 |
+
other_id = conv["user_high_id"] if conv["user_low_id"] == uid else conv["user_low_id"]
|
| 612 |
+
if not other_id or other_id == uid:
|
| 613 |
+
return None
|
| 614 |
+
cursor = await db.execute(
|
| 615 |
+
"""SELECT id, username, display_name, avatar_path, status, last_seen
|
| 616 |
+
FROM users WHERE id = ?""",
|
| 617 |
+
(other_id,)
|
| 618 |
+
)
|
| 619 |
+
row = await cursor.fetchone()
|
| 620 |
return dict(row) if row else None
|
| 621 |
|
| 622 |
|
|
|
|
| 669 |
"created_at": conv["created_at"],
|
| 670 |
"peer": None,
|
| 671 |
"peer_online": False,
|
| 672 |
+
"peer_removed": False,
|
| 673 |
"dm_number": dm_number,
|
| 674 |
"status": membership.get("status", "accepted"),
|
| 675 |
"role": membership.get("role", "member"),
|
|
|
|
| 716 |
"last_seen": peer["last_seen"],
|
| 717 |
}
|
| 718 |
summary["peer_online"] = peer["id"] in online_ids
|
| 719 |
+
peer_membership = await _membership(db, cid, peer["id"])
|
| 720 |
+
summary["peer_removed"] = bool(peer_membership and peer_membership.get("status") == "left")
|
| 721 |
summary["dm_number"] = await _dm_number(db, cid)
|
| 722 |
if not custom_name:
|
| 723 |
title = peer["display_name"] or peer["username"]
|
| 724 |
summary["title"] = title
|
| 725 |
+
# If no custom name and no peer (unlikely), keep generic title.
|
| 726 |
|
| 727 |
# Newest visible message (encrypted in DB - decrypt only what we preview)
|
| 728 |
cursor = await db.execute(
|
|
|
|
| 1168 |
|
| 1169 |
async def _create_dm(db: aiosqlite.Connection, uid: int, target_id: int,
|
| 1170 |
manager_ref=None) -> Optional[dict]:
|
| 1171 |
+
"""Create (or re-invite) a private chat. Returns summary or None if invalid.
|
| 1172 |
+
|
| 1173 |
+
Like group invites, every newly created DM is pending until the other person
|
| 1174 |
+
accepts. Re-inviting a conversation the user previously hid (or that the
|
| 1175 |
+
recipient rejected/left) also requires the recipient to accept again.
|
| 1176 |
|
| 1177 |
Up to MAX_PRIVATE_CHATS_PER_PAIR private chats are allowed per pair of users.
|
| 1178 |
+
Hidden ('left') chats do not consume that slot and are restored on re-invite.
|
| 1179 |
Blocked users cannot create or be added to a private chat; the global room
|
| 1180 |
remains the only shared conversation.
|
| 1181 |
"""
|
|
|
|
| 1194 |
|
| 1195 |
low, high = sorted([uid, target_id])
|
| 1196 |
cursor = await db.execute(
|
| 1197 |
+
"""SELECT id FROM conversations
|
| 1198 |
+
WHERE type = 'dm' AND is_group = 0 AND user_low_id = ? AND user_high_id = ?
|
| 1199 |
+
ORDER BY id ASC""",
|
| 1200 |
(low, high)
|
| 1201 |
)
|
| 1202 |
+
dm_ids = [r["id"] for r in await cursor.fetchall()]
|
| 1203 |
+
|
| 1204 |
+
# An active chat is one both sides still participate in (either the
|
| 1205 |
+
# recipient accepted, or is currently holding a pending invite). A chat
|
| 1206 |
+
# the recipient rejected/left (or that this user hid) does not consume a
|
| 1207 |
+
# slot and can be re-invited without creating an extra conversation.
|
| 1208 |
+
active_count = 0
|
| 1209 |
+
reusable_id = None
|
| 1210 |
+
for cid in dm_ids:
|
| 1211 |
+
mine = await _membership(db, cid, uid)
|
| 1212 |
+
other = await _membership(db, cid, target_id)
|
| 1213 |
+
status = mine.get("status") if mine else None
|
| 1214 |
+
other_status = other.get("status") if other else None
|
| 1215 |
+
if status in ("accepted", "pending") and other_status in ("accepted", "pending"):
|
| 1216 |
+
active_count += 1
|
| 1217 |
+
elif ((status == "accepted" and (other is None or other_status == "left"))
|
| 1218 |
+
or (status == "left" and other is not None)) and reusable_id is None:
|
| 1219 |
+
# Restore the oldest inactive chat. The recipient must accept
|
| 1220 |
+
# again (set to pending below) before the chat is usable.
|
| 1221 |
+
reusable_id = cid
|
| 1222 |
+
|
| 1223 |
+
if active_count >= MAX_PRIVATE_CHATS_PER_PAIR:
|
| 1224 |
raise HTTPException(
|
| 1225 |
400,
|
| 1226 |
f"You already have {MAX_PRIVATE_CHATS_PER_PAIR} private chats with "
|
| 1227 |
f"{target['username']} - delete one to start another"
|
| 1228 |
)
|
| 1229 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
now = int(time.time())
|
| 1231 |
+
if reusable_id is not None:
|
| 1232 |
+
cid = reusable_id
|
| 1233 |
+
await db.execute(
|
| 1234 |
+
"UPDATE conversation_members SET status = 'accepted' "
|
| 1235 |
+
"WHERE conversation_id = ? AND user_id = ?",
|
| 1236 |
+
(cid, uid)
|
| 1237 |
+
)
|
| 1238 |
+
other = await _membership(db, cid, target_id)
|
| 1239 |
+
target_status = other.get("status") if other else None
|
| 1240 |
+
if not other:
|
| 1241 |
+
await db.execute(
|
| 1242 |
+
"INSERT INTO conversation_members "
|
| 1243 |
+
"(conversation_id, user_id, joined_at, status, role) VALUES (?, ?, ?, 'pending', 'member')",
|
| 1244 |
+
(cid, target_id, now)
|
| 1245 |
+
)
|
| 1246 |
+
elif target_status != "pending":
|
| 1247 |
+
# Re-invite: the recipient has to accept again even if they had
|
| 1248 |
+
# accepted before the chat was hidden/left.
|
| 1249 |
+
await db.execute(
|
| 1250 |
+
"UPDATE conversation_members SET status = 'pending', role = 'member' "
|
| 1251 |
+
"WHERE conversation_id = ? AND user_id = ?",
|
| 1252 |
+
(cid, target_id)
|
| 1253 |
+
)
|
| 1254 |
+
else:
|
| 1255 |
+
cursor = await db.execute(
|
| 1256 |
+
"""INSERT INTO conversations (type, title, created_by, user_low_id, user_high_id, is_group)
|
| 1257 |
+
VALUES ('dm', '', ?, ?, ?, 0)""",
|
| 1258 |
+
(uid, low, high)
|
| 1259 |
+
)
|
| 1260 |
+
cid = cursor.lastrowid
|
| 1261 |
+
await db.execute(
|
| 1262 |
+
"INSERT INTO conversation_members "
|
| 1263 |
+
"(conversation_id, user_id, joined_at, status, role) VALUES (?, ?, ?, 'accepted', 'member')",
|
| 1264 |
+
(cid, uid, now)
|
| 1265 |
+
)
|
| 1266 |
+
await db.execute(
|
| 1267 |
+
"INSERT INTO conversation_members "
|
| 1268 |
+
"(conversation_id, user_id, joined_at, status, role) VALUES (?, ?, ?, 'pending', 'member')",
|
| 1269 |
+
(cid, target_id, now)
|
| 1270 |
+
)
|
| 1271 |
await db.commit()
|
| 1272 |
schedule_db_sync()
|
| 1273 |
|
|
|
|
| 1288 |
summary = await _create_dm(db, user['id'], user_id, manager)
|
| 1289 |
if summary is None:
|
| 1290 |
raise HTTPException(400, "Could not create private chat")
|
| 1291 |
+
# Live-push the new conversation. The initiator gets it immediately; the
|
| 1292 |
+
# recipient gets a pending conversation_updated they must accept.
|
| 1293 |
online = {u["id"] for u in manager.get_online_users()}
|
| 1294 |
await manager.send_to_user(user['id'], {"type": "dm_created", "conversation": summary})
|
| 1295 |
peer = summary.get("peer") or {}
|
| 1296 |
if peer.get("id"):
|
| 1297 |
peer_summary = await _build_conversation_summary(db, summary["id"], peer["id"], online)
|
| 1298 |
+
await manager.send_to_user(peer["id"], {"type": "conversation_updated",
|
| 1299 |
+
"conversation": peer_summary})
|
| 1300 |
return {"conversation": summary}
|
| 1301 |
finally:
|
| 1302 |
await db.close()
|
|
|
|
| 1390 |
async def _accept_conversation_invite(db: aiosqlite.Connection, uid: int, cid: int) -> dict:
|
| 1391 |
member = await _membership(db, cid, uid)
|
| 1392 |
if not member or member.get("status") != "pending":
|
| 1393 |
+
raise HTTPException(404, "You don't have a pending invite to this conversation")
|
| 1394 |
if await _conversation_blocked(db, cid, uid):
|
| 1395 |
raise HTTPException(403, "This conversation is hidden because of a block")
|
| 1396 |
await db.execute(
|
|
|
|
| 1405 |
async def _reject_conversation_invite(db: aiosqlite.Connection, uid: int, cid: int) -> bool:
|
| 1406 |
member = await _membership(db, cid, uid)
|
| 1407 |
if not member or member.get("status") != "pending":
|
| 1408 |
+
raise HTTPException(404, "You don't have a pending invite to this conversation")
|
| 1409 |
await db.execute("DELETE FROM conversation_members WHERE conversation_id = ? AND user_id = ?",
|
| 1410 |
(cid, uid))
|
| 1411 |
await db.commit()
|
| 1412 |
schedule_db_sync()
|
| 1413 |
+
# Tell the people who remain (e.g. the initiator) that this invite was
|
| 1414 |
+
# declined so they are not left wondering why the chat is still pending.
|
| 1415 |
+
online = {u["id"] for u in manager.get_online_users()}
|
| 1416 |
+
for mid in await _group_update_recipient_ids(db, cid):
|
| 1417 |
+
await manager.send_to_user(mid, {
|
| 1418 |
+
"type": "conversation_updated",
|
| 1419 |
+
"conversation": await _build_conversation_summary(db, cid, mid, online),
|
| 1420 |
+
"invite_rejected": True,
|
| 1421 |
+
"rejected_user_id": uid,
|
| 1422 |
+
})
|
| 1423 |
return True
|
| 1424 |
|
| 1425 |
|
|
|
|
| 1559 |
await db.close()
|
| 1560 |
|
| 1561 |
|
| 1562 |
+
async def _hard_delete_conversation(db: aiosqlite.Connection, cid: int):
|
| 1563 |
+
"""Permanently remove a conversation, its messages, receipts and members."""
|
| 1564 |
+
await db.execute(
|
| 1565 |
+
"DELETE FROM read_receipts WHERE message_id IN "
|
| 1566 |
+
"(SELECT id FROM messages WHERE conversation_id = ?)", (cid,)
|
| 1567 |
+
)
|
| 1568 |
+
await db.execute("DELETE FROM messages WHERE conversation_id = ?", (cid,))
|
| 1569 |
+
await db.execute("DELETE FROM conversation_members WHERE conversation_id = ?", (cid,))
|
| 1570 |
+
await db.execute("DELETE FROM conversations WHERE id = ?", (cid,))
|
| 1571 |
+
|
| 1572 |
+
|
| 1573 |
+
async def _delete_dm_side(db: aiosqlite.Connection, cid: int, uid: int,
|
| 1574 |
+
conv: dict) -> dict:
|
| 1575 |
+
"""Hide a private chat for `uid`, or delete it for everyone when no accepted
|
| 1576 |
+
peer remains.
|
| 1577 |
+
|
| 1578 |
+
Return the status to report back to the caller.
|
| 1579 |
+
"""
|
| 1580 |
+
member = await _membership(db, cid, uid)
|
| 1581 |
+
if not member:
|
| 1582 |
+
raise HTTPException(404, "You are not part of this private chat")
|
| 1583 |
+
if member.get("status") == "left":
|
| 1584 |
+
return {"status": "hidden", "conversation_id": cid}
|
| 1585 |
+
if member.get("status") != "accepted":
|
| 1586 |
+
raise HTTPException(403, "Accept or reject the invite before deleting this chat")
|
| 1587 |
+
|
| 1588 |
+
other_id = conv["user_high_id"] if conv["user_low_id"] == uid else conv["user_low_id"]
|
| 1589 |
+
other = await _membership(db, cid, other_id) if other_id else None
|
| 1590 |
+
other_active = other and other.get("status") == "accepted"
|
| 1591 |
+
online = {u["id"] for u in manager.get_online_users()}
|
| 1592 |
+
|
| 1593 |
+
if not other_active:
|
| 1594 |
+
# No one else has actually accepted: cancel/delete the chat for everyone.
|
| 1595 |
+
await _hard_delete_conversation(db, cid)
|
| 1596 |
+
await db.commit()
|
| 1597 |
+
schedule_db_sync()
|
| 1598 |
+
notify = {uid}
|
| 1599 |
+
if other is not None:
|
| 1600 |
+
notify.add(other_id)
|
| 1601 |
+
for mid in notify:
|
| 1602 |
+
await manager.send_to_user(mid, {
|
| 1603 |
+
"type": "conversation_deleted",
|
| 1604 |
+
"conversation_id": cid,
|
| 1605 |
+
})
|
| 1606 |
+
return {"status": "deleted", "conversation_id": cid}
|
| 1607 |
+
|
| 1608 |
+
# The other person is still in the chat: hide it only for the current user.
|
| 1609 |
+
await db.execute(
|
| 1610 |
+
"UPDATE conversation_members SET status = 'left' "
|
| 1611 |
+
"WHERE conversation_id = ? AND user_id = ?",
|
| 1612 |
+
(cid, uid)
|
| 1613 |
+
)
|
| 1614 |
+
await db.commit()
|
| 1615 |
+
schedule_db_sync()
|
| 1616 |
+
|
| 1617 |
+
await manager.send_to_user(uid, {
|
| 1618 |
+
"type": "conversation_left",
|
| 1619 |
+
"conversation_id": cid,
|
| 1620 |
+
"user_id": uid,
|
| 1621 |
+
"reason": "deleted",
|
| 1622 |
+
})
|
| 1623 |
+
await manager.send_to_user(other_id, {
|
| 1624 |
+
"type": "conversation_left",
|
| 1625 |
+
"conversation_id": cid,
|
| 1626 |
+
"user_id": uid,
|
| 1627 |
+
"reason": "deleted",
|
| 1628 |
+
"conversation": await _build_conversation_summary(db, cid, other_id, online),
|
| 1629 |
+
})
|
| 1630 |
+
return {"status": "hidden", "conversation_id": cid}
|
| 1631 |
+
|
| 1632 |
+
|
| 1633 |
@app.delete("/api/conversations/{conversation_id}")
|
| 1634 |
async def delete_or_leave_conversation_rest(
|
| 1635 |
conversation_id: int,
|
|
|
|
| 1645 |
conv = await cursor.fetchone()
|
| 1646 |
if not conv:
|
| 1647 |
raise HTTPException(404, "Conversation not found")
|
| 1648 |
+
|
| 1649 |
+
# Private chats hide for the deleting user; they are only removed for
|
| 1650 |
+
# everyone when the other person also deletes (or never accepted).
|
| 1651 |
if conv["is_group"] != 1:
|
| 1652 |
+
return await _delete_dm_side(db, conversation_id, user['id'], conv)
|
| 1653 |
+
|
| 1654 |
if not member or member.get("status") != "accepted":
|
| 1655 |
raise HTTPException(403, "You are not a member of this group")
|
| 1656 |
|
| 1657 |
notify_ids = await _group_update_recipient_ids(db, conversation_id)
|
| 1658 |
# Owner deletes the group permanently; members leave the group.
|
| 1659 |
if member and member.get("role") == "owner" and member.get("status") == "accepted":
|
| 1660 |
+
await _hard_delete_conversation(db, conversation_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1661 |
await db.commit()
|
| 1662 |
schedule_db_sync()
|
| 1663 |
for mid in notify_ids:
|
|
|
|
| 2373 |
})
|
| 2374 |
peer = summary.get("peer") or {}
|
| 2375 |
online = {u["id"] for u in manager.get_online_users()}
|
| 2376 |
+
# The recipient gets a pending invite (must accept), not a
|
| 2377 |
+
# ready-to-use chat.
|
| 2378 |
if peer.get("id"):
|
| 2379 |
db2 = await get_db()
|
| 2380 |
try:
|
| 2381 |
peer_summary = await _build_conversation_summary(
|
| 2382 |
db2, summary["id"], peer["id"], online)
|
| 2383 |
await manager.send_to_user(peer["id"], {
|
| 2384 |
+
"type": "conversation_updated",
|
| 2385 |
"conversation": peer_summary,
|
| 2386 |
})
|
| 2387 |
finally:
|