feat(gateway): add sender presence tracking
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ A gap item is considered implemented when:
|
||||
|
||||
### Misc (MISSING)
|
||||
|
||||
- Presence tracking
|
||||
|
||||
### Companion Apps / Devices (MISSING)
|
||||
|
||||
|
||||
@@ -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`
|
||||
+23
-3
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, ChannelAdapter> = new Map();
|
||||
private messageHandler?: MessageHandler;
|
||||
private presence: Map<string, PresenceRecord> = 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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<OutboundMessage> => {
|
||||
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<OutboundMessage> => {
|
||||
const uptime = Math.floor((Date.now() - deps.startTime) / 1000);
|
||||
const usage = deps.getUsage?.() ?? { totalSessions: 0, activeConnections: 0 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user