smodusermc commited on
Commit
655220c
·
verified ·
1 Parent(s): 2931047

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +197 -33
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: make them accepted.
 
 
220
  await db.execute(
221
  "UPDATE conversation_members SET status = 'accepted' "
222
- "WHERE status IS NULL OR status = '' OR status = 'pending'"
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
- # If no custom name and no peer (unlikely), keep generic title.
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/at limit.
 
 
 
 
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 COUNT(*) AS cnt FROM conversations
1167
- WHERE type = 'dm' AND is_group = 0 AND user_low_id = ? AND user_high_id = ?""",
 
1168
  (low, high)
1169
  )
1170
- existing = (await cursor.fetchone())["cnt"]
1171
-
1172
- if existing >= MAX_PRIVATE_CHATS_PER_PAIR:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- await db.executemany(
1187
- "INSERT OR IGNORE INTO conversation_members "
1188
- "(conversation_id, user_id, joined_at, status, role) VALUES (?, ?, ?, 'accepted', 'member')",
1189
- [(cid, uid, now), (cid, target_id, now)]
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 to both members (sidebar updates)
 
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": "dm_created", "conversation": peer_summary})
 
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 group")
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 group")
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
- raise HTTPException(400, "Only group chats can be left or deleted")
 
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 db.execute("DELETE FROM read_receipts WHERE message_id IN "
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
- # Send the same conversation into the other person's sidebar
 
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": "dm_created",
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: