Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd031f21e | ||
|
|
089a31db8d | ||
|
|
81ebbdc93e |
No files matched your search
@@ -12,18 +12,19 @@ Useful on its own: a Telegram where nothing disappears. In the [beaver](https://
|
||||
- **Presence** as a time series (TimescaleDB), read receipts, stories.
|
||||
- **Capture policy** from your Telegram folders with per-chat overrides: channels nothing, groups messages only, private chats everything, by default.
|
||||
- **Annotations and watches**: your notes on chats and rules that fire alerts, both yours to write in the web client.
|
||||
- **Watched bots**: bot tokens you add in the web client. Each one logs in over MTProto (`auth.importBotAuthorization`, the same door a bot-login client uses), backfills the history of the bot's dialogs once and then keeps the incoming stream: who writes to the bot, what, and when. A separate authorization, so it does not take `getUpdates` away from whoever runs that bot over the Bot API.
|
||||
|
||||
Nothing is ever deleted from the archive.
|
||||
|
||||
## Parts
|
||||
|
||||
- `backend/` - `userbot` (kurigram, the capture loop and jobs) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
|
||||
- `backend/` - `userbot` (kurigram, the capture loop and jobs), `watcher` (kurigram again, one bot-token session per watched bot) and `api` (FastAPI: the web API, the MCP server, and the built frontend as static files).
|
||||
- `frontend/` - SvelteKit, `adapter-static`; a chat client over the archive: folders, chat list, thread with media, versions and the deleted messages inline, search, stories, presence graphs.
|
||||
- `docker-compose.yml` - includes `backend/` (Postgres with TimescaleDB, `userbot`, `api`) and `frontend/` (the dev server); profiles `external` and `services`.
|
||||
|
||||
## MCP
|
||||
|
||||
Read-only: `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
|
||||
Read-only: `list_watched_bots`, `get_bot_stats`, `get_bot_updates`, `search_messages_tool`, `list_chats`, `get_chat_history` (with deleted messages included), `get_deleted_messages`, `get_message_versions`, `get_media`, `get_callbacks`, `presence_history`, `get_peer_history`, `get_stories`, `get_annotations`, `list_watches`, `list_alerts`. The one write is `set_watch`. There are no send, edit or delete tools and there will not be.
|
||||
|
||||
## Run
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ DB__SCRIPTS_CONNECTION_URL=postgresql://beavergram:beavergram@localhost:5432/bea
|
||||
TG__SESSION_NAME=beavergram
|
||||
TG__SESSIONS_DIR=sessions
|
||||
|
||||
BOTS__SESSIONS_DIR=sessions/bots
|
||||
BOTS__BACKFILL_CHATS=0
|
||||
BOTS__BACKFILL_PER_CHAT=0
|
||||
|
||||
API__HOST=0.0.0.0
|
||||
API__PORT=8080
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ COMPOSE := cd .. && docker compose
|
||||
IMAGE := beavergram/backend
|
||||
IMAGE_INPUTS := pyproject.toml uv.lock Dockerfile
|
||||
CODE := src scripts ../frontend
|
||||
SERVICES := api userbot
|
||||
SERVICES := api userbot watcher
|
||||
changed = ! git diff --quiet $(1) -- $(2) 2>/dev/null
|
||||
stamp = { git update-ref $(1) HEAD 2>/dev/null || true; }
|
||||
|
||||
|
||||
@@ -55,6 +55,30 @@ services:
|
||||
networks:
|
||||
database:
|
||||
|
||||
watcher:
|
||||
build: backend
|
||||
image: beavergram/backend
|
||||
profiles: [watcher, services]
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
- path: backend/.env
|
||||
required: false
|
||||
environment:
|
||||
RUN_ENVIRONMENT: prod
|
||||
TZ: ${TZ:-UTC}
|
||||
volumes:
|
||||
- ./backend/src:/app/src
|
||||
- ./backend/sessions:/app/sessions
|
||||
command: [watcher]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
networks:
|
||||
database:
|
||||
|
||||
postgres:
|
||||
image: timescale/timescaledb:2.27.1-pg17
|
||||
profiles: [postgres, external]
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""bot watcher
|
||||
|
||||
Revision ID: c8f3b6a2d571
|
||||
Revises: a6d2f8b41c9e
|
||||
Create Date: 2026-09-18 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c8f3b6a2d571"
|
||||
down_revision: str | None = "a6d2f8b41c9e"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
_BOTS = """
|
||||
CREATE TABLE watched_bots (
|
||||
bot_id bigint PRIMARY KEY,
|
||||
username text,
|
||||
title text,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
status_detail text,
|
||||
backfilled_at timestamptz,
|
||||
checked_at timestamptz,
|
||||
added_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
"""
|
||||
|
||||
_UPDATES = """
|
||||
CREATE TABLE bot_updates (
|
||||
bot_id bigint NOT NULL,
|
||||
kind text NOT NULL,
|
||||
chat_id bigint NOT NULL,
|
||||
item_id bigint NOT NULL,
|
||||
ts timestamptz NOT NULL,
|
||||
source text NOT NULL,
|
||||
outgoing boolean NOT NULL DEFAULT false,
|
||||
sender_id bigint,
|
||||
sender_username text,
|
||||
sender_name text,
|
||||
sender_is_bot boolean,
|
||||
chat_type text,
|
||||
chat_title text,
|
||||
text text,
|
||||
media_kind text,
|
||||
edited_at timestamptz,
|
||||
received_at timestamptz NOT NULL DEFAULT now(),
|
||||
raw jsonb NOT NULL,
|
||||
PRIMARY KEY (bot_id, kind, chat_id, item_id, ts)
|
||||
)
|
||||
"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(_BOTS)
|
||||
op.execute(_UPDATES)
|
||||
op.execute(
|
||||
"SELECT create_hypertable('bot_updates', by_range('ts', INTERVAL '1 month'))"
|
||||
)
|
||||
op.execute(
|
||||
"ALTER TABLE bot_updates SET ("
|
||||
"timescaledb.enable_columnstore = true, "
|
||||
"timescaledb.segmentby = 'bot_id', "
|
||||
"timescaledb.orderby = 'ts DESC')"
|
||||
)
|
||||
op.execute(
|
||||
"CALL add_columnstore_policy('bot_updates', after => INTERVAL '30 days')"
|
||||
)
|
||||
op.execute("CREATE INDEX ix_bot_updates_recent ON bot_updates (bot_id, ts DESC)")
|
||||
op.execute(
|
||||
"CREATE INDEX ix_bot_updates_sender ON bot_updates "
|
||||
"(bot_id, sender_id, ts DESC) WHERE NOT outgoing"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX ix_bot_updates_chat ON bot_updates (bot_id, chat_id, ts DESC)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS bot_updates")
|
||||
op.execute("DROP TABLE IF EXISTS watched_bots")
|
||||
@@ -23,18 +23,18 @@ dependencies = [
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.11.14,<0.12.0"]
|
||||
requires = ["uv_build>=0.12.13,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
module-name = ["userbot", "api", "utils", "dependencies"]
|
||||
module-name = ["userbot", "api", "watcher", "utils", "dependencies"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "RUF001", "RUF002", "RUF003", "TRY301"]
|
||||
ignore = ["CPY", "D1", "D203", "D212", "COM812", "BLE", "S101", "PT", "PLR0917", "RUF001", "RUF002", "RUF003", "TRY301"]
|
||||
unfixable = ["F401"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
|
||||
@@ -18,6 +18,7 @@ from api.routers import (
|
||||
annotations,
|
||||
avatars,
|
||||
backfill,
|
||||
bots,
|
||||
chats,
|
||||
custom_emoji,
|
||||
discover,
|
||||
@@ -73,6 +74,7 @@ async def health(pool: FromDishka[asyncpg.Pool]) -> dict[str, bool]:
|
||||
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(analytics.router)
|
||||
app.include_router(bots.router)
|
||||
app.include_router(policy.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(backfill.router)
|
||||
|
||||
@@ -13,9 +13,9 @@ from pydantic import BaseModel
|
||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||
from dependencies.container import container
|
||||
from utils.jobs import enqueue
|
||||
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||
from utils.read import annotations, bots, chats, media, peers, presence, social, watches
|
||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||
from utils.read.models import DEFAULT_LIMIT, Page
|
||||
from utils.read.models import DEFAULT_LIMIT, MAX_LIMIT, Page
|
||||
from utils.search.models import SearchFilters
|
||||
from utils.search.repository import search_messages
|
||||
from utils.storage import ContentAddressedStorage
|
||||
@@ -58,6 +58,12 @@ Typical flows:
|
||||
- Monitoring: `set_watch` creates a local rule, `list_watches` lists rules, and
|
||||
`list_alerts` reads what those rules fired.
|
||||
|
||||
Watched bots (a separate axis, `account_id` does not apply):
|
||||
- beavergram can also hold bot tokens of arbitrary bots and archive who writes
|
||||
to them over MTProto. `list_watched_bots` names them, `get_bot_stats` gives
|
||||
the aggregate picture and `get_bot_updates` the raw incoming stream. These
|
||||
take `bot_id` (the bot's Telegram id), never `account_id`.
|
||||
|
||||
Writes: everything is read-only except `set_watch`, the only allowed write.
|
||||
|
||||
Paging: list/search tools accept `limit` and `offset`.
|
||||
@@ -356,3 +362,41 @@ async def list_alerts(
|
||||
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,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
from typing import Annotated
|
||||
|
||||
import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from utils.read import bots
|
||||
from utils.read.models import DEFAULT_LIMIT, BotStats, BotUpdateView, BotView, Page
|
||||
from watcher import tokens
|
||||
|
||||
router = APIRouter(prefix="/api/bots", tags=["bots"], route_class=DishkaRoute)
|
||||
|
||||
INVALID_TOKEN = "token must look like 123456:ABC-DEF..." # noqa: S105
|
||||
|
||||
|
||||
class BotAdd(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_bots(pool: FromDishka[asyncpg.Pool]) -> list[BotView]:
|
||||
return await bots.list_bots(pool)
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
async def add_bot(pool: FromDishka[asyncpg.Pool], body: BotAdd) -> BotView:
|
||||
bot_id = tokens.bot_id_of(body.token)
|
||||
if bot_id is None:
|
||||
raise HTTPException(status_code=422, detail=INVALID_TOKEN)
|
||||
tokens.save(bot_id, body.token)
|
||||
await bots.register_bot(pool, bot_id)
|
||||
await bots.notify_bots_changed(pool)
|
||||
bot = await bots.get_bot(pool, bot_id)
|
||||
if bot is None:
|
||||
raise HTTPException(status_code=500, detail="bot was not registered")
|
||||
return bot
|
||||
|
||||
|
||||
@router.delete("/{bot_id}", status_code=204)
|
||||
async def remove_bot(pool: FromDishka[asyncpg.Pool], bot_id: int) -> None:
|
||||
if not await bots.remove_bot(pool, bot_id):
|
||||
raise HTTPException(status_code=404, detail="unknown bot")
|
||||
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,
|
||||
)
|
||||
@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from pyrogram import Client, enums
|
||||
|
||||
from utils.telegram import API_HASH, API_ID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from userbot.modules.capture import CaptureContext
|
||||
|
||||
@@ -22,8 +24,8 @@ class PyroClient(Client):
|
||||
super().__init__(
|
||||
name,
|
||||
workdir=workdir,
|
||||
api_id=2040,
|
||||
api_hash="b18441a1ff607e10a989891a5462e627",
|
||||
api_id=API_ID,
|
||||
api_hash=API_HASH,
|
||||
device_model=device_model or DEVICE_MODEL,
|
||||
system_version="Windows 11 x64",
|
||||
app_version="7.0.8 x64",
|
||||
|
||||
@@ -31,6 +31,12 @@ class TelegramSettings(BaseSettings):
|
||||
sessions_dir: str = "sessions"
|
||||
|
||||
|
||||
class BotsSettings(BaseSettings):
|
||||
sessions_dir: str = "sessions/bots"
|
||||
backfill_per_chat: int = 0
|
||||
backfill_chats: int = 0
|
||||
|
||||
|
||||
class ApiSettings(BaseSettings):
|
||||
host: str = "0.0.0.0" # noqa: S104
|
||||
port: int = 8080
|
||||
@@ -56,6 +62,7 @@ class LogSettings(BaseSettings):
|
||||
class Settings(BaseSettings):
|
||||
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
|
||||
tg: TelegramSettings = Field(default_factory=TelegramSettings)
|
||||
bots: BotsSettings = Field(default_factory=BotsSettings)
|
||||
api: ApiSettings = Field(default_factory=ApiSettings)
|
||||
auth: AuthSettings = Field(default_factory=AuthSettings)
|
||||
storage: StorageSettings = Field(default_factory=StorageSettings)
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import asyncpg
|
||||
|
||||
from utils.read.models import (
|
||||
BotBucket,
|
||||
BotCount,
|
||||
BotHour,
|
||||
BotSender,
|
||||
BotStats,
|
||||
BotSummary,
|
||||
BotUpdateView,
|
||||
BotView,
|
||||
)
|
||||
|
||||
BOTS_CHANGED_CHANNEL = "watched_bots_changed"
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_OK = "ok"
|
||||
STATUS_UNAUTHORIZED = "unauthorized"
|
||||
STATUS_ERROR = "error"
|
||||
STATUS_REMOVED = "removed"
|
||||
|
||||
_BOT_COLS = """
|
||||
SELECT b.bot_id, b.username, b.title, b.enabled, b.status, b.status_detail,
|
||||
b.backfilled_at, b.checked_at, b.added_at,
|
||||
coalesce(u.incoming, 0) AS incoming,
|
||||
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 = """
|
||||
INSERT INTO watched_bots (bot_id, status) VALUES ($1, 'pending')
|
||||
ON CONFLICT (bot_id) DO UPDATE SET
|
||||
enabled = true, status = 'pending', status_detail = NULL, updated_at = now()
|
||||
"""
|
||||
|
||||
_REMOVE = """
|
||||
UPDATE watched_bots
|
||||
SET enabled = false, status = 'removed', status_detail = NULL, updated_at = now()
|
||||
WHERE bot_id = $1 AND enabled
|
||||
"""
|
||||
|
||||
_SET_STATUS = """
|
||||
UPDATE watched_bots SET
|
||||
status = $2,
|
||||
status_detail = $3,
|
||||
username = coalesce($4, username),
|
||||
title = coalesce($5, title),
|
||||
checked_at = now(),
|
||||
updated_at = now()
|
||||
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:
|
||||
await pool.execute("SELECT pg_notify($1, '')", BOTS_CHANGED_CHANNEL)
|
||||
|
||||
|
||||
async def list_bots(pool: asyncpg.Pool) -> list[BotView]:
|
||||
rows = await pool.fetch(f"{_BOT_COLS} ORDER BY u.last_ts DESC NULLS LAST")
|
||||
return [BotView(**dict(row)) for row in rows]
|
||||
|
||||
|
||||
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)
|
||||
return BotView(**dict(row)) if row else None
|
||||
|
||||
|
||||
async def enabled_bot_ids(pool: asyncpg.Pool) -> set[int]:
|
||||
rows = await pool.fetch(
|
||||
"SELECT bot_id FROM watched_bots WHERE enabled AND status <> $1",
|
||||
STATUS_UNAUTHORIZED,
|
||||
)
|
||||
return {row["bot_id"] for row in rows}
|
||||
|
||||
|
||||
async def register_bot(pool: asyncpg.Pool, bot_id: int) -> None:
|
||||
await pool.execute(_REGISTER, bot_id)
|
||||
|
||||
|
||||
async def remove_bot(pool: asyncpg.Pool, bot_id: int) -> bool:
|
||||
return await pool.execute(_REMOVE, bot_id) != "UPDATE 0"
|
||||
|
||||
|
||||
async def set_status( # noqa: PLR0913
|
||||
pool: asyncpg.Pool,
|
||||
bot_id: int,
|
||||
status: str,
|
||||
*,
|
||||
detail: str | None = None,
|
||||
username: str | None = None,
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
await pool.execute(_SET_STATUS, bot_id, status, detail, username, title)
|
||||
|
||||
|
||||
async def needs_backfill(pool: asyncpg.Pool, bot_id: int) -> bool:
|
||||
return not await pool.fetchval(
|
||||
"SELECT backfilled_at IS NOT NULL FROM watched_bots WHERE bot_id = $1", bot_id
|
||||
)
|
||||
|
||||
|
||||
async def mark_backfilled(pool: asyncpg.Pool, bot_id: int) -> None:
|
||||
await pool.execute(
|
||||
"UPDATE watched_bots SET backfilled_at = now(), updated_at = now() "
|
||||
"WHERE bot_id = $1",
|
||||
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}
|
||||
@@ -423,3 +423,82 @@ class FileShareHitView(BaseModel):
|
||||
ip: str | None
|
||||
user_agent: str | None
|
||||
counted: bool
|
||||
|
||||
|
||||
class BotView(BaseModel):
|
||||
bot_id: int
|
||||
username: str | None
|
||||
title: str | None
|
||||
enabled: bool
|
||||
status: str
|
||||
status_detail: str | None
|
||||
backfilled_at: datetime | None
|
||||
checked_at: datetime | None
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
API_ID = 2040
|
||||
API_HASH = "b18441a1ff607e10a989891a5462e627"
|
||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
from .runner import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
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"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
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,156 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
import asyncpg
|
||||
from pyrogram import Client
|
||||
from pyrogram.errors import RPCError
|
||||
from pyrogram.handlers import (
|
||||
CallbackQueryHandler,
|
||||
ChatMemberUpdatedHandler,
|
||||
EditedMessageHandler,
|
||||
MessageHandler,
|
||||
)
|
||||
|
||||
from utils.logging import logger
|
||||
from utils.read import bots
|
||||
from utils.telegram import API_HASH, API_ID
|
||||
from watcher import tokens
|
||||
from watcher.backfill import backfill
|
||||
from watcher.capture import SOURCE_LIVE, callback_row, member_row, message_row, store
|
||||
|
||||
DEVICE_MODEL = "Beavergram Watcher"
|
||||
TOKEN_MISSING = "Токен не найден на диске" # noqa: S105
|
||||
INVALID_TOKEN_ERRORS = ("ACCESS_TOKEN_INVALID", "ACCESS_TOKEN_EXPIRED")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunningBot:
|
||||
client: Client
|
||||
backfill_task: asyncio.Task | None
|
||||
|
||||
|
||||
async def _cancel(task: asyncio.Task) -> None:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
def _handlers(pool: asyncpg.Pool, bot_id: int) -> tuple:
|
||||
async def on_message(_client: Client, message) -> None: # noqa: ANN001
|
||||
if message.empty or message.chat is None or message.date is None:
|
||||
return
|
||||
await store(pool, message_row(bot_id, message, source=SOURCE_LIVE))
|
||||
|
||||
async def on_callback(_client: Client, query) -> None: # noqa: ANN001
|
||||
await store(pool, callback_row(bot_id, query))
|
||||
|
||||
async def on_member(_client: Client, update) -> None: # noqa: ANN001
|
||||
await store(pool, member_row(bot_id, update))
|
||||
|
||||
return (
|
||||
MessageHandler(on_message),
|
||||
EditedMessageHandler(on_message),
|
||||
CallbackQueryHandler(on_callback),
|
||||
ChatMemberUpdatedHandler(on_member),
|
||||
)
|
||||
|
||||
|
||||
def _is_invalid_token(exc: RPCError) -> bool:
|
||||
return any(marker in str(exc) for marker in INVALID_TOKEN_ERRORS)
|
||||
|
||||
|
||||
class BotRegistry:
|
||||
def __init__(self, pool: asyncpg.Pool) -> None:
|
||||
self._pool = pool
|
||||
self._running: dict[int, RunningBot] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def running(self) -> list[int]:
|
||||
return list(self._running)
|
||||
|
||||
async def sync(self) -> None:
|
||||
async with self._lock:
|
||||
await self._adopt_dropped_in()
|
||||
enabled = await bots.enabled_bot_ids(self._pool)
|
||||
for bot_id in set(self._running) - enabled:
|
||||
await self._stop(bot_id)
|
||||
for bot_id in enabled - set(self._running):
|
||||
await self._start(bot_id)
|
||||
for bot_id in await bots.removed_bot_ids(self._pool):
|
||||
tokens.drop(bot_id)
|
||||
|
||||
async def close(self) -> None:
|
||||
for bot_id in list(self._running):
|
||||
await self._stop(bot_id)
|
||||
|
||||
async def _adopt_dropped_in(self) -> None:
|
||||
unknown = tokens.stored_bot_ids() - await bots.known_bot_ids(self._pool)
|
||||
for bot_id in unknown:
|
||||
await bots.register_bot(self._pool, bot_id)
|
||||
logger.info(f"[green]Bot token adopted from disk:[/] {bot_id}")
|
||||
|
||||
async def _start(self, bot_id: int) -> None:
|
||||
token = tokens.load(bot_id)
|
||||
if token is None:
|
||||
await bots.set_status(
|
||||
self._pool, bot_id, bots.STATUS_UNAUTHORIZED, detail=TOKEN_MISSING
|
||||
)
|
||||
return
|
||||
client = Client(
|
||||
str(bot_id),
|
||||
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)
|
||||
try:
|
||||
await client.start()
|
||||
me = client.me
|
||||
if me is None:
|
||||
msg = "bot session is not authorized"
|
||||
raise RuntimeError(msg)
|
||||
except RPCError as exc:
|
||||
status = (
|
||||
bots.STATUS_UNAUTHORIZED
|
||||
if _is_invalid_token(exc)
|
||||
else bots.STATUS_ERROR
|
||||
)
|
||||
await bots.set_status(self._pool, bot_id, status, detail=str(exc))
|
||||
logger.warning(f"[yellow]Bot {bot_id} failed to start:[/] {exc}")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.stop()
|
||||
return
|
||||
except Exception as exc:
|
||||
await bots.set_status(
|
||||
self._pool, bot_id, bots.STATUS_ERROR, detail=str(exc)
|
||||
)
|
||||
logger.exception(f"[red]Bot {bot_id} failed to start[/]")
|
||||
with contextlib.suppress(Exception):
|
||||
await client.stop()
|
||||
return
|
||||
|
||||
await bots.set_status(
|
||||
self._pool, bot_id, bots.STATUS_OK, username=me.username, title=me.full_name
|
||||
)
|
||||
task = (
|
||||
asyncio.create_task(backfill(client, self._pool, bot_id))
|
||||
if await bots.needs_backfill(self._pool, bot_id)
|
||||
else None
|
||||
)
|
||||
self._running[bot_id] = RunningBot(client, task)
|
||||
logger.info(f"[green]Bot watched:[/] @{me.username} ({bot_id})")
|
||||
|
||||
async def _stop(self, bot_id: int) -> None:
|
||||
running = self._running.pop(bot_id, None)
|
||||
if running is None:
|
||||
return
|
||||
if running.backfill_task is not None:
|
||||
await _cancel(running.backfill_task)
|
||||
with contextlib.suppress(Exception):
|
||||
await running.client.stop()
|
||||
logger.info(f"[yellow]Bot unwatched:[/] {bot_id}")
|
||||
@@ -0,0 +1,72 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import signal
|
||||
|
||||
import asyncpg
|
||||
import uvloop
|
||||
|
||||
from dependencies.container import container
|
||||
from utils.env import env
|
||||
from utils.logging import logger, setup_logging
|
||||
from utils.read.bots import BOTS_CHANGED_CHANNEL
|
||||
from watcher.registry import BotRegistry
|
||||
|
||||
setup_logging()
|
||||
|
||||
RETRY_INTERVAL = 300
|
||||
|
||||
|
||||
async def _retry_loop(registry: BotRegistry) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(RETRY_INTERVAL)
|
||||
with contextlib.suppress(Exception):
|
||||
await registry.sync()
|
||||
|
||||
|
||||
async def _listen_changes(
|
||||
registry: BotRegistry, tasks: set[asyncio.Task]
|
||||
) -> asyncpg.Connection:
|
||||
def on_change(
|
||||
_conn: asyncpg.Connection, _pid: int, _channel: str, _payload: str
|
||||
) -> None:
|
||||
task = asyncio.create_task(registry.sync())
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
|
||||
conn = await asyncpg.connect(dsn=env.db.connection_url)
|
||||
await conn.add_listener(BOTS_CHANGED_CHANNEL, on_change)
|
||||
return conn
|
||||
|
||||
|
||||
async def runner() -> None:
|
||||
pool = await container.get(asyncpg.Pool)
|
||||
registry = BotRegistry(pool)
|
||||
tasks: set[asyncio.Task] = set()
|
||||
listen_conn: asyncpg.Connection | None = None
|
||||
stop = asyncio.Event()
|
||||
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
|
||||
retry = asyncio.create_task(_retry_loop(registry))
|
||||
try:
|
||||
await registry.sync()
|
||||
if not registry.running:
|
||||
logger.warning("[yellow]No bots yet. Add a bot token in the web UI.[/]")
|
||||
listen_conn = await _listen_changes(registry, tasks)
|
||||
logger.info("[green]Bot watcher running.[/]")
|
||||
await stop.wait()
|
||||
finally:
|
||||
retry.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await retry
|
||||
if listen_conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await listen_conn.close()
|
||||
await registry.close()
|
||||
await container.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
uvloop.install()
|
||||
logger.info("Starting bot watcher...")
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(runner())
|
||||
logger.info("[red]Bot watcher stopped.[/]")
|
||||
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
from utils.env import env
|
||||
|
||||
TOKEN_SUFFIX = ".token" # noqa: S105
|
||||
SESSION_SUFFIX = ".session"
|
||||
TOKEN_MODE = 0o600
|
||||
|
||||
|
||||
def sessions_dir() -> Path:
|
||||
path = Path(env.bots.sessions_dir)
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def bot_id_of(token: str) -> int | None:
|
||||
head, _, secret = token.strip().partition(":")
|
||||
return int(head) if head.isdigit() and secret else None
|
||||
|
||||
|
||||
def save(bot_id: int, token: str) -> None:
|
||||
path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}"
|
||||
path.write_text(token.strip(), encoding="utf-8")
|
||||
path.chmod(TOKEN_MODE)
|
||||
|
||||
|
||||
def load(bot_id: int) -> str | None:
|
||||
path = sessions_dir() / f"{bot_id}{TOKEN_SUFFIX}"
|
||||
return path.read_text(encoding="utf-8").strip() if path.is_file() else None
|
||||
|
||||
|
||||
def drop(bot_id: int) -> None:
|
||||
for suffix in (TOKEN_SUFFIX, SESSION_SUFFIX):
|
||||
(sessions_dir() / f"{bot_id}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def stored_bot_ids() -> set[int]:
|
||||
return {
|
||||
int(path.stem)
|
||||
for path in sessions_dir().glob(f"*{TOKEN_SUFFIX}")
|
||||
if path.stem.isdigit()
|
||||
}
|
||||
+135
-35
@@ -11,7 +11,7 @@
|
||||
"uqr": "^0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@biomejs/biome": "2.5.13",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
@@ -25,33 +25,41 @@
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^7.0.0",
|
||||
"ultracite": "7.8.0",
|
||||
"ultracite": "7.12.0",
|
||||
"vite": "^8.0.7",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
|
||||
"@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
|
||||
"@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
|
||||
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
|
||||
"@biomejs/biome": ["@biomejs/biome@2.5.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.13", "@biomejs/cli-darwin-x64": "2.5.13", "@biomejs/cli-linux-arm64": "2.5.13", "@biomejs/cli-linux-arm64-musl": "2.5.13", "@biomejs/cli-linux-x64": "2.5.13", "@biomejs/cli-linux-x64-musl": "2.5.13", "@biomejs/cli-win32-arm64": "2.5.13", "@biomejs/cli-win32-x64": "2.5.13" }, "bin": { "biome": "bin/biome" } }, "sha512-+SEC/mFk1a+5mvUANZgbZTaiZXs1nj4iMhL/PHiqDT5TPUEPFIlliEtkKKZB4N862yylHC3UI+/Sj2I0HJEqhA=="],
|
||||
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
|
||||
"@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nYSuDJ6zgVqZUkAkJkvhXxsF2PYIrUk/g638K4voCXz7foI9f7b6C2/7+oAihsHQlivZNMUrm/y8ywlcHQtZOw=="],
|
||||
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
|
||||
"@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-KVy1ceEDuJ3AzFxjT9kkxbVy+UANw1pjEMUS6lvKfxjJ+fmkRvc7sQn1Xo6ETgseEI7wUQoV03KSga3XfE8YRQ=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
|
||||
"@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-VlNMtoxOqs0dUR6drxxHr18SNUvI7xxAuHZlH4s/dstYSf3g6RaqVgo8JRnhcOhKz9afWrvtJTboNG1JuYlIDQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
|
||||
"@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-CH32xpep3dNS5EVJpAHlYchkBYznxyVZQrx0b6YYYlPL9u9ZeD2NtqiK/6s64+HFS2a7hGnryLlqqZAOh8ax5g=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.3.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA=="],
|
||||
"@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-Fi6gIxbUaJ3ZCIXeG3ggIBPF76O2DjDN67WrPX/ODRv54GeXPm2gbEPC96Yb0yrJoiH0Eax1JLx1Pi0XbsNFZQ=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.4.0", "", { "dependencies": { "@clack/core": "1.3.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA=="],
|
||||
"@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.13", "", { "os": "linux", "cpu": "x64" }, "sha512-F3pmwl+VHoUuVJN/tbNLKeLt0SVK3EIyN1jXlMcNyQGYRQQTVqXF+GgkP0/CjGXFN87e/mv0sBfUnWqWza5PKQ=="],
|
||||
|
||||
"@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-+WD13qshXrr0Icv4BfsAdzm8Fs3TL+nZ59zEQxsYNd8lcgZJ0A5+OrlwsL1PipVCmWeRpxgIPD6DAcEd7smkCQ=="],
|
||||
|
||||
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.13", "", { "os": "win32", "cpu": "x64" }, "sha512-VOofU/nW761XWzUeUNE8zzYNyPrxuMuNFMizL0qz7J75yeV8N7WFheUlk1/K6dOkaFpH9wJ+lDKlwjAbw0346w=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
@@ -79,6 +87,12 @@
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="],
|
||||
|
||||
"@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="],
|
||||
@@ -143,6 +157,10 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.10", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA=="],
|
||||
@@ -243,28 +261,36 @@
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
|
||||
|
||||
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
|
||||
|
||||
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"bits-ui": ["bits-ui@2.18.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
|
||||
"cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@6.1.1", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
"commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="],
|
||||
|
||||
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||
@@ -275,37 +301,67 @@
|
||||
|
||||
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
|
||||
|
||||
"empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
|
||||
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
|
||||
|
||||
"esrap": ["esrap@2.2.9", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A=="],
|
||||
|
||||
"execa": ["execa@10.0.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "figures": "^6.1.0", "get-stream": "^9.0.1", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.3.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "which-command": "^0.1.0", "yoctocolors": "^2.1.2" } }, "sha512-ge98qjkRK4IB7tL7Ju/6qmm5LHoH1eEMt5FNZrz3f4UIYhF28lggX20z3FaX1sgc67msLEn0N0BscOs29iuwyw=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"find-workspaces": ["find-workspaces@0.3.1", "", { "dependencies": { "fast-glob": "^3.3.2", "pkg-types": "^1.0.3", "yaml": "^2.3.4" } }, "sha512-UDkGILGJSA1LN5Aa7McxCid4sqW3/e+UYsVwyxki3dDT0F8+ym0rAfnCkEfkL0rO7M+8/mvkim4t/s3IPHmg+w=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
|
||||
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
|
||||
|
||||
"immutable": ["immutable@5.1.6", "", {}, "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
|
||||
|
||||
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
@@ -339,17 +395,23 @@
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
|
||||
"log-update": ["log-update@8.0.0", "", { "dependencies": { "ansi-escapes": "^7.3.0", "cli-cursor": "^5.0.0", "slice-ansi": "^9.0.0", "string-width": "^8.2.0", "strip-ansi": "^7.2.0", "wrap-ansi": "^10.0.0" } }, "sha512-lddSgOt3bPASrylL54ZSpy8nBHns+vBVSoILlVOx+dei300pnLRN958rj/EdlVLKuWlSESU3qdnDZdAI7FXYGg=="],
|
||||
|
||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||
"lottie-web": ["lottie-web@5.13.0", "", {}, "sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ=="],
|
||||
|
||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||
"magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="],
|
||||
|
||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||
|
||||
@@ -359,15 +421,19 @@
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"nypm": ["nypm@0.6.6", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.1.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q=="],
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"nypm": ["nypm@0.6.10", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.3.1" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-W72Hrj1petq+b3Hk2aAC+9zswetlIFTnDW4s5djseZh2nYqBbyQLOtj472HwcbcWykPBUW1WpWpjOd4nK6gMRw=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"pako": ["pako@3.0.2", "", {}, "sha512-uBv6IT2aT1A78iU6dpNEbf6+CyhlV/6g9JlJs9kpgjFGFhruIICVRysF/W0SLzXg5+hCl+KroH7e4YUyfEmgLg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
|
||||
|
||||
"path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
|
||||
"path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -375,32 +441,54 @@
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||
|
||||
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="],
|
||||
|
||||
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
|
||||
|
||||
"sass": ["sass@1.100.0", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-B5j0rYMlinhhOo9tjQebMVVn0TfyXAF+wB3b2ggZUuJ/is/Y+7+JGjirAMxHZ9Z3hIP98NPfamlAkBHa1lAaXQ=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"svelte": ["svelte@5.55.10", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.9", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-v9mFVBY1USosyIWdXE7Cg4AN0ywyKCMcAhONvli8doMowEhFhMdNLKD1j7O/UnsrdVTHaUOk/jv8hD/HClVy+g=="],
|
||||
@@ -415,20 +503,26 @@
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.2", "", {}, "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g=="],
|
||||
"tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"ultracite": ["ultracite@7.8.0", "", { "dependencies": { "@clack/prompts": "^1.4.0", "commander": "^14.0.3", "cross-spawn": "^7.0.6", "deepmerge": "^4.3.1", "glob": "^13.0.6", "jsonc-parser": "^3.3.1", "nypm": "^0.6.6", "yaml": "^2.9.0", "zod": "^4.4.3" }, "peerDependencies": { "oxfmt": ">=0.1.0", "oxlint": "^1.0.0" }, "optionalPeers": ["oxfmt", "oxlint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-wAIdn7YTBjygSdpz3ubMCAqja0odk2SCn/YqrM6k17D7ASouo0qaODJa76Xo3tp13yPWnNnLvcduNlmLBAtzYg=="],
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"ultracite": ["ultracite@7.12.0", "", { "dependencies": { "@clack/prompts": "^1.7.0", "cli-truncate": "^6.1.1", "commander": "^15.0.0", "deepmerge": "^4.3.1", "empathic": "^2.0.1", "execa": "^10.0.1", "fast-glob": "^3.3.3", "find-workspaces": "^0.3.1", "jsonc-parser": "^3.3.1", "log-update": "^8.0.0", "magicast": "^0.5.4", "nypm": "^0.6.9", "resolve.exports": "^2.0.3", "semver": "^7.8.5", "string-width": "^8.2.2", "yaml": "^2.9.0", "zod": "^4.5.4" }, "peerDependencies": { "@biomejs/biome": "^2.5.0", "eslint": "^10.0.0", "oxfmt": ">=0.40.0", "oxlint": "^1.79.0", "prettier": "^3.0.0", "stylelint": "^17.0.0" }, "optionalPeers": ["@biomejs/biome", "eslint", "oxfmt", "oxlint", "prettier", "stylelint"], "bin": { "ultracite": "dist/index.js" } }, "sha512-thOrO9IMaEcGjQrwp44W5UNm9muLIY/vVD83hVPhQ5wZncSAQB+qWq6U78QgVAoWaD3mDpkZDvfwF+JLSQtZzg=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="],
|
||||
|
||||
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -437,13 +531,17 @@
|
||||
|
||||
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
"which-command": ["which-command@0.1.0", "", { "bin": { "which-command": "cli.js" } }, "sha512-XZyoF5/5hZtXitIwzrU4NKK+Wtbb9aB9CezUEw2Q0wlYK8NUYQxC1rRXgNueYLtBAJwXIb+/tFVk4dozciNJMA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@10.0.1", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0" } }, "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q=="],
|
||||
|
||||
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
|
||||
|
||||
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
"zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
@@ -457,6 +555,8 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"svelte-check/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"svelte-check/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.15",
|
||||
"@biomejs/biome": "2.5.13",
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
@@ -14,7 +14,7 @@
|
||||
"svelte-check": "^4.4.6",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^7.0.0",
|
||||
"ultracite": "7.8.0",
|
||||
"ultracite": "7.12.0",
|
||||
"vite": "^8.0.7"
|
||||
},
|
||||
"name": "frontend",
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
Alert,
|
||||
Annotation,
|
||||
AvatarHistoryView,
|
||||
BotStats,
|
||||
BotUpdate,
|
||||
CallbackView,
|
||||
CaptureToggles,
|
||||
Chat,
|
||||
@@ -34,6 +36,7 @@ import type {
|
||||
StoryView,
|
||||
VolumeBucket,
|
||||
Watch,
|
||||
WatchedBot,
|
||||
} from "$lib/api/types";
|
||||
import { accounts } from "$lib/stores/accounts.svelte";
|
||||
|
||||
@@ -568,3 +571,32 @@ export function updateAnnotation(
|
||||
export function deleteAnnotation(id: number): Promise<void> {
|
||||
return request<void>(`/annotations/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function listWatchedBots(): Promise<WatchedBot[]> {
|
||||
return request<WatchedBot[]>("/bots");
|
||||
}
|
||||
|
||||
export function addWatchedBot(token: string): Promise<WatchedBot> {
|
||||
return request<WatchedBot>("/bots", { method: "POST", body: { token } });
|
||||
}
|
||||
|
||||
export function removeWatchedBot(botId: number): Promise<void> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -546,3 +546,82 @@ export interface ShareSubject {
|
||||
storyId?: number;
|
||||
versionId?: number;
|
||||
}
|
||||
|
||||
export interface WatchedBot {
|
||||
added_at: string;
|
||||
backfilled_at: string | null;
|
||||
bot_id: number;
|
||||
checked_at: string | null;
|
||||
enabled: boolean;
|
||||
incoming: number;
|
||||
last_ts: string | null;
|
||||
senders: number;
|
||||
status: string;
|
||||
status_detail: string | null;
|
||||
title: 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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import AlertsPanel from "$lib/components/alerts/AlertsPanel.svelte";
|
||||
import AnnotationsPanel from "$lib/components/annotations/AnnotationsPanel.svelte";
|
||||
import BotsPanel from "$lib/components/bots/BotsPanel.svelte";
|
||||
import JobsPanel from "$lib/components/jobs/JobsPanel.svelte";
|
||||
import PolicyEditor from "$lib/components/policy/PolicyEditor.svelte";
|
||||
import AnalyticsPanel from "$lib/components/presence/AnalyticsPanel.svelte";
|
||||
@@ -33,6 +34,7 @@
|
||||
watches: "Отслеживания",
|
||||
alerts: "Алерты",
|
||||
shares: "Файлы по ссылке",
|
||||
bots: "Боты под наблюдением",
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -78,6 +80,8 @@
|
||||
<AnnotationsPanel />
|
||||
{:else if ui.rightPanel === "shares"}
|
||||
<SharesPanel />
|
||||
{:else if ui.rightPanel === "bots"}
|
||||
<BotsPanel />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
<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>
|
||||
@@ -0,0 +1,43 @@
|
||||
<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>
|
||||
@@ -0,0 +1,106 @@
|
||||
<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>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
addWatchedBot,
|
||||
listWatchedBots,
|
||||
removeWatchedBot,
|
||||
} from "$lib/api/endpoints";
|
||||
import type { WatchedBot } from "$lib/api/types";
|
||||
import BotStatsView from "$lib/components/bots/BotStatsView.svelte";
|
||||
import Button from "$lib/components/ui/Button.svelte";
|
||||
import Icon from "$lib/components/ui/Icon.svelte";
|
||||
import Spinner from "$lib/components/ui/Spinner.svelte";
|
||||
import { formatListDate } from "$lib/format/datetime";
|
||||
import { toasts } from "$lib/stores/toasts.svelte";
|
||||
|
||||
let bots = $state<WatchedBot[]>([]);
|
||||
let loading = $state(true);
|
||||
let token = $state("");
|
||||
let adding = $state(false);
|
||||
let selected = $state<number | null>(null);
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
pending: "подключается",
|
||||
ok: "слушает",
|
||||
unauthorized: "токен отклонён",
|
||||
error: "ошибка",
|
||||
removed: "отключён",
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
bots = await listWatchedBots();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
load();
|
||||
});
|
||||
|
||||
async function add() {
|
||||
if (!token.trim()) {
|
||||
return;
|
||||
}
|
||||
adding = true;
|
||||
try {
|
||||
await addWatchedBot(token.trim());
|
||||
token = "";
|
||||
await load();
|
||||
} catch {
|
||||
toasts.error("Не похоже на токен бота");
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(bot: WatchedBot) {
|
||||
await removeWatchedBot(bot.bot_id);
|
||||
if (selected === bot.bot_id) {
|
||||
selected = null;
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
function botName(bot: WatchedBot): string {
|
||||
return bot.username
|
||||
? `@${bot.username}`
|
||||
: (bot.title ?? String(bot.bot_id));
|
||||
}
|
||||
|
||||
const current = $derived(bots.find((bot) => bot.bot_id === selected) ?? null);
|
||||
</script>
|
||||
|
||||
<div class="panel-content">
|
||||
{#if current}
|
||||
<header class="bot-header">
|
||||
<Button
|
||||
variant="translucent"
|
||||
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}
|
||||
{#if loading}
|
||||
<Spinner />
|
||||
{:else if bots.length === 0}
|
||||
<p class="empty">Ботов пока нет.</p>
|
||||
{:else}
|
||||
<ul class="bots">
|
||||
{#each bots as bot (bot.bot_id)}
|
||||
<li class="bot">
|
||||
<button
|
||||
type="button"
|
||||
class="open"
|
||||
onclick={() => (selected = bot.bot_id)}
|
||||
>
|
||||
<span class="name">{botName(bot)}</span>
|
||||
<span class="meta">
|
||||
{statusLabels[bot.status] ?? bot.status}
|
||||
· {bot.incoming} от {bot.senders}
|
||||
{#if bot.last_ts}
|
||||
· {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}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/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, и не требует
|
||||
вебхука. Сколько прошлой переписки удастся вытащить, решает сервер
|
||||
Telegram: watcher просит историю диалогов бота и берёт, что дают.
|
||||
</p>
|
||||
<p class="note">
|
||||
В личке бот видит каждого, кто ему написал. В группах ему доставляют всё
|
||||
только при выключенном privacy mode, иначе — команды и ответы на его
|
||||
сообщения. Отключение бота убирает токен и сессию, но архив остаётся.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.bot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.bot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
|
||||
padding: 0.5rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-background-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.detail {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-borders);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
input {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--color-borders);
|
||||
border-radius: var(--border-radius-default);
|
||||
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -57,6 +57,11 @@
|
||||
label="Файлы по ссылке"
|
||||
onclick={() => ui.openPanel("shares")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="bots"
|
||||
label="Боты под наблюдением"
|
||||
onclick={() => ui.openPanel("bots")}
|
||||
/>
|
||||
<SettingsItem
|
||||
icon="eye"
|
||||
label="Отслеживания"
|
||||
|
||||
@@ -13,7 +13,8 @@ export type RightPanel =
|
||||
| "policy"
|
||||
| "watches"
|
||||
| "alerts"
|
||||
| "shares";
|
||||
| "shares"
|
||||
| "bots";
|
||||
|
||||
export type LeftView = "main" | "settings";
|
||||
|
||||
|
||||
Reference in new issue
Block a user