Files
Apple ef3473db21 snapshot: NODE1 production state 2026-02-09
Complete snapshot of /opt/microdao-daarion/ from NODE1 (144.76.224.179).
This represents the actual running production code that has diverged
significantly from the previous main branch.

Key changes from old main:
- Gateway (http_api.py): expanded from ~40KB to 164KB with full agent support
- Router: new /v1/agents/{id}/infer endpoint with vision + DeepSeek routing
- Behavior Policy: SOWA v2.2 (3-level: FULL/ACK/SILENT)
- Agent Registry: config/agent_registry.yml as single source of truth
- 13 agents configured (was 3)
- Memory service integration
- CrewAI teams and roles

Excluded from snapshot: venv/, .env, data/, backups, .tgz archives

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 08:46:46 -08:00

1216 lines
54 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="canonical" href="https://IvanTytar.github.io/microdao-daarion/realtime/GLOBAL_PRESENCE_AGGREGATOR_SPEC/">
<link rel="icon" href="../../assets/images/favicon.png">
<meta name="generator" content="mkdocs-1.5.3, mkdocs-material-9.5.18">
<title>GLOBAL PRESENCE AGGREGATOR — DAARION.city - DAARION Documentation</title>
<link rel="stylesheet" href="../../assets/stylesheets/main.66ac8b77.min.css">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
<script>__md_scope=new URL("../..",location),__md_hash=e=>[...e].reduce((e,_)=>(e<<5)-e+_.charCodeAt(0),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
</head>
<body dir="ltr">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
<a href="#global-presence-aggregator-daarioncity" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="../.." title="DAARION Documentation" class="md-header__button md-logo" aria-label="DAARION Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54Z"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3V6m0 5h18v2H3v-2m0 5h18v2H3v-2Z"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
DAARION Documentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
GLOBAL PRESENCE AGGREGATOR — DAARION.city
</span>
</div>
</div>
</div>
<script>var media,input,key,value,palette=__md_get("__palette");if(palette&&palette.color){"(prefers-color-scheme)"===palette.color.media&&(media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']"),palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent"));for([key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.516 6.516 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11h12Z"/></svg>
</label>
<nav class="md-search__options" aria-label="Search">
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41Z"/></svg>
</button>
</nav>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list" role="presentation"></ol>
</div>
</div>
</div>
</div>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="../.." title="DAARION Documentation" class="md-nav__button md-logo" aria-label="DAARION Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54Z"/></svg>
</a>
DAARION Documentation
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../../public/" class="md-nav__link">
<span class="md-ellipsis">
Home
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../public/getting-started/" class="md-nav__link">
<span class="md-ellipsis">
Getting Started
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../public/architecture-overview/" class="md-nav__link">
<span class="md-ellipsis">
Architecture
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../public/daiS_daos_overview/" class="md-nav__link">
<span class="md-ellipsis">
DAIS & DAOS
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--section md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_5" >
<label class="md-nav__link" for="__nav_5" id="__nav_5_label" tabindex="">
<span class="md-ellipsis">
Internal
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_5_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_5">
<span class="md-nav__icon md-icon"></span>
Internal
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_5_1" >
<label class="md-nav__link" for="__nav_5_1" id="__nav_5_1_label" tabindex="0">
<span class="md-ellipsis">
Infra
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_5_1_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_5_1">
<span class="md-nav__icon md-icon"></span>
Infra
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../../internal/infra/INFRA_AUTOMATION_PACK_V1/" class="md-nav__link">
<span class="md-ellipsis">
Infra Automation Pack v1
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../internal/infra/monitoring_overview/" class="md-nav__link">
<span class="md-ellipsis">
Monitoring Overview
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../internal/infra/nodes_registry_v0/" class="md-nav__link">
<span class="md-ellipsis">
Nodes Registry v0
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_5_2" >
<label class="md-nav__link" for="__nav_5_2" id="__nav_5_2_label" tabindex="0">
<span class="md-ellipsis">
Specs
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_5_2_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_5_2">
<span class="md-nav__icon md-icon"></span>
Specs
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../../internal/specs/matrix_presence_aggregator/" class="md-nav__link">
<span class="md-ellipsis">
Matrix Presence Aggregator
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../internal/specs/city_map_spec/" class="md-nav__link">
<span class="md-ellipsis">
City Map Spec
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../../internal/specs/node_join_protocol_draft/" class="md-nav__link">
<span class="md-ellipsis">
Node Join Protocol (Draft)
</span>
</a>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-sidebar md-sidebar--secondary" data-md-component="sidebar" data-md-type="toc" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
<label class="md-nav__title" for="__toc">
<span class="md-nav__icon md-icon"></span>
Table of contents
</label>
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
<li class="md-nav__item">
<a href="#0-purpose" class="md-nav__link">
<span class="md-ellipsis">
0. PURPOSE
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#1-architecture-overview" class="md-nav__link">
<span class="md-ellipsis">
1. ARCHITECTURE OVERVIEW
</span>
</a>
<nav class="md-nav" aria-label="1. ARCHITECTURE OVERVIEW">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#_1" class="md-nav__link">
<span class="md-ellipsis">
Компоненти
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#2-matrix-side" class="md-nav__link">
<span class="md-ellipsis">
2. MATRIX SIDE — ЗВІДКИ БРАТИ ПОДІЇ
</span>
</a>
<nav class="md-nav" aria-label="2. MATRIX SIDE — ЗВІДКИ БРАТИ ПОДІЇ">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#21-matrix-" class="md-nav__link">
<span class="md-ellipsis">
2.1. Окремий Matrix-юзер для агрегації
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#22-sync-loop" class="md-nav__link">
<span class="md-ellipsis">
2.2. Sync-loop на сервері
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#3-data-model-in-memory-aggregator" class="md-nav__link">
<span class="md-ellipsis">
3. DATA MODEL (IN-MEMORY AGGREGATOR)
</span>
</a>
<nav class="md-nav" aria-label="3. DATA MODEL (IN-MEMORY AGGREGATOR)">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#31-room-presence-state" class="md-nav__link">
<span class="md-ellipsis">
3.1. Room presence state
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#32-room-city-room" class="md-nav__link">
<span class="md-ellipsis">
3.2. Мапінг Room → City Room
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#4-nats-events" class="md-nav__link">
<span class="md-ellipsis">
4. NATS EVENTS
</span>
</a>
<nav class="md-nav" aria-label="4. NATS EVENTS">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#41-room-level-presence" class="md-nav__link">
<span class="md-ellipsis">
4.1. Room-level presence
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#42-user-level-presence" class="md-nav__link">
<span class="md-ellipsis">
4.2. User-level presence (опційний)
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#5-event-generation-logic" class="md-nav__link">
<span class="md-ellipsis">
5. EVENT GENERATION LOGIC
</span>
</a>
<nav class="md-nav" aria-label="5. EVENT GENERATION LOGIC">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#51-mpresence" class="md-nav__link">
<span class="md-ellipsis">
5.1. Обробка m.presence
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#52-mtyping" class="md-nav__link">
<span class="md-ellipsis">
5.2. Обробка m.typing
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#53-throttling" class="md-nav__link">
<span class="md-ellipsis">
5.3. Throttling
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#6-city-realtime-gateway-websocket" class="md-nav__link">
<span class="md-ellipsis">
6. CITY REALTIME GATEWAY (WEBSOCKET)
</span>
</a>
<nav class="md-nav" aria-label="6. CITY REALTIME GATEWAY (WEBSOCKET)">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#61-websocket-endpoint" class="md-nav__link">
<span class="md-ellipsis">
6.1. WebSocket endpoint
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#62" class="md-nav__link">
<span class="md-ellipsis">
6.2. Формат повідомлень
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#7-frontend-integration" class="md-nav__link">
<span class="md-ellipsis">
7. FRONTEND INTEGRATION
</span>
</a>
<nav class="md-nav" aria-label="7. FRONTEND INTEGRATION">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#71-city" class="md-nav__link">
<span class="md-ellipsis">
7.1. Список кімнат /city
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#72-ui" class="md-nav__link">
<span class="md-ellipsis">
7.2. UI
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#8-config-env" class="md-nav__link">
<span class="md-ellipsis">
8. CONFIG / ENV
</span>
</a>
<nav class="md-nav" aria-label="8. CONFIG / ENV">
<ul class="md-nav__list">
<li class="md-nav__item">
<a href="#matrix-presence-aggregator" class="md-nav__link">
<span class="md-ellipsis">
matrix-presence-aggregator
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#city-service-realtime-gateway" class="md-nav__link">
<span class="md-ellipsis">
city-service (realtime gateway)
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="#9-acceptance-criteria" class="md-nav__link">
<span class="md-ellipsis">
9. ACCEPTANCE CRITERIA
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#10-future-enhancements" class="md-nav__link">
<span class="md-ellipsis">
10. FUTURE ENHANCEMENTS
</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1 id="global-presence-aggregator-daarioncity">GLOBAL PRESENCE AGGREGATOR — DAARION.city<a class="headerlink" href="#global-presence-aggregator-daarioncity" title="Permanent link">&para;</a></h1>
<p>Version: 1.0.0
Location: docs/realtime/GLOBAL_PRESENCE_AGGREGATOR_SPEC.md</p>
<hr />
<h2 id="0-purpose">0. PURPOSE<a class="headerlink" href="#0-purpose" title="Permanent link">&para;</a></h2>
<p>Зробити <strong>єдиний центр правди про присутність (presence) та активність</strong> у місті:</p>
<ul>
<li>збирати Matrix presence/typing/room-activity на сервері,</li>
<li>агрегувати їх на рівні кімнат (<code>city_room</code>),</li>
<li>публікувати у NATS як події,</li>
<li>транслювати у фронтенд через WebSocket з <code>city-service</code>.</li>
</ul>
<p>Результат: DAARION має <strong>"живе місто"</strong>:</p>
<ul>
<li>список кімнат <code>/city</code> показує:</li>
<li>скільки людей онлайн,</li>
<li>активність у реальному часі,</li>
<li>майбутня City Map (2D/2.5D) живиться цими даними.</li>
</ul>
<hr />
<h2 id="1-architecture-overview">1. ARCHITECTURE OVERVIEW<a class="headerlink" href="#1-architecture-overview" title="Permanent link">&para;</a></h2>
<div class="codehilite"><pre><span></span><code><span class="err">┌─────────────────────────────────────────────────────────────────────────┐</span>
<span class="err"></span><span class="w"> </span><span class="n">DAARION</span><span class="w"> </span><span class="n">PRESENCE</span><span class="w"> </span><span class="n">SYSTEM</span><span class="w"> </span><span class="err"></span>
<span class="err">├─────────────────────────────────────────────────────────────────────────┤</span>
<span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err">┌─────────────┐</span><span class="w"> </span><span class="err">┌──────────────────────┐</span><span class="w"> </span><span class="err">┌─────────────────┐</span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Matrix</span><span class="w"> </span><span class="err">│────▶│</span><span class="w"> </span><span class="n">matrix</span><span class="o">-</span><span class="n">presence</span><span class="o">-</span><span class="w"> </span><span class="err">│────▶│</span><span class="w"> </span><span class="n">NATS</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Synapse</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">aggregator</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">JetStream</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="p">(</span><span class="n">sync</span><span class="w"> </span><span class="n">loop</span><span class="p">)</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err">└─────────────┘</span><span class="w"> </span><span class="err">└──────────────────────┘</span><span class="w"> </span><span class="err">└────────┬────────┘</span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err">┌─────────────┐</span><span class="w"> </span><span class="err">┌──────────────────────┐</span><span class="w"> </span><span class="err">┌─────────────────┐</span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="n">Browser</span><span class="w"> </span><span class="err">│◀────│</span><span class="w"> </span><span class="n">city</span><span class="o">-</span><span class="n">service</span><span class="w"> </span><span class="err">│◀────│</span><span class="w"> </span><span class="n">NATS</span><span class="w"> </span><span class="n">Sub</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="p">(</span><span class="n">WS</span><span class="p">)</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="o">/</span><span class="n">ws</span><span class="o">/</span><span class="n">city</span><span class="o">/</span><span class="n">presence</span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err">└─────────────┘</span><span class="w"> </span><span class="err">└──────────────────────┘</span><span class="w"> </span><span class="err">└─────────────────┘</span><span class="w"> </span><span class="err"></span>
<span class="err"></span><span class="w"> </span><span class="err"></span>
<span class="err">└─────────────────────────────────────────────────────────────────────────┘</span>
</code></pre></div>
<h3 id="_1">Компоненти<a class="headerlink" href="#_1" title="Permanent link">&para;</a></h3>
<ol>
<li><strong>matrix-presence-aggregator (новий сервіс)</strong></li>
<li>читає Matrix sync (presence, typing, room activity),</li>
<li>тримає у пам'яті поточний стан присутності,</li>
<li>
<p>публікує агреговані події в NATS.</p>
</li>
<li>
<p><strong>NATS JetStream</strong></p>
</li>
<li>
<p>канал для presence/events:</p>
<ul>
<li><code>city.presence.room.*</code></li>
<li><code>city.presence.user.*</code></li>
</ul>
</li>
<li>
<p><strong>city-service (розширення)</strong></p>
</li>
<li>підписується на NATS події,</li>
<li>тримає WebSocket з'єднання з фронтендом,</li>
<li>
<p>пушить presence/room-activity у браузер.</p>
</li>
<li>
<p><strong>web (Next.js UI)</strong></p>
</li>
<li>сторінка <code>/city</code>:<ul>
<li>показує <code>N online</code> по кожній кімнаті,</li>
<li>highlight "active" кімнати.</li>
</ul>
</li>
</ol>
<hr />
<h2 id="2-matrix-side">2. MATRIX SIDE — ЗВІДКИ БРАТИ ПОДІЇ<a class="headerlink" href="#2-matrix-side" title="Permanent link">&para;</a></h2>
<h3 id="21-matrix-">2.1. Окремий Matrix-юзер для агрегації<a class="headerlink" href="#21-matrix-" title="Permanent link">&para;</a></h3>
<p>Спец-акаунт:
- <code>@presence_daemon:daarion.space</code>
- права:
- читати presence/typing у всіх <code>city_*</code> кімнатах,
- бути учасником цих кімнат.</p>
<h3 id="22-sync-loop">2.2. Sync-loop на сервері<a class="headerlink" href="#22-sync-loop" title="Permanent link">&para;</a></h3>
<p>Сервіс <code>matrix-presence-aggregator</code>:</p>
<ul>
<li>використовує <code>/sync</code> Matrix (як клієнт),</li>
<li>фільтр:</li>
</ul>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;presence&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;types&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;m.presence&quot;</span><span class="p">]</span>
<span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;room&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;timeline&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;limit&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;state&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;limit&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="nt">&quot;ephemeral&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;types&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;m.typing&quot;</span><span class="p">,</span><span class="w"> </span><span class="s2">&quot;m.receipt&quot;</span><span class="p">]</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>
<ul>
<li>робить long-polling з <code>since</code> + <code>timeout</code>,</li>
<li>парсить:</li>
<li><code>presence.events</code><code>m.presence</code>,</li>
<li><code>rooms.join[roomId].ephemeral.events</code><code>m.typing</code>, <code>m.receipt</code>.</li>
</ul>
<hr />
<h2 id="3-data-model-in-memory-aggregator">3. DATA MODEL (IN-MEMORY AGGREGATOR)<a class="headerlink" href="#3-data-model-in-memory-aggregator" title="Permanent link">&para;</a></h2>
<h3 id="31-room-presence-state">3.1. Room presence state<a class="headerlink" href="#31-room-presence-state" title="Permanent link">&para;</a></h3>
<div class="codehilite"><pre><span></span><code><span class="kn">from</span><span class="w"> </span><span class="nn">dataclasses</span><span class="w"> </span><span class="kn">import</span> <span class="n">dataclass</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">typing</span><span class="w"> </span><span class="kn">import</span> <span class="n">Dict</span><span class="p">,</span> <span class="n">List</span><span class="p">,</span> <span class="n">Set</span><span class="p">,</span> <span class="n">Optional</span>
<span class="kn">from</span><span class="w"> </span><span class="nn">datetime</span><span class="w"> </span><span class="kn">import</span> <span class="n">datetime</span>
<span class="nd">@dataclass</span>
<span class="k">class</span><span class="w"> </span><span class="nc">UserPresence</span><span class="p">:</span>
<span class="n">user_id</span><span class="p">:</span> <span class="nb">str</span> <span class="c1"># &quot;@user:domain&quot;</span>
<span class="n">status</span><span class="p">:</span> <span class="nb">str</span> <span class="c1"># &quot;online&quot; | &quot;offline&quot; | &quot;unavailable&quot;</span>
<span class="n">last_active_ts</span><span class="p">:</span> <span class="nb">float</span> <span class="c1"># timestamp</span>
<span class="nd">@dataclass</span>
<span class="k">class</span><span class="w"> </span><span class="nc">RoomPresence</span><span class="p">:</span>
<span class="n">room_id</span><span class="p">:</span> <span class="nb">str</span> <span class="c1"># &quot;!....:daarion.space&quot;</span>
<span class="n">alias</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="c1"># &quot;#city_energy:daarion.space&quot;</span>
<span class="n">city_room_slug</span><span class="p">:</span> <span class="n">Optional</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="c1"># &quot;energy&quot;</span>
<span class="n">online_count</span><span class="p">:</span> <span class="nb">int</span>
<span class="n">typing_user_ids</span><span class="p">:</span> <span class="n">List</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span>
<span class="n">last_event_ts</span><span class="p">:</span> <span class="nb">float</span>
<span class="k">class</span><span class="w"> </span><span class="nc">PresenceState</span><span class="p">:</span>
<span class="n">users</span><span class="p">:</span> <span class="n">Dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">UserPresence</span><span class="p">]</span>
<span class="n">rooms</span><span class="p">:</span> <span class="n">Dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">RoomPresence</span><span class="p">]</span>
<span class="n">room_members</span><span class="p">:</span> <span class="n">Dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Set</span><span class="p">[</span><span class="nb">str</span><span class="p">]]</span> <span class="c1"># room_id -&gt; set of user_ids</span>
</code></pre></div>
<h3 id="32-room-city-room">3.2. Мапінг Room → City Room<a class="headerlink" href="#32-room-city-room" title="Permanent link">&para;</a></h3>
<p><code>matrix-presence-aggregator</code> має знати <code>matrix_room_id</code><code>city_room.slug</code>.</p>
<p><strong>Pull-mode (MVP):</strong>
- при старті сервісу:
- <code>GET /internal/city/rooms</code>
- зчитати всі <code>matrix_room_id</code> / <code>matrix_room_alias</code> / <code>slug</code>,
- зібрати мапу <code>roomId → slug</code>.
- періодично (кожні 5 хвилин) оновлювати.</p>
<hr />
<h2 id="4-nats-events">4. NATS EVENTS<a class="headerlink" href="#4-nats-events" title="Permanent link">&para;</a></h2>
<h3 id="41-room-level-presence">4.1. Room-level presence<a class="headerlink" href="#41-room-level-presence" title="Permanent link">&para;</a></h3>
<p>Subject:</p>
<div class="codehilite"><pre><span></span><code>city.presence.room.&lt;slug&gt;
</code></pre></div>
<p>Event payload:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;room.presence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;room_slug&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;energy&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;matrix_room_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;!gykdLyazhkcSZGHmbG:daarion.space&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;matrix_room_alias&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;#city_energy:daarion.space&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;online_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;typing_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;typing_users&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&quot;@user1:daarion.space&quot;</span><span class="p">],</span>
<span class="w"> </span><span class="nt">&quot;last_event_ts&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1732610000000</span>
<span class="p">}</span>
</code></pre></div>
<h3 id="42-user-level-presence">4.2. User-level presence (опційний)<a class="headerlink" href="#42-user-level-presence" title="Permanent link">&para;</a></h3>
<p>Subject:</p>
<div class="codehilite"><pre><span></span><code>city.presence.user.&lt;localpart&gt;
</code></pre></div>
<p>Payload:</p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;user.presence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;matrix_user_id&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;@user1:daarion.space&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;status&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;online&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;last_active_ts&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1732610000000</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2 id="5-event-generation-logic">5. EVENT GENERATION LOGIC<a class="headerlink" href="#5-event-generation-logic" title="Permanent link">&para;</a></h2>
<h3 id="51-mpresence">5.1. Обробка m.presence<a class="headerlink" href="#51-mpresence" title="Permanent link">&para;</a></h3>
<p>При кожному <code>m.presence</code>:
- оновити <code>PresenceState.users[userId]</code>,
- для всіх кімнат, де є цей юзер — перерахувати <code>onlineCount</code>,
- якщо <code>onlineCount</code> змінився — публікувати нову подію.</p>
<h3 id="52-mtyping">5.2. Обробка m.typing<a class="headerlink" href="#52-mtyping" title="Permanent link">&para;</a></h3>
<p>При <code>m.typing</code>:
- <code>content.user_ids</code> → список typing у кімнаті.
- Зберегти в <code>RoomPresence.typing_user_ids</code>.
- Згенерувати івент <code>city.presence.room.&lt;slug&gt;</code>.</p>
<h3 id="53-throttling">5.3. Throttling<a class="headerlink" href="#53-throttling" title="Permanent link">&para;</a></h3>
<ul>
<li>подію публікувати тільки якщо <code>onlineCount</code> змінився,</li>
<li>або не частіше ніж раз на 3 секунди на кімнату.</li>
</ul>
<hr />
<h2 id="6-city-realtime-gateway-websocket">6. CITY REALTIME GATEWAY (WEBSOCKET)<a class="headerlink" href="#6-city-realtime-gateway-websocket" title="Permanent link">&para;</a></h2>
<h3 id="61-websocket-endpoint">6.1. WebSocket endpoint<a class="headerlink" href="#61-websocket-endpoint" title="Permanent link">&para;</a></h3>
<div class="codehilite"><pre><span></span><code>GET /ws/city/presence
</code></pre></div>
<p>Auth: JWT токен у query param або header.</p>
<h3 id="62">6.2. Формат повідомлень<a class="headerlink" href="#62" title="Permanent link">&para;</a></h3>
<p><strong>Snapshot (при підключенні):</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;snapshot&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;rooms&quot;</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;room_slug&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;general&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;online_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">3</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;typing_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">},</span>
<span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nt">&quot;room_slug&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;welcome&quot;</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;online_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">,</span><span class="w"> </span><span class="nt">&quot;typing_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>
<p><strong>Incremental update:</strong></p>
<div class="codehilite"><pre><span></span><code><span class="p">{</span>
<span class="w"> </span><span class="nt">&quot;type&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;room.presence&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;room_slug&quot;</span><span class="p">:</span><span class="w"> </span><span class="s2">&quot;energy&quot;</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;online_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">5</span><span class="p">,</span>
<span class="w"> </span><span class="nt">&quot;typing_count&quot;</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span>
<span class="p">}</span>
</code></pre></div>
<hr />
<h2 id="7-frontend-integration">7. FRONTEND INTEGRATION<a class="headerlink" href="#7-frontend-integration" title="Permanent link">&para;</a></h2>
<h3 id="71-city">7.1. Список кімнат <code>/city</code><a class="headerlink" href="#71-city" title="Permanent link">&para;</a></h3>
<p>State:</p>
<div class="codehilite"><pre><span></span><code><span class="kr">type</span><span class="w"> </span><span class="nx">RoomPresenceUI</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">onlineCount</span><span class="o">:</span><span class="w"> </span><span class="kt">number</span><span class="p">;</span>
<span class="w"> </span><span class="nx">typingCount</span><span class="o">:</span><span class="w"> </span><span class="kt">number</span><span class="p">;</span>
<span class="p">};</span>
<span class="kd">const</span><span class="w"> </span><span class="p">[</span><span class="nx">presenceBySlug</span><span class="p">,</span><span class="w"> </span><span class="nx">setPresenceBySlug</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">useState</span><span class="o">&lt;</span><span class="nx">Record</span><span class="o">&lt;</span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="nx">RoomPresenceUI</span><span class="o">&gt;&gt;</span><span class="p">({});</span>
</code></pre></div>
<p>WebSocket handler:</p>
<div class="codehilite"><pre><span></span><code><span class="nx">ws</span><span class="p">.</span><span class="nx">onmessage</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">event</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">data</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">event</span><span class="p">.</span><span class="nx">data</span><span class="p">);</span>
<span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="kr">type</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="s1">&#39;snapshot&#39;</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="kd">const</span><span class="w"> </span><span class="nx">presence</span><span class="o">:</span><span class="w"> </span><span class="kt">Record</span><span class="o">&lt;</span><span class="kt">string</span><span class="p">,</span><span class="w"> </span><span class="nx">RoomPresenceUI</span><span class="o">&gt;</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{};</span>
<span class="w"> </span><span class="nx">data</span><span class="p">.</span><span class="nx">rooms</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">r</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">presence</span><span class="p">[</span><span class="nx">r</span><span class="p">.</span><span class="nx">room_slug</span><span class="p">]</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">onlineCount</span><span class="o">:</span><span class="w"> </span><span class="kt">r.online_count</span><span class="p">,</span>
<span class="w"> </span><span class="nx">typingCount</span><span class="o">:</span><span class="w"> </span><span class="kt">r.typing_count</span>
<span class="w"> </span><span class="p">};</span>
<span class="w"> </span><span class="p">});</span>
<span class="w"> </span><span class="nx">setPresenceBySlug</span><span class="p">(</span><span class="nx">presence</span><span class="p">);</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="nx">data</span><span class="p">.</span><span class="kr">type</span><span class="w"> </span><span class="o">===</span><span class="w"> </span><span class="s1">&#39;room.presence&#39;</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">setPresenceBySlug</span><span class="p">(</span><span class="nx">prev</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">({</span>
<span class="w"> </span><span class="p">...</span><span class="nx">prev</span><span class="p">,</span>
<span class="w"> </span><span class="p">[</span><span class="nx">data</span><span class="p">.</span><span class="nx">room_slug</span><span class="p">]</span><span class="o">:</span><span class="w"> </span><span class="p">{</span>
<span class="w"> </span><span class="nx">onlineCount</span><span class="o">:</span><span class="w"> </span><span class="kt">data.online_count</span><span class="p">,</span>
<span class="w"> </span><span class="nx">typingCount</span><span class="o">:</span><span class="w"> </span><span class="kt">data.typing_count</span>
<span class="w"> </span><span class="p">}</span>
<span class="w"> </span><span class="p">}));</span>
<span class="w"> </span><span class="p">}</span>
<span class="p">};</span>
</code></pre></div>
<h3 id="72-ui">7.2. UI<a class="headerlink" href="#72-ui" title="Permanent link">&para;</a></h3>
<ul>
<li>Room card: <code>X online</code>, typing badge</li>
<li>Active room: glow effect</li>
<li>Typing animation</li>
</ul>
<hr />
<h2 id="8-config-env">8. CONFIG / ENV<a class="headerlink" href="#8-config-env" title="Permanent link">&para;</a></h2>
<h3 id="matrix-presence-aggregator">matrix-presence-aggregator<a class="headerlink" href="#matrix-presence-aggregator" title="Permanent link">&para;</a></h3>
<div class="codehilite"><pre><span></span><code><span class="n">MATRIX_HS_URL</span><span class="o">=</span><span class="nl">https</span><span class="p">:</span><span class="o">//</span><span class="n">app</span><span class="p">.</span><span class="n">daarion</span><span class="p">.</span><span class="nf">space</span>
<span class="n">MATRIX_ACCESS_TOKEN</span><span class="o">=&lt;</span><span class="n">presence_daemon_token</span><span class="o">&gt;</span>
<span class="n">MATRIX_USER_ID</span><span class="o">=</span><span class="nv">@presence_daemon</span><span class="err">:</span><span class="n">daarion</span><span class="p">.</span><span class="nf">space</span>
<span class="n">CITY_SERVICE_INTERNAL_URL</span><span class="o">=</span><span class="nl">http</span><span class="p">:</span><span class="o">//</span><span class="n">city</span><span class="o">-</span><span class="nl">service</span><span class="p">:</span><span class="mi">7001</span>
<span class="n">NATS_URL</span><span class="o">=</span><span class="nl">nats</span><span class="p">:</span><span class="o">//</span><span class="nl">nats</span><span class="p">:</span><span class="mi">4222</span>
<span class="n">ROOM_PRESENCE_THROTTLE_MS</span><span class="o">=</span><span class="mi">3000</span>
</code></pre></div>
<h3 id="city-service-realtime-gateway">city-service (realtime gateway)<a class="headerlink" href="#city-service-realtime-gateway" title="Permanent link">&para;</a></h3>
<div class="codehilite"><pre><span></span><code>NATS_URL=nats://nats:4222
JWT_SECRET=&lt;secret&gt;
</code></pre></div>
<hr />
<h2 id="9-acceptance-criteria">9. ACCEPTANCE CRITERIA<a class="headerlink" href="#9-acceptance-criteria" title="Permanent link">&para;</a></h2>
<ul>
<li>[ ] matrix-presence-aggregator запущений і синхронізується з Matrix</li>
<li>[ ] NATS отримує події <code>city.presence.room.*</code></li>
<li>[ ] city-service має endpoint <code>/ws/city/presence</code></li>
<li>[ ] При підключенні WS клієнт отримує snapshot</li>
<li>[ ] При зміні presence клієнт отримує update</li>
<li>[ ] UI <code>/city</code> показує online count для кожної кімнати</li>
<li>[ ] Typing indicator відображається</li>
</ul>
<hr />
<h2 id="10-future-enhancements">10. FUTURE ENHANCEMENTS<a class="headerlink" href="#10-future-enhancements" title="Permanent link">&para;</a></h2>
<ol>
<li><strong>Agent presence</strong> — окремі статуси для AI-агентів</li>
<li><strong>City Map</strong> — візуалізація presence на 2D карті</li>
<li><strong>Push notifications</strong> — сповіщення про активність</li>
<li><strong>Historical analytics</strong> — статистика активності</li>
</ol>
</article>
</div>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
Made with
<a href="https://squidfunk.github.io/mkdocs-material/" target="_blank" rel="noopener">
Material for MkDocs
</a>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"base": "../..", "features": ["navigation.sections", "navigation.instant", "content.code.copy"], "search": "../../assets/javascripts/workers/search.b8dbb3d2.min.js", "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}}</script>
<script src="../../assets/javascripts/bundle.3220b9d7.min.js"></script>
</body>
</html>