@aight-cool/aight-utils 0.1.0

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/src/push.ts ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Push Notifications — aight.push.register, aight.push.unregister, sendPush()
3
+ *
4
+ * Auth model: the relay has a master secret. During device registration, the
5
+ * plugin calls relay /register with the device push token. The relay returns
6
+ * a sendKey = HMAC(masterSecret, pushToken). The plugin stores the sendKey
7
+ * alongside the device token. When sending a push, the plugin includes the
8
+ * sendKey. The relay recomputes the HMAC and verifies — zero state, no shared secrets.
9
+ */
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+ import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
15
+ import type { AightConfig } from "./config.js";
16
+ import { DEFAULT_RELAY_URL, DEFAULT_PUSH_MODE } from "./defaults.js";
17
+
18
+ // ── Device Token Store ──
19
+
20
+ export interface DeviceToken {
21
+ deviceId: string;
22
+ pushToken: string;
23
+ platform: "ios" | "android";
24
+ sandbox?: boolean;
25
+ sendKey?: string;
26
+ registeredAt: string;
27
+ }
28
+
29
+ const TOKEN_DIR = path.join(os.homedir(), ".openclaw", "aight");
30
+ const TOKEN_FILE = path.join(TOKEN_DIR, "devices.json");
31
+
32
+ export function loadTokens(): DeviceToken[] {
33
+ try {
34
+ if (!fs.existsSync(TOKEN_FILE)) return [];
35
+ const raw = fs.readFileSync(TOKEN_FILE, "utf-8");
36
+ const parsed = JSON.parse(raw);
37
+ return Array.isArray(parsed) ? parsed : [];
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ export function saveTokens(tokens: DeviceToken[]): void {
44
+ fs.mkdirSync(TOKEN_DIR, { recursive: true, mode: 0o700 });
45
+ fs.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { encoding: "utf-8", mode: 0o600 });
46
+ }
47
+
48
+ export function registerToken(token: DeviceToken): void {
49
+ const tokens = loadTokens();
50
+ const idx = tokens.findIndex((t) => t.deviceId === token.deviceId);
51
+ if (idx >= 0) {
52
+ tokens[idx] = token;
53
+ } else {
54
+ tokens.push(token);
55
+ }
56
+ saveTokens(tokens);
57
+ }
58
+
59
+ export function unregisterToken(deviceId: string): boolean {
60
+ const tokens = loadTokens();
61
+ const filtered = tokens.filter((t) => t.deviceId !== deviceId);
62
+ if (filtered.length === tokens.length) return false;
63
+ saveTokens(filtered);
64
+ return true;
65
+ }
66
+
67
+ // ── Relay Registration (get sendKey) ──
68
+
69
+ async function obtainSendKey(relayUrl: string, pushToken: string): Promise<string> {
70
+ const res = await fetch(`${relayUrl}/register`, {
71
+ method: "POST",
72
+ headers: { "Content-Type": "application/json" },
73
+ body: JSON.stringify({ token: pushToken }),
74
+ });
75
+
76
+ if (!res.ok) {
77
+ const text = await res.text();
78
+ throw new Error(`Relay /register returned ${res.status}: ${text}`);
79
+ }
80
+
81
+ const data = (await res.json()) as { ok: boolean; sendKey: string };
82
+ if (!data.sendKey) throw new Error("Relay did not return a sendKey");
83
+ return data.sendKey;
84
+ }
85
+
86
+ // ── Push Sending ──
87
+
88
+ export interface PushPayload {
89
+ title?: string;
90
+ body?: string;
91
+ data?: Record<string, unknown>;
92
+ silent?: boolean;
93
+ }
94
+
95
+ export async function sendPush(
96
+ deviceId: string,
97
+ payload: PushPayload,
98
+ config: AightConfig,
99
+ ): Promise<{ ok: boolean; error?: string }> {
100
+ const tokens = loadTokens();
101
+ const device = tokens.find((t) => t.deviceId === deviceId);
102
+ if (!device) {
103
+ return { ok: false, error: `No device token for ${deviceId}` };
104
+ }
105
+ if (!device.sendKey) {
106
+ return { ok: false, error: `No sendKey for device ${deviceId} — re-register to obtain one` };
107
+ }
108
+
109
+ const relayUrl = config.push?.relayUrl ?? DEFAULT_RELAY_URL;
110
+ const mode = config.push?.mode ?? DEFAULT_PUSH_MODE;
111
+
112
+ const pushBody: Record<string, unknown> = {
113
+ token: device.pushToken,
114
+ sendKey: device.sendKey,
115
+ platform: device.platform,
116
+ sandbox: device.sandbox ?? false,
117
+ };
118
+
119
+ if (mode === "rich" && !payload.silent) {
120
+ pushBody.title = payload.title;
121
+ pushBody.body = payload.body;
122
+ }
123
+
124
+ if (payload.data) {
125
+ pushBody.data = payload.data;
126
+ }
127
+
128
+ try {
129
+ const res = await fetch(`${relayUrl}/send`, {
130
+ method: "POST",
131
+ headers: { "Content-Type": "application/json" },
132
+ body: JSON.stringify(pushBody),
133
+ });
134
+
135
+ if (!res.ok) {
136
+ const text = await res.text();
137
+ return { ok: false, error: `Relay returned ${res.status}: ${text}` };
138
+ }
139
+
140
+ return { ok: true };
141
+ } catch (err) {
142
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
143
+ }
144
+ }
145
+
146
+ // ── Registration ──
147
+
148
+ export function registerPush(api: OpenClawPluginApi, _config: AightConfig) {
149
+ api.registerGatewayMethod(
150
+ "aight.push.register",
151
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
152
+ if (
153
+ !params ||
154
+ typeof params !== "object" ||
155
+ typeof params.deviceId !== "string" ||
156
+ typeof params.pushToken !== "string" ||
157
+ (params.platform !== "ios" && params.platform !== "android")
158
+ ) {
159
+ respond(false, { error: "deviceId, pushToken, and platform (ios|android) required" });
160
+ return;
161
+ }
162
+
163
+ const deviceId = params.deviceId as string;
164
+ const pushToken = params.pushToken as string;
165
+ const platform = params.platform as "ios" | "android";
166
+ const sandbox = !!params.sandbox;
167
+
168
+ // Register immediately (respond fast)
169
+ registerToken({
170
+ deviceId,
171
+ pushToken,
172
+ platform,
173
+ sandbox,
174
+ registeredAt: new Date().toISOString(),
175
+ });
176
+
177
+ respond(true, { ok: true, deviceId });
178
+ api.logger.info(`[aight-utils] Push token registered for device ${deviceId}`);
179
+
180
+ // Obtain sendKey from relay in background
181
+ const relayUrl = _config.push?.relayUrl ?? DEFAULT_RELAY_URL;
182
+ obtainSendKey(relayUrl, pushToken)
183
+ .then((sendKey) => {
184
+ // Update the token with the sendKey
185
+ registerToken({
186
+ deviceId,
187
+ pushToken,
188
+ platform,
189
+ sandbox,
190
+ sendKey,
191
+ registeredAt: new Date().toISOString(),
192
+ });
193
+ api.logger.info(`[aight-utils] sendKey obtained for device ${deviceId}`);
194
+ })
195
+ .catch((err) => {
196
+ const msg = err instanceof Error ? err.message : String(err);
197
+ api.logger.warn(`[aight-utils] Failed to obtain sendKey: ${msg}`);
198
+ });
199
+ },
200
+ );
201
+
202
+ api.registerGatewayMethod(
203
+ "aight.push.unregister",
204
+ ({ params, respond }: GatewayRequestHandlerOptions) => {
205
+ const deviceId = typeof params?.deviceId === "string" ? params.deviceId : "";
206
+ if (!deviceId) {
207
+ respond(false, { error: "deviceId required" });
208
+ return;
209
+ }
210
+
211
+ const ok = unregisterToken(deviceId);
212
+ respond(true, { ok, deviceId });
213
+ },
214
+ );
215
+
216
+ api.registerGatewayMethod(
217
+ "aight.push.test",
218
+ async ({ params, respond }: GatewayRequestHandlerOptions) => {
219
+ const deviceId = typeof params?.deviceId === "string" ? params.deviceId : "";
220
+ const tokens = loadTokens();
221
+ const device = deviceId ? tokens.find((t) => t.deviceId === deviceId) : tokens[0];
222
+ if (!device) {
223
+ respond(false, { error: "No registered device" });
224
+ return;
225
+ }
226
+
227
+ // Test always sends a visible push regardless of privacy mode
228
+ const result = await sendPush(
229
+ device.deviceId,
230
+ {
231
+ title: "Aight 🤙",
232
+ body: "Push notifications are working!",
233
+ },
234
+ { ..._config, push: { ..._config.push, mode: "rich" } },
235
+ );
236
+
237
+ respond(true, result);
238
+ },
239
+ );
240
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Reminders Service — background service checking scheduled items every 30s
3
+ */
4
+
5
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
6
+ import type { AightConfig } from "./config.js";
7
+ import { loadItems, saveItems } from "./items.js";
8
+ import { sendPush, loadTokens } from "./push.js";
9
+
10
+ const CHECK_INTERVAL_MS = 30_000;
11
+
12
+ export function registerReminders(api: OpenClawPluginApi, config: AightConfig) {
13
+ let timer: ReturnType<typeof setInterval> | null = null;
14
+
15
+ async function checkReminders() {
16
+ const now = Date.now();
17
+ const items = loadItems();
18
+ let changed = false;
19
+
20
+ const dueItems = items.filter(
21
+ (i) =>
22
+ i.type === "trigger" &&
23
+ i.status === "active" &&
24
+ i.scheduledFor &&
25
+ new Date(i.scheduledFor).getTime() <= now,
26
+ );
27
+
28
+ if (dueItems.length === 0) return;
29
+
30
+ const tokens = loadTokens();
31
+
32
+ for (const item of dueItems) {
33
+ item.status = "fired";
34
+ item.updatedAt = new Date().toISOString();
35
+ changed = true;
36
+
37
+ api.logger.info(`[aight-utils] Trigger fired: ${item.title} (${item.id})`);
38
+
39
+ for (const device of tokens) {
40
+ try {
41
+ await sendPush(
42
+ device.deviceId,
43
+ {
44
+ title: "Reminder",
45
+ body: item.title,
46
+ data: { itemId: item.id, type: "trigger" },
47
+ },
48
+ config,
49
+ );
50
+ } catch (err) {
51
+ api.logger.error(
52
+ `[aight-utils] Push failed for ${device.deviceId}: ${
53
+ err instanceof Error ? err.message : String(err)
54
+ }`,
55
+ );
56
+ }
57
+ }
58
+ }
59
+
60
+ if (changed) {
61
+ saveItems(items);
62
+ }
63
+ }
64
+
65
+ api.registerService({
66
+ id: "aight-reminders",
67
+ start: () => {
68
+ if (config.today?.enabled === false) {
69
+ api.logger.info("[aight-utils] Today view disabled, skipping reminders service");
70
+ return;
71
+ }
72
+ timer = setInterval(() => {
73
+ checkReminders().catch((err) => {
74
+ api.logger.error(
75
+ `[aight-utils] Reminder check error: ${
76
+ err instanceof Error ? err.message : String(err)
77
+ }`,
78
+ );
79
+ });
80
+ }, CHECK_INTERVAL_MS);
81
+ api.logger.info("[aight-utils] Reminders service started (30s interval)");
82
+ },
83
+ stop: () => {
84
+ if (timer) {
85
+ clearInterval(timer);
86
+ timer = null;
87
+ }
88
+ api.logger.info("[aight-utils] Reminders service stopped");
89
+ },
90
+ });
91
+ }
package/src/version.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Version RPC — aight.version
3
+ *
4
+ * Returns current installed version and latest available on npm.
5
+ */
6
+
7
+ import type { OpenClawPluginApi, GatewayRequestHandlerOptions } from "openclaw/plugin-sdk";
8
+ import { readFileSync } from "node:fs";
9
+ import { join, dirname } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ let cachedVersion: string | null = null;
13
+ let cachedLatest: { version: string; checkedAt: number } | null = null;
14
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
15
+
16
+ function getCurrentVersion(): string {
17
+ if (cachedVersion) return cachedVersion;
18
+ try {
19
+ const dir = dirname(fileURLToPath(import.meta.url));
20
+ const pkg = JSON.parse(readFileSync(join(dir, "..", "package.json"), "utf8"));
21
+ cachedVersion = pkg.version ?? "unknown";
22
+ } catch {
23
+ cachedVersion = "unknown";
24
+ }
25
+ return cachedVersion!;
26
+ }
27
+
28
+ async function getLatestVersion(): Promise<string> {
29
+ if (cachedLatest && Date.now() - cachedLatest.checkedAt < CACHE_TTL_MS) {
30
+ return cachedLatest.version;
31
+ }
32
+ try {
33
+ const res = await fetch("https://registry.npmjs.org/@aight-cool/aight-utils/latest", {
34
+ headers: { accept: "application/json" },
35
+ signal: AbortSignal.timeout(5000),
36
+ });
37
+ if (!res.ok) return "unknown";
38
+ const data = (await res.json()) as { version?: string };
39
+ const version = data.version ?? "unknown";
40
+ cachedLatest = { version, checkedAt: Date.now() };
41
+ return version;
42
+ } catch {
43
+ return cachedLatest?.version ?? "unknown";
44
+ }
45
+ }
46
+
47
+ export function registerVersion(api: OpenClawPluginApi) {
48
+ api.registerGatewayMethod("aight.version", async ({ respond }: GatewayRequestHandlerOptions) => {
49
+ try {
50
+ const current = getCurrentVersion();
51
+ const latest = await getLatestVersion();
52
+ respond(true, {
53
+ current,
54
+ latest,
55
+ updateAvailable: latest !== "unknown" && current !== latest,
56
+ });
57
+ } catch (err) {
58
+ respond(false, {
59
+ error: err instanceof Error ? err.message : String(err),
60
+ });
61
+ }
62
+ });
63
+ }