opencody-notify 0.2.0 → 0.3.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/README.md +18 -2
- package/dist/index.d.ts +73 -1
- package/dist/index.js +59 -9
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,11 +6,13 @@ OpenCode **V2** server plugin that sends end-to-end encrypted push notifications
|
|
|
6
6
|
- OpenCode asks for a permission (Allow once / Always / Deny right from the notification),
|
|
7
7
|
- the agent asks a question (form).
|
|
8
8
|
|
|
9
|
+
It also lets OpenCody send a test notification through the server (since 0.3.0), so the app can check the whole path: OpenCode server → relay → APNs → device.
|
|
10
|
+
|
|
9
11
|
The push goes through the OpenCody relay; the relay only sees ciphertext. No runtime dependencies.
|
|
10
12
|
|
|
11
13
|
## 1. Get a pairing string
|
|
12
14
|
|
|
13
|
-
OpenCody → server settings → *Push notifications* → *Pair*. Copy the single-line pairing string. It is a credential: treat it like a password. One string per iPhone.
|
|
15
|
+
OpenCody → server settings → *Push notifications* → *Pair*. Copy the single-line pairing string. It is a credential: treat it like a password. One string per device (iPhone or iPad).
|
|
14
16
|
|
|
15
17
|
## 2. Install the plugin (self-hosted V2 server)
|
|
16
18
|
|
|
@@ -27,7 +29,7 @@ This adds it to the global config (`~/.config/opencode/opencode.jsonc`, applies
|
|
|
27
29
|
"package": "opencody-notify",
|
|
28
30
|
"options": {
|
|
29
31
|
"label": "homelab", // shown in the notification; default: hostname
|
|
30
|
-
"pairings": ["<pairing string
|
|
32
|
+
"pairings": ["<pairing string device 1>", "<pairing string device 2>"] // optional, see env below
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
]
|
|
@@ -82,6 +84,20 @@ The server must be able to reach `https://push.willsoftwaresolutions.de` (outbou
|
|
|
82
84
|
- Each push carries a dedupe key (`<sessionID>@<idle time>` or `req:<requestID>`) inside the ciphertext and a SHA-256 prefix of it as APNs collapse id, so OpenCody shows one notification per event even when the app also notices it live.
|
|
83
85
|
- Relay answers `401`/`410` mean the pairing was removed: re-pair in OpenCody and replace the string.
|
|
84
86
|
|
|
87
|
+
## 5. Test notification (OpenCody → server → device)
|
|
88
|
+
|
|
89
|
+
OpenCody's *Send Test Notification* calls the plugin through OpenCode's plugin RPC route (OpenCode 2.1 or later), behind the server's normal authentication:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
POST /api/rpc/opencody-notify/test {"input": {"deviceId": "<deviceId from the pairing string>"}}
|
|
93
|
+
→ 200 {"output": {"server": "<label>"}} push sent, relay accepted it
|
|
94
|
+
→ 400 {"type": "not_paired", "data": {"pairings": <n>}, …} no pairing for this device on this server
|
|
95
|
+
→ 400 {"type": "relay_failed", "data": {"status": <relay status, 0 = unreachable>}, …}
|
|
96
|
+
→ 400 {"type": "rpc.unavailable", …} plugin not loaded, or older than 0.3.0
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
It sends one encrypted message of type `test` to that device only. No session, no LLM call, nothing stored on the server. The route is answered at the server's default location (its working directory), so install the plugin globally (`opencode plugin add`) for the test to find it.
|
|
100
|
+
|
|
85
101
|
## Development
|
|
86
102
|
|
|
87
103
|
Protocol: `SPEC.md` in the source repository.
|
package/dist/index.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ interface SessionInfo {
|
|
|
19
19
|
idle?: number;
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
interface RpcCallContext {
|
|
23
|
+
error(type: string, message: string, data?: unknown): unknown;
|
|
24
|
+
}
|
|
22
25
|
export interface PluginContext {
|
|
23
26
|
options?: Record<string, unknown>;
|
|
24
27
|
location?: {
|
|
@@ -34,7 +37,75 @@ export interface PluginContext {
|
|
|
34
37
|
sessionID: string;
|
|
35
38
|
}): Promise<SessionInfo>;
|
|
36
39
|
};
|
|
40
|
+
/** OpenCode serves registered methods at `POST /api/rpc/<id>/<method>` (2.1+). */
|
|
41
|
+
rpc?: {
|
|
42
|
+
register(definition: object, handlers: Record<string, (input: any, context: RpcCallContext) => Promise<unknown>>): Promise<{
|
|
43
|
+
dispose(): Promise<void>;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
37
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
|
|
49
|
+
* device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
|
|
50
|
+
*/
|
|
51
|
+
export declare const RPC: {
|
|
52
|
+
readonly id: "opencody-notify";
|
|
53
|
+
readonly methods: {
|
|
54
|
+
readonly test: {
|
|
55
|
+
readonly input: {
|
|
56
|
+
readonly type: "object";
|
|
57
|
+
readonly properties: {
|
|
58
|
+
readonly deviceId: {
|
|
59
|
+
readonly type: "string";
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
readonly required: readonly ["deviceId"];
|
|
63
|
+
};
|
|
64
|
+
readonly output: {
|
|
65
|
+
readonly type: "object";
|
|
66
|
+
readonly properties: {
|
|
67
|
+
readonly server: {
|
|
68
|
+
readonly type: "string";
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
readonly required: readonly ["server"];
|
|
72
|
+
};
|
|
73
|
+
readonly errors: {
|
|
74
|
+
readonly not_paired: {
|
|
75
|
+
readonly type: "object";
|
|
76
|
+
readonly properties: {
|
|
77
|
+
readonly pairings: {
|
|
78
|
+
readonly type: "number";
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
readonly required: readonly ["pairings"];
|
|
82
|
+
};
|
|
83
|
+
readonly relay_failed: {
|
|
84
|
+
readonly type: "object";
|
|
85
|
+
readonly properties: {
|
|
86
|
+
readonly status: {
|
|
87
|
+
readonly type: "number";
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
readonly required: readonly ["status"];
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
readonly events: {};
|
|
96
|
+
};
|
|
97
|
+
export type TestResult = {
|
|
98
|
+
ok: true;
|
|
99
|
+
} | {
|
|
100
|
+
ok: false;
|
|
101
|
+
error: "not_paired";
|
|
102
|
+
pairings: number;
|
|
103
|
+
} | {
|
|
104
|
+
ok: false;
|
|
105
|
+
error: "relay_failed";
|
|
106
|
+
status: number;
|
|
107
|
+
message: string;
|
|
108
|
+
};
|
|
38
109
|
export interface NotifierOptions {
|
|
39
110
|
pairings: Pairing[];
|
|
40
111
|
label: string;
|
|
@@ -47,6 +118,7 @@ export interface NotifierOptions {
|
|
|
47
118
|
}
|
|
48
119
|
export declare function createNotifier(options: NotifierOptions): {
|
|
49
120
|
onEvent(event: OpenCodeEvent): Promise<void>;
|
|
121
|
+
sendTest(deviceId: string): Promise<TestResult>;
|
|
50
122
|
};
|
|
51
123
|
export declare function loadConfig(options?: Record<string, unknown>, env?: Record<string, string | undefined>): {
|
|
52
124
|
pairings: Pairing[];
|
|
@@ -55,6 +127,6 @@ export declare function loadConfig(options?: Record<string, unknown>, env?: Reco
|
|
|
55
127
|
};
|
|
56
128
|
declare const _default: {
|
|
57
129
|
id: string;
|
|
58
|
-
setup(ctx: PluginContext): (
|
|
130
|
+
setup(ctx: PluginContext): () => void;
|
|
59
131
|
};
|
|
60
132
|
export default _default;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import { hostname } from "node:os";
|
|
2
2
|
import { CATEGORY, collapseId, encodeMessage, parsePairing, seal, splitPairings } from "./protocol.js";
|
|
3
|
+
/**
|
|
4
|
+
* `POST /api/rpc/opencody-notify/test {"input":{"deviceId":"…"}}` sends a test push to that
|
|
5
|
+
* device's pairing, so OpenCody can check the whole path server → relay → APNs → device.
|
|
6
|
+
*/
|
|
7
|
+
export const RPC = {
|
|
8
|
+
id: "opencody-notify",
|
|
9
|
+
methods: {
|
|
10
|
+
test: {
|
|
11
|
+
input: { type: "object", properties: { deviceId: { type: "string" } }, required: ["deviceId"] },
|
|
12
|
+
output: { type: "object", properties: { server: { type: "string" } }, required: ["server"] },
|
|
13
|
+
errors: {
|
|
14
|
+
// Error data is required: OpenCode validates it against these schemas.
|
|
15
|
+
not_paired: { type: "object", properties: { pairings: { type: "number" } }, required: ["pairings"] },
|
|
16
|
+
relay_failed: { type: "object", properties: { status: { type: "number" } }, required: ["status"] },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
events: {},
|
|
21
|
+
};
|
|
3
22
|
const SKIPPED_INTERRUPTS = new Set(["user", "superseded"]);
|
|
4
23
|
/**
|
|
5
24
|
* The session's `time.idle` for this run end, which OpenCody's catch-up keys on. OpenCode sets
|
|
@@ -113,10 +132,11 @@ export function createNotifier(options) {
|
|
|
113
132
|
}),
|
|
114
133
|
signal: AbortSignal.timeout(timeoutMs),
|
|
115
134
|
});
|
|
116
|
-
if (res.ok)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
const hint = res.status === 401 || res.status === 410 ? " (pairing revoked or invalid, re-pair in OpenCody)" : "";
|
|
137
|
+
log(`relay answered ${res.status} for device ${pairing.deviceId.slice(0, 6)}…${hint}`);
|
|
138
|
+
}
|
|
139
|
+
return res.status;
|
|
120
140
|
}
|
|
121
141
|
return {
|
|
122
142
|
async onEvent(event) {
|
|
@@ -133,6 +153,19 @@ export function createNotifier(options) {
|
|
|
133
153
|
log(`event handling failed: ${error?.message}`);
|
|
134
154
|
}
|
|
135
155
|
},
|
|
156
|
+
async sendTest(deviceId) {
|
|
157
|
+
const pairing = pairings.find((p) => p.deviceId === deviceId);
|
|
158
|
+
if (!pairing)
|
|
159
|
+
return { ok: false, error: "not_paired", pairings: pairings.length };
|
|
160
|
+
const message = { v: 1, type: "test", server: label, sessionID: "", title: "OpenCody", text: "Test notification from your server. Push works.", ts: now() };
|
|
161
|
+
try {
|
|
162
|
+
const status = await post(pairing, message);
|
|
163
|
+
return status >= 200 && status < 300 ? { ok: true } : { ok: false, error: "relay_failed", status, message: `relay answered ${status}` };
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return { ok: false, error: "relay_failed", status: 0, message: `relay not reachable: ${error?.message}` };
|
|
167
|
+
}
|
|
168
|
+
},
|
|
136
169
|
};
|
|
137
170
|
}
|
|
138
171
|
export function loadConfig(options = {}, env = process.env) {
|
|
@@ -157,16 +190,30 @@ export default {
|
|
|
157
190
|
const { pairings, label, errors } = loadConfig(ctx.options);
|
|
158
191
|
for (const error of errors)
|
|
159
192
|
log(`ignoring pairing: ${error}`);
|
|
160
|
-
if (!pairings.length) {
|
|
161
|
-
log("no valid pairing configured (OPENCODY_PAIRING or plugin option `pairings`); push disabled");
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
193
|
const notifier = createNotifier({
|
|
165
194
|
pairings,
|
|
166
195
|
label,
|
|
167
196
|
log,
|
|
168
197
|
getSession: (sessionID) => ctx.session.get({ sessionID }),
|
|
169
198
|
});
|
|
199
|
+
// Registered even without pairings, so OpenCody can tell "plugin missing" from "this device's pairing missing".
|
|
200
|
+
const registration = ctx.rpc
|
|
201
|
+
?.register(RPC, {
|
|
202
|
+
test: async ({ deviceId }, { error }) => {
|
|
203
|
+
const result = await notifier.sendTest(deviceId);
|
|
204
|
+
if (result.ok)
|
|
205
|
+
return { server: label };
|
|
206
|
+
if (result.error === "not_paired")
|
|
207
|
+
return error("not_paired", `no pairing for this device on "${label}" (${result.pairings} configured)`, { pairings: result.pairings });
|
|
208
|
+
return error("relay_failed", result.message, { status: result.status });
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
.catch((error) => log(`test endpoint unavailable: ${error?.message}`));
|
|
212
|
+
const unregister = () => void registration?.then((r) => r?.dispose()).catch(() => { });
|
|
213
|
+
if (!pairings.length) {
|
|
214
|
+
log("no valid pairing configured (OPENCODY_PAIRING or plugin option `pairings`); push disabled");
|
|
215
|
+
return unregister;
|
|
216
|
+
}
|
|
170
217
|
const debug = process.env.OPENCODY_DEBUG === "1";
|
|
171
218
|
const controller = new AbortController();
|
|
172
219
|
void (async () => {
|
|
@@ -189,6 +236,9 @@ export default {
|
|
|
189
236
|
}
|
|
190
237
|
})();
|
|
191
238
|
log(`push enabled for ${pairings.length} device(s) as "${label}"`);
|
|
192
|
-
return () =>
|
|
239
|
+
return () => {
|
|
240
|
+
controller.abort();
|
|
241
|
+
unregister();
|
|
242
|
+
};
|
|
193
243
|
},
|
|
194
244
|
};
|
package/dist/protocol.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface Pairing {
|
|
|
5
5
|
secret: string;
|
|
6
6
|
key: string;
|
|
7
7
|
}
|
|
8
|
-
export type PushType = "session.finished" | "session.failed" | "permission.asked" | "form.asked";
|
|
8
|
+
export type PushType = "session.finished" | "session.failed" | "permission.asked" | "form.asked" | "test";
|
|
9
9
|
export type Category = "OC_SESSION" | "OC_PERMISSION" | "OC_FORM";
|
|
10
10
|
export interface Message {
|
|
11
11
|
v: 1;
|
package/dist/protocol.js
CHANGED
|
@@ -4,6 +4,7 @@ export const CATEGORY = {
|
|
|
4
4
|
"session.failed": "OC_SESSION",
|
|
5
5
|
"permission.asked": "OC_PERMISSION",
|
|
6
6
|
"form.asked": "OC_FORM",
|
|
7
|
+
test: "OC_SESSION",
|
|
7
8
|
};
|
|
8
9
|
export const MAX_PLAINTEXT = 2048;
|
|
9
10
|
/** APNs collapse id for a dedupe key: hashed, so the relay and Apple never see session or request IDs. */
|
package/package.json
CHANGED