Long-press message selection + delete (message.delete frame, outbox removal, all-device sync)

This commit is contained in:
ARIA committed 2026-08-20 16:51:19 +02:00
1 parent 60ec2b44a7
commit 6f4bf3cb7f
11 files changed
+417 -18

No files matched your search

+46
View File
@@ -2216,6 +2216,52 @@ class AndroidAdapter(BasePlatformAdapter):
)
await self._ws_server.send_to(device_id, resp)
# ── Message deletion (app -> agent) ───────────────────────────────────
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
"""Handle an inbound ``message.delete`` request.
Removes the requested message(s) from the outbox (so ``history`` and
``sync`` no longer return them) and broadcasts ``message.deleted`` to
every device (outboxed too, so an offline device learns of the
deletion on its next ``sync``). Deleting is idempotent: a message that
is already gone (pruned by retention) simply yields 0 removed rows,
and the ``message.deleted`` broadcast is still emitted so live caches
drop it.
"""
payload = frame.payload
chat_id = frame.chat_id or payload.get("chat_id")
if not isinstance(chat_id, str) or not chat_id.strip():
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id),
)
return
chat_id = chat_id.strip()
thread_id = frame.thread_id or payload.get("thread_id")
if not isinstance(thread_id, str) or not thread_id.strip():
thread_id = None
message_ids = payload.get("message_ids")
if not isinstance(message_ids, list):
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
if not message_ids:
await self._ws_server.send_to(
device_id,
protocol.error(protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id),
)
return
removed = 0
for mid in message_ids:
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
logger.info(
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
device_id, chat_id, thread_id, message_ids, removed,
)
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
resp.id = frame.id
await self._broadcast_or_log(chat_id, resp)
# ── M5: push token registration ───────────────────────────────────────
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
+49
View File
@@ -277,6 +277,55 @@ class Outbox:
"oldest_message_id": oldest_message_id,
}
# ── message deletion ──────────────────────────────────────────────────
def delete_message(
self,
chat_id: str,
message_id: str,
thread_id: Optional[str] = None,
) -> int:
"""Remove every outbox frame belonging to *message_id* in *chat_id*.
A message can span several frames (``message`` / ``message.start`` /
``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames
with no ``thread_id``, and a thread delete matches only that thread's
frames (a ``message_id`` is unique to one lane, so this is a safety
net, not a filter that drops real frames). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned
by retention).
"""
if not message_id:
return 0
with self._lock:
rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall()
cursors: List[int] = []
for r in rows:
try:
frame = json.loads(r["frame"])
except (json.JSONDecodeError, TypeError):
continue
if not isinstance(frame, dict):
continue
if frame.get("thread_id") != thread_id:
continue
payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"]))
if not cursors:
return 0
placeholders = ",".join("?" * len(cursors))
self._conn.execute(
f"DELETE FROM outbox WHERE cursor IN ({placeholders})", cursors
)
self._conn.commit()
return len(cursors)
# ── retention ─────────────────────────────────────────────────────────
def _maybe_prune(self) -> None:
+31
View File
@@ -42,6 +42,10 @@ TYPE_MESSAGE_START = "message.start"
TYPE_MESSAGE_UPDATE = "message.update"
TYPE_MESSAGE_STOP = "message.stop"
# Message deletion (app requests; broadcast to all devices)
TYPE_MESSAGE_DELETE = "message.delete"
TYPE_MESSAGE_DELETED = "message.deleted"
# Tool activity (M2)
TYPE_TOOL_START = "tool.start"
TYPE_TOOL_PROGRESS = "tool.progress"
@@ -528,6 +532,33 @@ def history(
)
# ---------------------------------------------------------------------------
# Message deletion frames
# ---------------------------------------------------------------------------
def message_deleted(
chat_id: str,
message_ids: List[str],
*,
thread_id: Optional[str] = None,
id: Optional[int] = None,
) -> Frame:
"""Broadcast: the given message(s) were deleted from a chat/thread.
Carried by the response to a ``message.delete`` request (``id`` set) and
broadcast to every device so all of them drop the message(s) from their
cache. Also outboxed, so a device that was offline learns of the deletion
on its next ``sync``.
"""
return Frame(
type=TYPE_MESSAGE_DELETED,
id=id,
chat_id=chat_id,
thread_id=thread_id,
payload={"message_ids": list(message_ids)},
)
# ---------------------------------------------------------------------------
# Push / notification frames (M5)
# ---------------------------------------------------------------------------
+2
View File
@@ -380,6 +380,8 @@ class WsServer:
await self._adapter.on_sync(frame, device_id)
elif frame.type == protocol.TYPE_HISTORY:
await self._adapter.on_history(frame, device_id)
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
await self._adapter.on_message_delete(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
await self._adapter.on_media_upload_start(frame, device_id)
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END: