@ccmsg/protocol 0.2.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/protocol",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Wire contract (schema + types + op attribute table) shared by the ccmsg daemon and web UI",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ export * from "./control/translate.ts";
18
18
  export * from "./envelope.ts";
19
19
  export * from "./errors.ts";
20
20
  export * from "./identifiers.ts";
21
+ export * from "./messaging/direct-delivery.ts";
21
22
  export * from "./messaging/message.ts";
22
23
  export * from "./messaging/notify.ts";
23
24
  export * from "./messaging/say.ts";
@@ -0,0 +1,139 @@
1
+ import type { InboxMessage } from "./message.ts";
2
+ import type { Mid, Sid } from "../identifiers.ts";
3
+
4
+ /** How an `InboxMessage` is worded when it is handed to a session through the
5
+ * harness's own messaging socket rather than over this protocol.
6
+ *
7
+ * On that route the recipient is the model, not a client: it reads one block of
8
+ * text and has no frame to look at. So `mid` and `from` have to be in the text
9
+ * or the recipient cannot answer — it would know a message arrived and not what
10
+ * to answer or how. That is what this module puts there, and nothing more: the
11
+ * wording is the delivery's, not the sender's, and `text` is carried through
12
+ * untouched.
13
+ *
14
+ * The shape is the harness's own sender convention, `<cross-session-message>`
15
+ * embedded in the message body. Sitting on it means the receiving harness reads
16
+ * the origin it already knows how to read, and a session that has seen a peer
17
+ * message sees the same envelope here. */
18
+
19
+ /** The element senders wrap a peer message in. */
20
+ export const DIRECT_DELIVERY_TAG = "cross-session-message";
21
+
22
+ /** What the recipient's harness is told to answer to.
23
+ *
24
+ * Not the sender's session and not a `uds:` path. Those are the addresses the
25
+ * harness itself dials, and dialing one that has gone ends the recipient's turn
26
+ * in failure; the sender here is a daemon that offers no such socket anyway.
27
+ * A name asks for no answer, so there is nothing to dangle — the way back is
28
+ * the reply line, which the recipient runs rather than the harness. */
29
+ export const DIRECT_DELIVERY_FROM = "ccmsg";
30
+
31
+ /** What the recipient is told the sender is doing. `prompting` is what a peer
32
+ * mid-turn sends as, which is what a relayed message is. */
33
+ export const DIRECT_DELIVERY_FROM_MODE = "prompting";
34
+
35
+ /** How the recipient answers: the one line of instruction in the body.
36
+ *
37
+ * A command and not a description of one, because the recipient acts on it
38
+ * directly. It carries both halves of the address — the sender to send to, and
39
+ * the frame being answered — so nothing has to be looked up to use it. A `mid`
40
+ * does not name its sender, and the alternative to spelling the sid out here
41
+ * would be an op that resolves one, which means a sent-message index the
42
+ * daemon does not otherwise need. */
43
+ export function directDeliveryReplyLine(mid: Mid, from: Sid): string {
44
+ return `Reply with: ccmsg reply ${mid} --to ${from} <text>`;
45
+ }
46
+
47
+ /** An `InboxMessage` as it reaches a session through the messaging socket. */
48
+ export interface DirectDelivery {
49
+ mid: Mid;
50
+ from: Sid;
51
+ from_label: string;
52
+ reply_to?: Mid;
53
+ /** The sender's text, exactly as it was sent. */
54
+ text: string;
55
+ }
56
+
57
+ /** Attribute values are quoted, so a label the sender chose cannot be trusted
58
+ * to stay inside its quotes. The identifiers cannot contain any of these, but
59
+ * they go through the same escape so one rule covers every attribute. */
60
+ function escapeAttribute(value: string): string {
61
+ return value
62
+ .replaceAll("&", "&amp;")
63
+ .replaceAll("<", "&lt;")
64
+ .replaceAll(">", "&gt;")
65
+ .replaceAll('"', "&quot;");
66
+ }
67
+
68
+ function unescapeAttribute(value: string): string {
69
+ return value
70
+ .replaceAll("&quot;", '"')
71
+ .replaceAll("&gt;", ">")
72
+ .replaceAll("&lt;", "<")
73
+ .replaceAll("&amp;", "&");
74
+ }
75
+
76
+ /** Word a message for the messaging socket.
77
+ *
78
+ * `from` / `from-name` / `from-mode` are the harness's attributes, so the
79
+ * receiving harness reads the origin it expects. The `ccmsg-` ones are this
80
+ * protocol's: the harness has no use for them and a reader that does not know
81
+ * them sees an envelope it can still read, while a recipient that wants to
82
+ * answer has the `mid` and the sid without parsing prose.
83
+ *
84
+ * The body is not escaped. What the model reads is these characters, so
85
+ * entity-escaping them would hand the recipient a corrupted message to answer;
86
+ * a `</cross-session-message>` inside the text is left where the sender put it
87
+ * and the closing tag is found from the end (see `parseDirectDelivery`).
88
+ * Refusing such a message instead would lose it for a substring. */
89
+ export function renderDirectDelivery(message: InboxMessage): string {
90
+ const attributes = [
91
+ `from="${DIRECT_DELIVERY_FROM}"`,
92
+ `from-name="${escapeAttribute(message.from_label)}"`,
93
+ `from-mode="${DIRECT_DELIVERY_FROM_MODE}"`,
94
+ `ccmsg-mid="${escapeAttribute(message.mid)}"`,
95
+ `ccmsg-from="${escapeAttribute(message.from)}"`,
96
+ ];
97
+ if (message.reply_to !== undefined) {
98
+ attributes.push(`ccmsg-reply-to="${escapeAttribute(message.reply_to)}"`);
99
+ }
100
+ const body = `${message.text}\n\n${directDeliveryReplyLine(message.mid, message.from)}`;
101
+ return `<${DIRECT_DELIVERY_TAG} ${attributes.join(" ")}>\n${body}\n</${DIRECT_DELIVERY_TAG}>`;
102
+ }
103
+
104
+ const OPENING = new RegExp(`^<${DIRECT_DELIVERY_TAG}((?:\\s+[a-z-]+="[^"]*")*)\\s*>\\n`);
105
+ const ATTRIBUTE = /([a-z-]+)="([^"]*)"/g;
106
+
107
+ /** Read back what `renderDirectDelivery` wrote.
108
+ *
109
+ * For the recipient side: a session or client holding the delivered text can
110
+ * recover the frame it answers without the sender's help. Returns `undefined`
111
+ * for anything that is not one of these envelopes — a plain message, or one
112
+ * missing the identity that makes it answerable.
113
+ *
114
+ * The closing tag is taken from the end so a body containing one round-trips,
115
+ * and the reply line this delivery added is removed, leaving the sender's own
116
+ * text. */
117
+ export function parseDirectDelivery(delivered: string): DirectDelivery | undefined {
118
+ const opening = OPENING.exec(delivered);
119
+ if (!opening) return undefined;
120
+ const closing = `\n</${DIRECT_DELIVERY_TAG}>`;
121
+ const end = delivered.lastIndexOf(closing);
122
+ if (end < opening[0].length - 1) return undefined;
123
+
124
+ const attributes = new Map<string, string>();
125
+ for (const [, key, value] of (opening[1] ?? "").matchAll(ATTRIBUTE)) {
126
+ if (key !== undefined && value !== undefined) attributes.set(key, unescapeAttribute(value));
127
+ }
128
+ const mid = attributes.get("ccmsg-mid");
129
+ const from = attributes.get("ccmsg-from");
130
+ const from_label = attributes.get("from-name");
131
+ if (mid === undefined || from === undefined || from_label === undefined) return undefined;
132
+
133
+ let text = delivered.slice(opening[0].length, end);
134
+ const suffix = `\n\n${directDeliveryReplyLine(mid, from)}`;
135
+ if (text.endsWith(suffix)) text = text.slice(0, -suffix.length);
136
+
137
+ const reply_to = attributes.get("ccmsg-reply-to");
138
+ return { mid, from, from_label, ...(reply_to !== undefined ? { reply_to } : {}), text };
139
+ }
@@ -72,7 +72,8 @@ export const MessageSendResponse = response("message_send", MessageSendResult);
72
72
  *
73
73
  * To answer it, send to `from`. The route is the sender's id and nothing else,
74
74
  * so no reply instructions travel on the wire: the wording a session sees
75
- * belongs to whichever client renders it. */
75
+ * belongs to whoever renders it — see `direct-delivery.ts` for the one route
76
+ * whose recipient reads text instead of this frame. */
76
77
  export const InboxMessage = Type.Object(
77
78
  {
78
79
  mid: Mid,