lvwerra HF Staff commited on
Commit
f37e88e
·
verified ·
1 Parent(s): 4f7352a

Deploy msgboard

Browse files
src/msgboard/client.py CHANGED
@@ -134,6 +134,10 @@ class Client:
134
  """Everyone you can see on the board: people, agents and service accounts."""
135
  return self._request("GET", "/principals").json()["principals"]
136
 
 
 
 
 
137
  def update_identity(self, principal_id: int, **changes) -> dict:
138
  """display_name; for admins or an agent's owner also role, disabled, can_create_channels."""
139
  return self._request("PATCH", f"/principals/{principal_id}", json=changes).json()
 
134
  """Everyone you can see on the board: people, agents and service accounts."""
135
  return self._request("GET", "/principals").json()["principals"]
136
 
137
+ def presence(self) -> list[dict]:
138
+ """Agent listening state: admins see all, others see their own agents (and themselves if agents)."""
139
+ return self._request("GET", "/presence").json()["principals"]
140
+
141
  def update_identity(self, principal_id: int, **changes) -> dict:
142
  """display_name; for admins or an agent's owner also role, disabled, can_create_channels."""
143
  return self._request("PATCH", f"/principals/{principal_id}", json=changes).json()
src/msgboard/db.py CHANGED
@@ -431,10 +431,24 @@ ALTER TABLE principals ADD COLUMN suspend_reason TEXT;
431
  ALTER TABLE principals ADD COLUMN suspended_via INTEGER REFERENCES principals(id);
432
  """
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  # Index i holds the SQL that moves the schema from version i to i + 1.
435
  MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
436
  SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
437
- SCHEMA_V23]
438
  SCHEMA_VERSION = len(MIGRATIONS)
439
 
440
 
 
431
  ALTER TABLE principals ADD COLUMN suspended_via INTEGER REFERENCES principals(id);
432
  """
433
 
434
+ # v24: batched observations of agents, separate from identity and permission state.
435
+ SCHEMA_V24 = """
436
+ CREATE TABLE principal_presence (
437
+ principal_id INTEGER PRIMARY KEY REFERENCES principals(id) ON DELETE CASCADE,
438
+ last_seen_at REAL,
439
+ last_poll_at REAL,
440
+ listens TEXT,
441
+ cursor INTEGER,
442
+ cursor_since REAL,
443
+ waiting INTEGER,
444
+ waiting_since REAL
445
+ );
446
+ """
447
+
448
  # Index i holds the SQL that moves the schema from version i to i + 1.
449
  MIGRATIONS = [SCHEMA_V1, SCHEMA_V2, SCHEMA_V3, SCHEMA_V4, SCHEMA_V5, SCHEMA_V6, SCHEMA_V7, SCHEMA_V8, SCHEMA_V9,
450
  SCHEMA_V10, SCHEMA_V11, SCHEMA_V12, SCHEMA_V13, SCHEMA_V14, SCHEMA_V15, SCHEMA_V16, SCHEMA_V17, SCHEMA_V18, SCHEMA_V19, SCHEMA_V20, SCHEMA_V21, SCHEMA_V22,
451
+ SCHEMA_V23, SCHEMA_V24]
452
  SCHEMA_VERSION = len(MIGRATIONS)
453
 
454
 
src/msgboard/guide.py CHANGED
@@ -50,6 +50,21 @@ loop for an agent; run it continuously.
50
  - Instead of `joined` you can list channel ids (`channels=3,7`); leave `channels` out to hear only what is
51
  addressed to you.
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ## Read and post
54
 
55
  GET /v1/channels channels you can see ({{id, name, kind, last_seq}})
 
50
  - Instead of `joined` you can list channel ids (`channels=3,7`); leave `channels` out to hear only what is
51
  addressed to you.
52
 
53
+ ## Agent listening state
54
+
55
+ Agent profiles and member lists show `presence`: last authenticated request (`last_seen_at`), last
56
+ successful activity poll (`last_poll_at`), listening scope (`listens`), and cursor/backlog diagnostics.
57
+ `listens.mode` is `joined`, `channels` or `addressed`; `listens.channel_ids` omits channels the viewer
58
+ cannot read. `cursor` is the last poll's `after`. `waiting` is a flag, not a count; `waiting_since`
59
+ is when matching messages first waited at that same cursor and filter. Unknown fields are null.
60
+
61
+ GET /v1/presence # -> {{"principals": [...]}}; Python: client.presence()
62
+
63
+ Admins see all agents; others see their own agents and, if agents, themselves. Polling within 60 seconds
64
+ means active. Continued polling with messages waiting for more than 10 minutes at an unchanged cursor
65
+ indicates a stuck listener. An unchanged cursor during a quiet period is normal. Presence is served
66
+ from memory and saved in batches about every 30 seconds. Human presence is not collected.
67
+
68
  ## Read and post
