feat(backend): watches fire on new, edited and deleted messages and on coming online, legacy keyword included
This commit is contained in:
1 parent
8ed9ca1951
commit
4469938f47
12 files changed
+501
-60
No files matched your search
@@ -1,60 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from userbot.modules.watches import repository
|
||||
from userbot.modules.watches.evaluator import (
|
||||
KIND_KEYWORD,
|
||||
KIND_PEER_ONLINE,
|
||||
Rules,
|
||||
Seen,
|
||||
build_rules,
|
||||
match_chat_scope,
|
||||
match_keyword,
|
||||
match_peer_online,
|
||||
match_new_message,
|
||||
)
|
||||
from utils.logging import logger
|
||||
from utils.read import watches as watches_read
|
||||
from utils.read.accounts import self_user_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Coroutine
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.read.models import WatchView
|
||||
ONLINE_QUIET = timedelta(minutes=15)
|
||||
PREVIEW_CHARS = 500
|
||||
|
||||
|
||||
def _message_payload(event: str, seen: Seen) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"event": event,
|
||||
"chat_id": seen.chat_id,
|
||||
"message_id": seen.message_id,
|
||||
"sender_id": seen.sender_id,
|
||||
"text": (seen.text or "")[:PREVIEW_CHARS] or None,
|
||||
}
|
||||
if seen.date is not None:
|
||||
payload["date"] = seen.date.isoformat()
|
||||
return payload
|
||||
|
||||
|
||||
class WatchCache:
|
||||
def __init__(self, pool: asyncpg.Pool, account_id: int) -> None:
|
||||
self._pool = pool
|
||||
self._account_id = account_id
|
||||
self.watches: list[WatchView] = []
|
||||
self._online: set[int] = set()
|
||||
self.rules = Rules()
|
||||
self._self_id: int | None = None
|
||||
self._last_seen: dict[int, datetime] = {}
|
||||
|
||||
async def refresh(self) -> None:
|
||||
rows = await watches_read.list_watches(self._pool, self._account_id)
|
||||
self.watches = [watch for watch in rows if watch.enabled]
|
||||
logger.info(f"[green]Watches cached:[/] {len(self.watches)}")
|
||||
self.rules = build_rules(rows)
|
||||
self._self_id = await self_user_id(self._pool, self._account_id)
|
||||
logger.info(f"[green]Watches cached:[/] {len(self.rules)}")
|
||||
|
||||
async def on_text(self, chat_id: int, message_id: int, text: str | None) -> None:
|
||||
if not text:
|
||||
return
|
||||
for watch in self.watches:
|
||||
if watch.kind == KIND_KEYWORD and match_keyword(
|
||||
text, chat_id, watch.params
|
||||
):
|
||||
await watches_read.insert_alert(
|
||||
self._pool,
|
||||
self._account_id,
|
||||
watch.id,
|
||||
{"chat_id": chat_id, "message_id": message_id},
|
||||
dedup_key=f"{chat_id}:{message_id}",
|
||||
def _own(self, seen: Seen) -> bool:
|
||||
return seen.outgoing or (
|
||||
self._self_id is not None and seen.sender_id == self._self_id
|
||||
)
|
||||
|
||||
async def _guard(self, work: Coroutine[Any, Any, None]) -> None:
|
||||
try:
|
||||
await work
|
||||
except Exception:
|
||||
logger.exception("[red]Watch evaluation failed[/]")
|
||||
|
||||
async def _fire(self, watch_id: int, payload: dict[str, Any], key: str) -> None:
|
||||
await watches_read.insert_alert(
|
||||
self._pool, self._account_id, watch_id, payload, dedup_key=key
|
||||
)
|
||||
|
||||
async def _keyword(self, event: str, seen: Seen) -> None:
|
||||
for rule in self.rules.keyword:
|
||||
if match_keyword(rule, seen):
|
||||
await self._fire(
|
||||
rule.watch_id,
|
||||
_message_payload(event, seen),
|
||||
f"{seen.chat_id}:{seen.message_id}",
|
||||
)
|
||||
|
||||
async def on_status(self, peer_id: int, *, is_online: bool) -> None:
|
||||
if not is_online:
|
||||
self._online.discard(peer_id)
|
||||
async def on_message(self, seen: Seen) -> None:
|
||||
if self._own(seen):
|
||||
return
|
||||
if peer_id in self._online:
|
||||
return
|
||||
self._online.add(peer_id)
|
||||
for watch in self.watches:
|
||||
if watch.kind == KIND_PEER_ONLINE and match_peer_online(
|
||||
peer_id, watch.params
|
||||
):
|
||||
await watches_read.insert_alert(
|
||||
self._pool, self._account_id, watch.id, {"peer_id": peer_id}
|
||||
await self._guard(self._on_message(seen))
|
||||
|
||||
async def _on_message(self, seen: Seen) -> None:
|
||||
await self._keyword("message", seen)
|
||||
for watch_id, params in self.rules.new_message:
|
||||
if match_new_message(params, seen):
|
||||
await self._fire(
|
||||
watch_id,
|
||||
_message_payload("message", seen),
|
||||
f"{seen.chat_id}:{seen.message_id}",
|
||||
)
|
||||
|
||||
async def on_edit(self, seen: Seen) -> None:
|
||||
if self._own(seen):
|
||||
return
|
||||
await self._guard(self._on_edit(seen))
|
||||
|
||||
async def _on_edit(self, seen: Seen) -> None:
|
||||
await self._keyword("edit", seen)
|
||||
if seen.edit_date is None:
|
||||
return
|
||||
stamp = int(seen.edit_date.timestamp())
|
||||
for watch_id, params in self.rules.edited:
|
||||
if match_chat_scope(params, seen):
|
||||
await self._fire(
|
||||
watch_id,
|
||||
_message_payload("edit", seen),
|
||||
f"{seen.chat_id}:{seen.message_id}:{stamp}",
|
||||
)
|
||||
|
||||
async def on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
|
||||
if self.rules.keyword:
|
||||
await self._guard(self._on_transcript(chat_id, message_id, text))
|
||||
|
||||
async def _on_transcript(self, chat_id: int, message_id: int, text: str) -> None:
|
||||
sender_id = await repository.message_sender(
|
||||
self._pool, self._account_id, chat_id, message_id
|
||||
)
|
||||
seen = Seen(chat_id, message_id, sender_id, text)
|
||||
if not self._own(seen):
|
||||
await self._keyword("transcript", seen)
|
||||
|
||||
async def on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
|
||||
if self.rules.deleted and message_ids:
|
||||
await self._guard(self._on_deleted(chat_id, message_ids))
|
||||
|
||||
async def _on_deleted(self, chat_id: int | None, message_ids: list[int]) -> None:
|
||||
rows = await repository.deleted_messages(
|
||||
self._pool, self._account_id, chat_id, message_ids
|
||||
)
|
||||
for row in rows:
|
||||
seen = Seen(
|
||||
row["chat_id"],
|
||||
row["message_id"],
|
||||
row["sender_id"],
|
||||
row["text"],
|
||||
date=row["date"],
|
||||
)
|
||||
if self._own(seen):
|
||||
continue
|
||||
for watch_id, params in self.rules.deleted:
|
||||
if match_chat_scope(params, seen):
|
||||
await self._fire(
|
||||
watch_id,
|
||||
_message_payload("delete", seen),
|
||||
f"{seen.chat_id}:{seen.message_id}",
|
||||
)
|
||||
|
||||
async def on_status(
|
||||
self, peer_id: int, status: str, at: datetime, last_online: datetime | None
|
||||
) -> None:
|
||||
watch_ids = [
|
||||
watch_id
|
||||
for watch_id, params in self.rules.online
|
||||
if params.peer_id == peer_id
|
||||
]
|
||||
if not watch_ids:
|
||||
return
|
||||
if status != "online":
|
||||
self._last_seen[peer_id] = last_online or at
|
||||
return
|
||||
await self._guard(self._on_online(peer_id, at, watch_ids))
|
||||
|
||||
async def _on_online(
|
||||
self, peer_id: int, at: datetime, watch_ids: list[int]
|
||||
) -> None:
|
||||
stored = await repository.last_seen_before(
|
||||
self._pool, self._account_id, peer_id, at
|
||||
)
|
||||
known = [value for value in (stored, self._last_seen.get(peer_id)) if value]
|
||||
previous = max(known, default=None)
|
||||
self._last_seen[peer_id] = at
|
||||
if previous is not None and at - previous < ONLINE_QUIET:
|
||||
return
|
||||
session = int((previous or at).timestamp())
|
||||
payload: dict[str, Any] = {"event": "online", "peer_id": peer_id}
|
||||
if previous is not None:
|
||||
payload["since"] = previous.isoformat()
|
||||
for watch_id in watch_ids:
|
||||
await self._fire(watch_id, payload, f"{peer_id}:{session}")
|
||||
@@ -1,24 +1,104 @@
|
||||
import re
|
||||
from typing import Any
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
KIND_KEYWORD = "keyword"
|
||||
KIND_PEER_ONLINE = "peer_online"
|
||||
from utils.logging import logger
|
||||
from utils.read.models import WatchView
|
||||
from utils.watch_rules import (
|
||||
MAX_TEXT,
|
||||
DeletedParams,
|
||||
EditedParams,
|
||||
KeywordParams,
|
||||
NewMessageParams,
|
||||
PeerOnlineParams,
|
||||
WatchParamsError,
|
||||
compile_pattern,
|
||||
parse_params,
|
||||
)
|
||||
|
||||
|
||||
def match_keyword(text: str, chat_id: int, params: dict[str, Any]) -> bool:
|
||||
target_chat = params.get("chat_id")
|
||||
if target_chat is not None and target_chat != chat_id:
|
||||
return False
|
||||
pattern = params.get("pattern")
|
||||
if not pattern:
|
||||
return False
|
||||
if params.get("regex"):
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Seen:
|
||||
chat_id: int
|
||||
message_id: int
|
||||
sender_id: int | None
|
||||
text: str | None
|
||||
outgoing: bool = False
|
||||
date: datetime | None = None
|
||||
edit_date: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class KeywordRule:
|
||||
watch_id: int
|
||||
params: KeywordParams
|
||||
regex: re.Pattern[str] | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Rules:
|
||||
keyword: list[KeywordRule] = field(default_factory=list)
|
||||
new_message: list[tuple[int, NewMessageParams]] = field(default_factory=list)
|
||||
deleted: list[tuple[int, DeletedParams]] = field(default_factory=list)
|
||||
edited: list[tuple[int, EditedParams]] = field(default_factory=list)
|
||||
online: list[tuple[int, PeerOnlineParams]] = field(default_factory=list)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return (
|
||||
len(self.keyword)
|
||||
+ len(self.new_message)
|
||||
+ len(self.deleted)
|
||||
+ len(self.edited)
|
||||
+ len(self.online)
|
||||
)
|
||||
|
||||
|
||||
def build_rules(watches: list[WatchView]) -> Rules:
|
||||
rules = Rules()
|
||||
for watch in watches:
|
||||
if not watch.enabled:
|
||||
continue
|
||||
try:
|
||||
return re.search(pattern, text) is not None
|
||||
except re.error:
|
||||
return False
|
||||
return pattern.casefold() in text.casefold()
|
||||
params = parse_params(watch.kind, watch.params)
|
||||
except WatchParamsError as exc:
|
||||
logger.warning(f"[yellow]Watch {watch.id} skipped:[/] {exc}")
|
||||
continue
|
||||
match params:
|
||||
case KeywordParams():
|
||||
regex = compile_pattern(params.pattern) if params.regex else None
|
||||
rules.keyword.append(KeywordRule(watch.id, params, regex))
|
||||
case NewMessageParams():
|
||||
rules.new_message.append((watch.id, params))
|
||||
case DeletedParams():
|
||||
rules.deleted.append((watch.id, params))
|
||||
case EditedParams():
|
||||
rules.edited.append((watch.id, params))
|
||||
case PeerOnlineParams():
|
||||
rules.online.append((watch.id, params))
|
||||
return rules
|
||||
|
||||
|
||||
def match_peer_online(peer_id: int, params: dict[str, Any]) -> bool:
|
||||
return params.get("peer_id") == peer_id
|
||||
def _in_scope(chat_id: int | None, sender_id: int | None, seen: Seen) -> bool:
|
||||
if chat_id is not None and chat_id != seen.chat_id:
|
||||
return False
|
||||
return sender_id is None or sender_id == seen.sender_id
|
||||
|
||||
|
||||
def match_keyword(rule: KeywordRule, seen: Seen) -> bool:
|
||||
params = rule.params
|
||||
if not seen.text or not _in_scope(params.chat_id, params.sender_id, seen):
|
||||
return False
|
||||
sample = seen.text[:MAX_TEXT]
|
||||
if rule.regex is not None:
|
||||
return rule.regex.search(sample) is not None
|
||||
return params.pattern.casefold() in sample.casefold()
|
||||
|
||||
|
||||
def match_new_message(params: NewMessageParams, seen: Seen) -> bool:
|
||||
return _in_scope(params.chat_id, params.sender_id, seen)
|
||||
|
||||
|
||||
def match_chat_scope(params: DeletedParams | EditedParams, seen: Seen) -> bool:
|
||||
if params.chat_id is None:
|
||||
return seen.chat_id > 0
|
||||
return params.chat_id == seen.chat_id
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
|
||||
import asyncpg
|
||||
|
||||
from userbot.modules.capture.repository import CHANNEL_ID_THRESHOLD
|
||||
|
||||
_DELETED = """
|
||||
SELECT DISTINCT ON (chat_id, message_id) chat_id, message_id, sender_id, text, date
|
||||
FROM messages
|
||||
WHERE account_id = $1 AND message_id = ANY($2::bigint[])
|
||||
AND deleted_at IS NOT NULL AND {scope}
|
||||
ORDER BY chat_id, message_id, date DESC
|
||||
"""
|
||||
|
||||
_SENDER = """
|
||||
SELECT sender_id FROM messages
|
||||
WHERE account_id = $1 AND chat_id = $2 AND message_id = $3
|
||||
ORDER BY date DESC LIMIT 1
|
||||
"""
|
||||
|
||||
_LAST_SEEN = """
|
||||
SELECT CASE WHEN status = 'online' THEN ts ELSE coalesce(last_online_date, ts) END
|
||||
FROM presence
|
||||
WHERE account_id = $1 AND peer_id = $2 AND ts < $3
|
||||
ORDER BY ts DESC LIMIT 1
|
||||
"""
|
||||
|
||||
|
||||
async def deleted_messages(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int | None, message_ids: list[int]
|
||||
) -> list[asyncpg.Record]:
|
||||
if chat_id is None:
|
||||
query = _DELETED.format(scope="chat_id > $3")
|
||||
return await pool.fetch(query, account_id, message_ids, CHANNEL_ID_THRESHOLD)
|
||||
query = _DELETED.format(scope="chat_id = $3")
|
||||
return await pool.fetch(query, account_id, message_ids, chat_id)
|
||||
|
||||
|
||||
async def message_sender(
|
||||
pool: asyncpg.Pool, account_id: int, chat_id: int, message_id: int
|
||||
) -> int | None:
|
||||
return await pool.fetchval(_SENDER, account_id, chat_id, message_id)
|
||||
|
||||
|
||||
async def last_seen_before(
|
||||
pool: asyncpg.Pool, account_id: int, peer_id: int, at: datetime
|
||||
) -> datetime | None:
|
||||
return await pool.fetchval(_LAST_SEEN, account_id, peer_id, at)
|
||||
@@ -0,0 +1,16 @@
|
||||
from pyrogram.types import Message
|
||||
|
||||
from userbot.modules.capture.message import sender_id
|
||||
from userbot.modules.watches.evaluator import Seen
|
||||
|
||||
|
||||
def seen_message(chat_id: int, message: Message) -> Seen:
|
||||
return Seen(
|
||||
chat_id=chat_id,
|
||||
message_id=message.id,
|
||||
sender_id=sender_id(message),
|
||||
text=message.text or message.caption,
|
||||
outgoing=bool(message.outgoing),
|
||||
date=message.date,
|
||||
edit_date=message.edit_date,
|
||||
)
|
||||
Reference in new issue
Block a user