diff --git a/README.md b/README.md index c47c913..9e436cf 100644 --- a/README.md +++ b/README.md @@ -715,6 +715,7 @@ pairing: | `pairing.generate` | Generate a new pairing code (optional `label` param) | | `pairing.list` | List pending codes and approved senders | | `pairing.revoke` | Revoke an approved sender (`channel` + `senderId` params) | +| `system.presence` | List observed sender presence with online/offline status inferred from recent inbound activity | ## Shell Completion diff --git a/docs/api/PROTOCOL.md b/docs/api/PROTOCOL.md index e6bbe1c..bf3ae8b 100644 --- a/docs/api/PROTOCOL.md +++ b/docs/api/PROTOCOL.md @@ -274,6 +274,46 @@ Close the connection gracefully. } ``` +#### `system.presence` + +Return tracked sender presence snapshots (most recent first). + +Online/offline is inferred from inactivity threshold in the daemon. + +**Request:** +```json +{ + "id": 3, + "method": "system.presence", + "params": { + "channel": "telegram", + "status": "online", + "limit": 50 + } +} +``` + +**Response:** +```json +{ + "id": 3, + "result": { + "presence": [ + { + "channel": "telegram", + "senderId": "123456", + "senderName": "alice", + "firstSeenAt": 1739700000000, + "lastSeenAt": 1739700300000, + "messageCount": 12, + "status": "online" + } + ], + "summary": { "total": 1, "online": 1, "offline": 0 } + } +} +``` + **Response:** ```json { diff --git a/docs/plans/2026-02-06-openclaw-feature-gap-analysis.md b/docs/plans/2026-02-06-openclaw-feature-gap-analysis.md index 1a9bcdf..209561f 100644 --- a/docs/plans/2026-02-06-openclaw-feature-gap-analysis.md +++ b/docs/plans/2026-02-06-openclaw-feature-gap-analysis.md @@ -233,7 +233,7 @@ Flynn actually has MCP support that OpenClaw doesn't emphasise — OpenClaw reli |---------|----------|-------|--------| | Streaming & chunking | Full (per-channel limits) | Full (streaming + per-channel chunking) | **MATCH** | | Typing indicators | Full | Telegram, Discord, WhatsApp (per-adapter) | **MATCH** | -| Presence tracking | Full | -- | **MISSING** | +| Presence tracking | Full | Full (`system.presence` with online/offline inference from recent sender activity) | **MATCH** | | Web UI token dashboard | Usage visualization (v2026.2.6) | Full (Usage page with summary cards, per-session table, auto-refresh) | **MATCH** | | Usage tracking / cost | Full | Full (per-tier tokens, estimated cost via MODEL_COSTS) | **MATCH** | | Markdown rendering | Per-channel formatting | Full (TUI markdown renderer + channel-specific) | **MATCH** | @@ -261,8 +261,8 @@ Flynn actually has MCP support that OpenClaw doesn't emphasise — OpenClaw reli | Skills/Plugins | 5 | 4 | 0 | 1 | | Gateway/Infra | 13 | 8 | 0 | 5 | | Chat Commands | 6 | 6 | 0 | 0 | -| Misc | 10 | 9 | 0 | 1 | -| **TOTAL** | **128** | **100 (78%)** | **0 (0%)** | **28 (22%)** | +| Misc | 10 | 10 | 0 | 0 | +| **TOTAL** | **128** | **101 (79%)** | **0 (0%)** | **27 (21%)** | *Note: Match rate improved from 77% to 78% after implementing setup wizard (`flynn setup` + first-run auto-trigger).* @@ -314,7 +314,6 @@ All five Tier 3 items implemented: Lane Queue (per-session FIFO in gateway), cre - ~~Onboard wizard — guided setup~~ (DONE — `flynn setup` + first-run auto-trigger, 2026-02-10) - ClawHub/skill registry — community marketplace - QMD backend — experimental memory search -- Presence tracking — online/offline status --- diff --git a/docs/plans/2026-02-15-openclaw-gap-roadmap.md b/docs/plans/2026-02-15-openclaw-gap-roadmap.md index 652ff4f..1820d7c 100644 --- a/docs/plans/2026-02-15-openclaw-gap-roadmap.md +++ b/docs/plans/2026-02-15-openclaw-gap-roadmap.md @@ -61,7 +61,6 @@ A gap item is considered implemented when: ### Misc (MISSING) -- Presence tracking ### Companion Apps / Devices (MISSING) diff --git a/docs/plans/2026-02-16-presence-tracking-checklist.md b/docs/plans/2026-02-16-presence-tracking-checklist.md new file mode 100644 index 0000000..5f88314 --- /dev/null +++ b/docs/plans/2026-02-16-presence-tracking-checklist.md @@ -0,0 +1,27 @@ +# Presence Tracking Checklist + +Date: 2026-02-16 +Status: completed + +## Scope + +- Add runtime presence tracking for observed senders. +- Expose presence via gateway API for dashboard/ops visibility. + +## Completed + +- Added presence tracking to `ChannelRegistry`: + - records inbound sender activity (`channel + senderId`) + - tracks `firstSeenAt`, `lastSeenAt`, `messageCount` + - infers `online|offline` from inactivity window + - supports filtering (`channel`, `status`, `limit`) +- Added `system.presence` gateway method via system handlers. +- Wired gateway server to source presence data from `ChannelRegistry`. +- Added tests for registry presence behavior and handler output. +- Updated docs for new API surface. + +## Verification + +- `pnpm test:run src/channels/registry.test.ts` +- `pnpm test:run src/gateway/handlers/handlers.test.ts` +- `pnpm typecheck` diff --git a/docs/plans/state.json b/docs/plans/state.json index 5f8aa7c..0cfacf3 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -203,6 +203,26 @@ ], "test_status": "pnpm test:run src/channels/registry.test.ts src/automation/cron.test.ts src/automation/webhooks.test.ts src/config/schema.test.ts + pnpm typecheck passing" }, + "presence-tracking": { + "file": "2026-02-16-presence-tracking-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented sender presence tracking (online/offline inferred from recent activity) in ChannelRegistry and exposed it via new gateway method `system.presence` for operations visibility.", + "files_created": [ + "docs/plans/2026-02-16-presence-tracking-checklist.md" + ], + "files_modified": [ + "src/channels/registry.ts", + "src/channels/registry.test.ts", + "src/gateway/handlers/system.ts", + "src/gateway/handlers/handlers.test.ts", + "src/gateway/server.ts", + "README.md", + "docs/api/PROTOCOL.md" + ], + "test_status": "pnpm test:run src/channels/registry.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" + }, "skill-safety-scanner": { "file": "2026-02-15-skill-safety-scanner-checklist.md", "status": "completed", @@ -2232,7 +2252,7 @@ }, "overall_progress": { - "total_test_count": 1698, + "total_test_count": 1703, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -2247,12 +2267,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "106/128 match (83%), 0 partial (0%), 22 missing (17%)", + "feature_gap_scorecard": "107/128 match (84%), 0 partial (0%), 21 missing (16%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Pick the next OpenClaw gap milestone and create a scoped checklist (candidates: presence tracking, QMD backend, ClawHub registry)" + "next_up": "Pick the next OpenClaw gap milestone and create a scoped checklist (candidates: QMD backend, ClawHub registry, Bonjour/mDNS discovery)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts index 3bc1546..5b1bc43 100644 --- a/src/channels/registry.test.ts +++ b/src/channels/registry.test.ts @@ -132,6 +132,71 @@ describe('ChannelRegistry', () => { expect(adapter.sendFn).toHaveBeenCalledWith('user-42', { text: 'pong' }); }); + it('tracks presence from inbound messages', async () => { + const adapter = createMockAdapter('test-channel'); + registry.register(adapter); + registry.setMessageHandler(async () => {}); + + adapter.triggerMessage(makeMessage('test-channel')); + + await vi.waitFor(() => { + expect(registry.getPresence()).toHaveLength(1); + }); + + const [presence] = registry.getPresence(); + expect(presence.channel).toBe('test-channel'); + expect(presence.senderId).toBe('user-42'); + expect(presence.messageCount).toBe(1); + expect(presence.status).toBe('online'); + }); + + it('marks presence offline after inactivity window', async () => { + vi.useFakeTimers(); + try { + registry = new ChannelRegistry({ offlineAfterMs: 1000 }); + const adapter = createMockAdapter('test-channel'); + registry.register(adapter); + registry.setMessageHandler(async () => {}); + + adapter.triggerMessage(makeMessage('test-channel')); + await vi.runAllTimersAsync(); + + vi.advanceTimersByTime(1500); + const [presence] = registry.getPresence(); + expect(presence.status).toBe('offline'); + } finally { + vi.useRealTimers(); + } + }); + + it('filters presence by channel and status', async () => { + vi.useFakeTimers(); + try { + registry = new ChannelRegistry({ offlineAfterMs: 1000 }); + const a1 = createMockAdapter('telegram'); + const a2 = createMockAdapter('discord'); + registry.register(a1); + registry.register(a2); + registry.setMessageHandler(async () => {}); + + a1.triggerMessage(makeMessage('telegram')); + a2.triggerMessage({ ...makeMessage('discord'), senderId: 'user-99' }); + await vi.runAllTimersAsync(); + vi.advanceTimersByTime(1500); + a2.triggerMessage({ ...makeMessage('discord'), senderId: 'user-99' }); + + const telegramOnly = registry.getPresence({ channel: 'telegram' }); + expect(telegramOnly).toHaveLength(1); + expect(telegramOnly[0].channel).toBe('telegram'); + + const onlineOnly = registry.getPresence({ status: 'online' }); + expect(onlineOnly).toHaveLength(1); + expect(onlineOnly[0].channel).toBe('discord'); + } finally { + vi.useRealTimers(); + } + }); + it('routes reply using metadata.replyPeerId when provided', async () => { const adapter = createMockAdapter('test-channel'); registry.register(adapter); diff --git a/src/channels/registry.ts b/src/channels/registry.ts index 22afd29..f386bdc 100644 --- a/src/channels/registry.ts +++ b/src/channels/registry.ts @@ -14,9 +14,49 @@ import type { OutboundMessage, } from './types.js'; +type PresenceStatus = 'online' | 'offline'; + +interface PresenceRecord { + channel: string; + senderId: string; + senderName?: string; + firstSeenAt: number; + lastSeenAt: number; + messageCount: number; +} + +export interface PresenceEntry { + channel: string; + senderId: string; + senderName?: string; + firstSeenAt: number; + lastSeenAt: number; + messageCount: number; + status: PresenceStatus; +} + +interface PresenceQuery { + channel?: string; + status?: PresenceStatus; + limit?: number; +} + +interface ChannelRegistryOptions { + offlineAfterMs?: number; + maxPresenceEntries?: number; +} + export class ChannelRegistry { private adapters: Map = new Map(); private messageHandler?: MessageHandler; + private presence: Map = new Map(); + private readonly offlineAfterMs: number; + private readonly maxPresenceEntries: number; + + constructor(options?: ChannelRegistryOptions) { + this.offlineAfterMs = options?.offlineAfterMs ?? 5 * 60 * 1000; + this.maxPresenceEntries = options?.maxPresenceEntries ?? 10_000; + } /** Register an adapter. Throws if name already registered. */ register(adapter: ChannelAdapter): void { @@ -56,6 +96,37 @@ export class ChannelRegistry { this.messageHandler = handler; } + /** + * Return observed sender presence entries sorted by most recent activity. + * Status is inferred from lastSeenAt with an inactivity threshold. + */ + getPresence(query?: PresenceQuery): PresenceEntry[] { + const now = Date.now(); + const limit = Math.max(1, query?.limit ?? 100); + + const entries = Array.from(this.presence.values()) + .filter((record) => !query?.channel || record.channel === query.channel) + .map((record): PresenceEntry => { + const status: PresenceStatus = (now - record.lastSeenAt) <= this.offlineAfterMs ? 'online' : 'offline'; + return { ...record, status }; + }) + .filter((entry) => !query?.status || entry.status === query.status) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt); + + return entries.slice(0, limit); + } + + /** Aggregate quick presence stats for dashboards. */ + getPresenceSummary(): { total: number; online: number; offline: number } { + const entries = this.getPresence({ limit: this.maxPresenceEntries }); + const online = entries.filter((entry) => entry.status === 'online').length; + return { + total: entries.length, + online, + offline: entries.length - online, + }; + } + /** Start all registered adapters. Logs errors per adapter, doesn't throw. */ async startAll(): Promise { const adapters = Array.from(this.adapters.values()); @@ -92,6 +163,8 @@ export class ChannelRegistry { /** Internal: route an inbound message to the message handler. */ private handleInbound(msg: InboundMessage): void { + this.recordPresence(msg); + if (!this.messageHandler) { console.warn(`No message handler set, dropping message from '${msg.channel}'`); return; @@ -117,4 +190,42 @@ export class ChannelRegistry { console.error(`Error handling message from '${msg.channel}':`, err); }); } + + private recordPresence(msg: InboundMessage): void { + const key = `${msg.channel}:${msg.senderId}`; + const now = Date.now(); + const existing = this.presence.get(key); + if (existing) { + existing.lastSeenAt = now; + existing.messageCount += 1; + if (msg.senderName) { + existing.senderName = msg.senderName; + } + return; + } + + this.presence.set(key, { + channel: msg.channel, + senderId: msg.senderId, + senderName: msg.senderName, + firstSeenAt: now, + lastSeenAt: now, + messageCount: 1, + }); + + if (this.presence.size > this.maxPresenceEntries) { + // Keep bounded memory by evicting least recently seen entry. + let oldestKey: string | undefined; + let oldestTs = Number.POSITIVE_INFINITY; + for (const [candidateKey, record] of this.presence.entries()) { + if (record.lastSeenAt < oldestTs) { + oldestTs = record.lastSeenAt; + oldestKey = candidateKey; + } + } + if (oldestKey) { + this.presence.delete(oldestKey); + } + } + } } diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 91d8cb8..dd1283a 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -64,6 +64,57 @@ describe('system handlers', () => { { name: 'cron', type: 'automation', status: 'configured', description: 'Cron scheduler', itemCount: 2 }, ]); }); + + it('system.presence returns empty result when getPresence is not provided', async () => { + const req: GatewayRequest = { id: 4, method: 'system.presence' }; + const result = await handlers['system.presence'](req) as GatewayResponse; + expect(result.id).toBe(4); + expect((result.result as any).presence).toEqual([]); + expect((result.result as any).summary).toEqual({ total: 0, online: 0, offline: 0 }); + }); + + it('system.presence returns filtered presence entries', async () => { + const handlers = createSystemHandlers({ + ...deps, + getPresence: ({ channel, status, limit } = {}) => { + const all = [ + { + channel: 'telegram', + senderId: '1', + senderName: 'alice', + firstSeenAt: 1000, + lastSeenAt: 2000, + messageCount: 3, + status: 'online' as const, + }, + { + channel: 'discord', + senderId: '2', + senderName: 'bob', + firstSeenAt: 1000, + lastSeenAt: 1500, + messageCount: 1, + status: 'offline' as const, + }, + ]; + + return all + .filter((entry) => !channel || entry.channel === channel) + .filter((entry) => !status || entry.status === status) + .slice(0, limit ?? 100); + }, + }); + + const req: GatewayRequest = { + id: 5, + method: 'system.presence', + params: { channel: 'telegram', status: 'online', limit: 10 }, + }; + const result = await handlers['system.presence'](req) as GatewayResponse; + expect((result.result as any).presence).toHaveLength(1); + expect((result.result as any).presence[0].channel).toBe('telegram'); + expect((result.result as any).summary).toEqual({ total: 1, online: 1, offline: 0 }); + }); }); describe('system.tokenUsage handler', () => { diff --git a/src/gateway/handlers/system.ts b/src/gateway/handlers/system.ts index 7951d4c..387f460 100644 --- a/src/gateway/handlers/system.ts +++ b/src/gateway/handlers/system.ts @@ -11,6 +11,16 @@ export interface TokenUsageEntry { total: { inputTokens: number; outputTokens: number; calls: number; estimatedCost: number }; } +export interface PresenceEntry { + channel: string; + senderId: string; + senderName?: string; + firstSeenAt: number; + lastSeenAt: number; + messageCount: number; + status: 'online' | 'offline'; +} + export interface SystemHandlerDeps { startTime: number; version: string; @@ -31,6 +41,8 @@ export interface SystemHandlerDeps { getActiveRequests?: () => ActiveRequestInfo[]; /** Optional callback to retrieve all services (channels + automation). */ getServices?: () => ServiceInfo[]; + /** Optional callback to retrieve tracked sender presence. */ + getPresence?: (opts?: { channel?: string; status?: 'online' | 'offline'; limit?: number }) => PresenceEntry[]; } export function createSystemHandlers(deps: SystemHandlerDeps) { @@ -78,6 +90,28 @@ export function createSystemHandlers(deps: SystemHandlerDeps) { return makeResponse(request.id, { services: deps.getServices() }); }, + 'system.presence': async (request: GatewayRequest): Promise => { + if (!deps.getPresence) { + return makeResponse(request.id, { presence: [], summary: { total: 0, online: 0, offline: 0 } }); + } + + const params = request.params as { channel?: string; status?: 'online' | 'offline'; limit?: number } | undefined; + const presence = deps.getPresence({ + channel: params?.channel, + status: params?.status, + limit: params?.limit, + }); + const online = presence.filter((entry) => entry.status === 'online').length; + return makeResponse(request.id, { + presence, + summary: { + total: presence.length, + online, + offline: presence.length - online, + }, + }); + }, + 'system.usage': async (request: GatewayRequest): Promise => { const uptime = Math.floor((Date.now() - deps.startTime) / 1000); const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 }; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 40b5d01..215223d 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -121,6 +121,9 @@ export class GatewayServer { getServices: this.config.config && this.config.channelRegistry ? () => discoverServices(this.config.config!, this.config.channelRegistry!) : undefined, + getPresence: this.config.channelRegistry + ? (opts) => this.config.channelRegistry!.getPresence(opts) + : undefined, getUsage: () => ({ totalSessions: this.config.sessionManager.listSessions().length, activeConnections: this.sessionBridge.connectionCount,