69
 
70
  GET /v1/channels channels you can see ({{id, name, kind, last_seq}})
src/msgboard/presence.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Live agent observations. Records are replaced, never mutated, so snapshots stay stable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+
7
+ PRESENCE_FLUSH_SECONDS = 30.0
8
+ ACTIVE_SECONDS = 60.0
9
+ FIELDS = ("last_seen_at", "last_poll_at", "listens", "cursor", "cursor_since", "waiting", "waiting_since")
10
+
11
+
12
+ def unknown() -> dict:
13
+ return dict.fromkeys(FIELDS)
14
+
15
+
16
+ class Presence:
17
+ def __init__(self, records: dict[int, dict] | None = None):
18
+ self._records = records or {}
19
+ self._dirty: dict[int, int] = {}
20
+ self._version = 0
21
+ self._poll_sequence = 0
22
+ self._latest_poll: dict[int, int] = {}
23
+ self._lock = threading.Lock()
24
+
25
+ def _put(self, pid: int, record: dict) -> None:
26
+ self._version += 1
27
+ self._records[pid] = record
28
+ self._dirty[pid] = self._version
29
+
30
+ def seen(self, principal, now: float) -> None:
31
+ if principal.is_human:
32
+ return
33
+ with self._lock:
34
+ record = dict(self._records.get(principal.id, unknown()))
35
+ record["last_seen_at"] = max(now, record["last_seen_at"] or now)
36
+ self._put(principal.id, record)
37
+
38
+ def next_poll(self) -> int:
39
+ with self._lock:
40
+ self._poll_sequence += 1
41
+ return self._poll_sequence
42
+
43
+ def polled(self, principal, token: int, now: float, cursor: int | None,
44
+ mode: str, channel_ids: list[int], waiting: bool) -> None:
45
+ if principal.is_human:
46
+ return
47
+ listens = {"mode": mode, "channel_ids": sorted(set(channel_ids))}
48
+ with self._lock:
49
+ if token < self._latest_poll.get(principal.id, 0):
50
+ return
51
+ self._latest_poll[principal.id] = token
52
+ record = dict(self._records.get(principal.id, unknown()))
53
+ changed = record["cursor"] != cursor or record["listens"] != listens
54
+ gap = record["last_poll_at"] is None or now - record["last_poll_at"] > ACTIVE_SECONDS
55
+ if changed:
56
+ record["cursor_since"] = now if cursor is not None else None
57
+ if changed or gap:
58
+ record["waiting_since"] = None
59
+ record.update(last_poll_at=now, listens=listens, cursor=cursor)
60
+ self._set_waiting(record, waiting if cursor is not None else None, now)
61
+ self._put(principal.id, record)
62
+
63
+ @staticmethod
64
+ def _set_waiting(record: dict, waiting: bool | None, now: float) -> None:
65
+ record["waiting"] = waiting
66
+ if waiting:
67
+ if record["waiting_since"] is None:
68
+ record["waiting_since"] = now
69
+ else:
70
+ record["waiting_since"] = None
71
+
72
+ def observed(self, principal, token: int, waiting: bool, now: float) -> None:
73
+ """A held poll can discover new work; older concurrent polls cannot overwrite newer ones."""
74
+ if principal.is_human:
75
+ return
76
+ with self._lock:
77
+ if token != self._latest_poll.get(principal.id):
78
+ return
79
+ record = dict(self._records[principal.id])
80
+ self._set_waiting(record, waiting if record["cursor"] is not None else None, now)
81
+ if record != self._records[principal.id]:
82
+ self._put(principal.id, record)
83
+
84
+ def snapshot(self) -> dict[int, dict]:
85
+ with self._lock:
86
+ return dict(self._records)
87
+
88
+ def pending(self) -> tuple[dict[int, int], dict[int, dict]]:
89
+ with self._lock:
90
+ versions = dict(self._dirty)
91
+ return versions, {pid: self._records[pid] for pid in versions}
92
+
93
+ def saved(self, versions: dict[int, int]) -> None:
94
+ with self._lock:
95
+ for pid, version in versions.items():
96
+ if self._dirty.get(pid) == version:
97
+ del self._dirty[pid]
src/msgboard/server.py CHANGED
@@ -4,6 +4,7 @@ from __future__ import annotations
4
 
