Make thread/channel/message deletion complete (hard delete)
Deleting a thread, channel, or message was a no-op/soft-delete: messages were only dropped from the plugin outbox (still in hermes' session store, hence searchable/recoverable) and channels/threads were merely archived. Now deletion is complete and non-recoverable, with no search trace: - purge.py (new): hard-delete from hermes' session store (state.db). delete_lane wipes a channel's/thread's sessions + messages; deleting a messages row also drops it from the FTS5 index via the delete triggers. delete_message removes one message, matched by (session, role, exact content, closest timestamp) since plugin m_<hex> ids aren't persisted. - channels.py: delete() hard-deletes the row (and a channel's child threads) instead of archiving. - outbox.py: add delete_lane() (wipe all frames for a lane) and message_info() (read a message's final role/text/ts for the match). - adapter.py: on_channel_delete wipes outbox + session store; on_message_delete purges the session-store row per message. - App: delete confirmations no longer claim history stays for search; ChannelStore removes a channel's threads on channel delete. - Docs updated to describe hard deletion.
This commit is contained in:
1 parent
9286937e2d
commit
17bf41a0b9
11 files changed
+373
-51
No files matched your search
@@ -29,12 +29,21 @@ class ChannelStore {
|
|||||||
/** Reconcile a server frame into the cache. */
|
/** Reconcile a server frame into the cache. */
|
||||||
fun onFrame(frame: Frame) {
|
fun onFrame(frame: Frame) {
|
||||||
when (frame.type) {
|
when (frame.type) {
|
||||||
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
|
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> {
|
||||||
TYPE_CHANNEL_DELETED -> remove(frame)
|
upsert(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
TYPE_CHANNEL_DELETED -> {
|
||||||
|
remove(frame)
|
||||||
|
}
|
||||||
|
|
||||||
TYPE_CHANNEL_LIST -> {
|
TYPE_CHANNEL_LIST -> {
|
||||||
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
|
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
|
||||||
}
|
}
|
||||||
else -> Unit
|
|
||||||
|
else -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +57,10 @@ class ChannelStore {
|
|||||||
|
|
||||||
private fun remove(frame: Frame) {
|
private fun remove(frame: Frame) {
|
||||||
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
|
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
|
||||||
_channels.value = _channels.value.filter { it.chatId != p.chatId }
|
// A channel delete also removes its threads (child entries); a thread
|
||||||
|
// delete removes just that thread. Both are hard deletes server-side.
|
||||||
|
_channels.value =
|
||||||
|
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
|
||||||
@@ -62,20 +74,16 @@ class ChannelStore {
|
|||||||
// ── UI helpers ────────────────────────────────────────────────────────
|
// ── UI helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Channels (default + user channels) for the drawer/rail. */
|
/** Channels (default + user channels) for the drawer/rail. */
|
||||||
fun channelsForDrawer(): List<ChannelInfo> =
|
fun channelsForDrawer(): List<ChannelInfo> = _channels.value.filter { it.kind != "thread" }
|
||||||
_channels.value.filter { it.kind != "thread" }
|
|
||||||
|
|
||||||
/** Threads under a channel, for the topic switcher. */
|
/** Threads under a channel, for the topic switcher. */
|
||||||
fun threadsFor(chatId: String): List<ChannelInfo> =
|
fun threadsFor(chatId: String): List<ChannelInfo> = _channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
||||||
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
|
|
||||||
|
|
||||||
fun byId(chatId: String): ChannelInfo? =
|
fun byId(chatId: String): ChannelInfo? = _channels.value.firstOrNull { it.chatId == chatId }
|
||||||
_channels.value.firstOrNull { it.chatId == chatId }
|
|
||||||
|
|
||||||
fun defaultChannel(): ChannelInfo? =
|
fun defaultChannel(): ChannelInfo? = _channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
||||||
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
|
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_channels.value = emptyList()
|
_channels.value = emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -839,7 +839,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
onDismissRequest = { deleteThread = null },
|
onDismissRequest = { deleteThread = null },
|
||||||
title = { Text("Delete topic?") },
|
title = { Text("Delete topic?") },
|
||||||
text = {
|
text = {
|
||||||
Text("Delete \"${t.name}\"? It will be removed from this channel.")
|
Text("Delete \"${t.name}\"? The topic and all its messages will be permanently deleted.")
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
@@ -861,9 +861,9 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
text = {
|
text = {
|
||||||
Text(
|
Text(
|
||||||
if (n == 1) {
|
if (n == 1) {
|
||||||
"This message will be deleted for all devices."
|
"This message will be permanently deleted for all devices and can no longer be found in search."
|
||||||
} else {
|
} else {
|
||||||
"These messages will be deleted for all devices."
|
"These messages will be permanently deleted for all devices and can no longer be found in search."
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1049,7 +1049,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { channelToDelete = null },
|
onDismissRequest = { channelToDelete = null },
|
||||||
title = { Text("Delete channel?") },
|
title = { Text("Delete channel?") },
|
||||||
text = { Text("Delete \"${ch.name}\"? Its history stays available for search.") },
|
text = { Text("Delete \"${ch.name}\"? The channel and all its messages will be permanently deleted.") },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
channelToDelete = null
|
channelToDelete = null
|
||||||
|
|||||||
@@ -376,6 +376,11 @@ Answer an interactive picker.
|
|||||||
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
|
{"type":"channel.set_default","id":16,"chat_id":"android:chan_7","payload":{}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`channel.delete` is a **hard delete**: the channel/thread row is removed from
|
||||||
|
the directory and the lane's history is wiped from the outbox and the hermes
|
||||||
|
session store (no search trace, not recoverable). Deleting a channel also
|
||||||
|
removes its threads. The default channel cannot be deleted.
|
||||||
|
|
||||||
### `search`
|
### `search`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -409,10 +414,12 @@ Load a page of messages for a chat/thread (initial open, scroll-up pagination).
|
|||||||
|
|
||||||
### `message.delete`
|
### `message.delete`
|
||||||
|
|
||||||
Delete the given message(s) from a chat/thread. The server removes them from the
|
Completely delete the given message(s) from a chat/thread. The server removes
|
||||||
outbox (so `history`/`sync` no longer return them) and broadcasts
|
them from the outbox (so `history`/`sync` no longer return them) **and** from
|
||||||
`message.deleted` to every device. Idempotent: a message already gone (pruned by
|
the hermes session store (so no search trace survives and they are not
|
||||||
retention) still yields a `message.deleted` broadcast so live caches drop it.
|
recoverable), then broadcasts `message.deleted` to every device. Idempotent: a
|
||||||
|
message already gone (pruned by retention) still yields a `message.deleted`
|
||||||
|
broadcast so live caches drop it.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
{"type":"message.delete","id":30,"chat_id":"android:default","thread_id":null,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ The hermes gateway already models conversations as `SessionSource` with
|
|||||||
gateway identity concepts**.
|
gateway identity concepts**.
|
||||||
|
|
||||||
| App concept | hermes primitive | Example |
|
| App concept | hermes primitive | Example |
|
||||||
|---|---|---|
|
| --- | --- | --- |
|
||||||
| Default chat | home channel `chat_id` | `android:default` |
|
| Default chat | home channel `chat_id` | `android:default` |
|
||||||
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
|
| A thread (inside default chat) | `thread_id` under the default `chat_id` | `chat_id=android:default, thread_id=t_12` |
|
||||||
| A user-created channel | a new `chat_id` | `android:chan_7` |
|
| A user-created channel | a new `chat_id` | `android:chan_7` |
|
||||||
@@ -85,8 +85,10 @@ lane (nothing to title / session-scoped, not conversation starters).
|
|||||||
`chat_id = android:chan_<n>`, stores in directory, broadcasts
|
`chat_id = android:chan_<n>`, stores in directory, broadcasts
|
||||||
`channel.created` to all devices. The new channel appears in the channel list.
|
`channel.created` to all devices. The new channel appears in the channel list.
|
||||||
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
- **`channel.rename` / `channel.set_default` / `channel.delete`** manage the
|
||||||
directory (rename broadcasts `channel.renamed`; delete is soft — marks
|
directory (rename broadcasts `channel.renamed`; delete is a **hard delete**
|
||||||
archived, keeps history for search).
|
-- the channel/thread row is removed and the lane's history is wiped from
|
||||||
|
the outbox and the hermes session store, so nothing is recoverable and no
|
||||||
|
search trace survives).
|
||||||
- **Automation channels:** a channel can be marked *automation*
|
- **Automation channels:** a channel can be marked *automation*
|
||||||
(`channel.set_automation {on}`, long-press / right-click menu; the default
|
(`channel.set_automation {on}`, long-press / right-click menu; the default
|
||||||
channel cannot be marked). Automation channels are **read-only for the
|
channel cannot be marked). Automation channels are **read-only for the
|
||||||
@@ -148,4 +150,4 @@ lane (nothing to title / session-scoped, not conversation starters).
|
|||||||
|
|
||||||
`hello.ack` and `channel.*` frames carry the directory. App keeps a local copy
|
`hello.ack` and `channel.*` frames carry the directory. App keeps a local copy
|
||||||
(Room) and reconciles on `channel.*` events (merge, don't clobber — see
|
(Room) and reconciles on `channel.*` events (merge, don't clobber — see
|
||||||
`10-android-app.md` state rules).
|
`10-android-app.md` state rules).
|
||||||
+29
-10
@@ -6,7 +6,7 @@ the code is in `app/shared` (commonMain) so the Desktop app reuses it.
|
|||||||
## 10.1 Tech stack
|
## 10.1 Tech stack
|
||||||
|
|
||||||
| Concern | Choice |
|
| Concern | Choice |
|
||||||
|---|---|
|
| --- | --- |
|
||||||
| Language | Kotlin |
|
| Language | Kotlin |
|
||||||
| UI | Jetpack Compose (Material 3), Compose Navigation |
|
| UI | Jetpack Compose (Material 3), Compose Navigation |
|
||||||
| Async | Kotlinx Coroutines + Flow |
|
| Async | Kotlinx Coroutines + Flow |
|
||||||
@@ -74,6 +74,7 @@ app/shared/src/
|
|||||||
## 10.5 Feature implementation (your checklist)
|
## 10.5 Feature implementation (your checklist)
|
||||||
|
|
||||||
### Input box, auto-grow (max height)
|
### Input box, auto-grow (max height)
|
||||||
|
|
||||||
- Compose `BasicTextField` inside a `Box` with
|
- Compose `BasicTextField` inside a `Box` with
|
||||||
`Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at
|
`Modifier.heightIn(min = 1.line, max = 160.dp)`. Grows with lines, caps at
|
||||||
160dp, then scrolls internally.
|
160dp, then scrolls internally.
|
||||||
@@ -82,6 +83,7 @@ app/shared/src/
|
|||||||
(configurable: Enter=send vs Enter=newline).
|
(configurable: Enter=send vs Enter=newline).
|
||||||
|
|
||||||
### Slash commands
|
### Slash commands
|
||||||
|
|
||||||
- **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up
|
- **`/` drawer (implemented):** typing `/` in the composer rolls a drawer up
|
||||||
over the input listing the command catalog. The catalog is served by the
|
over the input listing the command catalog. The catalog is served by the
|
||||||
gateway via `commands.catalog` request → response with
|
gateway via `commands.catalog` request → response with
|
||||||
@@ -102,6 +104,7 @@ app/shared/src/
|
|||||||
sheet with the options; answer via `picker.select`) — planned.
|
sheet with the options; answer via `picker.select`) — planned.
|
||||||
|
|
||||||
### Streaming (app-controlled)
|
### Streaming (app-controlled)
|
||||||
|
|
||||||
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
|
- **Settings → "Streaming"** toggle (default on). When off, the app ignores
|
||||||
`message.start`/`message.update` frames and shows each reply as a single
|
`message.start`/`message.update` frames and shows each reply as a single
|
||||||
final message on `message.stop` (the typing indicator covers the wait).
|
final message on `message.stop` (the typing indicator covers the wait).
|
||||||
@@ -109,6 +112,7 @@ app/shared/src/
|
|||||||
devices (see `05-streaming.md` §5.1).
|
devices (see `05-streaming.md` §5.1).
|
||||||
|
|
||||||
### Tool output (app-controlled verbosity)
|
### Tool output (app-controlled verbosity)
|
||||||
|
|
||||||
- `ToolCard` renders `tool.start/progress/end` frames.
|
- `ToolCard` renders `tool.start/progress/end` frames.
|
||||||
- The gateway always supplies the **full** tool data: it forces
|
- The gateway always supplies the **full** tool data: it forces
|
||||||
`display.platforms.android.tool_progress: verbose` (so the progress line
|
`display.platforms.android.tool_progress: verbose` (so the progress line
|
||||||
@@ -123,6 +127,7 @@ app/shared/src/
|
|||||||
- Spinner while running; ✓/✗ + duration on `tool.end`.
|
- Spinner while running; ✓/✗ + duration on `tool.end`.
|
||||||
|
|
||||||
### Reasoning before message
|
### Reasoning before message
|
||||||
|
|
||||||
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
|
- `ReasoningBlock` (collapsible, "💭 Reasoning" header, monospace body, **copy**
|
||||||
button) rendered **above** the message body from the `reasoning` field.
|
button) rendered **above** the message body from the `reasoning` field.
|
||||||
Matches the reference screenshot.
|
Matches the reference screenshot.
|
||||||
@@ -132,9 +137,11 @@ app/shared/src/
|
|||||||
tap-to-toggle always works.
|
tap-to-toggle always works.
|
||||||
|
|
||||||
### Intermediate messages
|
### Intermediate messages
|
||||||
|
|
||||||
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
|
- `commentary` frames → dimmed/smaller bubble, distinct from final answers.
|
||||||
|
|
||||||
### Message selection + delete
|
### Message selection + delete
|
||||||
|
|
||||||
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
|
- **Long-press** a message bubble (touch) or **right-click** it (desktop mouse)
|
||||||
enters selection mode: the tapped message is selected (a circular check appears
|
enters selection mode: the tapped message is selected (a circular check appears
|
||||||
beside each bubble) and the composer is replaced by a selection toolbar
|
beside each bubble) and the composer is replaced by a selection toolbar
|
||||||
@@ -143,12 +150,15 @@ app/shared/src/
|
|||||||
message) exits selection mode. Only finalized messages are selectable — a
|
message) exits selection mode. Only finalized messages are selectable — a
|
||||||
streaming bubble has no final id yet and a pending echo isn't on the server.
|
streaming bubble has no final id yet and a pending echo isn't on the server.
|
||||||
- **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
|
- **Delete** → confirm dialog → `message.delete {message_ids:[…]}` for the
|
||||||
current lane. The server removes the message(s) from the outbox (so
|
current lane. The server completely deletes the message(s): they are removed
|
||||||
`history`/`sync` no longer return them) and broadcasts `message.deleted` to
|
from the outbox (so `history`/`sync` no longer return them) **and** from the
|
||||||
every device; each device drops them from its cache (the requesting device
|
hermes session store (so no search trace survives and they are not
|
||||||
also drops them locally for snappy UX). Deleting is idempotent.
|
recoverable), and `message.deleted` is broadcast to every device; each device
|
||||||
|
drops them from its cache (the requesting device also drops them locally for
|
||||||
|
snappy UX). Deleting is idempotent.
|
||||||
|
|
||||||
### Agent busy / stop / steer
|
### Agent busy / stop / steer
|
||||||
|
|
||||||
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
|
- `agent.busy` → show "thinking…" indicator in chat header (animated dots).
|
||||||
- `agent.idle` → clear indicator.
|
- `agent.idle` → clear indicator.
|
||||||
- **Stop button** (appears in header while busy): sends `agent.stop`.
|
- **Stop button** (appears in header while busy): sends `agent.stop`.
|
||||||
@@ -156,8 +166,9 @@ app/shared/src/
|
|||||||
`agent.steer` (injects mid-turn) rather than queuing a new `message.send`.
|
`agent.steer` (injects mid-turn) rather than queuing a new `message.send`.
|
||||||
|
|
||||||
### Threading + channels
|
### Threading + channels
|
||||||
|
|
||||||
- **Channel list** (drawer on single-pane; left rail on two-pane) = default chat
|
- **Channel list** (drawer on single-pane; left rail on two-pane) = default chat
|
||||||
+ user channels (avatar, name, last-message preview, unread badge, active
|
- user channels (avatar, name, last-message preview, unread badge, active
|
||||||
highlight bar) — matches the reference left sidebar.
|
highlight bar) — matches the reference left sidebar.
|
||||||
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
|
- **Thread toggle** in the default chat header: "Threads on/off". On → topic
|
||||||
switcher above the message list (each topic = a `thread_id`).
|
switcher above the message list (each topic = a `thread_id`).
|
||||||
@@ -171,23 +182,27 @@ app/shared/src/
|
|||||||
menu offers "Set as cron target".
|
menu offers "Set as cron target".
|
||||||
- **Topic context menu** — long-press a topic chip (touch) or right-click it
|
- **Topic context menu** — long-press a topic chip (touch) or right-click it
|
||||||
(desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled
|
(desktop mouse) → "Rename" / "Delete". Rename → `channel.rename` (prefilled
|
||||||
dialog); Delete → confirm → `channel.delete` (soft-delete; the thread leaves
|
dialog); Delete → confirm → `channel.delete` (hard delete; the thread leaves
|
||||||
the switcher and, if it was the open lane, the app falls back to the channel's
|
the switcher, its history is wiped from the outbox and session store, and if
|
||||||
flat lane). The right-click handler is a skiko `expect`/`actual`
|
it was the open lane the app falls back to the channel's flat lane). The
|
||||||
|
right-click handler is a skiko `expect`/`actual`
|
||||||
(`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it).
|
(`iris/ui/ContextMenu.kt`); on touch it is a no-op (long-press covers it).
|
||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
|
- Search bar (chat header or top) with a **scope toggle**: "Search everywhere" /
|
||||||
"Search in this chat/channel". → `search` frame → results list → tap jumps to
|
"Search in this chat/channel". → `search` frame → results list → tap jumps to
|
||||||
the message (navigate + highlight).
|
the message (navigate + highlight).
|
||||||
|
|
||||||
### Attach media
|
### Attach media
|
||||||
|
|
||||||
- Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF.
|
- Paperclip → system pickers (Photos / Files / Audio / Video / Docs) via SAF.
|
||||||
- Selected files show as **preview chips** in the composer (thumbnail + name +
|
- Selected files show as **preview chips** in the composer (thumbnail + name +
|
||||||
remove). On send: `media.upload` (chunked) for each, then `message.send` with
|
remove). On send: `media.upload` (chunked) for each, then `message.send` with
|
||||||
`media_refs`.
|
`media_refs`.
|
||||||
|
|
||||||
### Voice input (mic button)
|
### Voice input (mic button)
|
||||||
|
|
||||||
- Mic button (right of composer, toggles to send when text is present).
|
- Mic button (right of composer, toggles to send when text is present).
|
||||||
- Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder,
|
- Tap → request `RECORD_AUDIO` permission → start recording (MediaRecorder,
|
||||||
OGG/Opus, 44.1 kHz mono).
|
OGG/Opus, 44.1 kHz mono).
|
||||||
@@ -199,11 +214,13 @@ app/shared/src/
|
|||||||
transcribes it. No client-side STT.
|
transcribes it. No client-side STT.
|
||||||
|
|
||||||
### Push notifications
|
### Push notifications
|
||||||
|
|
||||||
- FCM service (see `08-push.md`): foreground banner + background foreground
|
- FCM service (see `08-push.md`): foreground banner + background foreground
|
||||||
service → `sync`. Notification channel per chat. Tap → deep-link to chat.
|
service → `sync`. Notification channel per chat. Tap → deep-link to chat.
|
||||||
- ntfy fallback: foreground service maintains the subscription.
|
- ntfy fallback: foreground service maintains the subscription.
|
||||||
|
|
||||||
### Live playback
|
### Live playback
|
||||||
|
|
||||||
- AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player
|
- AI-sent audio/video → `media.pull` → cache file → **ExoPlayer** inline player
|
||||||
(audio: mini-player; video: inline + fullscreen + PiP). Documents/images →
|
(audio: mini-player; video: inline + fullscreen + PiP). Documents/images →
|
||||||
viewer / open-with.
|
viewer / open-with.
|
||||||
@@ -211,12 +228,14 @@ app/shared/src/
|
|||||||
## 10.6 Layout (Telegram-style, per reference image)
|
## 10.6 Layout (Telegram-style, per reference image)
|
||||||
|
|
||||||
**Two layout modes** (decision: user-toggleable, **single-pane default**):
|
**Two layout modes** (decision: user-toggleable, **single-pane default**):
|
||||||
|
|
||||||
- **Single-pane (default on phones):** chat full-screen; channel list in a
|
- **Single-pane (default on phones):** chat full-screen; channel list in a
|
||||||
swipeable drawer (hamburger / edge swipe).
|
swipeable drawer (hamburger / edge swipe).
|
||||||
- **Two-pane (Telegram-style, like the reference):** persistent left channel
|
- **Two-pane (Telegram-style, like the reference):** persistent left channel
|
||||||
rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings.
|
rail + chat. Auto-enabled on tablets / large screens; toggleable in Settings.
|
||||||
|
|
||||||
**Chat screen anatomy (matches reference):**
|
**Chat screen anatomy (matches reference):**
|
||||||
|
|
||||||
- **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow
|
- **Header:** back (single-pane), avatar, name + "Bot" subtitle, edit + overflow
|
||||||
(⋮) menu (thread toggle, channel menu, set cron target, clear).
|
(⋮) menu (thread toggle, channel menu, set cron target, clear).
|
||||||
- **Message list:** date separators ("7. August"); user bubbles **right**
|
- **Message list:** date separators ("7. August"); user bubbles **right**
|
||||||
@@ -245,4 +264,4 @@ color. Accent = user's chosen brand color (default indigo, like the reference).
|
|||||||
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
does a real `hello` (not just a TCP probe — per hermes desktop guidance, the
|
||||||
auth leg must be exercised). On success → save (secure storage) → main.
|
auth leg must be exercised). On success → save (secure storage) → main.
|
||||||
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
- States: connecting / connected / reconnecting / degraded / auth-failed — each
|
||||||
with honest copy and a way out.
|
with honest copy and a way out.
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
"search": { "payload": { "query": { "type": "string" }, "scope": { "type": "string", "enum": ["all", "chat"] }, "chat_id": { "type": "string" }, "thread_id": { "type": "string" }, "limit": { "type": "integer", "description": "Optional; server default 20." } } },
|
||||||
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
"sync": { "description": "Reconnect catch-up; replays undelivered outbox frames only (not full history).", "payload": { "cursor": { "type": "integer" } } },
|
||||||
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
"history": { "description": "Load a page of full message history for a chat/thread (initial open, scroll-up pagination).", "payload": { "before_message_id": { "type": "string", "description": "Return messages older than this (omit for newest page)." }, "limit": { "type": "integer", "description": "Max messages (default 50, max 200)." } } },
|
||||||
"message.delete": { "description": "Delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
|
"message.delete": { "description": "Completely delete the given message(s) from a chat/thread. The server removes them from the outbox (so history/sync no longer return them) and from the hermes session store (so no search trace survives and they are not recoverable), then broadcasts message.deleted to every device. Idempotent: a message already gone (pruned) still yields a message.deleted broadcast.", "payload": { "message_ids": { "type": "array", "items": { "type": "string" }, "description": "One or more message_id values to delete." } } },
|
||||||
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
"fcm.register": { "payload": { "fcm_token": { "type": "string" }, "ntfy_topic": { "type": "string" } } },
|
||||||
"ping": { "payload": { "ts": { "type": "integer" } } }
|
"ping": { "payload": { "ts": { "type": "integer" } } }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ from hermes_constants import get_hermes_home # noqa: E402
|
|||||||
|
|
||||||
from . import media as media_bridge # noqa: E402
|
from . import media as media_bridge # noqa: E402
|
||||||
from . import protocol # noqa: E402
|
from . import protocol # noqa: E402
|
||||||
|
from . import purge as purge_bridge # noqa: E402
|
||||||
from . import search as search_bridge # noqa: E402
|
from . import search as search_bridge # noqa: E402
|
||||||
from .channels import get_directory # noqa: E402
|
from .channels import get_directory # noqa: E402
|
||||||
from .outbox import Outbox # noqa: E402
|
from .outbox import Outbox # noqa: E402
|
||||||
@@ -2519,6 +2520,28 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
# Complete deletion: wipe the lane's history from the outbox (so
|
||||||
|
# ``history`` / ``sync`` can't resurrect it) and from the hermes
|
||||||
|
# session store (so no search trace survives). A channel delete takes
|
||||||
|
# its threads with it (thread_id=None); a thread delete is scoped to
|
||||||
|
# its parent channel + thread_id.
|
||||||
|
if entry.get("kind") == "thread":
|
||||||
|
lane_chat_id = entry.get("parent_chat_id") or chat_id
|
||||||
|
thread_id = chat_id
|
||||||
|
else:
|
||||||
|
lane_chat_id = chat_id
|
||||||
|
thread_id = None
|
||||||
|
removed_frames = self._outbox.delete_lane(lane_chat_id, thread_id=thread_id)
|
||||||
|
removed_msgs = purge_bridge.delete_lane(
|
||||||
|
get_hermes_home() / "state.db", lane_chat_id, thread_id=thread_id
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"android: channel.delete %s kind=%s outbox_frames=%s session_msgs=%s",
|
||||||
|
chat_id,
|
||||||
|
entry.get("kind"),
|
||||||
|
removed_frames,
|
||||||
|
removed_msgs,
|
||||||
|
)
|
||||||
resp = protocol.channel_deleted(chat_id)
|
resp = protocol.channel_deleted(chat_id)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._ws_server.broadcast(resp)
|
||||||
@@ -2663,13 +2686,14 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_message_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
"""Handle an inbound ``message.delete`` request.
|
"""Handle an inbound ``message.delete`` request.
|
||||||
|
|
||||||
Removes the requested message(s) from the outbox (so ``history`` and
|
Completely deletes the requested message(s): they are removed from the
|
||||||
``sync`` no longer return them) and broadcasts ``message.deleted`` to
|
outbox (so ``history`` and ``sync`` no longer return them) **and** from
|
||||||
every device (outboxed too, so an offline device learns of the
|
the hermes session store (so no search trace survives and they are not
|
||||||
deletion on its next ``sync``). Deleting is idempotent: a message that
|
recoverable). ``message.deleted`` is broadcast to every device
|
||||||
is already gone (pruned by retention) simply yields 0 removed rows,
|
(outboxed too, so an offline device learns of the deletion on its next
|
||||||
and the ``message.deleted`` broadcast is still emitted so live caches
|
``sync``). Deleting is idempotent: a message that is already gone
|
||||||
drop it.
|
(pruned by retention) simply yields 0 removed rows, and the
|
||||||
|
``message.deleted`` broadcast is still emitted so live caches drop it.
|
||||||
"""
|
"""
|
||||||
payload = frame.payload
|
payload = frame.payload
|
||||||
chat_id = frame.chat_id or payload.get("chat_id")
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
@@ -2698,15 +2722,30 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
removed = 0
|
removed = 0
|
||||||
|
purged = 0
|
||||||
|
db_path = get_hermes_home() / "state.db"
|
||||||
for mid in message_ids:
|
for mid in message_ids:
|
||||||
|
# Read the final frame data first (role / text / ts) so the
|
||||||
|
# session-store row can be matched, then drop the outbox frames.
|
||||||
|
info = self._outbox.message_info(chat_id, mid, thread_id=thread_id)
|
||||||
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
|
removed += self._outbox.delete_message(chat_id, mid, thread_id=thread_id)
|
||||||
|
if info:
|
||||||
|
purged += purge_bridge.delete_message(
|
||||||
|
db_path,
|
||||||
|
chat_id,
|
||||||
|
thread_id,
|
||||||
|
info.get("role") or "",
|
||||||
|
info.get("text") or "",
|
||||||
|
info.get("ts"),
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s",
|
"android: message.delete from %s chat_id=%r thread_id=%r ids=%s removed=%s purged=%s",
|
||||||
device_id,
|
device_id,
|
||||||
chat_id,
|
chat_id,
|
||||||
thread_id,
|
thread_id,
|
||||||
message_ids,
|
message_ids,
|
||||||
removed,
|
removed,
|
||||||
|
purged,
|
||||||
)
|
)
|
||||||
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
|
resp = protocol.message_deleted(chat_id, message_ids, thread_id=thread_id)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
|
|||||||
@@ -293,20 +293,28 @@ class ChannelDirectory:
|
|||||||
return self.get(chat_id)
|
return self.get(chat_id)
|
||||||
|
|
||||||
def delete(self, chat_id: str) -> dict[str, Any] | None:
|
def delete(self, chat_id: str) -> dict[str, Any] | None:
|
||||||
"""Soft-delete (archive) a channel. History stays for search.
|
"""Hard-delete a channel or thread (and, for a channel, its threads).
|
||||||
|
|
||||||
The default channel cannot be deleted. Returns the (archived) entry,
|
The row is removed from the directory entirely -- not recoverable. The
|
||||||
or ``None`` when the id is unknown / is the default.
|
caller (adapter) is responsible for wiping the lane's history from the
|
||||||
|
outbox and the hermes session store so no search trace survives.
|
||||||
|
|
||||||
|
The default channel cannot be deleted. Returns the (deleted) entry, or
|
||||||
|
``None`` when the id is unknown / is the default.
|
||||||
"""
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
row = self._conn.execute(
|
row = self._conn.execute(
|
||||||
"SELECT is_default FROM channels WHERE chat_id = ?", (chat_id,)
|
"SELECT * FROM channels WHERE chat_id = ?", (chat_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None or row["is_default"]:
|
if row is None or row["is_default"]:
|
||||||
return None
|
return None
|
||||||
self._conn.execute("UPDATE channels SET archived = 1 WHERE chat_id = ?", (chat_id,))
|
entry = _row_to_entry(row)
|
||||||
|
self._conn.execute("DELETE FROM channels WHERE chat_id = ?", (chat_id,))
|
||||||
|
# A channel takes its threads with it.
|
||||||
|
if entry["kind"] != KIND_THREAD:
|
||||||
|
self._conn.execute("DELETE FROM channels WHERE parent_chat_id = ?", (chat_id,))
|
||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return self.get(chat_id)
|
return entry
|
||||||
|
|
||||||
# ── reads ─────────────────────────────────────────────────────────────
|
# ── reads ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -272,6 +272,56 @@ class Outbox:
|
|||||||
|
|
||||||
# ── message deletion ──────────────────────────────────────────────────
|
# ── message deletion ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def message_info(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
thread_id: str | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Look up a message's final frame data (role / text / ts) in the outbox.
|
||||||
|
|
||||||
|
Used to match a ``message.delete`` to the hermes session-store row
|
||||||
|
(which is keyed by content + timestamp, not the plugin's message id).
|
||||||
|
Returns ``{role, text, ts}`` for the message's final frame -- a
|
||||||
|
standalone ``message`` frame when present, else the ``message.stop``
|
||||||
|
frame of a streamed reply -- or ``None`` when the message is not in the
|
||||||
|
outbox (e.g. already pruned by retention).
|
||||||
|
"""
|
||||||
|
if not message_id:
|
||||||
|
return None
|
||||||
|
with self._lock:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
|
||||||
|
).fetchall()
|
||||||
|
msg_frame: dict[str, Any] | None = None
|
||||||
|
stop_frame: dict[str, Any] | None = None
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
frame = json.loads(r["frame"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
if not isinstance(frame, dict):
|
||||||
|
continue
|
||||||
|
if thread_id is not None and frame.get("thread_id") != thread_id:
|
||||||
|
continue
|
||||||
|
payload = frame.get("payload")
|
||||||
|
if not isinstance(payload, dict) or payload.get("message_id") != message_id:
|
||||||
|
continue
|
||||||
|
ftype = frame.get("type")
|
||||||
|
if ftype == "message":
|
||||||
|
msg_frame = {
|
||||||
|
"role": payload.get("role"),
|
||||||
|
"text": payload.get("text", ""),
|
||||||
|
"ts": payload.get("ts"),
|
||||||
|
}
|
||||||
|
elif ftype == "message.stop":
|
||||||
|
stop_frame = {
|
||||||
|
"role": "assistant",
|
||||||
|
"text": payload.get("final_text", ""),
|
||||||
|
"ts": payload.get("ts"),
|
||||||
|
}
|
||||||
|
return msg_frame or stop_frame
|
||||||
|
|
||||||
def delete_message(
|
def delete_message(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -319,6 +369,40 @@ class Outbox:
|
|||||||
self._conn.commit()
|
self._conn.commit()
|
||||||
return len(cursors)
|
return len(cursors)
|
||||||
|
|
||||||
|
def delete_lane(self, chat_id: str, thread_id: str | None = None) -> int:
|
||||||
|
"""Remove every outbox frame for a lane (channel or thread).
|
||||||
|
|
||||||
|
* ``thread_id is None`` -> a **channel**: all frames whose ``chat_id``
|
||||||
|
column is *chat_id* (the flat lane plus every thread under it).
|
||||||
|
* ``thread_id`` set -> a **thread**: frames for *chat_id* whose frame
|
||||||
|
carries that ``thread_id``.
|
||||||
|
|
||||||
|
Called on channel/thread deletion so neither ``history`` nor a ``sync``
|
||||||
|
replay can resurrect the lane's messages. Returns the number of rows
|
||||||
|
removed.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
if thread_id is None:
|
||||||
|
cursors.append(int(r["cursor"]))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
frame = json.loads(r["frame"])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
if isinstance(frame, dict) and frame.get("thread_id") == thread_id:
|
||||||
|
cursors.append(int(r["cursor"]))
|
||||||
|
if not cursors:
|
||||||
|
return 0
|
||||||
|
for cursor in cursors:
|
||||||
|
self._conn.execute("DELETE FROM outbox WHERE cursor = ?", (cursor,))
|
||||||
|
self._conn.commit()
|
||||||
|
return len(cursors)
|
||||||
|
|
||||||
# ── retention ─────────────────────────────────────────────────────────
|
# ── retention ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _maybe_prune(self) -> None:
|
def _maybe_prune(self) -> None:
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ def channel_renamed(entry: dict[str, Any]) -> Frame:
|
|||||||
|
|
||||||
|
|
||||||
def channel_deleted(chat_id: str) -> Frame:
|
def channel_deleted(chat_id: str) -> Frame:
|
||||||
"""Broadcast: a channel was archived (soft-deleted)."""
|
"""Broadcast: a channel or thread was deleted (its history wiped too)."""
|
||||||
return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
|
return Frame(type=TYPE_CHANNEL_DELETED, payload={"chat_id": chat_id})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Complete (hard) deletion of android messages from the hermes session store.
|
||||||
|
|
||||||
|
The android plugin mints its own message ids (``m_<hex>``) that are **not**
|
||||||
|
persisted in the hermes session DB (``state.db``), so a delete request cannot
|
||||||
|
join on an id. Instead a message is matched to its ``messages`` row by
|
||||||
|
(session, role, content, timestamp proximity) and that row is deleted.
|
||||||
|
|
||||||
|
Deleting a ``messages`` row also drops it from the FTS5 search index via the
|
||||||
|
``messages_fts_*_delete`` triggers, so **no search trace survives** and the
|
||||||
|
message is not recoverable.
|
||||||
|
|
||||||
|
Channel / thread deletion wipes the whole lane: every session (and its
|
||||||
|
messages) for the chat (a channel) or the specific thread.
|
||||||
|
|
||||||
|
The session DB is opened read-write with a busy timeout. It is normally held
|
||||||
|
by the gateway core in WAL mode, which allows one writer at a time, so a brief
|
||||||
|
write from a second connection is safe.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(str(db_path), timeout=10.0)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _flat_session_id(conn: sqlite3.Connection, chat_id: str) -> str | None:
|
||||||
|
"""The flat-lane session (thread_id NULL / empty) for *chat_id*."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM sessions WHERE chat_id = ? "
|
||||||
|
"AND (thread_id IS NULL OR thread_id = '') LIMIT 1",
|
||||||
|
(chat_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_session_id(conn: sqlite3.Connection, chat_id: str, thread_id: str) -> str | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ? LIMIT 1",
|
||||||
|
(chat_id, thread_id),
|
||||||
|
).fetchone()
|
||||||
|
return row["id"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_lane(db_path: Path, chat_id: str, thread_id: str | None = None) -> int:
|
||||||
|
"""Hard-delete a whole lane from the session store.
|
||||||
|
|
||||||
|
* ``thread_id is None`` -> a **channel**: every session under *chat_id*
|
||||||
|
(the flat lane plus all of its threads) and all of their messages.
|
||||||
|
* ``thread_id`` set -> a **thread**: the single session for
|
||||||
|
(chat_id, thread_id) and its messages.
|
||||||
|
|
||||||
|
Returns the number of message rows removed (0 when the lane is absent or
|
||||||
|
the DB is missing). Never raises: any DB error yields 0 (the caller still
|
||||||
|
deletes the directory entry + outbox frames, so the lane is gone from the
|
||||||
|
app's point of view even if the session-store wipe fails).
|
||||||
|
"""
|
||||||
|
db_path = Path(db_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
if thread_id:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM sessions WHERE chat_id = ? AND thread_id = ?",
|
||||||
|
(chat_id, thread_id),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT id FROM sessions WHERE chat_id = ?", (chat_id,)).fetchall()
|
||||||
|
ids = [r["id"] for r in rows]
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
# Delete per session with fully parameterized queries (a channel has
|
||||||
|
# only a handful of sessions: the flat lane plus its threads). Messages
|
||||||
|
# first (foreign_keys is not enforced on this DB), then the session
|
||||||
|
# rows. The FTS delete triggers fire on the message deletes.
|
||||||
|
n_msgs = 0
|
||||||
|
for sid in ids:
|
||||||
|
cur = conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
|
||||||
|
n_msgs += max(0, cur.rowcount)
|
||||||
|
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
|
||||||
|
conn.commit()
|
||||||
|
return n_msgs
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.warning("android purge: delete_lane failed: %s", e)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_message(
|
||||||
|
db_path: Path,
|
||||||
|
chat_id: str,
|
||||||
|
thread_id: str | None,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
ts_ms: int | None,
|
||||||
|
) -> int:
|
||||||
|
"""Hard-delete a single message from the session store.
|
||||||
|
|
||||||
|
Matches the ``messages`` row by (session, role, content) and, when several
|
||||||
|
rows share that content, the one whose timestamp is closest to *ts_ms*.
|
||||||
|
The content must match exactly (after stripping) -- a mismatch deletes
|
||||||
|
nothing rather than the wrong message. Returns 1 when a row was removed,
|
||||||
|
0 otherwise. Never raises.
|
||||||
|
"""
|
||||||
|
db_path = Path(db_path)
|
||||||
|
if not db_path.exists() or not content or not role:
|
||||||
|
return 0
|
||||||
|
conn = _connect(db_path)
|
||||||
|
try:
|
||||||
|
sid = (
|
||||||
|
_thread_session_id(conn, chat_id, thread_id)
|
||||||
|
if thread_id
|
||||||
|
else _flat_session_id(conn, chat_id)
|
||||||
|
)
|
||||||
|
if sid is None:
|
||||||
|
return 0
|
||||||
|
ts_s = (ts_ms or 0) / 1000.0
|
||||||
|
want = content.strip()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, timestamp, content FROM messages WHERE session_id = ? AND role = ?",
|
||||||
|
(sid, role),
|
||||||
|
).fetchall()
|
||||||
|
target: int | None = None
|
||||||
|
best_dt: float | None = None
|
||||||
|
for r in rows:
|
||||||
|
if (r["content"] or "").strip() != want:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
dt = abs(float(r["timestamp"]) - ts_s)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
dt = 0.0
|
||||||
|
if best_dt is None or dt < best_dt:
|
||||||
|
best_dt = dt
|
||||||
|
target = r["id"]
|
||||||
|
if target is None:
|
||||||
|
return 0
|
||||||
|
conn.execute("DELETE FROM messages WHERE id = ?", (target,))
|
||||||
|
conn.commit()
|
||||||
|
return 1
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
logger.warning("android purge: delete_message failed: %s", e)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
conn.close()
|
||||||
Reference in new issue
Block a user