nofax 0.2.0 → 0.2.2

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/ntfy.mjs CHANGED
@@ -1,259 +1,259 @@
1
- import { createRequestId, createResponseTopic, parseResponseMessage } from './protocol.mjs';
2
-
3
- const MAX_TITLE = 120;
4
- const MAX_MESSAGE = 2200;
5
- const DEFAULT_REFINE_SHORTCUT = 'Nofax Refine';
6
-
7
- function boundText(value, max, name) {
8
- if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`NOFAX_${name}_REQUIRED`);
9
- const text = value.trim();
10
- return text.length <= max ? text : `${text.slice(0, max - 14)}…[truncated]`;
11
- }
12
-
13
- function sleep(ms) {
14
- return new Promise((resolve) => setTimeout(resolve, ms));
15
- }
16
-
17
- function httpAction({ label, server, responseTopic, requestId, decision }) {
18
- return {
19
- action: 'http',
20
- label,
21
- url: `${server}/${responseTopic}`,
22
- method: 'POST',
23
- body: JSON.stringify({ v: 1, requestId, decision }),
24
- clear: true
25
- };
26
- }
27
-
28
- export function buildRefineShortcutUrl({ shortcutName = DEFAULT_REFINE_SHORTCUT, server, responseTopic, requestId }) {
29
- const payload = JSON.stringify({
30
- v: 1,
31
- requestId,
32
- callbackUrl: `${server}/${responseTopic}`
33
- });
34
- const url = new URL('shortcuts://run-shortcut');
35
- url.searchParams.set('name', shortcutName);
36
- url.searchParams.set('input', 'text');
37
- url.searchParams.set('text', payload);
38
- return url.toString();
39
- }
40
-
41
- function refineAction({ shortcutName, server, responseTopic, requestId }) {
42
- return {
43
- action: 'view',
44
- label: 'Refine',
45
- url: buildRefineShortcutUrl({ shortcutName, server, responseTopic, requestId }),
46
- clear: true
47
- };
48
- }
49
-
50
- async function ensureOk(response, code) {
51
- if (!response?.ok) throw new Error(`${code}_${response?.status ?? 'NETWORK'}`);
52
- return response;
53
- }
54
-
55
- export async function sendNotification({ config, title, message, actions, priority = 4, tags = ['bell'], fetchImpl = fetch }) {
56
- const payload = {
57
- topic: config.topic,
58
- title: boundText(title, MAX_TITLE, 'TITLE'),
59
- message: boundText(message, MAX_MESSAGE, 'MESSAGE'),
60
- priority,
61
- tags
62
- };
63
- if (actions?.length) payload.actions = actions;
64
-
65
- let response;
66
- try {
67
- response = await fetchImpl(`${config.server}/`, {
68
- method: 'POST',
69
- headers: { 'content-type': 'application/json' },
70
- body: JSON.stringify(payload)
71
- });
72
- } catch (error) {
73
- throw new Error(`NOFAX_NTFY_PUBLISH_NETWORK: ${error?.message ?? String(error)}`);
74
- }
75
- await ensureOk(response, 'NOFAX_NTFY_PUBLISH');
76
- }
77
-
78
- function normalizeOptions(options) {
79
- if (!Array.isArray(options) || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
80
- const normalized = options.map((option) => {
81
- if (typeof option === 'string') return { value: option, label: option };
82
- if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
83
- const value = option.value.trim();
84
- const label = option.label.trim();
85
- if (!value || !label || value.length > 80) throw new Error('NOFAX_CHOICE_INVALID');
86
- return { value, label: label.slice(0, 32) };
87
- });
88
- if (new Set(normalized.map((option) => option.value)).size !== normalized.length) throw new Error('NOFAX_CHOICE_DUPLICATE');
89
- return normalized;
90
- }
91
-
92
- export async function createRemoteRequest({
93
- config,
94
- title,
95
- message,
96
- options = [],
97
- includeRefine = false,
98
- shortcutName = config.refineShortcutName ?? DEFAULT_REFINE_SHORTCUT,
99
- fetchImpl = fetch
100
- }) {
101
- const normalized = normalizeOptions(options);
102
- const totalActions = normalized.length + (includeRefine ? 1 : 0);
103
- if (totalActions < 1 || totalActions > 3) throw new Error('NOFAX_CHOICE_LIMIT');
104
-
105
- const requestId = createRequestId();
106
- const responseTopic = createResponseTopic();
107
- const actions = [];
108
- const allowed = [];
109
-
110
- if (includeRefine && normalized.length === 2) {
111
- const [first, second] = normalized;
112
- actions.push(httpAction({ label: first.label, server: config.server, responseTopic, requestId, decision: first.value }));
113
- allowed.push(first.value);
114
- actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
115
- allowed.push('refine');
116
- actions.push(httpAction({ label: second.label, server: config.server, responseTopic, requestId, decision: second.value }));
117
- allowed.push(second.value);
118
- } else {
119
- for (const option of normalized) {
120
- actions.push(httpAction({ label: option.label, server: config.server, responseTopic, requestId, decision: option.value }));
121
- allowed.push(option.value);
122
- }
123
- if (includeRefine) {
124
- actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
125
- allowed.push('refine');
126
- }
127
- }
128
-
129
- await sendNotification({ config, title, message, actions, fetchImpl });
130
- return { requestId, responseTopic, allowed };
131
- }
132
-
133
- function parseNtfyPoll(text, requestId, allowed) {
134
- for (const line of text.split(/\r?\n/)) {
135
- if (!line.trim()) continue;
136
- let event;
137
- try {
138
- event = JSON.parse(line);
139
- } catch {
140
- continue;
141
- }
142
- if (event?.event !== 'message') continue;
143
- const response = parseResponseMessage(event.message, { requestId, allowed });
144
- if (response !== null) return response;
145
- }
146
- return null;
147
- }
148
-
149
- export async function pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl = fetch }) {
150
- let response;
151
- try {
152
- response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=10m`, {
153
- method: 'GET',
154
- headers: { accept: 'application/x-ndjson' }
155
- });
156
- } catch (error) {
157
- throw new Error(`NOFAX_NTFY_POLL_NETWORK: ${error?.message ?? String(error)}`);
158
- }
159
- await ensureOk(response, 'NOFAX_NTFY_POLL');
160
- return parseNtfyPoll(await response.text(), requestId, allowed);
161
- }
162
-
163
- export async function sendResponseConfirmation({ config, response, title = 'Nofax', fetchImpl = fetch }) {
164
- let confirmationTitle = 'Response received';
165
- let tag = 'white_check_mark';
166
- if (response.decision === 'allow') confirmationTitle = 'Approved';
167
- else if (response.decision === 'deny') {
168
- confirmationTitle = 'Denied';
169
- tag = 'no_entry';
170
- } else if (response.decision === 'refine') confirmationTitle = 'Refinement received';
171
- else confirmationTitle = 'Choice received';
172
-
173
- try {
174
- await sendNotification({
175
- config,
176
- title: `${confirmationTitle} - ${title}`,
177
- message: response.decision === 'refine'
178
- ? 'Your refinement was sent back to the agent.'
179
- : `Nofax recorded: ${response.decision}`,
180
- priority: 2,
181
- tags: [tag],
182
- fetchImpl
183
- });
184
- return true;
185
- } catch {
186
- return false;
187
- }
188
- }
189
-
190
- export async function waitRemoteResponse({
191
- config,
192
- responseTopic,
193
- requestId,
194
- allowed,
195
- timeoutMs,
196
- pollIntervalMs = 1000,
197
- fetchImpl = fetch,
198
- nowImpl = Date.now,
199
- sleepImpl = sleep
200
- }) {
201
- const deadline = nowImpl() + timeoutMs;
202
- while (nowImpl() < deadline) {
203
- const response = await pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl });
204
- if (response !== null) return response;
205
- const remaining = deadline - nowImpl();
206
- if (remaining <= 0) break;
207
- await sleepImpl(Math.min(pollIntervalMs, remaining));
208
- }
209
- return null;
210
- }
211
-
212
- async function requestDecision({
213
- config,
214
- title,
215
- message,
216
- options,
217
- includeRefine = false,
218
- fetchImpl = fetch,
219
- timeoutMs = config.timeoutSeconds * 1000,
220
- pollIntervalMs = 1000,
221
- nowImpl = Date.now,
222
- sleepImpl = sleep
223
- }) {
224
- const remote = await createRemoteRequest({ config, title, message, options, includeRefine, fetchImpl });
225
- const response = await waitRemoteResponse({
226
- config,
227
- ...remote,
228
- timeoutMs,
229
- pollIntervalMs,
230
- fetchImpl,
231
- nowImpl,
232
- sleepImpl
233
- });
234
- if (response === null) {
235
- return { decision: 'timeout', requestId: remote.requestId, responseTopic: remote.responseTopic };
236
- }
237
- await sendResponseConfirmation({ config, response, title, fetchImpl });
238
- return { ...response, requestId: remote.requestId, responseTopic: remote.responseTopic };
239
- }
240
-
241
- export async function requestApproval(options) {
242
- return requestDecision({
243
- ...options,
244
- options: [
245
- { value: 'allow', label: 'Allow' },
246
- { value: 'deny', label: 'Deny' }
247
- ]
248
- });
249
- }
250
-
251
- export async function requestRefinement(options) {
252
- return requestDecision({ ...options, options: [], includeRefine: true });
253
- }
254
-
255
- export async function requestChoice({ options, ...rest }) {
256
- const normalized = normalizeOptions(options);
257
- if (normalized.length < 1 || normalized.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
258
- return requestDecision({ ...rest, options: normalized });
259
- }
1
+ import { createRequestId, createResponseTopic, parseResponseMessage } from './protocol.mjs';
2
+
3
+ const MAX_TITLE = 120;
4
+ const MAX_MESSAGE = 2200;
5
+ const DEFAULT_REFINE_SHORTCUT = 'Nofax Refine';
6
+
7
+ function boundText(value, max, name) {
8
+ if (typeof value !== 'string' || value.trim().length === 0) throw new Error(`NOFAX_${name}_REQUIRED`);
9
+ const text = value.trim();
10
+ return text.length <= max ? text : `${text.slice(0, max - 14)}…[truncated]`;
11
+ }
12
+
13
+ function sleep(ms) {
14
+ return new Promise((resolve) => setTimeout(resolve, ms));
15
+ }
16
+
17
+ function httpAction({ label, server, responseTopic, requestId, decision }) {
18
+ return {
19
+ action: 'http',
20
+ label,
21
+ url: `${server}/${responseTopic}`,
22
+ method: 'POST',
23
+ body: JSON.stringify({ v: 1, requestId, decision }),
24
+ clear: true
25
+ };
26
+ }
27
+
28
+ export function buildRefineShortcutUrl({ shortcutName = DEFAULT_REFINE_SHORTCUT, server, responseTopic, requestId }) {
29
+ const payload = JSON.stringify({
30
+ v: 1,
31
+ requestId,
32
+ callbackUrl: `${server}/${responseTopic}`
33
+ });
34
+ const url = new URL('shortcuts://run-shortcut');
35
+ url.searchParams.set('name', shortcutName);
36
+ url.searchParams.set('input', 'text');
37
+ url.searchParams.set('text', payload);
38
+ return url.toString();
39
+ }
40
+
41
+ function refineAction({ shortcutName, server, responseTopic, requestId }) {
42
+ return {
43
+ action: 'view',
44
+ label: 'Refine',
45
+ url: buildRefineShortcutUrl({ shortcutName, server, responseTopic, requestId }),
46
+ clear: true
47
+ };
48
+ }
49
+
50
+ async function ensureOk(response, code) {
51
+ if (!response?.ok) throw new Error(`${code}_${response?.status ?? 'NETWORK'}`);
52
+ return response;
53
+ }
54
+
55
+ export async function sendNotification({ config, title, message, actions, priority = 4, tags = ['bell'], fetchImpl = fetch }) {
56
+ const payload = {
57
+ topic: config.topic,
58
+ title: boundText(title, MAX_TITLE, 'TITLE'),
59
+ message: boundText(message, MAX_MESSAGE, 'MESSAGE'),
60
+ priority,
61
+ tags
62
+ };
63
+ if (actions?.length) payload.actions = actions;
64
+
65
+ let response;
66
+ try {
67
+ response = await fetchImpl(`${config.server}/`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify(payload)
71
+ });
72
+ } catch (error) {
73
+ throw new Error(`NOFAX_NTFY_PUBLISH_NETWORK: ${error?.message ?? String(error)}`);
74
+ }
75
+ await ensureOk(response, 'NOFAX_NTFY_PUBLISH');
76
+ }
77
+
78
+ function normalizeOptions(options) {
79
+ if (!Array.isArray(options) || options.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
80
+ const normalized = options.map((option) => {
81
+ if (typeof option === 'string') return { value: option, label: option };
82
+ if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') throw new Error('NOFAX_CHOICE_INVALID');
83
+ const value = option.value.trim();
84
+ const label = option.label.trim();
85
+ if (!value || !label || value.length > 80) throw new Error('NOFAX_CHOICE_INVALID');
86
+ return { value, label: label.slice(0, 32) };
87
+ });
88
+ if (new Set(normalized.map((option) => option.value)).size !== normalized.length) throw new Error('NOFAX_CHOICE_DUPLICATE');
89
+ return normalized;
90
+ }
91
+
92
+ export async function createRemoteRequest({
93
+ config,
94
+ title,
95
+ message,
96
+ options = [],
97
+ includeRefine = false,
98
+ shortcutName = config.refineShortcutName ?? DEFAULT_REFINE_SHORTCUT,
99
+ fetchImpl = fetch
100
+ }) {
101
+ const normalized = normalizeOptions(options);
102
+ const totalActions = normalized.length + (includeRefine ? 1 : 0);
103
+ if (totalActions < 1 || totalActions > 3) throw new Error('NOFAX_CHOICE_LIMIT');
104
+
105
+ const requestId = createRequestId();
106
+ const responseTopic = createResponseTopic();
107
+ const actions = [];
108
+ const allowed = [];
109
+
110
+ if (includeRefine && normalized.length === 2) {
111
+ const [first, second] = normalized;
112
+ actions.push(httpAction({ label: first.label, server: config.server, responseTopic, requestId, decision: first.value }));
113
+ allowed.push(first.value);
114
+ actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
115
+ allowed.push('refine');
116
+ actions.push(httpAction({ label: second.label, server: config.server, responseTopic, requestId, decision: second.value }));
117
+ allowed.push(second.value);
118
+ } else {
119
+ for (const option of normalized) {
120
+ actions.push(httpAction({ label: option.label, server: config.server, responseTopic, requestId, decision: option.value }));
121
+ allowed.push(option.value);
122
+ }
123
+ if (includeRefine) {
124
+ actions.push(refineAction({ shortcutName, server: config.server, responseTopic, requestId }));
125
+ allowed.push('refine');
126
+ }
127
+ }
128
+
129
+ await sendNotification({ config, title, message, actions, fetchImpl });
130
+ return { requestId, responseTopic, allowed };
131
+ }
132
+
133
+ function parseNtfyPoll(text, requestId, allowed) {
134
+ for (const line of text.split(/\r?\n/)) {
135
+ if (!line.trim()) continue;
136
+ let event;
137
+ try {
138
+ event = JSON.parse(line);
139
+ } catch {
140
+ continue;
141
+ }
142
+ if (event?.event !== 'message') continue;
143
+ const response = parseResponseMessage(event.message, { requestId, allowed });
144
+ if (response !== null) return response;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ export async function pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl = fetch }) {
150
+ let response;
151
+ try {
152
+ response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=10m`, {
153
+ method: 'GET',
154
+ headers: { accept: 'application/x-ndjson' }
155
+ });
156
+ } catch (error) {
157
+ throw new Error(`NOFAX_NTFY_POLL_NETWORK: ${error?.message ?? String(error)}`);
158
+ }
159
+ await ensureOk(response, 'NOFAX_NTFY_POLL');
160
+ return parseNtfyPoll(await response.text(), requestId, allowed);
161
+ }
162
+
163
+ export async function sendResponseConfirmation({ config, response, title = 'Nofax', fetchImpl = fetch }) {
164
+ let confirmationTitle = 'Response received';
165
+ let tag = 'white_check_mark';
166
+ if (response.decision === 'allow') confirmationTitle = 'Approved';
167
+ else if (response.decision === 'deny') {
168
+ confirmationTitle = 'Denied';
169
+ tag = 'no_entry';
170
+ } else if (response.decision === 'refine') confirmationTitle = 'Refinement received';
171
+ else confirmationTitle = 'Choice received';
172
+
173
+ try {
174
+ await sendNotification({
175
+ config,
176
+ title: `${confirmationTitle} - ${title}`,
177
+ message: response.decision === 'refine'
178
+ ? 'Your refinement was sent back to the agent.'
179
+ : `Nofax recorded: ${response.decision}`,
180
+ priority: 2,
181
+ tags: [tag],
182
+ fetchImpl
183
+ });
184
+ return true;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ export async function waitRemoteResponse({
191
+ config,
192
+ responseTopic,
193
+ requestId,
194
+ allowed,
195
+ timeoutMs,
196
+ pollIntervalMs = 1000,
197
+ fetchImpl = fetch,
198
+ nowImpl = Date.now,
199
+ sleepImpl = sleep
200
+ }) {
201
+ const deadline = nowImpl() + timeoutMs;
202
+ while (nowImpl() < deadline) {
203
+ const response = await pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl });
204
+ if (response !== null) return response;
205
+ const remaining = deadline - nowImpl();
206
+ if (remaining <= 0) break;
207
+ await sleepImpl(Math.min(pollIntervalMs, remaining));
208
+ }
209
+ return null;
210
+ }
211
+
212
+ async function requestDecision({
213
+ config,
214
+ title,
215
+ message,
216
+ options,
217
+ includeRefine = false,
218
+ fetchImpl = fetch,
219
+ timeoutMs = config.timeoutSeconds * 1000,
220
+ pollIntervalMs = 1000,
221
+ nowImpl = Date.now,
222
+ sleepImpl = sleep
223
+ }) {
224
+ const remote = await createRemoteRequest({ config, title, message, options, includeRefine, fetchImpl });
225
+ const response = await waitRemoteResponse({
226
+ config,
227
+ ...remote,
228
+ timeoutMs,
229
+ pollIntervalMs,
230
+ fetchImpl,
231
+ nowImpl,
232
+ sleepImpl
233
+ });
234
+ if (response === null) {
235
+ return { decision: 'timeout', requestId: remote.requestId, responseTopic: remote.responseTopic };
236
+ }
237
+ await sendResponseConfirmation({ config, response, title, fetchImpl });
238
+ return { ...response, requestId: remote.requestId, responseTopic: remote.responseTopic };
239
+ }
240
+
241
+ export async function requestApproval(options) {
242
+ return requestDecision({
243
+ ...options,
244
+ options: [
245
+ { value: 'allow', label: 'Allow' },
246
+ { value: 'deny', label: 'Deny' }
247
+ ]
248
+ });
249
+ }
250
+
251
+ export async function requestRefinement(options) {
252
+ return requestDecision({ ...options, options: [], includeRefine: true });
253
+ }
254
+
255
+ export async function requestChoice({ options, ...rest }) {
256
+ const normalized = normalizeOptions(options);
257
+ if (normalized.length < 1 || normalized.length > 3) throw new Error('NOFAX_CHOICE_LIMIT');
258
+ return requestDecision({ ...rest, options: normalized });
259
+ }