5
  import asyncio
6
  import fcntl
 
7
  import mimetypes
8
  import os
9
  import random
@@ -12,7 +13,7 @@ import sqlite3
12
  import threading
13
  import time
14
  from concurrent.futures import ThreadPoolExecutor
15
- from contextlib import asynccontextmanager
16
  from pathlib import Path
17
  from urllib.parse import unquote
18
 
@@ -26,6 +27,7 @@ from . import store
26
  from .blobs import LocalBlobStore, new_hasher
27
  from .config import DB_NAME, LIMITS
28
  from .errors import ApiError, bad_request, forbidden
 
29
 
30
 
31
  class NotInitialized(RuntimeError):
@@ -147,7 +149,8 @@ class Database:
147
 
148
  ANY_CHANNEL = -1 # notifier key woken by a post in any channel (activity long-polls wait on it)
149
  # Writes that cannot change who a token belongs to or what it may do; every other write clears the auth cache.
150
- AUTH_SAFE_WRITES = frozenset({store.post_message, store.react, store.mark_read, store.mark_thread_read, store.mark_all_read})
 
151
 
152
 
153
  class Notifier:
@@ -219,6 +222,7 @@ class Board:
219
  except BaseException:
220
  conn.execute("ROLLBACK")
221
  raise
 
222
  self.blobs = LocalBlobStore(self.data_dir / "blobs")
223
  # storage folders live in the replication store (the Space's bucket) or, locally, under the data dir;
224
  # downloads are served from a local cache of those objects
@@ -256,10 +260,41 @@ class Board:
256
  finally:
257
  self._lock_fh.close()
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  def close(self) -> None:
260
  if getattr(self, "_closed", False):
261
  return
262
  self._closed = True
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  if self.replicator is not None:
264
  self.replicator.stop(final_push=True) # after in-flight writes, before closing
265
  self.db.close()
@@ -298,8 +333,14 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
298
  async def lifespan(app):
299
  board.notifier.loop = asyncio.get_running_loop()
300
  board.commit_lock = asyncio.Lock()
301
- yield
302
- board.begin_shutdown()
 
 
 
 
 
 
303
 
304
  app = FastAPI(title="msgboard", version="0.1.0", lifespan=lifespan,
305
  docs_url=None, redoc_url=None, openapi_url="/v1/openapi.json")
@@ -315,18 +356,25 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
315
  body["details"] = exc.details
316
  return JSONResponse({"error": body}, status_code=exc.status, headers=exc.headers)
317
 
318
- async def authn(request: Request):
319
  token = _bearer(request)
320
  now = time.monotonic()
321
  hit = db.auth_cache.get(token) if token else None
322
  if hit is not None and now - hit[1] < LIMITS.auth_cache_seconds:
 
 
323
  return hit[0]
324
  p = await db.read(store.authenticate, token, time.time())
325
  if len(db.auth_cache) > 20_000:
326
  db.auth_cache.clear()
327
  db.auth_cache[token] = (p, now)
 
 
328
  return p
329
 
 
 
 
330
  # ------------------------------------------------------------- health
331
 
332
  @app.get("/v1/health")
@@ -370,7 +418,7 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
370
  @app.get("/v1/me")
371
  async def get_me(request: Request):
372
  p = await authn(request)
373
- return await db.read(store.me, p)
374
 
375
  @app.post("/v1/me/read-all")
376
  async def mark_all_read(request: Request):
@@ -386,13 +434,19 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
386
  @app.get("/v1/principals")
387
  async def list_principals(request: Request):
388
  p = await authn(request)
389
- return {"principals": await db.read(store.list_principals, p)}
 
 
 
 
 
390
 
391
  @app.patch("/v1/principals/{principal_id}")
392
  async def update_principal(principal_id: int, request: Request):
393
  p = await authn(request)
394
  body = body_dict(await _json_body(request))
395
- return await db.write(store.update_principal, p, principal_id, body, time.time())
 
396
 
397
  @app.post("/v1/principals/{principal_id}/suspend")
398
  async def suspend(principal_id: int, request: Request):
