feat(backend): presence stats count time online from sessions in the browser time zone, capture uptime marks hours without data, presence reaches every stream of the account, message volume keeps empty days
This commit is contained in:
1 parent
bb38e23f29
commit
f44597b2dc
14 files changed
+585
-26
No files matched your search
@@ -20,9 +20,19 @@ from api.mcp.download import (
|
||||
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
|
||||
from dependencies.container import container
|
||||
from utils.files import resolve_mime
|
||||
from utils.read import annotations, chats, media, peers, presence, social, watches
|
||||
from utils.read import (
|
||||
annotations,
|
||||
chats,
|
||||
media,
|
||||
peers,
|
||||
presence,
|
||||
presence_stats,
|
||||
social,
|
||||
watches,
|
||||
)
|
||||
from utils.read.accounts import self_user_id, unknown_account_hint
|
||||
from utils.read.models import DEFAULT_LIMIT, Page
|
||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS, known_timezone
|
||||
from utils.read.read_receipts import read_info
|
||||
from utils.search.models import SearchFilters
|
||||
from utils.search.repository import search_messages
|
||||
@@ -335,6 +345,37 @@ async def presence_history(
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(name="presence_stats")
|
||||
async def presence_stats_tool(
|
||||
account_id: int,
|
||||
peer_id: int,
|
||||
days: int = 30,
|
||||
tz: str = DEFAULT_TZ,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Time a peer spent online, from status pushes turned into online sessions.
|
||||
|
||||
`tz` is an IANA zone name; days and hours are counted in it. `week` holds
|
||||
the average online minutes for each weekday (0 is Monday) and hour, null
|
||||
where the archive has no data for that hour. `days` gives online seconds
|
||||
per day, `gaps` the stretches when capture was down, `sessions` the latest
|
||||
sessions (online stretches with breaks under 5 minutes), paged by
|
||||
`limit`/`offset`. `hidden` means the peer hides their last seen time.
|
||||
"""
|
||||
pool = await _pool()
|
||||
if not 1 <= days <= MAX_DAYS:
|
||||
msg = f"days must be between 1 and {MAX_DAYS}"
|
||||
raise ToolError(msg)
|
||||
if not await known_timezone(pool, tz):
|
||||
msg = f"Unknown time zone: {tz}"
|
||||
raise ToolError(msg)
|
||||
stats = await presence_stats.presence_stats(
|
||||
pool, account_id, peer_id, Page(limit=limit, offset=offset), days=days, tz=tz
|
||||
)
|
||||
return stats.model_dump(mode="json")
|
||||
|
||||
|
||||
@mcp.tool
|
||||
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
|
||||
"""Get name/username/avatar change history of a contact."""
|
||||
|
||||
@@ -59,16 +59,12 @@ class EventHub:
|
||||
return
|
||||
account_id = event.get("account_id")
|
||||
chat_id = event.get("chat_id")
|
||||
scoped = event.get("kind") == "presence"
|
||||
account_wide = event.get("kind") == "presence"
|
||||
targets = [
|
||||
sub
|
||||
for sub in self._subscribers
|
||||
if sub.account_id == account_id
|
||||
and (
|
||||
sub.chat_id == chat_id
|
||||
if scoped
|
||||
else sub.chat_id is None or sub.chat_id == chat_id
|
||||
)
|
||||
and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
|
||||
@@ -4,8 +4,10 @@ import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from api.timezone import checked_tz
|
||||
from utils.read import analytics
|
||||
from utils.read.models import ResponseStats, VolumeBucket
|
||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS
|
||||
|
||||
router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
|
||||
|
||||
@@ -18,9 +20,12 @@ async def volume(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
chat_id: ChatId,
|
||||
days: Annotated[int, Query()] = 90,
|
||||
days: Annotated[int, Query(ge=1, le=MAX_DAYS)] = 90,
|
||||
tz: Annotated[str, Query()] = DEFAULT_TZ,
|
||||
) -> list[VolumeBucket]:
|
||||
return await analytics.message_volume(pool, account_id, chat_id, days=days)
|
||||
return await analytics.message_volume(
|
||||
pool, account_id, chat_id, days=days, tz=await checked_tz(pool, tz)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/response-time")
|
||||
|
||||
@@ -5,8 +5,17 @@ import asyncpg
|
||||
from dishka.integrations.fastapi import DishkaRoute, FromDishka
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from utils.read import presence
|
||||
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample
|
||||
from api.timezone import checked_tz
|
||||
from utils.read import presence, presence_stats
|
||||
from utils.read.models import (
|
||||
DEFAULT_LIMIT,
|
||||
Page,
|
||||
PresenceHourly,
|
||||
PresenceSample,
|
||||
PresenceSession,
|
||||
PresenceStats,
|
||||
)
|
||||
from utils.read.period import DEFAULT_TZ, MAX_DAYS
|
||||
|
||||
router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
|
||||
|
||||
@@ -14,6 +23,8 @@ AccountId = Annotated[int, Query()]
|
||||
PeerId = Annotated[int, Query()]
|
||||
DateFrom = Annotated[datetime | None, Query()]
|
||||
DateTo = Annotated[datetime | None, Query()]
|
||||
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
|
||||
Tz = Annotated[str, Query()]
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -54,3 +65,43 @@ async def presence_hourly(
|
||||
return await presence.presence_hourly(
|
||||
pool, account_id, peer_id, date_from=date_from, date_to=date_to
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
peer_id: PeerId,
|
||||
days: Days = 30,
|
||||
tz: Tz = DEFAULT_TZ,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> PresenceStats:
|
||||
return await presence_stats.presence_stats(
|
||||
pool,
|
||||
account_id,
|
||||
peer_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
days=days,
|
||||
tz=await checked_tz(pool, tz),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def sessions(
|
||||
pool: FromDishka[asyncpg.Pool],
|
||||
account_id: AccountId,
|
||||
peer_id: PeerId,
|
||||
days: Days = 30,
|
||||
tz: Tz = DEFAULT_TZ,
|
||||
limit: Annotated[int, Query()] = DEFAULT_LIMIT,
|
||||
offset: Annotated[int, Query()] = 0,
|
||||
) -> list[PresenceSession]:
|
||||
return await presence_stats.presence_sessions(
|
||||
pool,
|
||||
account_id,
|
||||
peer_id,
|
||||
Page(limit=limit, offset=offset),
|
||||
days=days,
|
||||
tz=await checked_tz(pool, tz),
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
import asyncpg
|
||||
from fastapi import HTTPException
|
||||
|
||||
from utils.read.period import known_timezone
|
||||
|
||||
|
||||
async def checked_tz(pool: asyncpg.Pool, tz: str) -> str:
|
||||
if not await known_timezone(pool, tz):
|
||||
raise HTTPException(status_code=422, detail=f"Unknown time zone: {tz}")
|
||||
return tz
|
||||
Reference in new issue
Block a user