diff --git a/backend/src/api/mcp/server.py b/backend/src/api/mcp/server.py
index 31eec40..5ab13c6 100644
--- a/backend/src/api/mcp/server.py
+++ b/backend/src/api/mcp/server.py
@@ -298,10 +298,11 @@ async def get_feed(
"""Deleted and edited messages and profile changes across chats, newest first.
`kind` picks deleted, edited, profile, messages (deleted and edited) or all;
- `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm,
- group, channel) narrow it down. Edited items carry `before` and `after`
- texts. A deleted item with `message_count` above 1 is a bulk deletion in
- one chat; read its messages with `get_deleted_messages` for that chat.
+ `since` keeps items at or after that time; `chat_id` or `chat_kind` (dm
+ with people, bot, group, channel) narrow it down. Edited items carry
+ `before` and `after` texts. A deleted item with `message_count` above 1 is
+ a bulk deletion in one chat; read its messages with `get_deleted_messages`
+ for that chat.
Profile items cover people in private chats or contacts and chats in the
dialog list: `detail` maps `name`, `username`, `photo` (unique file ids) or
`title` to `[old, new]`, changes a day apart or closer are merged. Pass
diff --git a/backend/src/api/routers/feed.py b/backend/src/api/routers/feed.py
index c1e514e..d906abf 100644
--- a/backend/src/api/routers/feed.py
+++ b/backend/src/api/routers/feed.py
@@ -29,6 +29,7 @@ async def list_feed(
kind: Annotated[FeedKind, Query()] = FeedKind.ALL,
chat_id: Annotated[int | None, Query()] = None,
chat_kind: Annotated[FeedChatKind | None, Query()] = None,
+ folder_id: Annotated[int | None, Query()] = None,
since: Annotated[datetime | None, Query()] = None,
cursor: Cursor = None,
limit: Limit = DEFAULT_LIMIT,
@@ -36,7 +37,13 @@ async def list_feed(
return await feed.list_feed(
pool,
account_id,
- FeedFilter(kind=kind, chat_id=chat_id, chat_kind=chat_kind, since=since),
+ FeedFilter(
+ kind=kind,
+ chat_id=chat_id,
+ chat_kind=chat_kind,
+ folder_id=folder_id,
+ since=since,
+ ),
limit=limit,
cursor=cursor,
)
diff --git a/backend/src/utils/read/feed.py b/backend/src/utils/read/feed.py
index 96cf01b..279c7f6 100644
--- a/backend/src/utils/read/feed.py
+++ b/backend/src/utils/read/feed.py
@@ -5,6 +5,7 @@ from enum import StrEnum
import asyncpg
+from utils.policy import repository as policy_repository
from utils.read import chats, peers
from utils.read.accounts import self_user_id
from utils.read.models import (
@@ -45,6 +46,13 @@ LEFT JOIN LATERAL (
) p ON true
"""
+_IS_BOT = """EXISTS (
+ SELECT 1 FROM peers p
+ WHERE p.account_id = feed_items.account_id AND p.peer_id = feed_items.chat_id
+ AND COALESCE((p.raw->>'is_bot')::bool, (p.raw->>'bot')::bool,
+ p.raw->>'type' = 'ChatType.BOT', false)
+)"""
+
_UNSEEN = """
SELECT count(*) FILTER (WHERE kind = 'deleted') AS deleted,
count(*) FILTER (WHERE kind = 'edited') AS edited
@@ -69,15 +77,25 @@ class FeedKind(StrEnum):
class FeedChatKind(StrEnum):
DM = "dm"
+ BOT = "bot"
GROUP = "group"
CHANNEL = "channel"
+_CHAT_KIND_CLAUSES = {
+ FeedChatKind.DM: f"chat_kind = 'dm' AND NOT {_IS_BOT}",
+ FeedChatKind.BOT: f"chat_kind = 'dm' AND {_IS_BOT}",
+ FeedChatKind.GROUP: "chat_kind = 'group'",
+ FeedChatKind.CHANNEL: "chat_kind = 'channel'",
+}
+
+
@dataclass(slots=True)
class FeedFilter:
kind: FeedKind = FeedKind.ALL
chat_id: int | None = None
chat_kind: FeedChatKind | None = None
+ folder_id: int | None = None
since: datetime | None = None
@@ -90,18 +108,34 @@ def decode_cursor(cursor: str) -> tuple[datetime, int]:
return _EPOCH + int(micros) * _MICROSECOND, int(key)
+async def _folder_chat_ids(
+ pool: asyncpg.Pool, account_id: int, folder_id: int | None
+) -> list[int] | None:
+ if folder_id is None:
+ return None
+ folder = await policy_repository.get_folder(pool, account_id, folder_id)
+ if folder is None:
+ return []
+ return await chats.folder_chat_ids(pool, account_id, folder)
+
+
def _where(
- account_id: int, feed: FeedFilter, cursor: str | None
+ account_id: int,
+ feed: FeedFilter,
+ folder_chat_ids: list[int] | None,
+ cursor: str | None,
) -> tuple[str, list[object]]:
params: list[object] = [account_id]
clauses = ["account_id = $1"]
if feed.kind is FeedKind.MESSAGES:
clauses.append("kind <> 'profile'")
+ if feed.chat_kind is not None:
+ clauses.append(_CHAT_KIND_CLAUSES[feed.chat_kind])
exact = feed.kind not in {FeedKind.ALL, FeedKind.MESSAGES}
conditions = (
("kind = {}", feed.kind.value if exact else None),
("chat_id = {}", feed.chat_id),
- ("chat_kind = {}", feed.chat_kind.value if feed.chat_kind else None),
+ ("chat_id = ANY({}::bigint[])", folder_chat_ids),
("at >= {}", feed.since),
)
for template, value in conditions:
@@ -191,7 +225,9 @@ async def _build_items(
chat=FeedChat(
chat_id=row["chat_id"],
title=chat.title if chat else None,
- kind=row["chat_kind"],
+ kind=FeedChatKind.BOT
+ if chat and chat.is_bot and row["chat_kind"] == FeedChatKind.DM
+ else row["chat_kind"],
has_avatar=chat.has_avatar if chat else False,
),
message_id=row["message_id"],
@@ -218,7 +254,8 @@ async def list_feed(
cursor: str | None = None,
) -> FeedPage:
size = max(1, min(limit, MAX_LIMIT))
- where, params = _where(account_id, feed, cursor)
+ folder_chat_ids = await _folder_chat_ids(pool, account_id, feed.folder_id)
+ where, params = _where(account_id, feed, folder_chat_ids, cursor)
params.append(size + 1)
rows = await pool.fetch(
f"SELECT {_ITEM_COLS} FROM feed_items WHERE {where} " # noqa: S608
diff --git a/frontend/src/lib/api/endpoints.ts b/frontend/src/lib/api/endpoints.ts
index ce0858b..5bc42e2 100644
--- a/frontend/src/lib/api/endpoints.ts
+++ b/frontend/src/lib/api/endpoints.ts
@@ -284,6 +284,7 @@ export interface FeedQuery {
chat_id?: number;
chat_kind?: FeedChatKind;
cursor?: string;
+ folder_id?: number;
kind?: FeedKind;
limit?: number;
}
diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts
index 771a434..134dc54 100644
--- a/frontend/src/lib/api/types.ts
+++ b/frontend/src/lib/api/types.ts
@@ -245,7 +245,7 @@ export interface MessageVersion {
}
export type FeedKind = "all" | "deleted" | "edited" | "profile";
-export type FeedChatKind = "dm" | "group" | "channel";
+export type FeedChatKind = "dm" | "bot" | "group" | "channel";
export interface FeedChat {
chat_id: number;
diff --git a/frontend/src/lib/components/FolderTabs.svelte b/frontend/src/lib/components/FolderTabs.svelte
index 8fe740c..4686d9d 100644
--- a/frontend/src/lib/components/FolderTabs.svelte
+++ b/frontend/src/lib/components/FolderTabs.svelte
@@ -2,8 +2,10 @@
import ContextMenu from "#lib/components/ui/ContextMenu.svelte";
import ContextMenuItem from "#lib/components/ui/ContextMenuItem.svelte";
import { t } from "#lib/i18n/locale.svelte.js";
+ import { feed } from "#lib/stores/feed.svelte.js";
import { folders } from "#lib/stores/folders.svelte.js";
import { policyUi } from "#lib/stores/policy.svelte.js";
+ import { page } from "$app/state";
interface Tab {
id: number | null;
@@ -93,6 +95,12 @@
{/snippet}
{#snippet menu()}
+ feed.open(page.url, tab.id)}
+ >
+ {t("feed.folder")}
+
editPolicy(tab)}>
{t("settings.policy")}
diff --git a/frontend/src/lib/components/feed/FeedFilters.svelte b/frontend/src/lib/components/feed/FeedFilters.svelte
index c91193c..17c8390 100644
--- a/frontend/src/lib/components/feed/FeedFilters.svelte
+++ b/frontend/src/lib/components/feed/FeedFilters.svelte
@@ -1,16 +1,32 @@