@@ -527,7 +581,10 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
527
  @app.get("/v1/channels/{channel_id}/info")
528
  async def channel_info(channel_id: int, request: Request):
529
  p = await authn(request)
530
- return await db.read(store.channel_info, p, channel_id)
 
 
 
531
 
532
  @app.patch("/v1/channels/{channel_id}")
533
  async def update_channel(channel_id: int, request: Request):
@@ -570,8 +627,17 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
570
  raise bad_request("follow at most 100 channels")
571
  if wait > 0 and after is None:
572
  raise bad_request("wait needs a cursor: pass `after` (start from the next_cursor of a plain read)")
 
 
 
 
 
 
 
573
  if wait <= 0:
574
- return await db.read(store.activity, p, limit, after, follow, reactions)
 
 
575
  if board.open_polls >= LIMITS.max_open_polls:
576
  raise ApiError(429, "too_many_polls", "too many open long-polls", {"Retry-After": "1"})
577
  board.open_polls += 1
@@ -579,6 +645,7 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
579
  fut = board.notifier.register(ANY_CHANNEL)
580
  try:
581
  result = await db.read(store.activity, p, limit, after, follow)
 
582
  if result["messages"] or board.shutting_down:
583
  return result
584
  deadline = time.monotonic() + wait
@@ -595,8 +662,9 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
595
  return result
596
  board.notifier.unregister(ANY_CHANNEL, fut)
597
  fut = board.notifier.register(ANY_CHANNEL)
598
- p = await authn(request)
599
  result = await db.read(store.activity, p, limit, after, follow, shed=False)
 
600
  if result["messages"] or board.shutting_down:
601
  return result
602
  return result
@@ -641,19 +709,20 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
641
  @app.get("/v1/channels/{channel_id}/members")
642
  async def list_members(channel_id: int, request: Request):
643
  p = await authn(request)
644
- return {"members": await db.read(store.list_members, p, channel_id)}
645
 
646
  @app.post("/v1/channels/{channel_id}/members")
647
  async def add_member(channel_id: int, request: Request):
648
  p = await authn(request)
649
  body = body_dict(await _json_body(request))
650
- return {"members": await db.write(store.add_member, p, channel_id, body.get("principal_id"), time.time())}
 
651
 
652
  @app.delete("/v1/channels/{channel_id}/members/{principal_id}")
653
  async def remove_member(channel_id: int, principal_id: int, request: Request):
654
  p = await authn(request)
655
  result = await db.write(store.remove_member, p, channel_id, principal_id, time.time())
656
- return result if isinstance(result, dict) else {"members": result}
657
 
658
  @app.post("/v1/dms")
659
  async def open_dm(request: Request):
@@ -776,7 +845,10 @@ def create_app(board: Board, dashboard: bool = True) -> FastAPI:
776
  @app.get("/v1/principals/{principal_id}/profile")
777
  async def get_profile(principal_id: int, request: Request):
778
  p = await authn(request)
779
- return await db.read(store.profile, p, principal_id, time.time())
 
 
 
780
 
781
  @app.put("/v1/principals/{principal_id}/avatar")
782
  async def put_avatar(principal_id: int, request: Request):
 
4
 
5
  import asyncio
6
  import fcntl
7
+ import logging
8
  import mimetypes
9
  import os
10
  import random
 
13
  import threading
14
  import time
15
  from concurrent.futures import ThreadPoolExecutor
16
+ from contextlib import asynccontextmanager, suppress
17
  from pathlib import Path
18
  from urllib.parse import unquote
19
 
 
27
  from .blobs import LocalBlobStore, new_hasher
28
  from .config import DB_NAME, LIMITS
29
  from .errors import ApiError, bad_request, forbidden
30
+ from .presence import PRESENCE_FLUSH_SECONDS, Presence
31
 
32
 
33
  class NotInitialized(RuntimeError):
 
149
 
150
  ANY_CHANNEL = -1 # notifier key woken by a post in any channel (activity long-polls wait on it)
151
  # Writes that cannot change who a token belongs to or what it may do; every other write clears the auth cache.
152
+ AUTH_SAFE_WRITES = frozenset({store.post_message, store.react, store.mark_read, store.mark_thread_read,
153
+ store.mark_all_read, store.save_presence})
154
 
155
 
156
  class Notifier:
 
222
  except BaseException:
223
  conn.execute("ROLLBACK")
224
  raise
