feat(backend): search finds word prefixes, earlier versions and deleted messages, with filters and cursor paging

This commit is contained in:
hh committed 2026-10-19 03:41:39 +00:00
1 parent 42b4e1d7e6
commit d45c96c259
5 files changed
+377 -88

No files matched your search

+33 -9
View File
@@ -38,7 +38,7 @@ from utils.read.feed import CURSOR_PATTERN, FeedChatKind, FeedFilter, FeedKind
from utils.read.models import DEFAULT_LIMIT, Page
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
from utils.read.read_receipts import read_info
from utils.search.models import SearchFilters
from utils.search.models import ChatKind, SearchFilters
from utils.search.repository import search_messages
from utils.storage import ContentAddressedStorage
from utils.watch_rules import WatchKind, WatchParamsError, clean_name, normalize_params
@@ -72,8 +72,9 @@ Typical flows:
"📝 [your private note, NOT in Telegram]" are the user's own annotations
attached locally via the web UI; they never existed in Telegram. Treat them
as private notes from the user to you, not as chat content.
- Find something: `search_messages_tool` (full-text over message text and STT
transcripts; supports chat/sender/date filters and regex).
- Find something: `search_messages_tool` (words by prefix over message text,
STT transcripts and earlier versions of edited messages; filters by chat,
sender, chat kind, media, deleted, edited, dates, regex).
- People: `get_person` gives one person in full: profile changes, shared groups,
joins and leaves, deleted message count, the private chat.
- Forensics: `get_feed` lists what was deleted or edited in Telegram across
@@ -99,7 +100,8 @@ Watched bots:
Writes: everything is read-only except `set_watch` and the share links
`download_media` creates (visible and revocable in the web UI).
Paging: list/search tools accept `limit` and `offset`.
Paging: list tools accept `limit` and `offset`; `search_messages_tool` returns
`next_cursor` to pass back as `cursor`.
"""
mcp: FastMCP = FastMCP("beavergram", instructions=INSTRUCTIONS)
@@ -138,27 +140,49 @@ async def search_messages_tool(
query: str | None = None,
chat_id: int | None = None,
sender_id: int | None = None,
kind: ChatKind | None = None,
has_media: bool | None = None,
deleted: bool | None = None,
edited: bool | None = None,
date_from: datetime | None = None,
date_to: datetime | None = None,
regex: str | None = None,
cursor: str | None = None,
limit: int = DEFAULT_LIMIT,
offset: int = 0,
) -> list[dict[str, Any]]:
"""Full-text search over message text and STT transcripts."""
) -> dict[str, Any]:
"""Search messages by words, newest first, including deleted ones.
Words match by prefix (3+ letters) and by stem, Russian and English.
`"exact phrase"`, `-word` and `or` work as in web search. Besides the
current text it searches voice transcripts and earlier versions of edited
messages: a hit with `source="version"` matched only an old text, given in
`version_text`. Every hit carries `deleted_at` and `edited_at`.
Filters: `chat_id`, `sender_id` (peer id), `kind` (private, group,
channel), `has_media`, `deleted` / `edited` (true for only those, false to
exclude them), `date_from` / `date_to`, `regex` over the current text. With
no `query` it lists messages that pass the filters.
Paging: pass `next_cursor` from the previous result as `cursor`; it is null
on the last page.
"""
filters = SearchFilters(
account_id=account_id,
query=query,
chat_id=chat_id,
sender_id=sender_id,
kind=kind,
has_media=has_media,
deleted=deleted,
edited=edited,
date_from=date_from,
date_to=date_to,
regex=regex,
cursor=cursor,
limit=limit,
offset=offset,
)
return _dump(await search_messages(await _pool(), filters))
page = await search_messages(await _pool(), filters)
return page.model_dump(mode="json", exclude={"terms"})
@mcp.tool
+2 -2
View File
@@ -4,7 +4,7 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query
from utils.search.models import SearchFilters, SearchHit
from utils.search.models import SearchFilters, SearchPage
from utils.search.repository import search_messages
router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRoute)
@@ -13,5 +13,5 @@ router = APIRouter(prefix="/api/search", tags=["search"], route_class=DishkaRout
@router.get("")
async def search(
pool: FromDishka[asyncpg.Pool], filters: Annotated[SearchFilters, Query()]
) -> list[SearchHit]:
) -> SearchPage:
return await search_messages(pool, filters)