openclaw-face-hooks 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/HOOK.md ADDED
@@ -0,0 +1,50 @@
1
+ ---
2
+ name: openclaw-face-hooks
3
+ description: "Push agent busy/idle status to Cloudflare R2 on command events"
4
+ metadata:
5
+ {
6
+ "openclaw":
7
+ {
8
+ "emoji": "📡",
9
+ "events": ["command:new", "command:stop", "command:reset"],
10
+ "requires": { "env": ["R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY", "R2_ENDPOINT", "R2_BUCKET"] },
11
+ },
12
+ }
13
+ ---
14
+
15
+ # R2 Status Hook
16
+
17
+ Pushes agent busy/idle status to Cloudflare R2 as `status.json` on command events. Designed for zero-port-exposure status visualization — a GitHub Pages dashboard polls R2 to display real-time agent state.
18
+
19
+ ## What It Does
20
+
21
+ - On `/new` command → uploads `{ "busy": true, ... }` to R2
22
+ - On `/stop` or `/reset` command → uploads `{ "busy": false, ... }` to R2
23
+ - Upload failures are caught and logged — they never interrupt OpenClaw operation
24
+
25
+ ## Status JSON Schema
26
+
27
+ ```json
28
+ {
29
+ "busy": true,
30
+ "ts": 1704067200000,
31
+ "sessionKey": "agent:main:main",
32
+ "source": "telegram"
33
+ }
34
+ ```
35
+
36
+ ## Requirements
37
+
38
+ - Cloudflare R2 bucket with public-read access
39
+ - R2 API token with `PutObject` permission
40
+
41
+ ## Configuration
42
+
43
+ Set environment variables:
44
+
45
+ | Variable | Description |
46
+ |----------|-------------|
47
+ | `R2_ACCESS_KEY_ID` | R2 API Access Key ID |
48
+ | `R2_SECRET_ACCESS_KEY` | R2 API Secret Access Key |
49
+ | `R2_ENDPOINT` | R2 endpoint URL (e.g., `https://[account-id].r2.cloudflarestorage.com`) |
50
+ | `R2_BUCKET` | Bucket name (e.g., `openclaw-status-alice`) |
@@ -0,0 +1,141 @@
1
+ /**
2
+ * R2 Status Hook - Handler Unit Tests
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ // Mock @aws-sdk/client-s3 before importing handler
8
+ const mockSend = vi.fn().mockResolvedValue({});
9
+ vi.mock('@aws-sdk/client-s3', () => ({
10
+ S3Client: vi.fn().mockImplementation(() => ({ send: mockSend })),
11
+ PutObjectCommand: vi.fn().mockImplementation((params) => params),
12
+ }));
13
+
14
+ import handler, { uploadStatus, StatusPayload } from '../handler';
15
+
16
+ describe('handler', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ vi.spyOn(console, 'log').mockImplementation(() => {});
20
+ vi.spyOn(console, 'error').mockImplementation(() => {});
21
+ process.env.R2_BUCKET = 'test-bucket';
22
+ process.env.R2_ENDPOINT = 'https://test.r2.cloudflarestorage.com';
23
+ process.env.R2_ACCESS_KEY_ID = 'test-key';
24
+ process.env.R2_SECRET_ACCESS_KEY = 'test-secret';
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ delete process.env.R2_BUCKET;
30
+ delete process.env.R2_ENDPOINT;
31
+ delete process.env.R2_ACCESS_KEY_ID;
32
+ delete process.env.R2_SECRET_ACCESS_KEY;
33
+ });
34
+
35
+ function createEvent(action: string, type = 'command') {
36
+ return {
37
+ type,
38
+ action,
39
+ sessionKey: 'agent:main:main',
40
+ timestamp: new Date('2025-01-01T00:00:00Z'),
41
+ messages: [] as string[],
42
+ context: { commandSource: 'telegram' },
43
+ };
44
+ }
45
+
46
+ describe('event filtering', () => {
47
+ it('should handle command:new events', async () => {
48
+ await handler(createEvent('new'));
49
+ // Fire-and-forget, give it a tick
50
+ await new Promise((r) => setTimeout(r, 10));
51
+ expect(mockSend).toHaveBeenCalled();
52
+ });
53
+
54
+ it('should handle command:stop events', async () => {
55
+ await handler(createEvent('stop'));
56
+ await new Promise((r) => setTimeout(r, 10));
57
+ expect(mockSend).toHaveBeenCalled();
58
+ });
59
+
60
+ it('should handle command:reset events', async () => {
61
+ await handler(createEvent('reset'));
62
+ await new Promise((r) => setTimeout(r, 10));
63
+ expect(mockSend).toHaveBeenCalled();
64
+ });
65
+
66
+ it('should ignore non-command events', async () => {
67
+ await handler(createEvent('bootstrap', 'agent'));
68
+ await new Promise((r) => setTimeout(r, 10));
69
+ expect(mockSend).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('should ignore unknown command actions', async () => {
73
+ await handler(createEvent('unknown'));
74
+ await new Promise((r) => setTimeout(r, 10));
75
+ expect(mockSend).not.toHaveBeenCalled();
76
+ });
77
+ });
78
+
79
+ describe('busy state mapping', () => {
80
+ it('command:new should set busy: true', async () => {
81
+ await handler(createEvent('new'));
82
+ await new Promise((r) => setTimeout(r, 10));
83
+
84
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3');
85
+ const call = vi.mocked(PutObjectCommand).mock.calls[0][0] as any;
86
+ const payload = JSON.parse(call.Body) as StatusPayload;
87
+ expect(payload.busy).toBe(true);
88
+ });
89
+
90
+ it('command:stop should set busy: false', async () => {
91
+ await handler(createEvent('stop'));
92
+ await new Promise((r) => setTimeout(r, 10));
93
+
94
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3');
95
+ const calls = vi.mocked(PutObjectCommand).mock.calls;
96
+ const call = calls[calls.length - 1][0] as any;
97
+ const payload = JSON.parse(call.Body) as StatusPayload;
98
+ expect(payload.busy).toBe(false);
99
+ });
100
+
101
+ it('command:reset should set busy: false', async () => {
102
+ await handler(createEvent('reset'));
103
+ await new Promise((r) => setTimeout(r, 10));
104
+
105
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3');
106
+ const calls = vi.mocked(PutObjectCommand).mock.calls;
107
+ const call = calls[calls.length - 1][0] as any;
108
+ const payload = JSON.parse(call.Body) as StatusPayload;
109
+ expect(payload.busy).toBe(false);
110
+ });
111
+ });
112
+
113
+ describe('payload structure', () => {
114
+ it('should include sessionKey and source from event', async () => {
115
+ await handler(createEvent('new'));
116
+ await new Promise((r) => setTimeout(r, 10));
117
+
118
+ const { PutObjectCommand } = await import('@aws-sdk/client-s3');
119
+ const call = vi.mocked(PutObjectCommand).mock.calls[0][0] as any;
120
+ const payload = JSON.parse(call.Body) as StatusPayload;
121
+ expect(payload.sessionKey).toBe('agent:main:main');
122
+ expect(payload.source).toBe('telegram');
123
+ expect(typeof payload.ts).toBe('number');
124
+ });
125
+ });
126
+
127
+ describe('uploadStatus', () => {
128
+ it('should skip upload when R2_BUCKET is not set', async () => {
129
+ delete process.env.R2_BUCKET;
130
+ await uploadStatus({ busy: true, ts: Date.now() });
131
+ expect(mockSend).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it('should not throw on upload failure', async () => {
135
+ mockSend.mockRejectedValueOnce(new Error('Network error'));
136
+ await expect(
137
+ uploadStatus({ busy: true, ts: Date.now() })
138
+ ).resolves.not.toThrow();
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * R2 Status Hook - Property-Based Tests
3
+ *
4
+ * Validates correctness properties of the status payload.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import * as fc from 'fast-check';
9
+ import { StatusPayload } from '../handler';
10
+
11
+ const minTimestamp = new Date('2020-01-01').getTime();
12
+ const maxTimestamp = new Date('2030-01-01').getTime();
13
+ const timestampArb = fc.integer({ min: minTimestamp, max: maxTimestamp });
14
+
15
+ const sessionKeyArb = fc.string({ minLength: 1, maxLength: 50 });
16
+ const sourceArb: fc.Arbitrary<string | undefined> = fc.oneof(
17
+ fc.constant(undefined as undefined),
18
+ fc.constantFrom('telegram', 'whatsapp', 'test')
19
+ );
20
+
21
+ const statusPayloadArb = fc.record({
22
+ busy: fc.boolean(),
23
+ ts: timestampArb,
24
+ sessionKey: fc.oneof(fc.constant(undefined as undefined), sessionKeyArb),
25
+ source: sourceArb,
26
+ });
27
+
28
+ function itProperty(name: string, property: fc.IProperty<any>, numRuns = 50) {
29
+ it(name, () => {
30
+ fc.assert(property, { numRuns });
31
+ });
32
+ }
33
+
34
+ describe('StatusPayload properties', () => {
35
+ describe('JSON serialization round-trip', () => {
36
+ itProperty('serialization and parsing produces equivalent object',
37
+ fc.property(statusPayloadArb, (payload) => {
38
+ const serialized = JSON.stringify(payload);
39
+ const parsed = JSON.parse(serialized);
40
+
41
+ return (
42
+ parsed.busy === payload.busy &&
43
+ parsed.ts === payload.ts &&
44
+ (payload.sessionKey !== undefined ? parsed.sessionKey === payload.sessionKey : parsed.sessionKey === undefined) &&
45
+ (payload.source !== undefined ? parsed.source === payload.source : parsed.source === undefined)
46
+ );
47
+ })
48
+ );
49
+
50
+ itProperty('round-trip preserves field types',
51
+ fc.property(statusPayloadArb, (payload) => {
52
+ const parsed = JSON.parse(JSON.stringify(payload));
53
+ return (
54
+ typeof parsed.busy === 'boolean' &&
55
+ typeof parsed.ts === 'number'
56
+ );
57
+ })
58
+ );
59
+ });
60
+
61
+ describe('JSON size limit', () => {
62
+ itProperty('serialized JSON is less than 1024 bytes',
63
+ fc.property(statusPayloadArb, (payload) => {
64
+ const json = JSON.stringify(payload);
65
+ return new Blob([json]).size < 1024;
66
+ })
67
+ );
68
+ });
69
+
70
+ describe('timestamp validity', () => {
71
+ itProperty('timestamps are valid Unix epoch milliseconds',
72
+ fc.property(timestampArb, (ts) => {
73
+ const date = new Date(ts);
74
+ return date.getTime() === ts && ts >= minTimestamp;
75
+ })
76
+ );
77
+ });
78
+
79
+ describe('busy state mapping', () => {
80
+ itProperty('command:new always maps to busy: true',
81
+ fc.property(timestampArb, sessionKeyArb, sourceArb, (ts, sessionKey, source) => {
82
+ const payload: StatusPayload = { busy: true, ts, sessionKey, source };
83
+ return payload.busy === true;
84
+ })
85
+ );
86
+
87
+ itProperty('command:stop always maps to busy: false',
88
+ fc.property(timestampArb, sessionKeyArb, sourceArb, (ts, sessionKey, source) => {
89
+ const payload: StatusPayload = { busy: false, ts, sessionKey, source };
90
+ return payload.busy === false;
91
+ })
92
+ );
93
+ });
94
+ });
package/handler.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * R2 Status Hook - Handler
3
+ *
4
+ * Pushes agent busy/idle status to Cloudflare R2 on command events.
5
+ * Follows the OpenClaw HookHandler convention.
6
+ */
7
+
8
+ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
9
+ import { config } from 'dotenv';
10
+
11
+ config();
12
+
13
+ /**
14
+ * Status payload uploaded to R2
15
+ */
16
+ export interface StatusPayload {
17
+ busy: boolean;
18
+ ts: number;
19
+ sessionKey?: string;
20
+ source?: string;
21
+ }
22
+
23
+ /**
24
+ * Lazily initialized S3 client (created on first use)
25
+ */
26
+ let client: S3Client | null = null;
27
+
28
+ function getClient(): S3Client {
29
+ if (!client) {
30
+ client = new S3Client({
31
+ region: 'auto',
32
+ endpoint: process.env.R2_ENDPOINT,
33
+ credentials: {
34
+ accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '',
35
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '',
36
+ },
37
+ });
38
+ }
39
+ return client;
40
+ }
41
+
42
+ /**
43
+ * Uploads status payload to R2.
44
+ * Does not throw — catches errors and logs them.
45
+ */
46
+ export async function uploadStatus(payload: StatusPayload): Promise<void> {
47
+ const bucket = process.env.R2_BUCKET;
48
+ if (!bucket) {
49
+ console.error('[r2-status] R2_BUCKET not set, skipping upload');
50
+ return;
51
+ }
52
+
53
+ try {
54
+ await getClient().send(
55
+ new PutObjectCommand({
56
+ Bucket: bucket,
57
+ Key: 'status.json',
58
+ Body: JSON.stringify(payload),
59
+ ContentType: 'application/json',
60
+ CacheControl: 'no-cache',
61
+ })
62
+ );
63
+ console.log(`[r2-status] Uploaded status (busy: ${payload.busy})`);
64
+ } catch (err) {
65
+ console.error(
66
+ '[r2-status] Upload failed:',
67
+ err instanceof Error ? err.message : String(err)
68
+ );
69
+ }
70
+ }
71
+
72
+ /**
73
+ * OpenClaw HookHandler
74
+ *
75
+ * Listens for command events:
76
+ * - command:new → busy: true
77
+ * - command:stop → busy: false
78
+ * - command:reset → busy: false
79
+ */
80
+ const handler = async (event: {
81
+ type: string;
82
+ action: string;
83
+ sessionKey: string;
84
+ timestamp: Date;
85
+ messages: string[];
86
+ context: {
87
+ commandSource?: string;
88
+ senderId?: string;
89
+ [key: string]: unknown;
90
+ };
91
+ }): Promise<void> => {
92
+ if (event.type !== 'command') {
93
+ return;
94
+ }
95
+
96
+ let busy: boolean;
97
+ switch (event.action) {
98
+ case 'new':
99
+ busy = true;
100
+ break;
101
+ case 'stop':
102
+ case 'reset':
103
+ busy = false;
104
+ break;
105
+ default:
106
+ return;
107
+ }
108
+
109
+ const payload: StatusPayload = {
110
+ busy,
111
+ ts: event.timestamp.getTime(),
112
+ sessionKey: event.sessionKey,
113
+ source: event.context.commandSource,
114
+ };
115
+
116
+ // Fire and forget — don't block command processing
117
+ void uploadStatus(payload);
118
+ };
119
+
120
+ export default handler;
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "openclaw-face-hooks",
3
+ "version": "0.0.1",
4
+ "description": "OpenClaw hook for pushing agent status to Cloudflare R2",
5
+ "scripts": {
6
+ "test": "vitest",
7
+ "test:run": "vitest run",
8
+ "test:push": "tsx test-push.ts"
9
+ },
10
+ "keywords": [
11
+ "openclaw",
12
+ "hooks",
13
+ "r2",
14
+ "face"
15
+ ],
16
+ "author": "",
17
+ "license": "MIT",
18
+ "devDependencies": {
19
+ "@types/node": "^20.10.0",
20
+ "fast-check": "^3.15.0",
21
+ "tsx": "^4.21.0",
22
+ "vitest": "^1.0.4"
23
+ },
24
+ "dependencies": {
25
+ "@aws-sdk/client-s3": "^3.450.0",
26
+ "dotenv": "^17.2.4"
27
+ },
28
+ "openclaw": {
29
+ "hooks": [
30
+ "./"
31
+ ]
32
+ }
33
+ }
package/test-push.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * R2 Status Hook - Integration Test
3
+ *
4
+ * Simulates command events to verify R2 push functionality.
5
+ * Requires R2 environment variables to be set (or .env file).
6
+ *
7
+ * Usage: pnpm test:push
8
+ */
9
+
10
+ import { config } from 'dotenv';
11
+ import { resolve } from 'path';
12
+
13
+ config({ path: resolve(__dirname, '.env') });
14
+
15
+ import { uploadStatus, StatusPayload } from './handler';
16
+
17
+ const REQUIRED_VARS = ['R2_ACCESS_KEY_ID', 'R2_SECRET_ACCESS_KEY', 'R2_ENDPOINT', 'R2_BUCKET'] as const;
18
+
19
+ const missing = REQUIRED_VARS.filter((v) => !process.env[v]);
20
+ if (missing.length > 0) {
21
+ console.warn(`Warning: Missing environment variables: ${missing.join(', ')}`);
22
+ console.warn('Set them or create a .env file. Continuing anyway...\n');
23
+ }
24
+
25
+ async function testPush(label: string, payload: StatusPayload): Promise<boolean> {
26
+ console.log(`--- ${label} ---`);
27
+ try {
28
+ await uploadStatus(payload);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function main() {
36
+ console.log('=== R2 Status Push Integration Test ===\n');
37
+
38
+ const test1 = await testPush('Test 1: command:new (busy: true)', {
39
+ busy: true,
40
+ ts: Date.now(),
41
+ sessionKey: 'agent:main:main',
42
+ source: 'test',
43
+ });
44
+
45
+ await new Promise((r) => setTimeout(r, 2000));
46
+
47
+ const test2 = await testPush('Test 2: command:stop (busy: false)', {
48
+ busy: false,
49
+ ts: Date.now(),
50
+ sessionKey: 'agent:main:main',
51
+ source: 'test',
52
+ });
53
+
54
+ console.log('\n=== Results ===');
55
+ console.log('command:new push:', test1 ? 'PASSED' : 'FAILED');
56
+ console.log('command:stop push:', test2 ? 'PASSED' : 'FAILED');
57
+
58
+ process.exit(test1 && test2 ? 0 : 1);
59
+ }
60
+
61
+ main();
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "moduleResolution": "node"
12
+ },
13
+ "include": ["*.ts", "__tests__/*.ts"],
14
+ "exclude": ["node_modules", "dist"]
15
+ }