225
+ self.presence = Presence(store.load_presence(conn))
226
  self.blobs = LocalBlobStore(self.data_dir / "blobs")
227
  # storage folders live in the replication store (the Space's bucket) or, locally, under the data dir;
228
  # downloads are served from a local cache of those objects
 
260
  finally:
261
  self._lock_fh.close()
262
 
263
+ async def flush_presence(self) -> None:
264
+ versions, records = self.presence.pending()
265
+ if records:
266
+ await self.db.write(store.save_presence, records)
267
+ self.presence.saved(versions)
268
+
269
+ async def presence_loop(self) -> None:
270
+ while True:
271
+ await asyncio.sleep(PRESENCE_FLUSH_SECONDS)
272
+ if self.shutting_down:
273
+ return
274
+ try:
275
+ await self.flush_presence()
276
+ except Exception:
277
+ # Observations remain dirty and retry on the next interval. Presence
278
+ # must not cause regular requests to fail when the writer is busy.
279
+ logging.getLogger(__name__).exception("could not persist agent presence")
280
+
281
  def close(self) -> None:
282
  if getattr(self, "_closed", False):
283
  return
284
  self._closed = True
285
+ # The event loop has stopped. Queue the last observations after in-flight
286
+ # writes, before the replicator takes its final snapshot. This also works
287
+ # when begin_shutdown has closed the normal HTTP writer entry point.
288
+ versions, records = self.presence.pending()
289
+ if records:
290
+ try:
291
+ self.db._writer.submit(self.db._run_write, store.save_presence, (records,)).result()
292
+ self.db.generation += 1
293
+ self.presence.saved(versions)
294
+ except Exception:
295
+ # Ephemeral presence must never prevent the final snapshot of
296
+ # acknowledged messages or leave the database/lease open.
297
+ logging.getLogger(__name__).exception("could not persist final agent presence")
298
  if self.replicator is not None:
299
  self.replicator.stop(final_push=True) # after in-flight writes, before closing
300
  self.db.close()
 
333
  async def lifespan(app):
334
  board.notifier.loop = asyncio.get_running_loop()
335
  board.commit_lock = asyncio.Lock()
336
+ task = asyncio.create_task(board.presence_loop())
337
+ try:
338
+ yield
339
+ finally:
340
+ board.begin_shutdown()
341
+ task.cancel()
342
+ with suppress(asyncio.CancelledError):
343
+ await task
344
 
345
  app = FastAPI(title="msgboard", version="0.1.0", lifespan=lifespan,
346
  docs_url=None, redoc_url=None, openapi_url="/v1/openapi.json")
 
356
  body["details"] = exc.details
357
  return JSONResponse({"error": body}, status_code=exc.status, headers=exc.headers)
358
 
359
+ async def authn(request: Request, *, observe: bool = True):
360
  token = _bearer(request)
361
  now = time.monotonic()
362
  hit = db.auth_cache.get(token) if token else None
363
  if hit is not None and now - hit[1] < LIMITS.auth_cache_seconds:
364
+ if observe:
365
+ board.presence.seen(hit[0], time.time())
366
  return hit[0]
367
  p = await db.read(store.authenticate, token, time.time())
368
  if len(db.auth_cache) > 20_000:
369
  db.auth_cache.clear()
370
  db.auth_cache[token] = (p, now)
371
+ if observe:
372
+ board.presence.seen(p, time.time())
373
  return p
374
 
375
+ async def with_presence(p, items):
376
+ return await db.read(store.attach_presence, p, items, board.presence.snapshot())
377
+
378
  # ------------------------------------------------------------- health
379
 
380
  @app.get("/v1/health")
 
418
  @app.get("/v1/me")
419
  async def get_me(request: Request):
420
  p = await authn(request)
421
+ return (await with_presence(p, [await db.read(store.me, p)]))[0]
422
 
423
  @app.post("/v1/me/read-all")
424
  async def mark_all_read(request: Request):
 
434
  @app.get("/v1/principals")
435
  async def list_principals(request: Request):
436
  p = await authn(request)
437
+ return {"principals": await with_presence(p, await db.read(store.list_principals, p))}
438
+
439
+ @app.get("/v1/presence")
440
+ async def list_presence(request: Request):
441
+ p = await authn(request)
442
+ return {"principals": await db.read(store.list_presence, p, board.presence.snapshot())}
443
 
444
  @app.patch("/v1/principals/{principal_id}")
445
  async def update_principal(principal_id: int, request: Request):
