Long-press message selection + delete (message.delete frame, outbox removal, all-device sync)
This commit is contained in:
1 parent
60ec2b44a7
commit
6f4bf3cb7f
11 files changed
+417
-18
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user