agentfax 0.0.1 → 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/LICENSE +21 -0
- package/README.md +126 -3
- package/SPEC.md +188 -0
- package/dist/agent-section.d.ts +2 -0
- package/dist/agent-section.js +59 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +10 -0
- package/dist/cli.d.ts +17 -0
- package/dist/cli.js +202 -0
- package/dist/client.d.ts +1 -0
- package/dist/client.js +109 -0
- package/dist/fax.d.ts +168 -0
- package/dist/fax.js +50 -0
- package/dist/html-to-text.d.ts +1 -0
- package/dist/html-to-text.js +60 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/page.d.ts +11 -0
- package/dist/page.js +152 -0
- package/dist/reply-link.d.ts +4 -0
- package/dist/reply-link.js +33 -0
- package/dist/server.d.ts +15 -0
- package/dist/server.js +250 -0
- package/dist/sqlite.d.ts +2 -0
- package/dist/sqlite.js +104 -0
- package/dist/store.d.ts +47 -0
- package/dist/store.js +39 -0
- package/dist/token.d.ts +2 -0
- package/dist/token.js +17 -0
- package/package.json +64 -6
- package/spec/fax.schema.json +202 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Served at /assets/fax.js. The page's CSP allows only same-origin scripts, so this can't be inline.
|
|
2
|
+
// It reads a reply link's fragment, fills in the form and removes the fragment. It never sends anything:
|
|
3
|
+
// the reply goes out only when the person clicks Send.
|
|
4
|
+
export const CLIENT_JS = `(function () {
|
|
5
|
+
var form = document.getElementById('reply-form');
|
|
6
|
+
// Runs on load, and again when a reply link is opened on a page that's already open:
|
|
7
|
+
// browsers then only change the fragment and don't reload.
|
|
8
|
+
function prefill() {
|
|
9
|
+
if (apply(location.hash.slice(1))) history.replaceState(null, '', location.pathname + location.search);
|
|
10
|
+
}
|
|
11
|
+
// Fills the form from a reply fragment. Returns true if anything was filled.
|
|
12
|
+
function apply(raw) {
|
|
13
|
+
if (form && raw) {
|
|
14
|
+
var filled = 0;
|
|
15
|
+
raw.split('&').forEach(function (pair) {
|
|
16
|
+
var i = pair.indexOf('=');
|
|
17
|
+
if (i < 1) return;
|
|
18
|
+
var id = pair.slice(0, i), value;
|
|
19
|
+
try { value = decodeURIComponent(pair.slice(i + 1)); } catch (e) { return; }
|
|
20
|
+
var fields = form.querySelectorAll('[name="' + CSS.escape(id) + '"]');
|
|
21
|
+
var ok = false;
|
|
22
|
+
fields.forEach(function (el) {
|
|
23
|
+
if (el.type === 'radio') { if (el.value === value) { el.checked = true; ok = true; } }
|
|
24
|
+
else { el.value = value; ok = true; }
|
|
25
|
+
});
|
|
26
|
+
if (ok) {
|
|
27
|
+
filled++;
|
|
28
|
+
var tag = form.querySelector('[data-filled-for="' + CSS.escape(id) + '"]');
|
|
29
|
+
if (tag) tag.hidden = false;
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
if (filled) {
|
|
33
|
+
document.getElementById('prefill-banner').hidden = false;
|
|
34
|
+
var ask = document.getElementById('ask-row');
|
|
35
|
+
if (ask) ask.hidden = true;
|
|
36
|
+
}
|
|
37
|
+
if (filled) form.scrollIntoView({ block: 'start' });
|
|
38
|
+
return filled > 0;
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
prefill();
|
|
43
|
+
addEventListener('hashchange', prefill);
|
|
44
|
+
|
|
45
|
+
// When a pasted reply names exactly one option of an unanswered choice, pick it and say so.
|
|
46
|
+
// Never overrides a choice the person made.
|
|
47
|
+
var box = form && form.querySelector('textarea[name="message"]');
|
|
48
|
+
if (box) {
|
|
49
|
+
box.addEventListener('input', function () {
|
|
50
|
+
var text = box.value.toLowerCase();
|
|
51
|
+
var groups = {};
|
|
52
|
+
form.querySelectorAll('input[type="radio"]').forEach(function (r) { (groups[r.name] = groups[r.name] || []).push(r); });
|
|
53
|
+
Object.keys(groups).forEach(function (name) {
|
|
54
|
+
var radios = groups[name];
|
|
55
|
+
if (radios.some(function (r) { return r.checked; })) return;
|
|
56
|
+
var hits = radios.filter(function (r) {
|
|
57
|
+
var word = r.value.toLowerCase().replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&');
|
|
58
|
+
return new RegExp('(^|[^\\\\p{L}\\\\p{N}])' + word + '($|[^\\\\p{L}\\\\p{N}])', 'u').test(text);
|
|
59
|
+
});
|
|
60
|
+
if (hits.length !== 1) return;
|
|
61
|
+
hits[0].checked = true;
|
|
62
|
+
var tag = form.querySelector('[data-picked-for="' + CSS.escape(name) + '"]');
|
|
63
|
+
if (tag) tag.hidden = false;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A reply needs the required answers or a message in the box. Got it is always fine.
|
|
69
|
+
// The server checks the same thing; this just says it without a round trip.
|
|
70
|
+
if (form) {
|
|
71
|
+
var required = [];
|
|
72
|
+
try { required = JSON.parse(form.getAttribute('data-required') || '[]'); } catch (e) {}
|
|
73
|
+
form.addEventListener('submit', function (e) {
|
|
74
|
+
if (e.submitter && e.submitter.name === 'ack') return;
|
|
75
|
+
var note = document.getElementById('form-note');
|
|
76
|
+
var message = form.querySelector('[name="message"]');
|
|
77
|
+
if (message && message.value.trim()) return;
|
|
78
|
+
var data = new FormData(form);
|
|
79
|
+
var missing = required.filter(function (q) { return !String(data.get(q.id) || '').trim(); })[0];
|
|
80
|
+
var answered = Array.from(data.entries()).some(function (p) { return p[0] !== 'message' && String(p[1]).trim(); });
|
|
81
|
+
if (missing) { e.preventDefault(); note.textContent = 'Answer \\u201c' + missing.label + '\\u201d, or type a reply in the box.'; }
|
|
82
|
+
else if (!answered) { e.preventDefault(); note.textContent = 'Type a reply in the box first.'; }
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Size the sender frame to its content. Only a height number is accepted, and only from that frame.
|
|
87
|
+
var frame = document.querySelector('.frame iframe');
|
|
88
|
+
if (frame) {
|
|
89
|
+
addEventListener('message', function (e) {
|
|
90
|
+
var d = e.data;
|
|
91
|
+
if (e.source !== frame.contentWindow || !d || d.type !== 'agentfax:height' || typeof d.height !== 'number' || !isFinite(d.height)) return;
|
|
92
|
+
frame.style.minHeight = '0';
|
|
93
|
+
frame.style.height = Math.max(60, Math.min(8000, Math.round(d.height))) + 'px';
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
var button = document.getElementById('ask-ai');
|
|
97
|
+
if (button) {
|
|
98
|
+
button.hidden = false;
|
|
99
|
+
button.addEventListener('click', function () {
|
|
100
|
+
var prompt = button.getAttribute('data-prompt');
|
|
101
|
+
var note = document.getElementById('ask-note');
|
|
102
|
+
navigator.clipboard.writeText(prompt).then(
|
|
103
|
+
function () { note.textContent = 'Copied. Paste it into your AI.'; },
|
|
104
|
+
function () { note.textContent = prompt; }
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
`;
|
package/dist/fax.d.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const QuestionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
3
|
+
id: z.ZodString;
|
|
4
|
+
label: z.ZodString;
|
|
5
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
6
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
7
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
8
|
+
type: z.ZodEnum<{
|
|
9
|
+
longtext: "longtext";
|
|
10
|
+
number: "number";
|
|
11
|
+
text: "text";
|
|
12
|
+
}>;
|
|
13
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
14
|
+
id: z.ZodString;
|
|
15
|
+
label: z.ZodString;
|
|
16
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
17
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
18
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
19
|
+
type: z.ZodLiteral<"choice">;
|
|
20
|
+
options: z.ZodArray<z.ZodString>;
|
|
21
|
+
}, z.core.$strict>], "type">;
|
|
22
|
+
export declare const FaxSchema: z.ZodObject<{
|
|
23
|
+
agentfax: z.ZodLiteral<"0.1">;
|
|
24
|
+
title: z.ZodString;
|
|
25
|
+
from: z.ZodObject<{
|
|
26
|
+
name: z.ZodString;
|
|
27
|
+
origin: z.ZodURL;
|
|
28
|
+
}, z.core.$strip>;
|
|
29
|
+
to: z.ZodOptional<z.ZodString>;
|
|
30
|
+
note: z.ZodOptional<z.ZodString>;
|
|
31
|
+
content: z.ZodObject<{
|
|
32
|
+
text: z.ZodString;
|
|
33
|
+
view: z.ZodOptional<z.ZodURL>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
questions: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
36
|
+
id: z.ZodString;
|
|
37
|
+
label: z.ZodString;
|
|
38
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
39
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
40
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
41
|
+
type: z.ZodEnum<{
|
|
42
|
+
longtext: "longtext";
|
|
43
|
+
number: "number";
|
|
44
|
+
text: "text";
|
|
45
|
+
}>;
|
|
46
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
47
|
+
id: z.ZodString;
|
|
48
|
+
label: z.ZodString;
|
|
49
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
50
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
51
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
52
|
+
type: z.ZodLiteral<"choice">;
|
|
53
|
+
options: z.ZodArray<z.ZodString>;
|
|
54
|
+
}, z.core.$strict>], "type">>;
|
|
55
|
+
ack: z.ZodOptional<z.ZodBoolean>;
|
|
56
|
+
reply: z.ZodOptional<z.ZodObject<{
|
|
57
|
+
link: z.ZodURL;
|
|
58
|
+
format: z.ZodString;
|
|
59
|
+
}, z.core.$strip>>;
|
|
60
|
+
expires: z.ZodOptional<z.ZodISODateTime>;
|
|
61
|
+
about: z.ZodURL;
|
|
62
|
+
}, z.core.$strip>;
|
|
63
|
+
export type Question = z.infer<typeof QuestionSchema>;
|
|
64
|
+
export type Fax = z.infer<typeof FaxSchema>;
|
|
65
|
+
export declare const faxJsonSchema: () => {
|
|
66
|
+
$schema?: "https://json-schema.org/draft/2020-12/schema" | "http://json-schema.org/draft-07/schema#" | "http://json-schema.org/draft-04/schema#";
|
|
67
|
+
$anchor?: string;
|
|
68
|
+
$ref?: string;
|
|
69
|
+
$dynamicRef?: string;
|
|
70
|
+
$dynamicAnchor?: string;
|
|
71
|
+
$vocabulary?: Record<string, boolean>;
|
|
72
|
+
$comment?: string;
|
|
73
|
+
$defs?: Record<string, z.core.JSONSchema.JSONSchema>;
|
|
74
|
+
type?: z.core.JSONSchema.SchemaType | z.core.JSONSchema.SchemaType[];
|
|
75
|
+
additionalItems?: z.core.JSONSchema._JSONSchema;
|
|
76
|
+
unevaluatedItems?: z.core.JSONSchema._JSONSchema;
|
|
77
|
+
prefixItems?: z.core.JSONSchema._JSONSchema[];
|
|
78
|
+
items?: z.core.JSONSchema._JSONSchema | z.core.JSONSchema._JSONSchema[];
|
|
79
|
+
contains?: z.core.JSONSchema._JSONSchema;
|
|
80
|
+
additionalProperties?: z.core.JSONSchema._JSONSchema;
|
|
81
|
+
unevaluatedProperties?: z.core.JSONSchema._JSONSchema;
|
|
82
|
+
properties?: Record<string, z.core.JSONSchema._JSONSchema>;
|
|
83
|
+
patternProperties?: Record<string, z.core.JSONSchema._JSONSchema>;
|
|
84
|
+
dependentSchemas?: Record<string, z.core.JSONSchema._JSONSchema>;
|
|
85
|
+
propertyNames?: z.core.JSONSchema._JSONSchema;
|
|
86
|
+
if?: z.core.JSONSchema._JSONSchema;
|
|
87
|
+
then?: z.core.JSONSchema._JSONSchema;
|
|
88
|
+
else?: z.core.JSONSchema._JSONSchema;
|
|
89
|
+
allOf?: z.core.JSONSchema.JSONSchema[];
|
|
90
|
+
anyOf?: z.core.JSONSchema.JSONSchema[];
|
|
91
|
+
oneOf?: z.core.JSONSchema.JSONSchema[];
|
|
92
|
+
not?: z.core.JSONSchema._JSONSchema;
|
|
93
|
+
multipleOf?: number;
|
|
94
|
+
maximum?: number;
|
|
95
|
+
exclusiveMaximum?: number | boolean;
|
|
96
|
+
minimum?: number;
|
|
97
|
+
exclusiveMinimum?: number | boolean;
|
|
98
|
+
maxLength?: number;
|
|
99
|
+
minLength?: number;
|
|
100
|
+
pattern?: string;
|
|
101
|
+
maxItems?: number;
|
|
102
|
+
minItems?: number;
|
|
103
|
+
uniqueItems?: boolean;
|
|
104
|
+
maxContains?: number;
|
|
105
|
+
minContains?: number;
|
|
106
|
+
maxProperties?: number;
|
|
107
|
+
minProperties?: number;
|
|
108
|
+
required?: string[];
|
|
109
|
+
dependentRequired?: Record<string, string[]>;
|
|
110
|
+
enum?: Array<string | number | boolean | null>;
|
|
111
|
+
const?: string | number | boolean | null;
|
|
112
|
+
id?: string;
|
|
113
|
+
description?: string;
|
|
114
|
+
default?: unknown;
|
|
115
|
+
deprecated?: boolean;
|
|
116
|
+
readOnly?: boolean;
|
|
117
|
+
writeOnly?: boolean;
|
|
118
|
+
nullable?: boolean;
|
|
119
|
+
examples?: unknown[];
|
|
120
|
+
format?: string;
|
|
121
|
+
contentMediaType?: string;
|
|
122
|
+
contentEncoding?: string;
|
|
123
|
+
contentSchema?: z.core.JSONSchema.JSONSchema;
|
|
124
|
+
_prefault?: unknown;
|
|
125
|
+
"~standard": z.core.ZodStandardSchemaWithJSON<z.ZodObject<{
|
|
126
|
+
agentfax: z.ZodLiteral<"0.1">;
|
|
127
|
+
title: z.ZodString;
|
|
128
|
+
from: z.ZodObject<{
|
|
129
|
+
name: z.ZodString;
|
|
130
|
+
origin: z.ZodURL;
|
|
131
|
+
}, z.core.$strip>;
|
|
132
|
+
to: z.ZodOptional<z.ZodString>;
|
|
133
|
+
note: z.ZodOptional<z.ZodString>;
|
|
134
|
+
content: z.ZodObject<{
|
|
135
|
+
text: z.ZodString;
|
|
136
|
+
view: z.ZodOptional<z.ZodURL>;
|
|
137
|
+
}, z.core.$strip>;
|
|
138
|
+
questions: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
139
|
+
id: z.ZodString;
|
|
140
|
+
label: z.ZodString;
|
|
141
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
142
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
143
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
144
|
+
type: z.ZodEnum<{
|
|
145
|
+
longtext: "longtext";
|
|
146
|
+
number: "number";
|
|
147
|
+
text: "text";
|
|
148
|
+
}>;
|
|
149
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
150
|
+
id: z.ZodString;
|
|
151
|
+
label: z.ZodString;
|
|
152
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
153
|
+
hint: z.ZodOptional<z.ZodString>;
|
|
154
|
+
probes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
155
|
+
type: z.ZodLiteral<"choice">;
|
|
156
|
+
options: z.ZodArray<z.ZodString>;
|
|
157
|
+
}, z.core.$strict>], "type">>;
|
|
158
|
+
ack: z.ZodOptional<z.ZodBoolean>;
|
|
159
|
+
reply: z.ZodOptional<z.ZodObject<{
|
|
160
|
+
link: z.ZodURL;
|
|
161
|
+
format: z.ZodString;
|
|
162
|
+
}, z.core.$strip>>;
|
|
163
|
+
expires: z.ZodOptional<z.ZodISODateTime>;
|
|
164
|
+
about: z.ZodURL;
|
|
165
|
+
}, z.core.$strip>>;
|
|
166
|
+
$id: string;
|
|
167
|
+
title: string;
|
|
168
|
+
};
|
package/dist/fax.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
// Question ids go straight into the reply fragment (#id=value), so they must never need encoding.
|
|
3
|
+
// `message` and `ack` are reserved: every reply can carry a free-text message, and ack is the read confirmation.
|
|
4
|
+
const QuestionId = z.string().regex(/^(?!(message|ack)$)[a-z][a-z0-9_]{0,39}$/, 'lowercase letters, digits and _, starting with a letter, max 40; not message or ack');
|
|
5
|
+
const base = {
|
|
6
|
+
id: QuestionId,
|
|
7
|
+
label: z.string().min(1).max(200),
|
|
8
|
+
required: z.boolean().optional(),
|
|
9
|
+
// Shown to people under the question and to agents in the agent section.
|
|
10
|
+
hint: z.string().min(1).max(200).optional(),
|
|
11
|
+
// Follow-ups the sender wants the answer to cover.
|
|
12
|
+
probes: z.array(z.string().min(1).max(150)).min(1).max(3).optional(),
|
|
13
|
+
};
|
|
14
|
+
const FreeQuestion = z.strictObject({ ...base, type: z.enum(['text', 'longtext', 'number']) });
|
|
15
|
+
const ChoiceQuestion = z.strictObject({
|
|
16
|
+
...base,
|
|
17
|
+
type: z.literal('choice'),
|
|
18
|
+
options: z.array(z.string().min(1).max(100)).min(1).max(20),
|
|
19
|
+
});
|
|
20
|
+
export const QuestionSchema = z.discriminatedUnion('type', [FreeQuestion, ChoiceQuestion]);
|
|
21
|
+
export const FaxSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
agentfax: z.literal('0.1'),
|
|
24
|
+
title: z.string().min(1).max(200),
|
|
25
|
+
from: z.object({ name: z.string().min(1).max(100), origin: z.url() }),
|
|
26
|
+
to: z.string().max(100).optional(),
|
|
27
|
+
note: z.string().max(2000).optional(),
|
|
28
|
+
content: z.object({ text: z.string(), view: z.url().optional() }),
|
|
29
|
+
questions: z.array(QuestionSchema).max(20),
|
|
30
|
+
ack: z.boolean().optional(),
|
|
31
|
+
reply: z.object({ link: z.url().endsWith('#'), format: z.string() }).optional(),
|
|
32
|
+
expires: z.iso.datetime().optional(),
|
|
33
|
+
about: z.url(),
|
|
34
|
+
})
|
|
35
|
+
// Cross-field rules. JSON Schema can't express these, so SPEC.md states them as rules.
|
|
36
|
+
.superRefine((fax, ctx) => {
|
|
37
|
+
const ids = fax.questions.map((q) => q.id);
|
|
38
|
+
if (new Set(ids).size !== ids.length)
|
|
39
|
+
ctx.addIssue({ code: 'custom', path: ['questions'], message: 'question ids must be unique' });
|
|
40
|
+
const expectsReply = fax.questions.length > 0 || fax.ack === true;
|
|
41
|
+
if (expectsReply && !fax.reply)
|
|
42
|
+
ctx.addIssue({ code: 'custom', path: ['reply'], message: 'reply is required when the fax has questions or ack' });
|
|
43
|
+
if (!expectsReply && fax.reply)
|
|
44
|
+
ctx.addIssue({ code: 'custom', path: ['reply'], message: 'reply is only allowed when the fax has questions or ack' });
|
|
45
|
+
});
|
|
46
|
+
export const faxJsonSchema = () => ({
|
|
47
|
+
$id: 'https://github.com/sptr11/agentfax/blob/main/spec/fax.schema.json',
|
|
48
|
+
title: 'AgentFax 0.1',
|
|
49
|
+
...z.toJSONSchema(FaxSchema, { target: 'draft-2020-12', io: 'input' }),
|
|
50
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function htmlToText(html: string): string;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Plain-text version of a sender's page, for the agent section.
|
|
2
|
+
// Drops anything a person wouldn't see, so hidden text can't reach the agent.
|
|
3
|
+
// Text hidden through CSS classes can't be detected here; neutralization still applies downstream.
|
|
4
|
+
const DROP_ELEMENTS = /<(script|style|template|noscript|head|svg)\b[\s\S]*?<\/\1\s*>/gi;
|
|
5
|
+
const HIDDEN_ATTR = /\s(hidden|aria-hidden\s*=\s*["']?true["']?|style\s*=\s*["'][^"']*(display\s*:\s*none|visibility\s*:\s*hidden)[^"']*["'])/i;
|
|
6
|
+
const BLOCK = /^(p|div|section|article|header|footer|main|aside|nav|h[1-6]|li|ul|ol|tr|table|blockquote|pre|br|hr|dt|dd|figure|figcaption)$/i;
|
|
7
|
+
const VOID = /^(br|hr|img|input|meta|link|area|base|col|embed|source|track|wbr)$/i;
|
|
8
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“' };
|
|
9
|
+
function decode(s) {
|
|
10
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
|
|
11
|
+
if (e[0] === '#') {
|
|
12
|
+
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
13
|
+
return Number.isFinite(code) && code <= 0x10ffff ? String.fromCodePoint(code) : m;
|
|
14
|
+
}
|
|
15
|
+
return ENTITIES[e.toLowerCase()] ?? m;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
export function htmlToText(html) {
|
|
19
|
+
const src = html.replace(/<!--[\s\S]*?-->/g, '').replace(DROP_ELEMENTS, '');
|
|
20
|
+
let out = '';
|
|
21
|
+
let hiddenDepth = 0;
|
|
22
|
+
const hiddenStack = [];
|
|
23
|
+
for (const part of src.split(/(<[^>]+>)/)) {
|
|
24
|
+
if (!part.startsWith('<')) {
|
|
25
|
+
// Source newlines are just whitespace in HTML; only block elements break lines.
|
|
26
|
+
if (hiddenDepth === 0)
|
|
27
|
+
out += part.replace(/\s+/g, ' ');
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const m = part.match(/^<\s*(\/)?\s*([a-z0-9]+)/i);
|
|
31
|
+
if (!m)
|
|
32
|
+
continue;
|
|
33
|
+
const [, closing, tag] = m;
|
|
34
|
+
if (closing) {
|
|
35
|
+
if (hiddenStack.length && hiddenStack[hiddenStack.length - 1] === tag.toLowerCase()) {
|
|
36
|
+
hiddenStack.pop();
|
|
37
|
+
hiddenDepth--;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (hiddenDepth > 0 && !VOID.test(tag) && !part.endsWith('/>')) {
|
|
41
|
+
if (hiddenStack.length && hiddenStack[hiddenStack.length - 1] === tag.toLowerCase()) {
|
|
42
|
+
hiddenStack.push(tag.toLowerCase());
|
|
43
|
+
hiddenDepth++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (HIDDEN_ATTR.test(part) && !VOID.test(tag)) {
|
|
47
|
+
hiddenStack.push(tag.toLowerCase());
|
|
48
|
+
hiddenDepth++;
|
|
49
|
+
}
|
|
50
|
+
if (hiddenDepth === 0 && BLOCK.test(tag))
|
|
51
|
+
out += '\n';
|
|
52
|
+
else if (hiddenDepth === 0 && !closing && /^t[dh]$/i.test(tag))
|
|
53
|
+
out += ' | ';
|
|
54
|
+
}
|
|
55
|
+
return decode(out)
|
|
56
|
+
.split('\n')
|
|
57
|
+
.map((l) => l.replace(/[ \t\r\f\v ]+/g, ' ').trim().replace(/^\|\s*/, '').replace(/\s*\|$/, ''))
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('\n');
|
|
60
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FaxSchema, QuestionSchema, faxJsonSchema, type Fax, type Question } from './fax.js';
|
|
2
|
+
export { renderAgentSection } from './agent-section.js';
|
|
3
|
+
export { buildReplyLink, parseReplyFragment, type Answers } from './reply-link.js';
|
|
4
|
+
export { htmlToText } from './html-to-text.js';
|
|
5
|
+
export { memoryStore, type Store, type FaxRecord, type LinkRecord, type ReplyRecord, type StoredFax } from './store.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The fax format and helpers. The server lives at 'agentfax/hono', the SQLite store at 'agentfax/sqlite'.
|
|
2
|
+
export { FaxSchema, QuestionSchema, faxJsonSchema } from './fax.js';
|
|
3
|
+
export { renderAgentSection } from './agent-section.js';
|
|
4
|
+
export { buildReplyLink, parseReplyFragment } from './reply-link.js';
|
|
5
|
+
export { htmlToText } from './html-to-text.js';
|
|
6
|
+
export { memoryStore } from './store.js';
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Fax } from './fax.js';
|
|
2
|
+
import type { ReplyRecord } from './store.js';
|
|
3
|
+
export declare const esc: (s: string) => string;
|
|
4
|
+
export declare function stamp(iso: string): string;
|
|
5
|
+
export declare function faxPage(fax: Fax, faxPath: string, faxUrl: string, agentSection: string, hasHtml: boolean, sentAt: string): string;
|
|
6
|
+
export declare function receiptPage(fax: Fax, sentAt: string, answers: Record<string, string>, message: string, ack: boolean, repliedAt: string): string;
|
|
7
|
+
export declare function problemPage(title: string, message: string, back?: {
|
|
8
|
+
href: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}, issues?: string[]): string;
|
|
11
|
+
export declare function inboxPage(title: string, replies: ReplyRecord[]): string;
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
export const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
2
|
+
// System fonts only: the page CSP blocks font CDNs. Themeable through the custom properties on :root.
|
|
3
|
+
const STYLE = `
|
|
4
|
+
:root{--desk:#e4e7e3;--paper:#fcfcfa;--ink:#1d2126;--muted:#5e666e;--rule:#d5d9d4;--accent:#27467a;--accent-ink:#fff;--accent-soft:#e6ecf5;--ok:#1f6b45;--ok-soft:#e3f1e8;--warn:#8a5300;--warn-soft:#fbf0dc;--frame:#fff;--shadow:0 1px 2px rgba(20,24,28,.06),0 8px 28px rgba(20,24,28,.08);--font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,Roboto,"Helvetica Neue",Arial,sans-serif;--font-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;color-scheme:light}
|
|
5
|
+
@media (prefers-color-scheme:dark){:root{--desk:#101215;--paper:#1a1d21;--ink:#e5e8eb;--muted:#9aa3ab;--rule:#2c3137;--accent:#8fb0e6;--accent-ink:#0d1a2e;--accent-soft:#1d2a3d;--ok:#74cf9c;--ok-soft:#14291e;--warn:#e8b665;--warn-soft:#2e2412;--frame:#22262b;--shadow:0 1px 2px rgba(0,0,0,.4),0 8px 28px rgba(0,0,0,.35);color-scheme:dark}}
|
|
6
|
+
*{box-sizing:border-box}
|
|
7
|
+
body{margin:0;background:var(--desk);color:var(--ink);font:16px/1.55 var(--font-sans);padding:24px max(16px,env(safe-area-inset-right)) 64px max(16px,env(safe-area-inset-left))}
|
|
8
|
+
[hidden]{display:none!important}
|
|
9
|
+
.sheet{max-width:760px;margin:0 auto;background:var(--paper);box-shadow:var(--shadow);border-radius:3px;overflow:hidden}
|
|
10
|
+
.tx{font:11.5px/1.4 var(--font-mono);letter-spacing:.04em;text-transform:uppercase;color:var(--muted);padding:10px 28px;border-bottom:1px dashed var(--rule);display:flex;flex-wrap:wrap;gap:4px 22px}
|
|
11
|
+
.tx b{color:var(--ink);font-weight:600}
|
|
12
|
+
.body{padding:28px 28px 8px;display:flex;flex-direction:column;gap:18px}
|
|
13
|
+
h1{margin:0;font-size:clamp(26px,5vw,34px);line-height:1.12;letter-spacing:-.015em;text-wrap:balance;overflow-wrap:anywhere}
|
|
14
|
+
.note{margin:0;font-size:17px;max-width:60ch;overflow-wrap:anywhere}
|
|
15
|
+
.from{font-size:14px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px;align-items:center}
|
|
16
|
+
.from strong{color:var(--ink);font-weight:600}
|
|
17
|
+
.origin{font-family:var(--font-mono);font-size:12.5px;padding:2px 7px;border-radius:4px;background:var(--accent-soft);color:var(--accent);overflow-wrap:anywhere}
|
|
18
|
+
.frame{border:1px solid var(--rule);border-radius:6px;background:var(--frame);overflow:hidden}
|
|
19
|
+
.frame-tag{font:10.5px var(--font-mono);letter-spacing:.06em;text-transform:uppercase;color:var(--muted);padding:6px 12px;border-bottom:1px solid var(--rule);display:flex;gap:8px}
|
|
20
|
+
.frame-tag span:first-child{margin-right:auto}
|
|
21
|
+
.frame iframe{display:block;width:100%;min-height:420px;border:0;background:#fff}
|
|
22
|
+
.frame pre{margin:0;padding:16px 18px;font:15px/1.6 var(--font-sans);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
23
|
+
.cut{display:flex;align-items:center;gap:12px;font:11px var(--font-mono);letter-spacing:.12em;color:var(--muted);padding:6px 28px 0}
|
|
24
|
+
.cut::before,.cut::after{content:"";flex:1;border-top:1px dashed var(--rule)}
|
|
25
|
+
.reply{padding:18px 28px 28px;display:flex;flex-direction:column;gap:20px}
|
|
26
|
+
.banner{background:var(--accent-soft);border-radius:8px;padding:12px 14px;font-size:14.5px}
|
|
27
|
+
.banner b{color:var(--accent)}
|
|
28
|
+
.q{display:flex;flex-direction:column;gap:8px;border:0;padding:0;margin:0;min-width:0}
|
|
29
|
+
.q>.label,.q>legend{font-weight:600;font-size:15.5px;padding:0}
|
|
30
|
+
.req,.filled{font:11px var(--font-mono);letter-spacing:.04em;text-transform:uppercase;font-weight:400;margin-left:6px;color:var(--muted)}
|
|
31
|
+
.filled{color:var(--accent);font-weight:500}
|
|
32
|
+
.chips{display:flex;flex-wrap:wrap;gap:8px}
|
|
33
|
+
.chip{position:relative}
|
|
34
|
+
.chip input{position:absolute;opacity:0;inset:0;margin:0;cursor:pointer}
|
|
35
|
+
.chip span{display:inline-block;padding:8px 14px;border:1px solid var(--rule);border-radius:999px;font-size:15px;background:var(--paper)}
|
|
36
|
+
.chip input:checked+span{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);font-weight:600}
|
|
37
|
+
.chip input:focus-visible+span,textarea:focus-visible,input:focus-visible,.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
38
|
+
textarea,input[type=text],input[type=number]{font:15.5px/1.5 var(--font-sans);color:var(--ink);background:var(--paper);border:1px solid var(--rule);border-radius:8px;padding:10px 12px;width:100%}
|
|
39
|
+
textarea{min-height:96px;resize:vertical}
|
|
40
|
+
.hint{font-size:14px;color:var(--muted);margin:0}
|
|
41
|
+
.cover{font-size:14px;color:var(--muted);margin:0;padding-left:18px}
|
|
42
|
+
.actions{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center}
|
|
43
|
+
.btn{font:600 15.5px var(--font-sans);padding:11px 22px;border-radius:8px;border:1px solid var(--accent);background:var(--accent);color:var(--accent-ink);cursor:pointer}
|
|
44
|
+
.btn.ghost{background:transparent;color:var(--accent)}
|
|
45
|
+
.small{font-size:13.5px;color:var(--muted);margin:0}
|
|
46
|
+
.report{margin:0;font:12.5px/1.7 var(--font-mono);background:var(--ok-soft);color:var(--ok);border-radius:8px;padding:14px 16px}
|
|
47
|
+
.answers{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;font-size:15px;margin:0}
|
|
48
|
+
.answers dt{color:var(--muted)}
|
|
49
|
+
.answers dd{margin:0;overflow-wrap:anywhere;white-space:pre-wrap}
|
|
50
|
+
.own{border-top:1px dashed var(--rule);padding-top:14px;font-size:14.5px;color:var(--muted);margin:0}
|
|
51
|
+
.own a,.back{color:var(--accent);font-weight:600;text-decoration:none}
|
|
52
|
+
.gone{background:var(--warn-soft);color:var(--warn);border-radius:8px;padding:14px 16px;font-size:15px;margin:0}
|
|
53
|
+
.agent{max-width:760px;margin:28px auto 0;color:var(--muted)}
|
|
54
|
+
.agent h2{font:600 11px var(--font-mono);letter-spacing:.1em;text-transform:uppercase;margin:0 0 8px}
|
|
55
|
+
.agent pre{margin:0;font:12px/1.6 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere;border-left:2px solid var(--rule);padding:4px 0 4px 14px}
|
|
56
|
+
.foot{max-width:760px;margin:18px auto 0;font:11.5px var(--font-mono);color:var(--muted);text-align:center}
|
|
57
|
+
.foot a{color:inherit}
|
|
58
|
+
.replies{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
|
|
59
|
+
.replies li{border-top:1px dashed var(--rule);padding-top:12px}
|
|
60
|
+
.ai-help{display:flex;flex-wrap:wrap;gap:10px 14px;align-items:center}
|
|
61
|
+
.pair{display:flex;gap:10px;flex-wrap:wrap}
|
|
62
|
+
@media (max-width:560px){.actions>.btn[type=submit]{width:100%}.ai-help{width:100%}.pair{display:grid;grid-template-columns:1fr 1fr;width:100%}.pair .btn{padding-left:8px;padding-right:8px}.tx,.body,.reply,.cut{padding-left:18px;padding-right:18px}.answers{grid-template-columns:minmax(0,1fr);gap:2px}.answers dd{margin-bottom:10px}.frame-tag .long{display:none}}
|
|
63
|
+
@media (min-width:561px){.frame-tag .long+span{display:none}}
|
|
64
|
+
`;
|
|
65
|
+
const FOOT = '<p class="foot">Sent with <a href="https://github.com/sptr11/agentfax">AgentFax</a>. Your AI can read this page too.</p>';
|
|
66
|
+
const OWN = '<p class="own">Want your own AI to send pages like this? <a href="https://github.com/sptr11/agentfax">Set up AgentFax →</a></p>';
|
|
67
|
+
function shell(title, body, script = false) {
|
|
68
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>${esc(title)}</title><style>${STYLE}</style>${script ? '<script src="/assets/fax.js" defer></script>' : ''}</head><body>${body}</body></html>`;
|
|
69
|
+
}
|
|
70
|
+
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
|
71
|
+
export function stamp(iso) {
|
|
72
|
+
const d = new Date(iso);
|
|
73
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
74
|
+
return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())} UTC`;
|
|
75
|
+
}
|
|
76
|
+
const day = (iso) => stamp(iso).split(' ').slice(0, 3).join(' ');
|
|
77
|
+
function header(fax, sentAt) {
|
|
78
|
+
return `<div class="tx"><span>From <b>${esc(fax.from.name)}</b></span>${fax.to ? `<span>To <b>${esc(fax.to)}</b></span>` : ''}<span>${stamp(sentAt)}</span><span>Page 1/1</span></div>`;
|
|
79
|
+
}
|
|
80
|
+
function intro(fax) {
|
|
81
|
+
const host = new URL(fax.from.origin).host;
|
|
82
|
+
return `<div class="from"><strong>${esc(fax.from.name)}</strong><span>sent from</span><span class="origin">${esc(host)}</span></div><h1>${esc(fax.title)}</h1>${fax.note ? `<p class="note">${esc(fax.note)}</p>` : ''}`;
|
|
83
|
+
}
|
|
84
|
+
function field(q) {
|
|
85
|
+
const id = `q-${q.id}`;
|
|
86
|
+
const tags = `${q.required ? '<span class="req">required</span>' : ''}<span class="filled" data-filled-for="${q.id}" hidden>from your AI</span>${q.type === 'choice' ? `<span class="filled" data-picked-for="${q.id}" hidden>picked from your reply</span>` : ''}`;
|
|
87
|
+
const help = `${q.hint ? `<p class="hint">${esc(q.hint)}</p>` : ''}${q.probes ? `<ul class="cover">${q.probes.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>` : ''}`;
|
|
88
|
+
if (q.type === 'choice') {
|
|
89
|
+
const chips = q.options.map((o, i) => `<label class="chip"><input type="radio" name="${q.id}" id="${id}-${i}" value="${esc(o)}"><span>${esc(o)}</span></label>`).join('');
|
|
90
|
+
return `<fieldset class="q"><legend>${esc(q.label)}${tags}</legend>${help}<div class="chips">${chips}</div></fieldset>`;
|
|
91
|
+
}
|
|
92
|
+
const input = q.type === 'longtext'
|
|
93
|
+
? `<textarea id="${id}" name="${q.id}"></textarea>`
|
|
94
|
+
: `<input id="${id}" name="${q.id}" type="${q.type === 'number' ? 'number' : 'text'}"${q.type === 'number' ? ' step="any" inputmode="decimal"' : ''}>`;
|
|
95
|
+
return `<div class="q"><label class="label" for="${id}">${esc(q.label)}${tags}</label>${help}${input}</div>`;
|
|
96
|
+
}
|
|
97
|
+
export function faxPage(fax, faxPath, faxUrl, agentSection, hasHtml, sentAt) {
|
|
98
|
+
const content = hasHtml
|
|
99
|
+
? `<div class="frame"><div class="frame-tag"><span>From the sender</span><span class="long">can show anything, can’t send anything</span><span>sandboxed</span></div><iframe src="${faxPath}/view" sandbox="allow-scripts" title="${esc(fax.title)}" loading="lazy"></iframe></div>`
|
|
100
|
+
: `<div class="frame"><pre>${esc(fax.content.text)}</pre></div>`;
|
|
101
|
+
const sender = esc(fax.from.name);
|
|
102
|
+
const prompt = fax.questions.length
|
|
103
|
+
? `Read ${faxUrl} and help me reply. Draft the answers, show me, then give me the reply link.`
|
|
104
|
+
: `Read ${faxUrl} and summarize it for me.`;
|
|
105
|
+
const askRow = `<span id="ask-row" class="ai-help"><span class="pair"><button class="btn ghost" type="button" id="ask-ai" data-prompt="${esc(prompt)}" hidden>Ask your AI</button></span><span class="small" id="ask-note">${fax.questions.length ? 'Your AI can read this page and draft your answers.' : 'Your AI can read and summarize this page.'}</span></span>`;
|
|
106
|
+
// Every fax has a reply box: the fallback when an AI's reply link won't open, and a way to ask the sender something.
|
|
107
|
+
const messageBox = `<div class="q"><label class="label" for="q-message">Or type or paste your reply here</label><textarea id="q-message" name="message" rows="3" placeholder="Anything for ${sender}. Your AI’s draft works too."></textarea></div>`;
|
|
108
|
+
const required = JSON.stringify(fax.questions.filter((q) => q.required).map((q) => ({ id: q.id, label: q.label })));
|
|
109
|
+
const formNote = '<p class="small" id="form-note" aria-live="polite"></p>';
|
|
110
|
+
let reply = '';
|
|
111
|
+
if (fax.questions.length) {
|
|
112
|
+
reply = `<div class="cut">REPLY</div><form class="reply" id="reply-form" method="post" action="${faxPath}/reply" data-required="${esc(required)}">` +
|
|
113
|
+
`<div class="banner" id="prefill-banner" hidden><b>Your AI filled this in.</b> Check the answers, change anything, then send. Nothing has been sent yet.</div>` +
|
|
114
|
+
fax.questions.map(field).join('') + messageBox +
|
|
115
|
+
`<div class="actions"><button class="btn" type="submit">Send to ${sender}</button>${askRow}</div>${formNote}</form>`;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
const gotIt = fax.ack ? `<button class="btn" type="submit" name="ack" value="1" formnovalidate>Got it</button>` : '';
|
|
119
|
+
reply = `<div class="cut">${fax.ack ? 'NO REPLY NEEDED' : 'REPLY IF YOU LIKE'}</div><form class="reply" id="reply-form" method="post" action="${faxPath}/reply">` +
|
|
120
|
+
(fax.ack ? `<p class="small">Click Got it to let ${sender} know you read it.</p>` : '') +
|
|
121
|
+
`${messageBox}<div class="actions">${gotIt}<button class="btn${fax.ack ? ' ghost' : ''}" type="submit">Send to ${sender}</button></div>${formNote}<div class="actions">${askRow}</div></form>`;
|
|
122
|
+
}
|
|
123
|
+
return shell(fax.title, `<article class="sheet">${header(fax, sentAt)}<div class="body">${intro(fax)}${content}</div>${reply}</article>` +
|
|
124
|
+
`<section class="agent"><h2>For AI assistants</h2><pre>${esc(agentSection)}</pre></section>${FOOT}`, true);
|
|
125
|
+
}
|
|
126
|
+
export function receiptPage(fax, sentAt, answers, message, ack, repliedAt) {
|
|
127
|
+
const sender = fax.from.name.toUpperCase();
|
|
128
|
+
const pairs = fax.questions.filter((q) => answers[q.id]).map((q) => `<dt>${esc(q.label)}</dt><dd>${esc(answers[q.id])}</dd>`).join('') +
|
|
129
|
+
(message ? `<dt>Message</dt><dd>${esc(message)}</dd>` : '');
|
|
130
|
+
const n = Object.keys(answers).length;
|
|
131
|
+
const what = [n ? `${n} ANSWER${n === 1 ? '' : 'S'}` : '', message ? 'A MESSAGE' : ''].filter(Boolean).join(' AND ');
|
|
132
|
+
const report = ack && !n && !message
|
|
133
|
+
? `<p class="report"><b>OK</b> · ${esc(sender)} KNOWS YOU READ IT · ${stamp(repliedAt)}</p>`
|
|
134
|
+
: `<p class="report"><b>OK</b> · SENT TO ${esc(sender)} · ${stamp(repliedAt)}${what ? ` · ${what}` : ''}</p>`;
|
|
135
|
+
const again = fax.questions.length && fax.to && fax.expires
|
|
136
|
+
? `<p class="small">You can change your answers until the fax expires on ${day(fax.expires)}. Sending again replaces them.</p>`
|
|
137
|
+
: '';
|
|
138
|
+
return shell(`Sent: ${fax.title}`, `<article class="sheet">${header(fax, sentAt)}<div class="body">${intro(fax)}</div><div class="cut">TRANSMISSION REPORT</div><div class="reply">${report}${pairs ? `<dl class="answers">${pairs}</dl>` : ''}${again}${OWN}</div></article>${FOOT}`);
|
|
139
|
+
}
|
|
140
|
+
export function problemPage(title, message, back, issues) {
|
|
141
|
+
const list = issues?.length ? `<ul class="cover">${issues.map((i) => `<li>${esc(i)}</li>`).join('')}</ul>` : '';
|
|
142
|
+
return shell(title, `<article class="sheet"><div class="body"><h1>${esc(title)}</h1><p class="gone">${esc(message)}</p>${list}${back ? `<p><a class="back" href="${esc(back.href)}">${esc(back.label)}</a></p>` : ''}</div></article>${FOOT}`);
|
|
143
|
+
}
|
|
144
|
+
export function inboxPage(title, replies) {
|
|
145
|
+
const rows = replies.length
|
|
146
|
+
? replies.map((r) => {
|
|
147
|
+
const pairs = Object.entries(r.answers).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('') + (r.message ? `<dt>message</dt><dd>${esc(r.message)}</dd>` : '');
|
|
148
|
+
return `<li><p class="small"><b>${esc(r.recipient ?? 'Someone')}</b> · ${stamp(r.createdAt)}${r.ack ? ' · Got it' : ''}</p>${pairs ? `<dl class="answers">${pairs}</dl>` : ''}</li>`;
|
|
149
|
+
}).join('')
|
|
150
|
+
: '<li><p class="small">No replies yet.</p></li>';
|
|
151
|
+
return shell(`Replies: ${title}`, `<article class="sheet"><div class="tx"><span>Inbox</span><span>${replies.length} repl${replies.length === 1 ? 'y' : 'ies'}</span></div><div class="body"><h1>${esc(title)}</h1><ul class="replies">${rows}</ul></div></article>${FOOT}`);
|
|
152
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Question } from './fax.js';
|
|
2
|
+
export type Answers = Record<string, string>;
|
|
3
|
+
export declare function parseReplyFragment(fragment: string, questions: Question[]): Answers;
|
|
4
|
+
export declare function buildReplyLink(faxUrl: string, questions: Question[], answers: Answers): string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Reads `id=value&id=value` from a reply link's fragment.
|
|
2
|
+
// Values are percent-decoded only: `+` stays a plus (assistants encode spaces as %20 and plus as %2B).
|
|
3
|
+
// Unknown ids and pairs that fail to decode are dropped. A repeated key keeps its last value.
|
|
4
|
+
export function parseReplyFragment(fragment, questions) {
|
|
5
|
+
const ids = new Set(questions.map((q) => q.id));
|
|
6
|
+
const out = {};
|
|
7
|
+
for (const pair of fragment.replace(/^#/, '').split('&')) {
|
|
8
|
+
const i = pair.indexOf('=');
|
|
9
|
+
if (i < 1)
|
|
10
|
+
continue;
|
|
11
|
+
const id = pair.slice(0, i);
|
|
12
|
+
if (!ids.has(id))
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
out[id] = decodeURIComponent(pair.slice(i + 1));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
export function buildReplyLink(faxUrl, questions, answers) {
|
|
24
|
+
const pairs = questions.flatMap((q) => {
|
|
25
|
+
const v = answers[q.id];
|
|
26
|
+
return v === undefined || v === '' ? [] : [`${q.id}=${encode(v)}`];
|
|
27
|
+
});
|
|
28
|
+
return `${faxUrl}#${pairs.join('&')}`;
|
|
29
|
+
}
|
|
30
|
+
// encodeURIComponent leaves ' ( ) ! * alone; assistants in the receiver tests encoded ' as %27.
|
|
31
|
+
function encode(value) {
|
|
32
|
+
return encodeURIComponent(value).replace(/['()!*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
33
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
export type AgentfaxOptions = {
|
|
4
|
+
store: Store;
|
|
5
|
+
publicUrl?: string | (() => string | undefined);
|
|
6
|
+
senderKey?: string;
|
|
7
|
+
authorize?: (req: Request) => Promise<string | null>;
|
|
8
|
+
now?: () => Date;
|
|
9
|
+
about?: string;
|
|
10
|
+
repliesPerMinute?: {
|
|
11
|
+
personal?: number;
|
|
12
|
+
shared?: number;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export declare function agentfax(opts: AgentfaxOptions): Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
|