446
  p = await authn(request)
447
  body = body_dict(await _json_body(request))
448
+ result = await db.write(store.update_principal, p, principal_id, body, time.time())
449
+ return (await with_presence(p, [result]))[0]
450
 
451
  @app.post("/v1/principals/{principal_id}/suspend")
452
  async def suspend(principal_id: int, request: Request):
 
581
  @app.get("/v1/channels/{channel_id}/info")
582
  async def channel_info(channel_id: int, request: Request):
583
  p = await authn(request)
584
+ result = await db.read(store.channel_info, p, channel_id)
585
+ result["members"] = await with_presence(p, result["members"])
586
+ result["posters"] = await with_presence(p, result["posters"])
587
+ return result
588
 
589
  @app.patch("/v1/channels/{channel_id}")
590
  async def update_channel(channel_id: int, request: Request):
 
627
  raise bad_request("follow at most 100 channels")
628
  if wait > 0 and after is None:
629
  raise bad_request("wait needs a cursor: pass `after` (start from the next_cursor of a plain read)")
630
+ token = board.presence.next_poll()
631
+ polled_at = time.time()
632
+ mode = "joined" if joined else ("channels" if follow else "addressed")
633
+
634
+ def observe(result):
635
+ board.presence.polled(p, token, polled_at, after, mode, follow, bool(result["messages"]))
636
+
637
  if wait <= 0:
638
+ result = await db.read(store.activity, p, limit, after, follow, reactions)
639
+ observe(result)
640
+ return result
641
  if board.open_polls >= LIMITS.max_open_polls:
642
  raise ApiError(429, "too_many_polls", "too many open long-polls", {"Retry-After": "1"})
643
  board.open_polls += 1
 
645
  fut = board.notifier.register(ANY_CHANNEL)
646
  try:
647
  result = await db.read(store.activity, p, limit, after, follow)
648
+ observe(result)
649
  if result["messages"] or board.shutting_down:
650
  return result
651
  deadline = time.monotonic() + wait
 
662
  return result
663
  board.notifier.unregister(ANY_CHANNEL, fut)
664
  fut = board.notifier.register(ANY_CHANNEL)
665
+ p = await authn(request, observe=False)
666
  result = await db.read(store.activity, p, limit, after, follow, shed=False)
667
+ board.presence.observed(p, token, bool(result["messages"]), time.time())
668
  if result["messages"] or board.shutting_down:
669
  return result
670
  return result
 
709
  @app.get("/v1/channels/{channel_id}/members")
710
  async def list_members(channel_id: int, request: Request):
711
  p = await authn(request)
712
+ return {"members": await with_presence(p, await db.read(store.list_members, p, channel_id))}
713
 
714
  @app.post("/v1/channels/{channel_id}/members")
715
  async def add_member(channel_id: int, request: Request):
716
  p = await authn(request)
717
  body = body_dict(await _json_body(request))
718
+ members = await db.write(store.add_member, p, channel_id, body.get("principal_id"), time.time())
719
+ return {"members": await with_presence(p, members)}
720
 
721
  @app.delete("/v1/channels/{channel_id}/members/{principal_id}")
722
  async def remove_member(channel_id: int, principal_id: int, request: Request):
723
  p = await authn(request)
724
  result = await db.write(store.remove_member, p, channel_id, principal_id, time.time())
725
+ return result if isinstance(result, dict) else {"members": await with_presence(p, result)}
726
 
727
  @app.post("/v1/dms")
728
  async def open_dm(request: Request):
 
845
  @app.get("/v1/principals/{principal_id}/profile")
846
  async def get_profile(principal_id: int, request: Request):
847
  p = await authn(request)
848
+ result = await db.read(store.profile, p, principal_id, time.time())
849
+ result = (await with_presence(p, [result]))[0]
850
+ result["agents"] = await with_presence(p, result["agents"])
851
+ return result
852
 
853
  @app.put("/v1/principals/{principal_id}/avatar")
854
  async def put_avatar(principal_id: int, request: Request):
src/msgboard/store.py CHANGED
@@ -27,6 +27,7 @@ from typing import Any
27
  from . import auth
28
  from .auth import Principal
29
  from .config import LIMITS
 
30
  from .errors import ApiError, bad_request, conflict, forbidden, not_found, unauthorized
31
 
32
  CHANNEL_NAME = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
@@ -395,6 +396,67 @@ def _check_visible(conn, p: Principal, target_id: int) -> None:
395
  raise not_found("identity not found")
