feat(watcher,api,frontend): watched bots become accounts and drain the update backlog
This commit is contained in:
1 parent
4bd031f21e
commit
b0ed041c2e
21 files changed
+414
-1155
No files matched your search
@@ -0,0 +1,61 @@
|
|||||||
|
"""bot accounts
|
||||||
|
|
||||||
|
Revision ID: e2c9a41f7b68
|
||||||
|
Revises: c8f3b6a2d571
|
||||||
|
Create Date: 2026-09-18 16:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "e2c9a41f7b68"
|
||||||
|
down_revision: str | None = "c8f3b6a2d571"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_ACCOUNTS = """
|
||||||
|
INSERT INTO accounts
|
||||||
|
(tg_user_id, label, phone, session_name, is_active, is_bot, raw)
|
||||||
|
SELECT b.bot_id,
|
||||||
|
coalesce(b.title, '@' || b.username, b.bot_id::text),
|
||||||
|
NULL,
|
||||||
|
b.bot_id::text,
|
||||||
|
b.enabled,
|
||||||
|
true,
|
||||||
|
jsonb_build_object('id', b.bot_id, 'username', b.username,
|
||||||
|
'first_name', b.title)
|
||||||
|
FROM watched_bots b
|
||||||
|
ON CONFLICT (tg_user_id) DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
_LINK = """
|
||||||
|
UPDATE watched_bots b SET account_id = a.account_id
|
||||||
|
FROM accounts a WHERE a.tg_user_id = b.bot_id
|
||||||
|
"""
|
||||||
|
|
||||||
|
_MERGE_MESSAGES = """
|
||||||
|
INSERT INTO messages
|
||||||
|
(account_id, chat_id, message_id, date, sender_id, text, raw, has_media)
|
||||||
|
SELECT b.account_id, u.chat_id, u.item_id, u.ts, u.sender_id, u.text, u.raw,
|
||||||
|
u.media_kind IS NOT NULL
|
||||||
|
FROM bot_updates u
|
||||||
|
JOIN watched_bots b ON b.bot_id = u.bot_id
|
||||||
|
WHERE u.kind = 'message' AND b.account_id IS NOT NULL
|
||||||
|
ON CONFLICT (account_id, chat_id, message_id, date) DO NOTHING
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE accounts ADD COLUMN is_bot boolean NOT NULL DEFAULT false")
|
||||||
|
op.execute("ALTER TABLE watched_bots ADD COLUMN account_id integer")
|
||||||
|
op.execute(_ACCOUNTS)
|
||||||
|
op.execute(_LINK)
|
||||||
|
op.execute(_MERGE_MESSAGES)
|
||||||
|
op.execute("DROP TABLE IF EXISTS bot_updates")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("ALTER TABLE watched_bots DROP COLUMN IF EXISTS account_id")
|
||||||
|
op.execute("ALTER TABLE accounts DROP COLUMN IF EXISTS is_bot")
|
||||||
@@ -13,9 +13,9 @@ from pydantic import BaseModel
|
|||||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||||
from dependencies.container import container
|
from dependencies.container import container
|
||||||
from utils.jobs import enqueue
|
from utils.jobs import enqueue
|
||||||
from utils.read import annotations, bots, chats, media, peers, presence, social, watches
|
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||||
from utils.read.models import DEFAULT_LIMIT, MAX_LIMIT, Page
|
from utils.read.models import DEFAULT_LIMIT, Page
|
||||||
from utils.search.models import SearchFilters
|
from utils.search.models import SearchFilters
|
||||||
from utils.search.repository import search_messages
|
from utils.search.repository import search_messages
|
||||||
from utils.storage import ContentAddressedStorage
|
from utils.storage import ContentAddressedStorage
|
||||||
@@ -58,11 +58,10 @@ Typical flows:
|
|||||||
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
|
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
|
||||||
`list_alerts` reads what those rules fired.
|
`list_alerts` reads what those rules fired.
|
||||||
|
|
||||||
Watched bots (a separate axis, `account_id` does not apply):
|
Watched bots:
|
||||||
- beavergram can also hold bot tokens of arbitrary bots and archive who writes
|
- a bot whose token the user added is just another archived account: it has its
|
||||||
to them over MTProto. `list_watched_bots` names them, `get_bot_stats` gives
|
own `account_id` and its chats, messages and media read through the same
|
||||||
the aggregate picture and `get_bot_updates` the raw incoming stream. These
|
tools as a user account. Nothing bot-specific to call.
|
||||||
take `bot_id` (the bot's Telegram id), never `account_id`.
|
|
||||||
|
|
||||||
Writes: everything is read-only except `set_watch`, the only allowed write.
|
Writes: everything is read-only except `set_watch`, the only allowed write.
|
||||||
|
|
||||||
@@ -362,41 +361,3 @@ async def list_alerts(
|
|||||||
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
|
await _pool(), account_id, Page(limit=limit, offset=offset), seen=seen
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def list_watched_bots() -> list[dict[str, Any]]:
|
|
||||||
"""List bots this beavergram watches, with their status and counters."""
|
|
||||||
return _dump(await bots.list_bots(await _pool()))
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def get_bot_stats(
|
|
||||||
bot_id: int, days: int = 90, senders: int = 20
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Who writes to a watched bot: totals, daily volume, hours, top senders."""
|
|
||||||
result = await bots.stats(await _pool(), bot_id, days=days, senders=senders)
|
|
||||||
return result.model_dump(mode="json") if result else None
|
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
async def get_bot_updates(
|
|
||||||
bot_id: int,
|
|
||||||
sender_id: int | None = None,
|
|
||||||
chat_id: int | None = None,
|
|
||||||
incoming_only: bool = True,
|
|
||||||
limit: int = DEFAULT_LIMIT,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Read what people sent to a watched bot, newest first."""
|
|
||||||
return _dump(
|
|
||||||
await bots.list_updates(
|
|
||||||
await _pool(),
|
|
||||||
bot_id,
|
|
||||||
sender_id=sender_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
incoming_only=incoming_only,
|
|
||||||
limit=min(limit, MAX_LIMIT),
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
from typing import Annotated
|
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from utils.read import bots
|
from utils.read import bots
|
||||||
from utils.read.models import DEFAULT_LIMIT, BotStats, BotUpdateView, BotView, Page
|
from utils.read.models import BotView
|
||||||
from watcher import tokens
|
from watcher import tokens
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute)
|
router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute)
|
||||||
@@ -42,38 +40,3 @@ async def remove_bot(pool: FromDishka[asyncpg.Pool], bot_id: int) -> None:
|
|||||||
if not await bots.remove_bot(pool, bot_id):
|
if not await bots.remove_bot(pool, bot_id):
|
||||||
raise HTTPException(status_code=404, detail="unknown bot")
|
raise HTTPException(status_code=404, detail="unknown bot")
|
||||||
await bots.notify_bots_changed(pool)
|
await bots.notify_bots_changed(pool)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bot_id}/stats")
|
|
||||||
async def stats(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
bot_id: int,
|
|
||||||
days: Annotated[int, Query()] = 90,
|
|
||||||
senders: Annotated[int, Query()] = 20,
|
|
||||||
) -> BotStats:
|
|
||||||
result = await bots.stats(pool, bot_id, days=days, senders=senders)
|
|
||||||
if result is None:
|
|
||||||
raise HTTPException(status_code=404, detail="unknown bot")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bot_id}/updates")
|
|
||||||
async def list_updates(
|
|
||||||
pool: FromDishka[asyncpg.Pool],
|
|
||||||
bot_id: int,
|
|
||||||
sender_id: Annotated[int | None, Query()] = None,
|
|
||||||
chat_id: Annotated[int | None, Query()] = None,
|
|
||||||
incoming_only: Annotated[bool, Query()] = True,
|
|
||||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
|
||||||
offset: Annotated[int, Query()] = 0,
|
|
||||||
) -> list[BotUpdateView]:
|
|
||||||
page = Page(limit=limit, offset=offset)
|
|
||||||
return await bots.list_updates(
|
|
||||||
pool,
|
|
||||||
bot_id,
|
|
||||||
sender_id=sender_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
incoming_only=incoming_only,
|
|
||||||
limit=page.capped_limit,
|
|
||||||
offset=page.offset,
|
|
||||||
)
|
|
||||||
@@ -13,19 +13,23 @@ DEVICE_MODEL_LIMIT = 32
|
|||||||
|
|
||||||
|
|
||||||
class PyroClient(Client):
|
class PyroClient(Client):
|
||||||
def __init__(
|
def __init__( # noqa: PLR0913
|
||||||
self,
|
self,
|
||||||
name: str,
|
name: str,
|
||||||
*,
|
*,
|
||||||
workdir: str = "sessions",
|
workdir: str = "sessions",
|
||||||
device_model: str | None = None,
|
device_model: str | None = None,
|
||||||
load_handlers: bool = True,
|
load_handlers: bool = True,
|
||||||
|
bot_token: str | None = None,
|
||||||
|
skip_updates: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
name,
|
name,
|
||||||
workdir=workdir,
|
workdir=workdir,
|
||||||
api_id=API_ID,
|
api_id=API_ID,
|
||||||
api_hash=API_HASH,
|
api_hash=API_HASH,
|
||||||
|
bot_token=bot_token,
|
||||||
|
skip_updates=skip_updates,
|
||||||
device_model=device_model or DEVICE_MODEL,
|
device_model=device_model or DEVICE_MODEL,
|
||||||
system_version="Windows 11 x64",
|
system_version="Windows 11 x64",
|
||||||
app_version="7.0.8 x64",
|
app_version="7.0.8 x64",
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ from utils.read.models import AccountView
|
|||||||
|
|
||||||
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
|
ACCOUNTS_CHANGED_CHANNEL = "accounts_changed"
|
||||||
|
|
||||||
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, device_model"
|
_ACCOUNT_COLS = "account_id, label, phone, tg_user_id, is_active, is_bot, device_model"
|
||||||
|
|
||||||
_UPSERT_ACCOUNT = """
|
_UPSERT_ACCOUNT = """
|
||||||
INSERT INTO accounts
|
INSERT INTO accounts
|
||||||
(tg_user_id, label, phone, session_name, is_active, raw, updated_at)
|
(tg_user_id, label, phone, session_name, is_active, is_bot, raw, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, TRUE, $5::jsonb, now())
|
VALUES ($1, $2, $3, $4, TRUE, $6, $5::jsonb, now())
|
||||||
ON CONFLICT (tg_user_id) DO UPDATE SET
|
ON CONFLICT (tg_user_id) DO UPDATE SET
|
||||||
label = EXCLUDED.label,
|
label = EXCLUDED.label,
|
||||||
phone = EXCLUDED.phone,
|
phone = EXCLUDED.phone,
|
||||||
session_name = EXCLUDED.session_name,
|
session_name = EXCLUDED.session_name,
|
||||||
is_active = TRUE,
|
is_active = TRUE,
|
||||||
|
is_bot = EXCLUDED.is_bot,
|
||||||
raw = EXCLUDED.raw,
|
raw = EXCLUDED.raw,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING account_id
|
RETURNING account_id
|
||||||
@@ -89,7 +90,9 @@ async def get_account(pool: asyncpg.Pool, account_id: int) -> AccountView | None
|
|||||||
return AccountView(**dict(row)) if row else None
|
return AccountView(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int:
|
async def sync_account(
|
||||||
|
pool: asyncpg.Pool, me: User, session_name: str, *, is_bot: bool = False
|
||||||
|
) -> int:
|
||||||
raw = json.dumps(
|
raw = json.dumps(
|
||||||
{
|
{
|
||||||
"id": me.id,
|
"id": me.id,
|
||||||
@@ -101,7 +104,7 @@ async def sync_account(pool: asyncpg.Pool, me: User, session_name: str) -> int:
|
|||||||
)
|
)
|
||||||
label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username
|
label = " ".join(filter(None, [me.first_name, me.last_name])) or me.username
|
||||||
return await pool.fetchval(
|
return await pool.fetchval(
|
||||||
_UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw
|
_UPSERT_ACCOUNT, me.id, label, me.phone_number, session_name, raw, is_bot
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+33
-170
@@ -1,17 +1,13 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
"""Registry of bot tokens the watcher holds.
|
||||||
|
|
||||||
|
Only session bookkeeping lives here: what the archive itself keeps about a
|
||||||
|
bot is an ordinary account row and ordinary messages, read through
|
||||||
|
``utils.read.accounts`` and ``utils.read.chats`` like any other account.
|
||||||
|
"""
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
|
|
||||||
from utils.read.models import (
|
from utils.read.models import BotView
|
||||||
BotBucket,
|
|
||||||
BotCount,
|
|
||||||
BotHour,
|
|
||||||
BotSender,
|
|
||||||
BotStats,
|
|
||||||
BotSummary,
|
|
||||||
BotUpdateView,
|
|
||||||
BotView,
|
|
||||||
)
|
|
||||||
|
|
||||||
BOTS_CHANGED_CHANNEL = "watched_bots_changed"
|
BOTS_CHANGED_CHANNEL = "watched_bots_changed"
|
||||||
|
|
||||||
@@ -22,17 +18,9 @@ STATUS_ERROR = "error"
|
|||||||
STATUS_REMOVED = "removed"
|
STATUS_REMOVED = "removed"
|
||||||
|
|
||||||
_BOT_COLS = """
|
_BOT_COLS = """
|
||||||
SELECT b.bot_id, b.username, b.title, b.enabled, b.status, b.status_detail,
|
SELECT bot_id, account_id, username, title, enabled, status, status_detail,
|
||||||
b.backfilled_at, b.checked_at, b.added_at,
|
backfilled_at, checked_at, added_at
|
||||||
coalesce(u.incoming, 0) AS incoming,
|
FROM watched_bots
|
||||||
coalesce(u.senders, 0) AS senders,
|
|
||||||
u.last_ts
|
|
||||||
FROM watched_bots b
|
|
||||||
LEFT JOIN LATERAL (
|
|
||||||
SELECT count(*) AS incoming, count(DISTINCT sender_id) AS senders,
|
|
||||||
max(ts) AS last_ts
|
|
||||||
FROM bot_updates WHERE bot_id = b.bot_id AND NOT outgoing
|
|
||||||
) u ON true
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_REGISTER = """
|
_REGISTER = """
|
||||||
@@ -58,91 +46,18 @@ UPDATE watched_bots SET
|
|||||||
WHERE bot_id = $1
|
WHERE bot_id = $1
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_STORE = """
|
|
||||||
INSERT INTO bot_updates
|
|
||||||
(bot_id, kind, chat_id, item_id, ts, source, outgoing, sender_id,
|
|
||||||
sender_username, sender_name, sender_is_bot, chat_type, chat_title,
|
|
||||||
text, media_kind, edited_at, raw)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
|
|
||||||
$16, $17::jsonb)
|
|
||||||
ON CONFLICT (bot_id, kind, chat_id, item_id, ts) DO UPDATE SET
|
|
||||||
text = EXCLUDED.text,
|
|
||||||
media_kind = EXCLUDED.media_kind,
|
|
||||||
chat_title = coalesce(EXCLUDED.chat_title, bot_updates.chat_title),
|
|
||||||
sender_username = coalesce(EXCLUDED.sender_username,
|
|
||||||
bot_updates.sender_username),
|
|
||||||
sender_name = coalesce(EXCLUDED.sender_name, bot_updates.sender_name),
|
|
||||||
edited_at = coalesce(EXCLUDED.edited_at, bot_updates.edited_at),
|
|
||||||
raw = EXCLUDED.raw
|
|
||||||
"""
|
|
||||||
|
|
||||||
_INCOMING = "bot_id = $1 AND NOT outgoing AND ts >= $2"
|
|
||||||
|
|
||||||
_SUMMARY = """
|
|
||||||
SELECT count(*) FILTER (WHERE NOT outgoing) AS incoming,
|
|
||||||
count(*) FILTER (WHERE outgoing) AS outgoing,
|
|
||||||
count(DISTINCT sender_id) FILTER (WHERE NOT outgoing) AS senders,
|
|
||||||
count(DISTINCT chat_id) AS chats,
|
|
||||||
min(ts) AS first_ts, max(ts) AS last_ts
|
|
||||||
FROM bot_updates WHERE bot_id = $1 AND ts >= $2
|
|
||||||
"""
|
|
||||||
|
|
||||||
_TIMELINE = f"""
|
|
||||||
SELECT time_bucket('1 day', ts) AS bucket, count(*) AS incoming,
|
|
||||||
count(DISTINCT sender_id) AS senders
|
|
||||||
FROM bot_updates WHERE {_INCOMING}
|
|
||||||
GROUP BY bucket ORDER BY bucket
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_KINDS = f"""
|
|
||||||
SELECT kind AS key, count(*) AS incoming
|
|
||||||
FROM bot_updates WHERE {_INCOMING}
|
|
||||||
GROUP BY kind ORDER BY incoming DESC
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_MEDIA = f"""
|
|
||||||
SELECT media_kind AS key, count(*) AS incoming
|
|
||||||
FROM bot_updates WHERE {_INCOMING} AND media_kind IS NOT NULL
|
|
||||||
GROUP BY media_kind ORDER BY incoming DESC
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_HOURS = f"""
|
|
||||||
SELECT EXTRACT(HOUR FROM ts)::int AS hour, count(*) AS incoming
|
|
||||||
FROM bot_updates WHERE {_INCOMING}
|
|
||||||
GROUP BY hour ORDER BY hour
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_SENDERS = f"""
|
|
||||||
SELECT sender_id,
|
|
||||||
(array_agg(sender_username ORDER BY ts DESC) FILTER (
|
|
||||||
WHERE sender_username IS NOT NULL))[1] AS username,
|
|
||||||
(array_agg(sender_name ORDER BY ts DESC) FILTER (
|
|
||||||
WHERE sender_name IS NOT NULL))[1] AS name,
|
|
||||||
bool_or(sender_is_bot) AS is_bot,
|
|
||||||
count(*) AS incoming,
|
|
||||||
(array_agg(chat_id ORDER BY ts DESC))[1] AS chat_id,
|
|
||||||
min(ts) AS first_ts, max(ts) AS last_ts
|
|
||||||
FROM bot_updates WHERE {_INCOMING} AND sender_id IS NOT NULL
|
|
||||||
GROUP BY sender_id ORDER BY incoming DESC LIMIT $3
|
|
||||||
""" # noqa: S608
|
|
||||||
|
|
||||||
_VIEW_COLS = (
|
|
||||||
"kind, chat_id, item_id, ts, source, outgoing, sender_id, sender_username, "
|
|
||||||
"sender_name, sender_is_bot, chat_type, chat_title, text, media_kind, edited_at"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def notify_bots_changed(pool: asyncpg.Pool) -> None:
|
async def notify_bots_changed(pool: asyncpg.Pool) -> None:
|
||||||
await pool.execute("SELECT pg_notify($1, '')", BOTS_CHANGED_CHANNEL)
|
await pool.execute("SELECT pg_notify($1, '')", BOTS_CHANGED_CHANNEL)
|
||||||
|
|
||||||
|
|
||||||
async def list_bots(pool: asyncpg.Pool) -> list[BotView]:
|
async def list_bots(pool: asyncpg.Pool) -> list[BotView]:
|
||||||
rows = await pool.fetch(f"{_BOT_COLS} ORDER BY u.last_ts DESC NULLS LAST")
|
rows = await pool.fetch(f"{_BOT_COLS} ORDER BY added_at")
|
||||||
return [BotView(**dict(row)) for row in rows]
|
return [BotView(**dict(row)) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
async def get_bot(pool: asyncpg.Pool, bot_id: int) -> BotView | None:
|
async def get_bot(pool: asyncpg.Pool, bot_id: int) -> BotView | None:
|
||||||
row = await pool.fetchrow(f"{_BOT_COLS} WHERE b.bot_id = $1", bot_id)
|
row = await pool.fetchrow(f"{_BOT_COLS} WHERE bot_id = $1", bot_id)
|
||||||
return BotView(**dict(row)) if row else None
|
return BotView(**dict(row)) if row else None
|
||||||
|
|
||||||
|
|
||||||
@@ -154,6 +69,19 @@ async def enabled_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
|||||||
return {row["bot_id"] for row in rows}
|
return {row["bot_id"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def removed_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT bot_id FROM watched_bots WHERE NOT enabled AND status = $1",
|
||||||
|
STATUS_REMOVED,
|
||||||
|
)
|
||||||
|
return {row["bot_id"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def known_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
||||||
|
rows = await pool.fetch("SELECT bot_id FROM watched_bots")
|
||||||
|
return {row["bot_id"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
async def register_bot(pool: asyncpg.Pool, bot_id: int) -> None:
|
async def register_bot(pool: asyncpg.Pool, bot_id: int) -> None:
|
||||||
await pool.execute(_REGISTER, bot_id)
|
await pool.execute(_REGISTER, bot_id)
|
||||||
|
|
||||||
@@ -162,6 +90,14 @@ async def remove_bot(pool: asyncpg.Pool, bot_id: int) -> bool:
|
|||||||
return await pool.execute(_REMOVE, bot_id) != "UPDATE 0"
|
return await pool.execute(_REMOVE, bot_id) != "UPDATE 0"
|
||||||
|
|
||||||
|
|
||||||
|
async def link_account(pool: asyncpg.Pool, bot_id: int, account_id: int) -> None:
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE watched_bots SET account_id = $2, updated_at = now() WHERE bot_id = $1",
|
||||||
|
bot_id,
|
||||||
|
account_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def set_status( # noqa: PLR0913
|
async def set_status( # noqa: PLR0913
|
||||||
pool: asyncpg.Pool,
|
pool: asyncpg.Pool,
|
||||||
bot_id: int,
|
bot_id: int,
|
||||||
@@ -186,76 +122,3 @@ async def mark_backfilled(pool: asyncpg.Pool, bot_id: int) -> None:
|
|||||||
"WHERE bot_id = $1",
|
"WHERE bot_id = $1",
|
||||||
bot_id,
|
bot_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def store_update(pool: asyncpg.Pool, values: tuple) -> None:
|
|
||||||
await pool.execute(_STORE, *values)
|
|
||||||
|
|
||||||
|
|
||||||
async def store_updates(pool: asyncpg.Pool, values: list[tuple]) -> None:
|
|
||||||
if values:
|
|
||||||
await pool.executemany(_STORE, values)
|
|
||||||
|
|
||||||
|
|
||||||
async def stats(
|
|
||||||
pool: asyncpg.Pool, bot_id: int, *, days: int = 90, senders: int = 20
|
|
||||||
) -> BotStats | None:
|
|
||||||
bot = await get_bot(pool, bot_id)
|
|
||||||
if bot is None:
|
|
||||||
return None
|
|
||||||
since = datetime.now(UTC) - timedelta(days=days)
|
|
||||||
summary = await pool.fetchrow(_SUMMARY, bot_id, since)
|
|
||||||
timeline = await pool.fetch(_TIMELINE, bot_id, since)
|
|
||||||
kinds = await pool.fetch(_KINDS, bot_id, since)
|
|
||||||
media = await pool.fetch(_MEDIA, bot_id, since)
|
|
||||||
hours = await pool.fetch(_HOURS, bot_id, since)
|
|
||||||
sender_rows = await pool.fetch(_SENDERS, bot_id, since, senders)
|
|
||||||
return BotStats(
|
|
||||||
bot=bot,
|
|
||||||
summary=BotSummary(**dict(summary)),
|
|
||||||
timeline=[BotBucket(**dict(row)) for row in timeline],
|
|
||||||
kinds=[BotCount(**dict(row)) for row in kinds],
|
|
||||||
media=[BotCount(**dict(row)) for row in media],
|
|
||||||
hours=[BotHour(**dict(row)) for row in hours],
|
|
||||||
senders=[BotSender(**dict(row)) for row in sender_rows],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def list_updates( # noqa: PLR0913
|
|
||||||
pool: asyncpg.Pool,
|
|
||||||
bot_id: int,
|
|
||||||
*,
|
|
||||||
sender_id: int | None = None,
|
|
||||||
chat_id: int | None = None,
|
|
||||||
incoming_only: bool = True,
|
|
||||||
limit: int = 50,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> list[BotUpdateView]:
|
|
||||||
rows = await pool.fetch(
|
|
||||||
f"SELECT {_VIEW_COLS} FROM bot_updates " # noqa: S608
|
|
||||||
"WHERE bot_id = $1 "
|
|
||||||
"AND ($2::bigint IS NULL OR sender_id = $2) "
|
|
||||||
"AND ($3::bigint IS NULL OR chat_id = $3) "
|
|
||||||
"AND (NOT $4 OR NOT outgoing) "
|
|
||||||
"ORDER BY ts DESC LIMIT $5 OFFSET $6",
|
|
||||||
bot_id,
|
|
||||||
sender_id,
|
|
||||||
chat_id,
|
|
||||||
incoming_only,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
)
|
|
||||||
return [BotUpdateView(**dict(row)) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
async def removed_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
|
||||||
rows = await pool.fetch(
|
|
||||||
"SELECT bot_id FROM watched_bots WHERE NOT enabled AND status = $1",
|
|
||||||
STATUS_REMOVED,
|
|
||||||
)
|
|
||||||
return {row["bot_id"] for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def known_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
|
||||||
rows = await pool.fetch("SELECT bot_id FROM watched_bots")
|
|
||||||
return {row["bot_id"] for row in rows}
|
|
||||||
@@ -22,6 +22,7 @@ class AccountView(BaseModel):
|
|||||||
phone: str | None
|
phone: str | None
|
||||||
tg_user_id: int | None
|
tg_user_id: int | None
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_bot: bool
|
||||||
device_model: str | None
|
device_model: str | None
|
||||||
|
|
||||||
|
|
||||||
@@ -427,6 +428,7 @@ class FileShareHitView(BaseModel):
|
|||||||
|
|
||||||
class BotView(BaseModel):
|
class BotView(BaseModel):
|
||||||
bot_id: int
|
bot_id: int
|
||||||
|
account_id: int | None
|
||||||
username: str | None
|
username: str | None
|
||||||
title: str | None
|
title: str | None
|
||||||
enabled: bool
|
enabled: bool
|
||||||
@@ -435,70 +437,3 @@ class BotView(BaseModel):
|
|||||||
backfilled_at: datetime | None
|
backfilled_at: datetime | None
|
||||||
checked_at: datetime | None
|
checked_at: datetime | None
|
||||||
added_at: datetime
|
added_at: datetime
|
||||||
incoming: int
|
|
||||||
senders: int
|
|
||||||
last_ts: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
class BotSummary(BaseModel):
|
|
||||||
incoming: int
|
|
||||||
outgoing: int
|
|
||||||
senders: int
|
|
||||||
chats: int
|
|
||||||
first_ts: datetime | None
|
|
||||||
last_ts: datetime | None
|
|
||||||
|
|
||||||
|
|
||||||
class BotSender(BaseModel):
|
|
||||||
sender_id: int
|
|
||||||
username: str | None
|
|
||||||
name: str | None
|
|
||||||
is_bot: bool | None
|
|
||||||
incoming: int
|
|
||||||
chat_id: int | None
|
|
||||||
first_ts: datetime
|
|
||||||
last_ts: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class BotBucket(BaseModel):
|
|
||||||
bucket: datetime
|
|
||||||
incoming: int
|
|
||||||
senders: int
|
|
||||||
|
|
||||||
|
|
||||||
class BotCount(BaseModel):
|
|
||||||
key: str
|
|
||||||
incoming: int
|
|
||||||
|
|
||||||
|
|
||||||
class BotHour(BaseModel):
|
|
||||||
hour: int
|
|
||||||
incoming: int
|
|
||||||
|
|
||||||
|
|
||||||
class BotStats(BaseModel):
|
|
||||||
bot: BotView
|
|
||||||
summary: BotSummary
|
|
||||||
timeline: list[BotBucket]
|
|
||||||
kinds: list[BotCount]
|
|
||||||
media: list[BotCount]
|
|
||||||
hours: list[BotHour]
|
|
||||||
senders: list[BotSender]
|
|
||||||
|
|
||||||
|
|
||||||
class BotUpdateView(BaseModel):
|
|
||||||
kind: str
|
|
||||||
chat_id: int
|
|
||||||
item_id: int
|
|
||||||
ts: datetime
|
|
||||||
source: str
|
|
||||||
outgoing: bool
|
|
||||||
sender_id: int | None
|
|
||||||
sender_username: str | None
|
|
||||||
sender_name: str | None
|
|
||||||
sender_is_bot: bool | None
|
|
||||||
chat_type: str | None
|
|
||||||
chat_title: str | None
|
|
||||||
text: str | None
|
|
||||||
media_kind: str | None
|
|
||||||
edited_at: datetime | None
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from pyrogram import Client
|
|
||||||
from pyrogram.errors import FloodWait, RPCError
|
|
||||||
|
|
||||||
from utils.env import env
|
|
||||||
from utils.logging import logger
|
|
||||||
from utils.read import bots
|
|
||||||
from watcher.capture import SOURCE_HISTORY, Row, message_row, store_many
|
|
||||||
|
|
||||||
BATCH = 200
|
|
||||||
MAX_FLOOD_WAIT = 300
|
|
||||||
HISTORY_UNAVAILABLE = "История недоступна: {}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _flush(pool: asyncpg.Pool, rows: list[Row]) -> int:
|
|
||||||
await store_many(pool, rows)
|
|
||||||
stored = len(rows)
|
|
||||||
rows.clear()
|
|
||||||
return stored
|
|
||||||
|
|
||||||
|
|
||||||
async def _chat_history(
|
|
||||||
client: Client, pool: asyncpg.Pool, bot_id: int, chat_id: int
|
|
||||||
) -> int:
|
|
||||||
rows: list[Row] = []
|
|
||||||
stored = 0
|
|
||||||
async for message in client.get_chat_history(
|
|
||||||
chat_id, limit=env.bots.backfill_per_chat
|
|
||||||
):
|
|
||||||
if message.empty or message.chat is None or message.date is None:
|
|
||||||
continue
|
|
||||||
rows.append(message_row(bot_id, message, source=SOURCE_HISTORY))
|
|
||||||
if len(rows) >= BATCH:
|
|
||||||
stored += await _flush(pool, rows)
|
|
||||||
return stored + await _flush(pool, rows)
|
|
||||||
|
|
||||||
|
|
||||||
async def backfill(client: Client, pool: asyncpg.Pool, bot_id: int) -> None:
|
|
||||||
try:
|
|
||||||
dialogs = [
|
|
||||||
dialog.chat.id
|
|
||||||
async for dialog in client.get_dialogs(limit=env.bots.backfill_chats)
|
|
||||||
if dialog.chat is not None and dialog.chat.id is not None
|
|
||||||
]
|
|
||||||
except RPCError as exc:
|
|
||||||
logger.warning(f"[yellow]Bot {bot_id}: dialogs unavailable:[/] {exc}")
|
|
||||||
await bots.set_status(
|
|
||||||
pool, bot_id, bots.STATUS_OK, detail=HISTORY_UNAVAILABLE.format(exc)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
stored = 0
|
|
||||||
for chat_id in dialogs:
|
|
||||||
try:
|
|
||||||
stored += await _chat_history(client, pool, bot_id, chat_id)
|
|
||||||
except FloodWait as exc:
|
|
||||||
wait = exc.value if isinstance(exc.value, int) else MAX_FLOOD_WAIT + 1
|
|
||||||
if wait > MAX_FLOOD_WAIT:
|
|
||||||
logger.warning(f"[yellow]Bot {bot_id}: flood wait {wait}s, stop[/]")
|
|
||||||
break
|
|
||||||
await asyncio.sleep(wait)
|
|
||||||
except RPCError as exc:
|
|
||||||
logger.warning(f"[yellow]Bot {bot_id}: chat {chat_id} skipped:[/] {exc}")
|
|
||||||
|
|
||||||
await bots.mark_backfilled(pool, bot_id)
|
|
||||||
logger.info(
|
|
||||||
f"[green]Bot {bot_id}: backfilled[/] {stored} messages "
|
|
||||||
f"from {len(dialogs)} chats"
|
|
||||||
)
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
from datetime import UTC, datetime
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from pyrogram.types import CallbackQuery, ChatMemberUpdated, Message, User
|
|
||||||
|
|
||||||
from userbot.modules.raw_json import raw_json
|
|
||||||
from utils.read import bots
|
|
||||||
|
|
||||||
SOURCE_HISTORY = "history"
|
|
||||||
SOURCE_LIVE = "live"
|
|
||||||
|
|
||||||
KIND_MESSAGE = "message"
|
|
||||||
KIND_CALLBACK = "callback_query"
|
|
||||||
KIND_MEMBER = "member"
|
|
||||||
|
|
||||||
MEDIA_KINDS = (
|
|
||||||
"photo",
|
|
||||||
"video",
|
|
||||||
"animation",
|
|
||||||
"audio",
|
|
||||||
"voice",
|
|
||||||
"video_note",
|
|
||||||
"document",
|
|
||||||
"sticker",
|
|
||||||
"contact",
|
|
||||||
"location",
|
|
||||||
"venue",
|
|
||||||
"poll",
|
|
||||||
"dice",
|
|
||||||
"story",
|
|
||||||
"game",
|
|
||||||
)
|
|
||||||
|
|
||||||
Row = tuple
|
|
||||||
|
|
||||||
|
|
||||||
def _media_kind(message: Message) -> str | None:
|
|
||||||
return next(
|
|
||||||
(kind for kind in MEDIA_KINDS if getattr(message, kind, None) is not None), None
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _full_name(user: User | None) -> str | None:
|
|
||||||
if user is None:
|
|
||||||
return None
|
|
||||||
parts = [user.first_name, user.last_name]
|
|
||||||
return " ".join(part for part in parts if part) or None
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_title(message: Message) -> str | None:
|
|
||||||
chat = message.chat
|
|
||||||
if chat is None:
|
|
||||||
return None
|
|
||||||
parts = [chat.first_name, chat.last_name]
|
|
||||||
return chat.title or " ".join(part for part in parts if part) or None
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_type(message: Message) -> str | None:
|
|
||||||
kind = getattr(message.chat, "type", None)
|
|
||||||
return kind.value if kind is not None else None
|
|
||||||
|
|
||||||
|
|
||||||
def message_row(bot_id: int, message: Message, *, source: str) -> Row:
|
|
||||||
user = message.from_user
|
|
||||||
chat = message.chat
|
|
||||||
sender = user.id if user is not None else None
|
|
||||||
if sender is None and message.sender_chat is not None:
|
|
||||||
sender = message.sender_chat.id
|
|
||||||
return (
|
|
||||||
bot_id,
|
|
||||||
KIND_MESSAGE,
|
|
||||||
chat.id if chat is not None else 0,
|
|
||||||
message.id,
|
|
||||||
message.date,
|
|
||||||
source,
|
|
||||||
bool(message.outgoing),
|
|
||||||
sender,
|
|
||||||
user.username if user is not None else None,
|
|
||||||
_full_name(user),
|
|
||||||
user.is_bot if user is not None else None,
|
|
||||||
_chat_type(message),
|
|
||||||
_chat_title(message),
|
|
||||||
message.text or message.caption,
|
|
||||||
_media_kind(message),
|
|
||||||
message.edit_date,
|
|
||||||
raw_json(message),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def callback_row(bot_id: int, query: CallbackQuery) -> Row:
|
|
||||||
user = query.from_user
|
|
||||||
message = query.message
|
|
||||||
chat = message.chat if message is not None else None
|
|
||||||
return (
|
|
||||||
bot_id,
|
|
||||||
KIND_CALLBACK,
|
|
||||||
chat.id if chat is not None else 0,
|
|
||||||
message.id if message is not None else 0,
|
|
||||||
datetime.now(UTC),
|
|
||||||
SOURCE_LIVE,
|
|
||||||
False,
|
|
||||||
user.id if user is not None else None,
|
|
||||||
user.username if user is not None else None,
|
|
||||||
_full_name(user),
|
|
||||||
user.is_bot if user is not None else None,
|
|
||||||
chat.type.value if chat is not None and chat.type is not None else None,
|
|
||||||
chat.title if chat is not None else None,
|
|
||||||
query.data,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
raw_json(query),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def member_row(bot_id: int, update: ChatMemberUpdated) -> Row:
|
|
||||||
member = update.new_chat_member
|
|
||||||
user = member.user if member is not None else None
|
|
||||||
chat = update.chat
|
|
||||||
status = member.status.value if member is not None else None
|
|
||||||
return (
|
|
||||||
bot_id,
|
|
||||||
KIND_MEMBER,
|
|
||||||
chat.id if chat is not None else 0,
|
|
||||||
user.id if user is not None else 0,
|
|
||||||
update.date or datetime.now(UTC),
|
|
||||||
SOURCE_LIVE,
|
|
||||||
False,
|
|
||||||
update.from_user.id if update.from_user is not None else None,
|
|
||||||
update.from_user.username if update.from_user is not None else None,
|
|
||||||
_full_name(update.from_user),
|
|
||||||
update.from_user.is_bot if update.from_user is not None else None,
|
|
||||||
chat.type.value if chat is not None and chat.type is not None else None,
|
|
||||||
chat.title if chat is not None else None,
|
|
||||||
status,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
raw_json(update),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def store(pool: asyncpg.Pool, row: Row) -> None:
|
|
||||||
await bots.store_update(pool, row)
|
|
||||||
|
|
||||||
|
|
||||||
async def store_many(pool: asyncpg.Pool, rows: list[Row]) -> None:
|
|
||||||
await bots.store_updates(pool, rows)
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""A capture context for a bot session.
|
||||||
|
|
||||||
|
Same context the userbot capture path expects, minus the two caches a bot
|
||||||
|
may not fill: ``messages.getDialogFilters`` and ``contacts.getContacts`` are
|
||||||
|
both refused for bots, so folders and contacts simply stay empty and policy
|
||||||
|
falls back to the per-account defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from pyrogram import Client
|
||||||
|
|
||||||
|
from userbot.modules.capture.context import CaptureContext
|
||||||
|
from userbot.modules.contacts import ContactCache
|
||||||
|
from userbot.modules.folders import FolderCache
|
||||||
|
from utils.storage import ContentAddressedStorage
|
||||||
|
|
||||||
|
|
||||||
|
async def build_bot_capture_context(
|
||||||
|
client: Client,
|
||||||
|
pool: asyncpg.Pool,
|
||||||
|
storage: ContentAddressedStorage,
|
||||||
|
account_id: int,
|
||||||
|
) -> CaptureContext:
|
||||||
|
ctx = CaptureContext(
|
||||||
|
account_id,
|
||||||
|
pool,
|
||||||
|
storage,
|
||||||
|
FolderCache(client, pool, account_id),
|
||||||
|
ContactCache(client),
|
||||||
|
)
|
||||||
|
await ctx.reload_policies()
|
||||||
|
await ctx.watches.refresh()
|
||||||
|
return ctx
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Pull the updates Telegram still holds for a bot that nobody consumed.
|
||||||
|
|
||||||
|
A bot's undelivered updates outlive the session they were meant for: the
|
||||||
|
same queue Bot API ``getUpdates`` drains is reachable over MTProto through
|
||||||
|
``updates.getDifference``. A fresh login normally starts from
|
||||||
|
``updates.getState``, which throws that queue away - this walks it instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pyrogram import raw, types
|
||||||
|
from pyrogram.errors import RPCError
|
||||||
|
|
||||||
|
from userbot import PyroClient
|
||||||
|
from userbot.modules.capture import capture_message
|
||||||
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
|
from userbot.modules.capture.context import CaptureContext
|
||||||
|
from utils.logging import logger
|
||||||
|
|
||||||
|
EARLIEST_PTS = 1
|
||||||
|
EARLIEST_QTS = 1
|
||||||
|
MAX_SLICES = 50
|
||||||
|
|
||||||
|
|
||||||
|
async def _store(
|
||||||
|
client: PyroClient, ctx: CaptureContext, message: types.Message
|
||||||
|
) -> int:
|
||||||
|
if message.empty or message.chat is None or message.date is None:
|
||||||
|
return 0
|
||||||
|
meta = meta_from_chat(message.chat, ctx.contacts.ids)
|
||||||
|
toggles = ctx.resolve(meta)
|
||||||
|
if not toggles.messages:
|
||||||
|
return 0
|
||||||
|
await capture_message(client, message, ctx, toggles)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_all(
|
||||||
|
client: PyroClient, new_messages: list, users: list, chats: list
|
||||||
|
) -> list[types.Message]:
|
||||||
|
user_map = {u.id: u for u in users}
|
||||||
|
chat_map = {c.id: c for c in chats}
|
||||||
|
parsed = []
|
||||||
|
for raw_message in new_messages:
|
||||||
|
if isinstance(raw_message, raw.types.MessageEmpty):
|
||||||
|
continue
|
||||||
|
parsed.append(
|
||||||
|
await types.Message._parse(client, raw_message, user_map, chat_map) # noqa: SLF001
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
async def drain_backlog(client: PyroClient, account_id: int) -> int:
|
||||||
|
"""Walk the bot's pending update queue into the archive. Returns stored count."""
|
||||||
|
ctx = client.capture
|
||||||
|
if ctx is None:
|
||||||
|
return 0
|
||||||
|
pts, qts, date = EARLIEST_PTS, EARLIEST_QTS, 0
|
||||||
|
stored = 0
|
||||||
|
for _ in range(MAX_SLICES):
|
||||||
|
try:
|
||||||
|
diff = await client.invoke(
|
||||||
|
raw.functions.updates.GetDifference(pts=pts, date=date, qts=qts)
|
||||||
|
)
|
||||||
|
except RPCError as exc:
|
||||||
|
logger.warning(
|
||||||
|
f"[yellow]Account {account_id}: getDifference failed:[/] {exc}"
|
||||||
|
)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
if isinstance(diff, raw.types.updates.DifferenceEmpty):
|
||||||
|
logger.info(f"Account {account_id}: backlog empty at pts={pts}")
|
||||||
|
return stored
|
||||||
|
if isinstance(diff, raw.types.updates.DifferenceTooLong):
|
||||||
|
logger.info(
|
||||||
|
f"Account {account_id}: backlog too long, server pts={diff.pts}; "
|
||||||
|
f"nothing older is retrievable"
|
||||||
|
)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
for message in await _parse_all(
|
||||||
|
client, diff.new_messages, diff.users, diff.chats
|
||||||
|
):
|
||||||
|
stored += await _store(client, ctx, message)
|
||||||
|
|
||||||
|
if isinstance(diff, raw.types.updates.Difference):
|
||||||
|
logger.info(
|
||||||
|
f"[green]Account {account_id}: backlog drained,[/] {stored} messages"
|
||||||
|
)
|
||||||
|
return stored
|
||||||
|
|
||||||
|
pts, qts, date = (
|
||||||
|
diff.intermediate_state.pts,
|
||||||
|
diff.intermediate_state.qts,
|
||||||
|
diff.intermediate_state.date,
|
||||||
|
)
|
||||||
|
logger.warning(f"[yellow]Account {account_id}: backlog walk hit the slice cap[/]")
|
||||||
|
return stored
|
||||||
@@ -3,31 +3,37 @@ import contextlib
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from pyrogram import Client
|
from pyrogram import Client, enums
|
||||||
from pyrogram.errors import RPCError
|
from pyrogram.errors import RPCError
|
||||||
from pyrogram.handlers import (
|
from pyrogram.handlers import (
|
||||||
CallbackQueryHandler,
|
|
||||||
ChatMemberUpdatedHandler,
|
ChatMemberUpdatedHandler,
|
||||||
EditedMessageHandler,
|
EditedMessageHandler,
|
||||||
MessageHandler,
|
MessageHandler,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from userbot import PyroClient
|
||||||
|
from userbot.modules.capture import capture_message
|
||||||
|
from userbot.modules.capture.chat_meta import meta_from_chat
|
||||||
|
from userbot.modules.jobs import JobConsumer
|
||||||
|
from utils.events import notify_bg_event
|
||||||
|
from utils.jobs import enqueue
|
||||||
from utils.logging import logger
|
from utils.logging import logger
|
||||||
from utils.read import bots
|
from utils.read import accounts, bots
|
||||||
from utils.telegram import API_HASH, API_ID
|
from utils.storage import ContentAddressedStorage
|
||||||
from watcher import tokens
|
from watcher import tokens
|
||||||
from watcher.backfill import backfill
|
from watcher.context import build_bot_capture_context
|
||||||
from watcher.capture import SOURCE_LIVE, callback_row, member_row, message_row, store
|
from watcher.drain import drain_backlog
|
||||||
|
|
||||||
DEVICE_MODEL = "Beavergram Watcher"
|
DEVICE_MODEL = "Beavergram Bot"
|
||||||
TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
|
TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
|
||||||
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
|
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RunningBot:
|
class RunningBot:
|
||||||
client: Client
|
client: PyroClient
|
||||||
backfill_task: asyncio.Task | None
|
account_id: int
|
||||||
|
tasks: list[asyncio.Task]
|
||||||
|
|
||||||
|
|
||||||
async def _cancel(task: asyncio.Task) -> None:
|
async def _cancel(task: asyncio.Task) -> None:
|
||||||
@@ -36,22 +42,40 @@ async def _cancel(task: asyncio.Task) -> None:
|
|||||||
await task
|
await task
|
||||||
|
|
||||||
|
|
||||||
def _handlers(pool: asyncpg.Pool, bot_id: int) -> tuple:
|
def _handlers(pool: asyncpg.Pool) -> tuple:
|
||||||
async def on_message(_client: Client, message) -> None: # noqa: ANN001
|
async def on_message(client: Client, message) -> None: # noqa: ANN001
|
||||||
if message.empty or message.chat is None or message.date is None:
|
ctx = getattr(client, "capture", None)
|
||||||
|
if ctx is None or message.empty or message.chat is None or message.date is None:
|
||||||
return
|
return
|
||||||
await store(pool, message_row(bot_id, message, source=SOURCE_LIVE))
|
meta = meta_from_chat(message.chat, ctx.contacts.ids)
|
||||||
|
await ctx.watches.on_text(
|
||||||
|
meta.chat_id, message.id, message.text or message.caption
|
||||||
|
)
|
||||||
|
toggles = ctx.resolve(meta)
|
||||||
|
if not toggles.messages:
|
||||||
|
return
|
||||||
|
await capture_message(client, message, ctx, toggles)
|
||||||
|
await notify_bg_event(
|
||||||
|
ctx.pool,
|
||||||
|
"message",
|
||||||
|
ctx.account_id,
|
||||||
|
chat_id=meta.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
if message.chat.type is not enums.ChatType.PRIVATE:
|
||||||
|
await enqueue(
|
||||||
|
pool, ctx.account_id, "enrich_chat", {"chat_id": meta.chat_id}
|
||||||
|
)
|
||||||
|
|
||||||
async def on_callback(_client: Client, query) -> None: # noqa: ANN001
|
async def on_member(client: Client, update) -> None: # noqa: ANN001
|
||||||
await store(pool, callback_row(bot_id, query))
|
ctx = getattr(client, "capture", None)
|
||||||
|
if ctx is None or update.chat is None:
|
||||||
async def on_member(_client: Client, update) -> None: # noqa: ANN001
|
return
|
||||||
await store(pool, member_row(bot_id, update))
|
await enqueue(pool, ctx.account_id, "enrich_chat", {"chat_id": update.chat.id})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
MessageHandler(on_message),
|
MessageHandler(on_message),
|
||||||
EditedMessageHandler(on_message),
|
EditedMessageHandler(on_message),
|
||||||
CallbackQueryHandler(on_callback),
|
|
||||||
ChatMemberUpdatedHandler(on_member),
|
ChatMemberUpdatedHandler(on_member),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,8 +85,9 @@ def _is_invalid_token(exc: RPCError) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class BotRegistry:
|
class BotRegistry:
|
||||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
def __init__(self, pool: asyncpg.Pool, storage: ContentAddressedStorage) -> None:
|
||||||
self._pool = pool
|
self._pool = pool
|
||||||
|
self._storage = storage
|
||||||
self._running: dict[int, RunningBot] = {}
|
self._running: dict[int, RunningBot] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
@@ -91,6 +116,16 @@ class BotRegistry:
|
|||||||
await bots.register_bot(self._pool, bot_id)
|
await bots.register_bot(self._pool, bot_id)
|
||||||
logger.info(f"[green]Bot token adopted from disk:[/] {bot_id}")
|
logger.info(f"[green]Bot token adopted from disk:[/] {bot_id}")
|
||||||
|
|
||||||
|
def _client(self, bot_id: int, token: str) -> PyroClient:
|
||||||
|
return PyroClient(
|
||||||
|
str(bot_id),
|
||||||
|
workdir=str(tokens.sessions_dir()),
|
||||||
|
device_model=DEVICE_MODEL,
|
||||||
|
load_handlers=False,
|
||||||
|
bot_token=token,
|
||||||
|
skip_updates=False,
|
||||||
|
)
|
||||||
|
|
||||||
async def _start(self, bot_id: int) -> None:
|
async def _start(self, bot_id: int) -> None:
|
||||||
token = tokens.load(bot_id)
|
token = tokens.load(bot_id)
|
||||||
if token is None:
|
if token is None:
|
||||||
@@ -98,15 +133,8 @@ class BotRegistry:
|
|||||||
self._pool, bot_id, bots.STATUS_UNAUTHORIZED, detail=TOKEN_MISSING
|
self._pool, bot_id, bots.STATUS_UNAUTHORIZED, detail=TOKEN_MISSING
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
client = Client(
|
client = self._client(bot_id, token)
|
||||||
str(bot_id),
|
for handler in _handlers(self._pool):
|
||||||
api_id=API_ID,
|
|
||||||
api_hash=API_HASH,
|
|
||||||
bot_token=token,
|
|
||||||
workdir=str(tokens.sessions_dir()),
|
|
||||||
device_model=DEVICE_MODEL,
|
|
||||||
)
|
|
||||||
for handler in _handlers(self._pool, bot_id):
|
|
||||||
client.add_handler(handler)
|
client.add_handler(handler)
|
||||||
try:
|
try:
|
||||||
await client.start()
|
await client.start()
|
||||||
@@ -134,23 +162,39 @@ class BotRegistry:
|
|||||||
await client.stop()
|
await client.stop()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
account_id = await accounts.sync_account(
|
||||||
|
self._pool, me, str(bot_id), is_bot=True
|
||||||
|
)
|
||||||
|
client.capture = await build_bot_capture_context(
|
||||||
|
client, self._pool, self._storage, account_id
|
||||||
|
)
|
||||||
|
await bots.link_account(self._pool, bot_id, account_id)
|
||||||
await bots.set_status(
|
await bots.set_status(
|
||||||
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
||||||
)
|
)
|
||||||
task = (
|
await accounts.notify_accounts_changed(self._pool)
|
||||||
asyncio.create_task(backfill(client, self._pool, bot_id))
|
|
||||||
if await bots.needs_backfill(self._pool, bot_id)
|
consumer = JobConsumer(client, self._pool, account_id)
|
||||||
else None
|
tasks = [asyncio.create_task(consumer.run())]
|
||||||
|
if await bots.needs_backfill(self._pool, bot_id):
|
||||||
|
tasks.append(asyncio.create_task(self._backlog(client, bot_id, account_id)))
|
||||||
|
self._running[bot_id] = RunningBot(client, account_id, tasks)
|
||||||
|
logger.info(
|
||||||
|
f"[green]Bot account {account_id} watched:[/] @{me.username} ({bot_id})"
|
||||||
)
|
)
|
||||||
self._running[bot_id] = RunningBot(client, task)
|
|
||||||
logger.info(f"[green]Bot watched:[/] @{me.username} ({bot_id})")
|
async def _backlog(self, client: PyroClient, bot_id: int, account_id: int) -> None:
|
||||||
|
stored = await drain_backlog(client, account_id)
|
||||||
|
await bots.mark_backfilled(self._pool, bot_id)
|
||||||
|
if stored:
|
||||||
|
await accounts.notify_accounts_changed(self._pool)
|
||||||
|
|
||||||
async def _stop(self, bot_id: int) -> None:
|
async def _stop(self, bot_id: int) -> None:
|
||||||
running = self._running.pop(bot_id, None)
|
running = self._running.pop(bot_id, None)
|
||||||
if running is None:
|
if running is None:
|
||||||
return
|
return
|
||||||
if running.backfill_task is not None:
|
for task in running.tasks:
|
||||||
await _cancel(running.backfill_task)
|
await _cancel(task)
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await running.client.stop()
|
await running.client.stop()
|
||||||
logger.info(f"[yellow]Bot unwatched:[/] {bot_id}")
|
logger.info(f"[yellow]Bot unwatched:[/] {bot_id}")
|
||||||
@@ -9,6 +9,7 @@ from dependencies.container import container
|
|||||||
from utils.env import env
|
from utils.env import env
|
||||||
from utils.logging import logger, setup_logging
|
from utils.logging import logger, setup_logging
|
||||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||||
|
from utils.storage import ContentAddressedStorage
|
||||||
from watcher.registry import BotRegistry
|
from watcher.registry import BotRegistry
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -40,7 +41,8 @@ async def _listen_changes(
|
|||||||
|
|
||||||
async def runner() -> None:
|
async def runner() -> None:
|
||||||
pool = await container.get(asyncpg.Pool)
|
pool = await container.get(asyncpg.Pool)
|
||||||
registry = BotRegistry(pool)
|
storage = await container.get(ContentAddressedStorage)
|
||||||
|
registry = BotRegistry(pool, storage)
|
||||||
tasks: set[asyncio.Task] = set()
|
tasks: set[asyncio.Task] = set()
|
||||||
listen_conn: asyncpg.Connection | None = None
|
listen_conn: asyncpg.Connection | None = None
|
||||||
stop = asyncio.Event()
|
stop = asyncio.Event()
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import type {
|
|||||||
Alert,
|
Alert,
|
||||||
Annotation,
|
Annotation,
|
||||||
AvatarHistoryView,
|
AvatarHistoryView,
|
||||||
BotStats,
|
|
||||||
BotUpdate,
|
|
||||||
CallbackView,
|
CallbackView,
|
||||||
CaptureToggles,
|
CaptureToggles,
|
||||||
Chat,
|
Chat,
|
||||||
@@ -583,20 +581,3 @@ export function addWatchedBot(token: string): Promise<WatchedBot> {
|
|||||||
export function removeWatchedBot(botId: number): Promise<void> {
|
export function removeWatchedBot(botId: number): Promise<void> {
|
||||||
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
return request<void>(`/bots/${botId}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function botStats(botId: number, days = 90): Promise<BotStats> {
|
|
||||||
return request<BotStats>(`/bots/${botId}/stats`, { query: { days } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function botUpdates(
|
|
||||||
botId: number,
|
|
||||||
options: { limit?: number; offset?: number; senderId?: number } = {}
|
|
||||||
): Promise<BotUpdate[]> {
|
|
||||||
return request<BotUpdate[]>(`/bots/${botId}/updates`, {
|
|
||||||
query: {
|
|
||||||
limit: options.limit,
|
|
||||||
offset: options.offset,
|
|
||||||
sender_id: options.senderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -18,6 +18,7 @@ export interface Account {
|
|||||||
account_id: number;
|
account_id: number;
|
||||||
device_model: string | null;
|
device_model: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_bot: boolean;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
tg_user_id: number | null;
|
tg_user_id: number | null;
|
||||||
@@ -548,80 +549,14 @@ export interface ShareSubject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WatchedBot {
|
export interface WatchedBot {
|
||||||
|
account_id: number | null;
|
||||||
added_at: string;
|
added_at: string;
|
||||||
backfilled_at: string | null;
|
backfilled_at: string | null;
|
||||||
bot_id: number;
|
bot_id: number;
|
||||||
checked_at: string | null;
|
checked_at: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
incoming: number;
|
|
||||||
last_ts: string | null;
|
|
||||||
senders: number;
|
|
||||||
status: string;
|
status: string;
|
||||||
status_detail: string | null;
|
status_detail: string | null;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BotSummary {
|
|
||||||
chats: number;
|
|
||||||
first_ts: string | null;
|
|
||||||
incoming: number;
|
|
||||||
last_ts: string | null;
|
|
||||||
outgoing: number;
|
|
||||||
senders: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotSender {
|
|
||||||
chat_id: number | null;
|
|
||||||
first_ts: string;
|
|
||||||
incoming: number;
|
|
||||||
is_bot: boolean | null;
|
|
||||||
last_ts: string;
|
|
||||||
name: string | null;
|
|
||||||
sender_id: number;
|
|
||||||
username: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotBucket {
|
|
||||||
bucket: string;
|
|
||||||
incoming: number;
|
|
||||||
senders: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotCount {
|
|
||||||
incoming: number;
|
|
||||||
key: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotHour {
|
|
||||||
hour: number;
|
|
||||||
incoming: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotStats {
|
|
||||||
bot: WatchedBot;
|
|
||||||
hours: BotHour[];
|
|
||||||
kinds: BotCount[];
|
|
||||||
media: BotCount[];
|
|
||||||
senders: BotSender[];
|
|
||||||
summary: BotSummary;
|
|
||||||
timeline: BotBucket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BotUpdate {
|
|
||||||
chat_id: number;
|
|
||||||
chat_title: string | null;
|
|
||||||
chat_type: string | null;
|
|
||||||
edited_at: string | null;
|
|
||||||
item_id: number;
|
|
||||||
kind: string;
|
|
||||||
media_kind: string | null;
|
|
||||||
outgoing: boolean;
|
|
||||||
sender_id: number | null;
|
|
||||||
sender_is_bot: boolean | null;
|
|
||||||
sender_name: string | null;
|
|
||||||
sender_username: string | null;
|
|
||||||
source: string;
|
|
||||||
text: string | null;
|
|
||||||
ts: string;
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
watches: "Отслеживания",
|
watches: "Отслеживания",
|
||||||
alerts: "Алерты",
|
alerts: "Алерты",
|
||||||
shares: "Файлы по ссылке",
|
shares: "Файлы по ссылке",
|
||||||
bots: "Боты под наблюдением",
|
bots: "Боты",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,234 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { botStats, botUpdates } from "$lib/api/endpoints";
|
|
||||||
import type { BotSender, BotStats, BotUpdate } from "$lib/api/types";
|
|
||||||
import BotTimeline from "$lib/components/bots/BotTimeline.svelte";
|
|
||||||
import BotUpdateList from "$lib/components/bots/BotUpdateList.svelte";
|
|
||||||
import Button from "$lib/components/ui/Button.svelte";
|
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
|
||||||
import { formatListDate } from "$lib/format/datetime";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
botId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { botId }: Props = $props();
|
|
||||||
|
|
||||||
let stats = $state<BotStats | null>(null);
|
|
||||||
let updates = $state<BotUpdate[]>([]);
|
|
||||||
let sender = $state<number | null>(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loading = true;
|
|
||||||
botStats(botId)
|
|
||||||
.then((result) => {
|
|
||||||
stats = result;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
botUpdates(botId, { limit: 50, senderId: sender ?? undefined }).then(
|
|
||||||
(result) => {
|
|
||||||
updates = result;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
function senderLabel(item: BotSender): string {
|
|
||||||
return (
|
|
||||||
item.name ??
|
|
||||||
(item.username ? `@${item.username}` : String(item.sender_id))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSender(id: number): void {
|
|
||||||
sender = sender === id ? null : id;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading && stats === null}
|
|
||||||
<Spinner />
|
|
||||||
{:else if stats}
|
|
||||||
<div class="stats">
|
|
||||||
<div class="tiles">
|
|
||||||
<div class="tile">
|
|
||||||
<span class="value">{stats.summary.incoming}</span>
|
|
||||||
<span class="label">входящих</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<span class="value">{stats.summary.senders}</span>
|
|
||||||
<span class="label">отправителей</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<span class="value">{stats.summary.chats}</span>
|
|
||||||
<span class="label">чатов</span>
|
|
||||||
</div>
|
|
||||||
<div class="tile">
|
|
||||||
<span class="value">{stats.summary.outgoing}</span>
|
|
||||||
<span class="label">ответов бота</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if stats.timeline.length > 0}
|
|
||||||
<section>
|
|
||||||
<h3>По дням</h3>
|
|
||||||
<BotTimeline timeline={stats.timeline} />
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if stats.media.length > 0}
|
|
||||||
<section>
|
|
||||||
<h3>Вложения</h3>
|
|
||||||
<ul class="counts">
|
|
||||||
{#each stats.media as item (item.key)}
|
|
||||||
<li><span>{item.key}</span><b>{item.incoming}</b></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3>Кто пишет</h3>
|
|
||||||
{#if stats.senders.length === 0}
|
|
||||||
<p class="empty">Пока никто.</p>
|
|
||||||
{:else}
|
|
||||||
<ul class="senders">
|
|
||||||
{#each stats.senders as item (item.sender_id)}
|
|
||||||
<li>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="sender"
|
|
||||||
class:active={sender === item.sender_id}
|
|
||||||
onclick={() => toggleSender(item.sender_id)}
|
|
||||||
>
|
|
||||||
<span class="name">{senderLabel(item)}</span>
|
|
||||||
<span class="meta">
|
|
||||||
{item.incoming}
|
|
||||||
· {formatListDate(item.last_ts)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="section-head">
|
|
||||||
<h3>Лента</h3>
|
|
||||||
{#if sender !== null}
|
|
||||||
<Button variant="translucent" smaller onclick={() => (sender = null)}>
|
|
||||||
Все
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if updates.length === 0}
|
|
||||||
<p class="empty">Ничего не пришло.</p>
|
|
||||||
{:else}
|
|
||||||
<BotUpdateList {updates} />
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.stats {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiles {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.125rem;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
border-radius: var(--border-radius-default);
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counts,
|
|
||||||
.senders {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.counts li {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--border-radius-default);
|
|
||||||
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: inherit;
|
|
||||||
text-align: left;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
background-color: var(--color-background-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { BotBucket } from "$lib/api/types";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
timeline: BotBucket[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { timeline }: Props = $props();
|
|
||||||
|
|
||||||
const max = $derived(
|
|
||||||
Math.max(1, ...timeline.map((bucket) => bucket.incoming))
|
|
||||||
);
|
|
||||||
|
|
||||||
function barTitle(bucket: BotBucket): string {
|
|
||||||
return `${bucket.bucket.slice(0, 10)} — ${bucket.incoming} входящих от ${bucket.senders}`;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="bars">
|
|
||||||
{#each timeline as bucket (bucket.bucket)}
|
|
||||||
<div
|
|
||||||
class="bar"
|
|
||||||
style:height={`${(bucket.incoming / max) * 100}%`}
|
|
||||||
title={barTitle(bucket)}
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.bars {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 1px;
|
|
||||||
height: 7.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 2px;
|
|
||||||
border-radius: 2px 2px 0 0;
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { BotUpdate } from "$lib/api/types";
|
|
||||||
import { formatFull } from "$lib/format/datetime";
|
|
||||||
import { mediaKindLabel } from "$lib/format/media";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
updates: BotUpdate[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { updates }: Props = $props();
|
|
||||||
|
|
||||||
const kindLabels: Record<string, string> = {
|
|
||||||
message: "сообщение",
|
|
||||||
callback_query: "кнопка",
|
|
||||||
member: "статус",
|
|
||||||
};
|
|
||||||
|
|
||||||
function senderName(update: BotUpdate): string {
|
|
||||||
if (update.sender_name) {
|
|
||||||
return update.sender_name;
|
|
||||||
}
|
|
||||||
if (update.sender_username) {
|
|
||||||
return `@${update.sender_username}`;
|
|
||||||
}
|
|
||||||
return update.sender_id === null ? "неизвестно" : String(update.sender_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function body(update: BotUpdate): string {
|
|
||||||
const media = mediaKindLabel(update.media_kind);
|
|
||||||
const text = update.text ?? "";
|
|
||||||
if (media && text) {
|
|
||||||
return `[${media}] ${text}`;
|
|
||||||
}
|
|
||||||
return media ? `[${media}]` : text;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ul class="updates">
|
|
||||||
{#each updates as update (`${update.kind}:${update.chat_id}:${update.item_id}:${update.ts}`)}
|
|
||||||
<li class="update">
|
|
||||||
<div class="head">
|
|
||||||
<span class="sender">{senderName(update)}</span>
|
|
||||||
{#if update.sender_username && update.sender_name}
|
|
||||||
<span class="username">@{update.sender_username}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="kind">{kindLabels[update.kind] ?? update.kind}</span>
|
|
||||||
{#if update.source === "history"}
|
|
||||||
<span class="kind">история</span>
|
|
||||||
{/if}
|
|
||||||
<time>{formatFull(update.ts)}</time>
|
|
||||||
</div>
|
|
||||||
{#if body(update)}
|
|
||||||
<p class="body">{body(update)}</p>
|
|
||||||
{/if}
|
|
||||||
{#if update.chat_title && update.chat_type !== "private"}
|
|
||||||
<div class="chat">{update.chat_title}</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.updates {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.75rem;
|
|
||||||
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update {
|
|
||||||
padding-bottom: 0.75rem;
|
|
||||||
border-bottom: 1px solid var(--color-borders);
|
|
||||||
}
|
|
||||||
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
font-weight: var(--font-weight-medium);
|
|
||||||
}
|
|
||||||
|
|
||||||
.username,
|
|
||||||
.kind,
|
|
||||||
time {
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
margin: 0.25rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -5,18 +5,17 @@
|
|||||||
removeWatchedBot,
|
removeWatchedBot,
|
||||||
} from "$lib/api/endpoints";
|
} from "$lib/api/endpoints";
|
||||||
import type { WatchedBot } from "$lib/api/types";
|
import type { WatchedBot } from "$lib/api/types";
|
||||||
import BotStatsView from "$lib/components/bots/BotStatsView.svelte";
|
|
||||||
import Button from "$lib/components/ui/Button.svelte";
|
import Button from "$lib/components/ui/Button.svelte";
|
||||||
import Icon from "$lib/components/ui/Icon.svelte";
|
import Icon from "$lib/components/ui/Icon.svelte";
|
||||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||||
import { formatListDate } from "$lib/format/datetime";
|
import { accounts } from "$lib/stores/accounts.svelte";
|
||||||
import { toasts } from "$lib/stores/toasts.svelte";
|
import { toasts } from "$lib/stores/toasts.svelte";
|
||||||
|
import { ui } from "$lib/stores/ui.svelte";
|
||||||
|
|
||||||
let bots = $state<WatchedBot[]>([]);
|
let bots = $state<WatchedBot[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let token = $state("");
|
let token = $state("");
|
||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let selected = $state<number | null>(null);
|
|
||||||
|
|
||||||
const statusLabels: Record<string, string> = {
|
const statusLabels: Record<string, string> = {
|
||||||
pending: "подключается",
|
pending: "подключается",
|
||||||
@@ -48,6 +47,7 @@
|
|||||||
await addWatchedBot(token.trim());
|
await addWatchedBot(token.trim());
|
||||||
token = "";
|
token = "";
|
||||||
await load();
|
await load();
|
||||||
|
await accounts.load();
|
||||||
} catch {
|
} catch {
|
||||||
toasts.error("Не похоже на токен бота");
|
toasts.error("Не похоже на токен бота");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -57,106 +57,87 @@
|
|||||||
|
|
||||||
async function remove(bot: WatchedBot) {
|
async function remove(bot: WatchedBot) {
|
||||||
await removeWatchedBot(bot.bot_id);
|
await removeWatchedBot(bot.bot_id);
|
||||||
if (selected === bot.bot_id) {
|
|
||||||
selected = null;
|
|
||||||
}
|
|
||||||
await load();
|
await load();
|
||||||
|
await accounts.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function botName(bot: WatchedBot): string {
|
function botName(bot: WatchedBot): string {
|
||||||
return bot.username
|
return bot.username ? `@${bot.username}` : (bot.title ?? String(bot.bot_id));
|
||||||
? `@${bot.username}`
|
|
||||||
: (bot.title ?? String(bot.bot_id));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const current = $derived(bots.find((bot) => bot.bot_id === selected) ?? null);
|
function open(bot: WatchedBot) {
|
||||||
|
if (bot.account_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accounts.select(bot.account_id);
|
||||||
|
ui.closeSettings();
|
||||||
|
ui.closePanel();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
{#if current}
|
{#if loading}
|
||||||
<header class="bot-header">
|
<Spinner />
|
||||||
<Button
|
{:else if bots.length === 0}
|
||||||
variant="translucent"
|
<p class="empty">Ботов пока нет.</p>
|
||||||
round
|
|
||||||
smaller
|
|
||||||
onclick={() => (selected = null)}
|
|
||||||
aria-label="Назад"
|
|
||||||
>
|
|
||||||
<Icon name="arrow-left" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<div class="name">{botName(current)}</div>
|
|
||||||
<div class="meta">
|
|
||||||
{statusLabels[current.status] ?? current.status}
|
|
||||||
{#if current.backfilled_at === null && current.status === "ok"}
|
|
||||||
· история загружается
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{#if current.status_detail}
|
|
||||||
<p class="detail">{current.status_detail}</p>
|
|
||||||
{/if}
|
|
||||||
<BotStatsView botId={current.bot_id} />
|
|
||||||
{:else}
|
{:else}
|
||||||
{#if loading}
|
<ul class="bots">
|
||||||
<Spinner />
|
{#each bots as bot (bot.bot_id)}
|
||||||
{:else if bots.length === 0}
|
<li class="bot">
|
||||||
<p class="empty">Ботов пока нет.</p>
|
<button
|
||||||
{:else}
|
type="button"
|
||||||
<ul class="bots">
|
class="open"
|
||||||
{#each bots as bot (bot.bot_id)}
|
disabled={bot.account_id === null}
|
||||||
<li class="bot">
|
onclick={() => open(bot)}
|
||||||
<button
|
>
|
||||||
type="button"
|
<span class="name">{botName(bot)}</span>
|
||||||
class="open"
|
<span class="meta">
|
||||||
onclick={() => (selected = bot.bot_id)}
|
{statusLabels[bot.status] ?? bot.status}
|
||||||
>
|
{#if bot.account_id !== null}
|
||||||
<span class="name">{botName(bot)}</span>
|
· аккаунт {bot.account_id}
|
||||||
<span class="meta">
|
{/if}
|
||||||
{statusLabels[bot.status] ?? bot.status}
|
</span>
|
||||||
· {bot.incoming} от {bot.senders}
|
{#if bot.status_detail}
|
||||||
{#if bot.last_ts}
|
<span class="detail">{bot.status_detail}</span>
|
||||||
· {formatListDate(bot.last_ts)}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{#if bot.enabled}
|
|
||||||
<Button
|
|
||||||
variant="translucent"
|
|
||||||
round
|
|
||||||
smaller
|
|
||||||
onclick={() => remove(bot)}
|
|
||||||
aria-label="Отключить"
|
|
||||||
>
|
|
||||||
<Icon name="delete" />
|
|
||||||
</Button>
|
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</button>
|
||||||
{/each}
|
{#if bot.enabled}
|
||||||
</ul>
|
<Button
|
||||||
{/if}
|
variant="translucent"
|
||||||
|
round
|
||||||
<div class="add">
|
smaller
|
||||||
<label class="field">
|
onclick={() => remove(bot)}
|
||||||
<span>Токен бота от @BotFather</span>
|
aria-label="Отключить"
|
||||||
<input type="password" bind:value={token} placeholder="123456:AA…">
|
>
|
||||||
</label>
|
<Icon name="delete" />
|
||||||
<Button fluid loading={adding} onclick={add}>Добавить</Button>
|
</Button>
|
||||||
</div>
|
{/if}
|
||||||
|
</li>
|
||||||
<p class="note">
|
{/each}
|
||||||
Бот логинится по MTProto, отдельной авторизацией. Это не забирает
|
</ul>
|
||||||
getUpdates у того, кто держит того же бота через Bot API, и не требует
|
|
||||||
вебхука. Сколько прошлой переписки удастся вытащить, решает сервер
|
|
||||||
Telegram: watcher просит историю диалогов бота и берёт, что дают.
|
|
||||||
</p>
|
|
||||||
<p class="note">
|
|
||||||
В личке бот видит каждого, кто ему написал. В группах ему доставляют всё
|
|
||||||
только при выключенном privacy mode, иначе — команды и ответы на его
|
|
||||||
сообщения. Отключение бота убирает токен и сессию, но архив остаётся.
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="add">
|
||||||
|
<label class="field">
|
||||||
|
<span>Токен бота от @BotFather</span>
|
||||||
|
<input type="password" bind:value={token} placeholder="123456:AA…">
|
||||||
|
</label>
|
||||||
|
<Button fluid loading={adding} onclick={add}>Добавить</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
Бот входит по MTProto отдельной авторизацией — это не отнимает getUpdates у
|
||||||
|
того, кто держит того же бота через Bot API, и не требует вебхука. Дальше он
|
||||||
|
становится обычным аккаунтом архива: чаты, сообщения, медиа и участники
|
||||||
|
групп видны там же, где переписка остальных аккаунтов.
|
||||||
|
</p>
|
||||||
|
<p class="note">
|
||||||
|
Прошлое подтягивается из очереди недоставленных апдейтов, которую Telegram
|
||||||
|
держит для бота; история диалогов боту запрещена, глубже этой очереди
|
||||||
|
заглянуть нельзя. В личке бот видит каждого, кто ему написал; в группах всё
|
||||||
|
— только при выключенном privacy mode. Отключение убирает токен и сессию,
|
||||||
|
архив остаётся.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -167,12 +148,6 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bot-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bots {
|
.bots {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -205,24 +180,27 @@
|
|||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
background-color: var(--color-background-secondary);
|
background-color: var(--color-background-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
font-weight: var(--font-weight-medium);
|
font-weight: var(--font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta {
|
.meta,
|
||||||
|
.detail {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
margin: 0;
|
overflow-wrap: anywhere;
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add {
|
.add {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
icon="bots"
|
icon="bots"
|
||||||
label="Боты под наблюдением"
|
label="Боты"
|
||||||
onclick={() => ui.openPanel("bots")}
|
onclick={() => ui.openPanel("bots")}
|
||||||
/>
|
/>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
|
|||||||
Reference in new issue
Block a user