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:
hh committed 2026-10-19 02:41:39 +00:00
1 parent bb38e23f29
commit f44597b2dc
14 files changed
+585 -26

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ Nothing is ever deleted from the archive.
## MCP ## MCP
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. 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`, `presence_stats`, `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 ## Run
@@ -0,0 +1,50 @@
"""capture uptime
Revision ID: d8f4a1c6e2b7
Revises: d7a3c5e9f2b1
Create Date: 2026-10-19 03:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
revision: str = "d8f4a1c6e2b7"
down_revision: str | None = "d7a3c5e9f2b1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_UPTIME = """
CREATE TABLE capture_uptime (
account_id integer NOT NULL,
started_at timestamptz NOT NULL,
seen_at timestamptz NOT NULL,
PRIMARY KEY (account_id, started_at)
)
"""
_BACKFILL = """
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT account_id, min(ts), max(ts)
FROM (
SELECT account_id, ts,
count(*) FILTER (WHERE gap > interval '30 minutes')
OVER (PARTITION BY account_id ORDER BY ts) AS stretch
FROM (
SELECT account_id, ts,
ts - lag(ts) OVER (PARTITION BY account_id ORDER BY ts) AS gap
FROM presence
) gaps
) stretches
GROUP BY account_id, stretch
"""
def upgrade() -> None:
op.execute(_UPTIME)
op.execute(_BACKFILL)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS capture_uptime")
+42 -1
View File
@@ -20,9 +20,19 @@ from api.mcp.download import (
from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names from api.mcp.format import build_transcript, load_notes, load_photos, resolve_names
from dependencies.container import container from dependencies.container import container
from utils.files import resolve_mime 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.accounts import self_user_id, unknown_account_hint
from utils.read.models import DEFAULT_LIMIT, Page 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.read.read_receipts import read_info
from utils.search.models import SearchFilters from utils.search.models import SearchFilters
from utils.search.repository import search_messages from utils.search.repository import search_messages
@@ -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 @mcp.tool
async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]: async def get_peer_history(account_id: int, peer_id: int) -> list[dict[str, Any]]:
"""Get name/username/avatar change history of a contact.""" """Get name/username/avatar change history of a contact."""
+2 -6
View File
@@ -59,16 +59,12 @@ class EventHub:
return return
account_id = event.get("account_id") account_id = event.get("account_id")
chat_id = event.get("chat_id") chat_id = event.get("chat_id")
scoped = event.get("kind") == "presence" account_wide = event.get("kind") == "presence"
targets = [ targets = [
sub sub
for sub in self._subscribers for sub in self._subscribers
if sub.account_id == account_id if sub.account_id == account_id
and ( and (account_wide or sub.chat_id is None or sub.chat_id == chat_id)
sub.chat_id == chat_id
if scoped
else sub.chat_id is None or sub.chat_id == chat_id
)
] ]
if not targets: if not targets:
return return
+7 -2
View File
@@ -4,8 +4,10 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from api.timezone import checked_tz
from utils.read import analytics from utils.read import analytics
from utils.read.models import ResponseStats, VolumeBucket 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) router = APIRouter(prefix="/api/analytics", tags=["analytics"], route_class=DishkaRoute)
@@ -18,9 +20,12 @@ async def volume(
pool: FromDishka[asyncpg.Pool], pool: FromDishka[asyncpg.Pool],
account_id: AccountId, account_id: AccountId,
chat_id: ChatId, 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]: ) -> 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") @router.get("/response-time")
+53 -2
View File
@@ -5,8 +5,17 @@ import asyncpg
from dishka.integrations.fastapi import DishkaRoute, FromDishka from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from utils.read import presence from api.timezone import checked_tz
from utils.read.models import DEFAULT_LIMIT, Page, PresenceHourly, PresenceSample 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) router = APIRouter(prefix="/api/presence", tags=["presence"], route_class=DishkaRoute)
@@ -14,6 +23,8 @@ AccountId = Annotated[int, Query()]
PeerId = Annotated[int, Query()] PeerId = Annotated[int, Query()]
DateFrom = Annotated[datetime | None, Query()] DateFrom = Annotated[datetime | None, Query()]
DateTo = Annotated[datetime | None, Query()] DateTo = Annotated[datetime | None, Query()]
Days = Annotated[int, Query(ge=1, le=MAX_DAYS)]
Tz = Annotated[str, Query()]
@router.get("") @router.get("")
@@ -54,3 +65,43 @@ async def presence_hourly(
return await presence.presence_hourly( return await presence.presence_hourly(
pool, account_id, peer_id, date_from=date_from, date_to=date_to 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),
)
+10
View File
@@ -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
Whitespace-only changes.
@@ -0,0 +1,16 @@
import asyncpg
_HEARTBEAT = """
WITH extended AS (
UPDATE capture_uptime SET seen_at = now()
WHERE account_id = $1 AND seen_at >= now() - interval '150 seconds'
RETURNING account_id
)
INSERT INTO capture_uptime (account_id, started_at, seen_at)
SELECT $1, now(), now()
WHERE NOT EXISTS (SELECT 1 FROM extended)
"""
async def heartbeat(pool: asyncpg.Pool, account_id: int) -> None:
await pool.execute(_HEARTBEAT, account_id)
+13
View File
@@ -14,6 +14,7 @@ from userbot.modules.capture import CaptureContext, build_capture_context
from userbot.modules.catchup import SavedState, catch_up, read_saved_state from userbot.modules.catchup import SavedState, catch_up, read_saved_state
from userbot.modules.client import PyroClient from userbot.modules.client import PyroClient
from userbot.modules.jobs import JobConsumer from userbot.modules.jobs import JobConsumer
from userbot.modules.uptime import repository as uptime
from utils.env import env from utils.env import env
from utils.jobs import enqueue from utils.jobs import enqueue
from utils.listen import Listener from utils.listen import Listener
@@ -31,6 +32,7 @@ setup_logging()
SESSION_FLUSH_INTERVAL = 60 SESSION_FLUSH_INTERVAL = 60
SESSION_RETRY_INTERVAL = 60 SESSION_RETRY_INTERVAL = 60
UPTIME_INTERVAL = 60
@dataclass @dataclass
@@ -171,6 +173,15 @@ async def _retry_sessions(registry: AccountRegistry) -> None:
logger.exception("[red]Session sync failed[/]") logger.exception("[red]Session sync failed[/]")
async def _keep_uptime(registry: AccountRegistry, pool: asyncpg.Pool) -> None:
while True:
for client in registry.clients:
if client.capture is not None and client.is_connected:
with contextlib.suppress(Exception):
await uptime.heartbeat(pool, client.capture.account_id)
await asyncio.sleep(UPTIME_INTERVAL)
def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener: def _change_listener(registry: AccountRegistry, tasks: set[asyncio.Task]) -> Listener:
def spawn(coro: Coroutine[Any, Any, None]) -> None: def spawn(coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro) task = asyncio.create_task(coro)
@@ -220,6 +231,7 @@ async def runner() -> None:
asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set) asyncio.get_running_loop().add_signal_handler(signal.SIGTERM, stop.set)
flush = asyncio.create_task(_flush_sessions(registry)) flush = asyncio.create_task(_flush_sessions(registry))
retry = asyncio.create_task(_retry_sessions(registry)) retry = asyncio.create_task(_retry_sessions(registry))
beat = asyncio.create_task(_keep_uptime(registry, pool))
try: try:
await registry.sync() await registry.sync()
if not registry.clients: if not registry.clients:
@@ -230,6 +242,7 @@ async def runner() -> None:
finally: finally:
await _cancel(flush) await _cancel(flush)
await _cancel(retry) await _cancel(retry)
await _cancel(beat)
await listener.stop() await listener.stop()
await registry.close() await registry.close()
await container.close() await container.close()
+20 -12
View File
@@ -4,23 +4,31 @@ import asyncpg
from utils.read.accounts import self_user_id from utils.read.accounts import self_user_id
from utils.read.models import ResponseStats, VolumeBucket from utils.read.models import ResponseStats, VolumeBucket
from utils.read.period import period_bounds
_VOLUME = """
WITH counts AS (
SELECT (date AT TIME ZONE $5)::date AS day, count(*) AS total,
count(*) FILTER (WHERE sender_id = $3) AS outgoing
FROM messages
WHERE account_id = $1 AND chat_id = $2 AND date >= $4
GROUP BY 1
)
SELECT local_day::date AS bucket, coalesce(total, 0) AS total,
coalesce(outgoing, 0) AS outgoing
FROM generate_series(($4 AT TIME ZONE $5)::date, (now() AT TIME ZONE $5)::date,
interval '1 day') AS local_day
LEFT JOIN counts ON counts.day = local_day::date
ORDER BY 1
"""
async def message_volume( async def message_volume(
pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int = 90 pool: asyncpg.Pool, account_id: int, chat_id: int, *, days: int, tz: str
) -> list[VolumeBucket]: ) -> list[VolumeBucket]:
self_id = await self_user_id(pool, account_id) self_id = await self_user_id(pool, account_id)
rows = await pool.fetch( since, _ = await period_bounds(pool, days, tz)
"SELECT date_trunc('day', date) AS bucket, count(*) AS total, " rows = await pool.fetch(_VOLUME, account_id, chat_id, self_id, since, tz)
"count(*) FILTER (WHERE sender_id = $3) AS outgoing "
"FROM messages "
"WHERE account_id = $1 AND chat_id = $2 AND date >= $4 "
"GROUP BY bucket ORDER BY bucket",
account_id,
chat_id,
self_id,
datetime.now(UTC) - timedelta(days=days),
)
return [ return [
VolumeBucket( VolumeBucket(
bucket=row["bucket"], bucket=row["bucket"],
+55 -2
View File
@@ -1,4 +1,4 @@
from datetime import datetime from datetime import date, datetime
from typing import Any from typing import Any
from pydantic import BaseModel from pydantic import BaseModel
@@ -362,8 +362,61 @@ class PresenceHourly(BaseModel):
last_seen: datetime | None last_seen: datetime | None
class PresenceSession(BaseModel):
started: datetime
ended: datetime
seconds: float
online_seconds: float
ongoing: bool
class PresenceHour(BaseModel):
weekday: int
hour: int
hours: int
online_minutes: float | None
covered_hours: float
class PresenceDay(BaseModel):
day: date
online_seconds: float
covered_seconds: float
class CoverageGap(BaseModel):
started: datetime
ended: datetime
class PresenceSummary(BaseModel):
online_seconds: float
daily_seconds: float | None
covered_seconds: float
session_count: int
median_session_seconds: float | None
first_online: int | None
last_offline: int | None
last_seen: datetime | None
class PresenceStats(BaseModel):
peer_id: int
tz: str
since: datetime
until: datetime
current: PresenceSample | None
online: bool
hidden: bool
summary: PresenceSummary
week: list[PresenceHour]
days: list[PresenceDay]
gaps: list[CoverageGap]
sessions: list[PresenceSession]
class VolumeBucket(BaseModel): class VolumeBucket(BaseModel):
bucket: datetime bucket: date
total: int total: int
outgoing: int outgoing: int
incoming: int incoming: int
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
import asyncpg
DEFAULT_TZ = "UTC"
MAX_DAYS = 365
_timezones: set[str] = set()
async def known_timezone(pool: asyncpg.Pool, tz: str) -> bool:
if not _timezones:
rows = await pool.fetch("SELECT name FROM pg_timezone_names")
_timezones.update(row["name"] for row in rows)
return tz in _timezones
async def period_bounds(
pool: asyncpg.Pool, days: int, tz: str
) -> tuple[datetime, datetime]:
row = await pool.fetchrow(
"SELECT (date_trunc('day', now() AT TIME ZONE $1) "
"- make_interval(days => $2 - 1)) AT TIME ZONE $1 AS since, now() AS until",
tz,
days,
)
return row["since"], row["until"]
+289
View File
@@ -0,0 +1,289 @@
import json
import asyncpg
from utils.read.models import (
CoverageGap,
Page,
PresenceDay,
PresenceHour,
PresenceSession,
PresenceStats,
PresenceSummary,
)
from utils.read.period import period_bounds
from utils.read.presence import current_presence
SECONDS_IN_HOUR = 3600
SECONDS_IN_DAY = 86_400
MIN_CELL_COVERAGE = 1800
MIN_CELL_SHARE = 0.5
STATS_WORK_MEM = "32MB"
FUZZY_STATUSES = {"recently", "last_week", "last_month", "long_time_ago"}
_SESSIONS = """
marks AS (
SELECT ts, status,
least(next_offline_date, ts + interval '5 minutes',
lead(coalesce(last_online_date, ts)) OVER w, $4) AS ended,
lag(CASE WHEN status = 'online'
THEN least(next_offline_date, ts + interval '5 minutes')
ELSE coalesce(last_online_date, ts) END) OVER w AS previous
FROM presence
WHERE account_id = $1 AND peer_id = $2
AND ts >= $3::timestamptz - interval '5 minutes' AND ts <= $4
WINDOW w AS (ORDER BY ts)
),
online AS (
SELECT greatest(ts, $3) AS started, ended,
count(*) FILTER (WHERE previous IS NULL
OR ts > previous + interval '5 minutes')
OVER (ORDER BY ts) AS visit
FROM marks
WHERE status = 'online' AND ended > greatest(ts, $3)
),
sessions AS (
SELECT min(started) AS started, max(ended) AS ended,
extract(epoch FROM sum(ended - started))::float8 AS online_seconds
FROM online
GROUP BY visit
)
"""
_SESSION_FIELDS = """
started, ended, extract(epoch FROM ended - started)::float8 AS seconds,
online_seconds, ended >= $4 AS ongoing
"""
_SESSION_PAGE = f"""
WITH {_SESSIONS}
SELECT {_SESSION_FIELDS}
FROM sessions
ORDER BY started DESC
LIMIT $5 OFFSET $6
""" # noqa: S608
_COVERED_HOURS = """
SELECT local_hour,
extract(epoch FROM sum(
least(ended, (local_hour + interval '1 hour') AT TIME ZONE $5)
- greatest(started, local_hour AT TIME ZONE $5)))::float8 AS seconds
FROM uptime,
generate_series(date_trunc('hour', started AT TIME ZONE $5),
date_trunc('hour', ended AT TIME ZONE $5),
interval '1 hour') AS local_hour
GROUP BY local_hour
"""
_STATS = f"""
WITH {_SESSIONS},
uptime AS (
SELECT greatest(started_at, $3) AS started,
least(seen_at + interval '90 seconds', $4) AS ended
FROM capture_uptime
WHERE account_id = $1 AND started_at < $4
AND seen_at + interval '90 seconds' > $3
),
quarters AS (
SELECT quarter, sum(ended - started - spill) AS inside, sum(spill) AS spill
FROM (SELECT started, ended, quarter,
greatest(ended - quarter - interval '15 minutes',
interval '0') AS spill
FROM (SELECT started, ended,
date_bin('15 minutes', started, timestamptz 'epoch') AS quarter
FROM online) binned) split
GROUP BY quarter
),
online_hours AS (
SELECT date_trunc('hour', quarter AT TIME ZONE $5) AS local_hour,
extract(epoch FROM sum(share))::float8 AS seconds
FROM (SELECT quarter, inside AS share FROM quarters
UNION ALL
SELECT quarter + interval '15 minutes', spill FROM quarters) shares
GROUP BY 1
),
covered_hours AS ({_COVERED_HOURS}),
hours AS (
SELECT local_hour,
extract(epoch FROM
least((local_hour + interval '1 hour') AT TIME ZONE $5, $4)
- greatest(local_hour AT TIME ZONE $5, $3))::float8 AS span,
coalesce(online.seconds, 0) AS online,
least(3600, greatest(coalesce(covered.seconds, 0),
coalesce(online.seconds, 0))) AS covered
FROM generate_series(date_trunc('hour', $3 AT TIME ZONE $5),
date_trunc('hour', $4 AT TIME ZONE $5),
interval '1 hour') AS local_hour
LEFT JOIN online_hours AS online USING (local_hour)
LEFT JOIN covered_hours AS covered USING (local_hour)
),
week AS (
SELECT extract(isodow FROM local_hour)::int - 1 AS weekday,
extract(hour FROM local_hour)::int AS hour,
count(*) AS hours, sum(span) AS span,
sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1, 2
),
days AS (
SELECT local_hour::date AS day, sum(online) AS online, sum(covered) AS covered
FROM hours
GROUP BY 1
),
uptime_reach AS (
SELECT started,
coalesce(max(ended) OVER (ORDER BY started
ROWS BETWEEN UNBOUNDED PRECEDING
AND 1 PRECEDING), $3) AS reached
FROM uptime
),
gaps AS (
SELECT reached AS started, started AS ended FROM uptime_reach
UNION ALL
SELECT coalesce(max(ended), $3), $4 FROM uptime
),
quiet AS (
SELECT hour
FROM week
GROUP BY hour
ORDER BY sum(online) / nullif(sum(covered), 0) NULLS LAST, hour
LIMIT 1
),
activity_days AS (
SELECT (sessions.started AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS started,
(sessions.ended AT TIME ZONE $5) - make_interval(hours => quiet.hour)
AS ended,
(($4 AT TIME ZONE $5) - make_interval(hours => quiet.hour))::date AS today
FROM sessions, quiet
),
daily AS (
SELECT extract(epoch FROM min(started - started::date))::float8 AS first_online,
extract(epoch FROM max(ended - started::date))::float8 AS last_offline
FROM activity_days
WHERE started::date < today
GROUP BY started::date
)
SELECT
(SELECT count(*) FROM sessions) AS session_count,
(SELECT coalesce(sum(online_seconds), 0) FROM sessions) AS online_seconds,
(SELECT sum(covered) FROM hours) AS covered_seconds,
(SELECT percentile_cont(0.5) WITHIN GROUP (
ORDER BY extract(epoch FROM ended - started)) FROM sessions) AS median_session,
(SELECT max(ended) FROM sessions) AS last_seen,
(SELECT hour FROM quiet) AS day_start,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY first_online)
FROM daily) AS first_online,
(SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY last_offline)
FROM daily) AS last_offline,
(SELECT json_agg(json_build_object(
'weekday', weekday, 'hour', hour, 'hours', hours, 'span', span,
'online', online, 'covered', covered))
FROM week) AS week,
(SELECT json_agg(json_build_object(
'day', day, 'online_seconds', online, 'covered_seconds', covered)
ORDER BY day)
FROM days) AS days,
(SELECT json_agg(json_build_object('started', started, 'ended', ended)
ORDER BY started)
FROM gaps WHERE ended - started > interval '5 minutes') AS gaps,
(SELECT json_agg(row_to_json(page) ORDER BY page.started DESC)
FROM (SELECT {_SESSION_FIELDS} FROM sessions
ORDER BY started DESC LIMIT $6 OFFSET $7) page) AS sessions
""" # noqa: S608
def _time_of_day(shifted: float | None, day_start: int | None) -> int | None:
if shifted is None or day_start is None:
return None
return round(shifted + day_start * SECONDS_IN_HOUR) % SECONDS_IN_DAY
def _week(rows: list[dict]) -> list[PresenceHour]:
cells = {(row["weekday"], row["hour"]): row for row in rows}
week: list[PresenceHour] = []
for weekday in range(7):
for hour in range(24):
row = cells.get(
(weekday, hour), {"hours": 0, "span": 0, "online": 0, "covered": 0}
)
covered = row["covered"]
minutes = (
min(60.0, row["online"] / covered * 60)
if covered > 0
and covered >= min(MIN_CELL_COVERAGE, row["span"] * MIN_CELL_SHARE)
else None
)
week.append(
PresenceHour(
weekday=weekday,
hour=hour,
hours=row["hours"],
online_minutes=minutes,
covered_hours=covered / SECONDS_IN_HOUR,
)
)
return week
async def presence_stats( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> PresenceStats:
since, until = await period_bounds(pool, days, tz)
async with pool.acquire() as conn, conn.transaction():
await conn.execute(f"SET LOCAL work_mem = '{STATS_WORK_MEM}'")
row = await conn.fetchrow(
_STATS,
account_id,
peer_id,
since,
until,
tz,
page.capped_limit,
page.offset,
)
current = await current_presence(pool, account_id, peer_id)
sessions = [PresenceSession(**item) for item in json.loads(row["sessions"] or "[]")]
covered = row["covered_seconds"] or 0
online = row["online_seconds"]
return PresenceStats(
peer_id=peer_id,
tz=tz,
since=since,
until=until,
current=current,
online=current is not None
and current.status == "online"
and current.next_offline_date is not None
and current.next_offline_date > until,
hidden=row["session_count"] == 0
and current is not None
and current.status in FUZZY_STATUSES,
summary=PresenceSummary(
online_seconds=online,
daily_seconds=online / covered * SECONDS_IN_DAY
if covered >= SECONDS_IN_HOUR
else None,
covered_seconds=covered,
session_count=row["session_count"],
median_session_seconds=row["median_session"],
first_online=_time_of_day(row["first_online"], row["day_start"]),
last_offline=_time_of_day(row["last_offline"], row["day_start"]),
last_seen=row["last_seen"]
or (current.last_online_date if current else None),
),
week=_week(json.loads(row["week"] or "[]")),
days=[PresenceDay(**item) for item in json.loads(row["days"] or "[]")],
gaps=[CoverageGap(**item) for item in json.loads(row["gaps"] or "[]")],
sessions=sessions,
)
async def presence_sessions( # noqa: PLR0913
pool: asyncpg.Pool, account_id: int, peer_id: int, page: Page, *, days: int, tz: str
) -> list[PresenceSession]:
since, until = await period_bounds(pool, days, tz)
rows = await pool.fetch(
_SESSION_PAGE, account_id, peer_id, since, until, page.capped_limit, page.offset
)
return [PresenceSession(**dict(row)) for row in rows]