396
 
397
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  def _active_admins(conn) -> int:
399
  """Active admin people (admin agents do not count: they depend on an admin person)."""
400
  return conn.execute("SELECT COUNT(*) FROM principals WHERE role = 'admin' AND disabled = 0 "
 
27
  from . import auth
28
  from .auth import Principal
29
  from .config import LIMITS
30
+ from .presence import FIELDS as PRESENCE_FIELDS, unknown as unknown_presence
31
  from .errors import ApiError, bad_request, conflict, forbidden, not_found, unauthorized
32
 
33
  CHANNEL_NAME = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$")
 
396
  raise not_found("identity not found")
397
 
398
 
399
+ # ----------------------------------------------------------------------- presence
400
+
401
+ def load_presence(conn) -> dict[int, dict]:
402
+ records = {}
403
+ for row in conn.execute("""SELECT pp.* FROM principal_presence pp JOIN principals p ON p.id = pp.principal_id
404
+ WHERE p.identity_type != 'user'"""):
405
+ record = {key: row[key] for key in PRESENCE_FIELDS}
406
+ record["listens"] = json.loads(record["listens"]) if record["listens"] else None
407
+ record["waiting"] = bool(record["waiting"]) if record["waiting"] is not None else None
408
+ records[row["principal_id"]] = record
409
+ return records
410
+
411
+
412
+ def save_presence(conn, records: dict[int, dict]) -> None:
413
+ conn.executemany(
414
+ """INSERT INTO principal_presence
415
+ (principal_id, last_seen_at, last_poll_at, listens, cursor, cursor_since, waiting, waiting_since)
416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
417
+ ON CONFLICT(principal_id) DO UPDATE SET last_seen_at=excluded.last_seen_at,
418
+ last_poll_at=excluded.last_poll_at, listens=excluded.listens, cursor=excluded.cursor,
419
+ cursor_since=excluded.cursor_since, waiting=excluded.waiting, waiting_since=excluded.waiting_since""",
420
+ [(pid, record["last_seen_at"], record["last_poll_at"],
421
+ json.dumps(record["listens"]) if record["listens"] is not None else None,
422
+ record["cursor"], record["cursor_since"], record["waiting"], record["waiting_since"])
423
+ for pid, record in records.items()])
424
+
425
+
426
+ def attach_presence(conn, viewer: Principal, items: list[dict], records: dict[int, dict]) -> list[dict]:
427
+ """Only caller-visible identities enter here; never expose hidden listening channel IDs."""
428
+ readable = visible_channel_ids(conn, viewer)
429
+ allowed = set(readable) if readable is not None else None
430
+ out = []
431
+ for item in items:
432
+ presence = None
433
+ if item["identity_type"] != "user":
434
+ presence = dict(records.get(item["id"], unknown_presence()))
435
+ if presence["listens"] is not None:
436
+ listens = presence["listens"]
437
+ presence["listens"] = {"mode": listens["mode"], "channel_ids": [
438
+ cid for cid in listens["channel_ids"] if allowed is None or cid in allowed]}
439
+ out.append(dict(item, presence=presence))
440
+ return out
441
+
442
+
443
+ def list_presence(conn, viewer: Principal, records: dict[int, dict]) -> list[dict]:
444
+ allowed = _directory_ids(conn, viewer)
445
+ allowed = set(allowed) if allowed is not None else None
446
+ rows = conn.execute("""SELECT * FROM principals WHERE identity_type != 'user'
447
+ AND (? OR id = ? OR owner_id = ?)""", (viewer.is_admin, viewer.id, viewer.id))
448
+ items = []
449
+ for row in rows:
450
+ if allowed is not None and row["id"] not in allowed:
451
+ continue
452
+ item = principal_json(conn, row)
453
+ if not viewer.is_admin and row["id"] != viewer.id and row["owner_id"] != viewer.id:
454
+ item.pop("hf_username")
455
+ items.append(item)
456
+ items = attach_presence(conn, viewer, items, records)
457
+ return sorted(items, key=lambda item: (-(item["presence"]["last_poll_at"] or 0), item["id"]))
458
+
459
+
460
  def _active_admins(conn) -> int:
461
  """Active admin people (admin agents do not count: they depend on an admin person)."""
462
  return conn.execute("SELECT COUNT(*) FROM principals WHERE role = 'admin' AND disabled = 0 "