@stage-labs/metro 0.1.0-beta.231 → 0.1.0-beta.233
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 +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/marketplace/plugin/STAGE.md +18 -1
- package/runtime/node_modules/@metro-labs/core/src/stations/types.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +5 -1
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/call-tools.ts +32 -18
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-prompt.ts +12 -7
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +25 -2
- package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +12 -3
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +36 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +30 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +99 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +1 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.233",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: stage
|
|
3
|
-
description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages. Works only when this agent has an XMTP account in metro.
|
|
3
|
+
description: How to use Stage channels (XMTP groups, https://stage.box) through metro. Create a channel, rename it, add or remove members, set labels and assignees, read its info, and send, reply, react, unreact, delete and read messages, and post frames (interactive ChatKit widget views). Works only when this agent has an XMTP account in metro.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
Stage (https://stage.box) is a messenger built on XMTP. A Stage channel is an XMTP group. You use it with the metro tools below (`mcp__metro__<name>`).
|
|
@@ -40,3 +40,20 @@ Move it from Backlog or To-do to In progress, then In review, then Done. Use Blo
|
|
|
40
40
|
- History: `read {"line": "…", "limit": 20}`.
|
|
41
41
|
- Poll: `ask {"line": "…", "question": "Ship today?", "options": ["Yes", "No"]}`.
|
|
42
42
|
- File: `create_upload {"name": "report.pdf"}`, run the `curl` line it returns in a shell, then `send {"line": "…", "attachments": [{"upload": "<upload_id>"}]}`. Files that are not images are refused over about 190 KiB.
|
|
43
|
+
|
|
44
|
+
**Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows it in the chat as a card with a title and a description, and opens it full screen on a tap.
|
|
45
|
+
- Send: `send {"line": "…", "frame": {"title": "Deploy", "description": "Version 2.4 is ready", "widget": {…}}}`. A frame goes with `send`, like a file: there is no separate tool. `title` and `description` are optional: they come from the first `Title` and text in the widget when missing. With `text` as well, the text goes first, and the `message_id` you get back is the frame's.
|
|
46
|
+
- `widget` is OpenAI ChatKit widget JSON, with ChatKit's names and props (https://openai.github.io/chatkit-js/): a `Card`, `ListView` or `Basic` root, with `Row`, `Col`, `Box`, `Text`, `Title`, `Caption`, `Markdown`, `Badge`, `Icon`, `Image`, `Button`, `Divider`, `Spacer`, `Form`, `Input`, `Textarea`, `Select`, `DatePicker`, `Checkbox`, `RadioGroup` and `Table`. Images must be `https://`. Number spacing is in steps of 4px. At most 64K characters, 16 levels deep and 500 nodes. Stage shows a small notice for anything it does not know.
|
|
47
|
+
- Example, the arguments of one `send`:
|
|
48
|
+
```json
|
|
49
|
+
{"line": "…", "frame": {"widget": {"type": "Card", "children": [
|
|
50
|
+
{"type": "Title", "value": "Deploy version 2.4?"},
|
|
51
|
+
{"type": "Text", "value": "3 fixes, no migration.", "color": "secondary"},
|
|
52
|
+
{"type": "Input", "name": "note", "placeholder": "Note (optional)"},
|
|
53
|
+
{"type": "Row", "children": [
|
|
54
|
+
{"type": "Button", "label": "Deploy", "onClickAction": {"type": "deploy", "payload": {"version": "2.4"}}},
|
|
55
|
+
{"type": "Button", "label": "Not now", "color": "secondary", "onClickAction": {"type": "deploy.cancel"}}
|
|
56
|
+
]}
|
|
57
|
+
]}}}
|
|
58
|
+
```
|
|
59
|
+
- A tap comes back to you as a reply to the frame: `Frame action: deploy {"version":"2.4","note":"…"} (tapped "Deploy")`. The values of the fields (`Input`, `Select`, `Checkbox`, …) are added to the payload by their `name`. `Button.onClickAction`, `ListViewItem.onClickAction`, a `Card` `confirm`/`cancel` (`{"label", "action"}`) and a `Form` `onSubmitAction` send one. Answer it like any message.
|
|
@@ -112,6 +112,7 @@ export interface Station {
|
|
|
112
112
|
readsProfiles?: boolean;
|
|
113
113
|
readFilters?: ReadonlySet<ReadFilter>;
|
|
114
114
|
approvals?: boolean;
|
|
115
|
+
sendsFrames?: boolean;
|
|
115
116
|
attachmentMode: AttachmentMode;
|
|
116
117
|
forget?: (accountId: string) => void;
|
|
117
118
|
forgetExcept?: (keptAccountIds: readonly string[]) => void;
|
|
@@ -160,7 +160,11 @@ const PRIOR_METRO: ReadonlySet<string> = new Set([
|
|
|
160
160
|
'5ce3f76d610aae91adc922d0edc6c8be1f27aa2fec4a5e3a6d6231a04fb9310b',
|
|
161
161
|
'b910597d24f31fa479b130a2545f94dd23684d802d99e36b637fc57c818a33be',
|
|
162
162
|
]);
|
|
163
|
-
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
163
|
+
const PRIOR_STAGE: ReadonlySet<string> = new Set([
|
|
164
|
+
'dd7d30474765de3865ddc87c1cf3220161ded09502027cdff18597e5df0aab43',
|
|
165
|
+
'47439ecbe661ca70471a6839ab30a963714c296ad8c14ca23c6eebc3929038cc',
|
|
166
|
+
'e3b8f9bbdb205311bc643aaf91d9d903a91204fd5b55ca949b85cdfa6fd00136',
|
|
167
|
+
]);
|
|
164
168
|
const PRIOR_MEMORY: ReadonlySet<string> = new Set();
|
|
165
169
|
|
|
166
170
|
interface ShippedSkill {
|
|
@@ -60,6 +60,21 @@ function assertDelivered(
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
const frameOf = (m: MessageArgs): Record<string, unknown> =>
|
|
64
|
+
m.a.frame === undefined ? {} : { frame: m.a.frame };
|
|
65
|
+
|
|
66
|
+
const sentLabels = (m: MessageArgs, text: string | undefined): string[] => [
|
|
67
|
+
...(text ? ['text'] : []),
|
|
68
|
+
...(m.a.frame === undefined ? [] : ['frame']),
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
function nativeBody(m: MessageArgs, text: string | undefined, replyTo: string | undefined): Record<string, unknown> {
|
|
72
|
+
const body: Record<string, unknown> = { line: m.line, ...frameOf(m) };
|
|
73
|
+
if (text) body.text = text;
|
|
74
|
+
if (text && replyTo) body.replyTo = replyTo;
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
async function sendNative(
|
|
64
79
|
m: MessageArgs,
|
|
65
80
|
text: string | undefined,
|
|
@@ -67,16 +82,9 @@ async function sendNative(
|
|
|
67
82
|
atts: ResolvedAttachment[],
|
|
68
83
|
): Promise<Sent> {
|
|
69
84
|
const { line, ctx, station } = m;
|
|
70
|
-
const labels
|
|
85
|
+
const labels = sentLabels(m, text);
|
|
71
86
|
let messageId: string | undefined;
|
|
72
|
-
if (text)
|
|
73
|
-
const response = await ctx.call(
|
|
74
|
-
'send',
|
|
75
|
-
replyTo ? { line, text, replyTo } : { line, text },
|
|
76
|
-
);
|
|
77
|
-
messageId = messageIdOf(response);
|
|
78
|
-
labels.push('text');
|
|
79
|
-
}
|
|
87
|
+
if (labels.length) messageId = messageIdOf(await ctx.call('send', nativeBody(m, text, replyTo)));
|
|
80
88
|
if (!atts.length) return { labels, messageId };
|
|
81
89
|
const delivered = station.sendAttachments
|
|
82
90
|
? await station.sendAttachments(line, atts, ctx)
|
|
@@ -93,7 +101,7 @@ async function sendForwarded(
|
|
|
93
101
|
atts: ResolvedAttachment[],
|
|
94
102
|
): Promise<Sent> {
|
|
95
103
|
const { line, ctx, station } = m;
|
|
96
|
-
const args: Record<string, unknown> = { line };
|
|
104
|
+
const args: Record<string, unknown> = { line, ...frameOf(m) };
|
|
97
105
|
if (text) args.text = text;
|
|
98
106
|
if (typeof m.a.subject === 'string' && m.a.subject.trim() !== '') args.subject = m.a.subject;
|
|
99
107
|
if (replyTo) args.replyTo = replyTo;
|
|
@@ -101,8 +109,7 @@ async function sendForwarded(
|
|
|
101
109
|
const response = await ctx.call('send', args);
|
|
102
110
|
const messageId = messageIdOf(response);
|
|
103
111
|
const thread = threadOf(response, line);
|
|
104
|
-
const labels
|
|
105
|
-
if (text) labels.push('text');
|
|
112
|
+
const labels = sentLabels(m, text);
|
|
106
113
|
if (atts.length) {
|
|
107
114
|
const delivered = deliveredLabels(response);
|
|
108
115
|
assertDelivered(station, delivered, atts);
|
|
@@ -116,6 +123,16 @@ const unsupported = (station: Station, atts: CanonicalAttachment[]): string =>
|
|
|
116
123
|
.map((a) => kindOf(a.mime ?? '', a.path ?? a.url ?? a.name ?? ''))
|
|
117
124
|
.join(', ')}); send a link in \`text\` instead`;
|
|
118
125
|
|
|
126
|
+
const NOTHING_TO_SEND = 'send requires `text`, `attachments` or `frame`';
|
|
127
|
+
|
|
128
|
+
function refusal(m: MessageArgs, text: string | undefined, requested: CanonicalAttachment[]): string | undefined {
|
|
129
|
+
if (!text && !requested.length && m.a.frame === undefined) return NOTHING_TO_SEND;
|
|
130
|
+
if (m.a.frame !== undefined && m.station.sendsFrames !== true)
|
|
131
|
+
return `${m.station.name} cannot send a frame; frames are Stage (XMTP) only`;
|
|
132
|
+
if (requested.length && m.station.attachmentMode === 'none') return unsupported(m.station, requested);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
119
136
|
function noteSent(m: MessageArgs, messageId: string | undefined): void {
|
|
120
137
|
if (messageId !== undefined) m.onSent?.(messageId);
|
|
121
138
|
}
|
|
@@ -124,10 +141,8 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
|
|
|
124
141
|
const text = m.a.text as string | undefined;
|
|
125
142
|
const replyTo = m.a.reply_to as string | undefined;
|
|
126
143
|
const requested = (m.a.attachments as CanonicalAttachment[] | undefined) ?? [];
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (requested.length && m.station.attachmentMode === 'none')
|
|
130
|
-
return errResult(unsupported(m.station, requested));
|
|
144
|
+
const refused = refusal(m, text, requested);
|
|
145
|
+
if (refused !== undefined) return errResult(refused);
|
|
131
146
|
const atts = await resolveAttachments(requested, {
|
|
132
147
|
allowed: allowedAgents(currentIdentity()),
|
|
133
148
|
});
|
|
@@ -138,8 +153,7 @@ async function handleSend(m: MessageArgs): Promise<ToolResult> {
|
|
|
138
153
|
const sent = native
|
|
139
154
|
? await sendNative(m, text, replyTo, atts)
|
|
140
155
|
: await sendForwarded(m, text, replyTo, atts);
|
|
141
|
-
if (!sent.labels.length)
|
|
142
|
-
return errResult('send requires `text` or `attachments`');
|
|
156
|
+
if (!sent.labels.length) return errResult(NOTHING_TO_SEND);
|
|
143
157
|
noteSent(m, sent.messageId);
|
|
144
158
|
const done = withId(`sent: ${sent.labels.join(', ')}`, sent.messageId);
|
|
145
159
|
return ok(sent.thread === undefined ? done : `${done} — new thread line: ${sent.thread}`);
|
|
@@ -37,6 +37,17 @@ function channelOf(input: Record<string, unknown>): string | undefined {
|
|
|
37
37
|
return typeof input.station === 'string' && input.station !== '' ? input.station : undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
function argText(value: unknown): string | undefined {
|
|
41
|
+
const plain = valueText(value);
|
|
42
|
+
if (plain !== undefined || value === null || typeof value !== 'object') return plain;
|
|
43
|
+
return JSON.stringify(value);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fieldText(key: string, value: unknown): string | undefined {
|
|
47
|
+
if (key === 'attachments' && Array.isArray(value)) return `${String(value.length)} file(s)`;
|
|
48
|
+
return key === 'frame' ? argText(value) : valueText(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
41
52
|
const lines = [`Approval needed: ${tool}`];
|
|
42
53
|
const channel = channelOf(input);
|
|
@@ -44,7 +55,7 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
|
44
55
|
const keys = [...LEAD, ...Object.keys(input).filter((k) => !LEAD.includes(k))];
|
|
45
56
|
for (const key of keys) {
|
|
46
57
|
if (PLACE.has(key)) continue;
|
|
47
|
-
const text = key
|
|
58
|
+
const text = fieldText(key, input[key]);
|
|
48
59
|
if (text !== undefined) lines.push(`${key === 'text' ? 'Text' : key}: ${key === 'text' ? `"${shorten(text)}"` : shorten(text)}`);
|
|
49
60
|
}
|
|
50
61
|
return lines;
|
|
@@ -52,12 +63,6 @@ function metroLines(tool: string, input: Record<string, unknown>): string[] {
|
|
|
52
63
|
|
|
53
64
|
const ARGS_MAX = 8;
|
|
54
65
|
|
|
55
|
-
function argText(value: unknown): string | undefined {
|
|
56
|
-
const plain = valueText(value);
|
|
57
|
-
if (plain !== undefined || value === null || typeof value !== 'object') return plain;
|
|
58
|
-
return JSON.stringify(value);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
66
|
function connectorLines(name: string, tool: string, input: Record<string, unknown> | undefined): string[] {
|
|
62
67
|
const lines = [`Approval needed: ${tool}`, `Connector: ${name}`];
|
|
63
68
|
const args = Object.entries(input ?? {}).flatMap(([key, value]) => {
|
|
@@ -34,7 +34,7 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
34
34
|
group: 'write',
|
|
35
35
|
description:
|
|
36
36
|
'Send a message (and/or media) to a Metro conversation. Args: line, text?, reply_to?, ' +
|
|
37
|
-
'subject?, attachments?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
37
|
+
'subject?, attachments?, frame?. The station is derived from the line. Outlook and Gmail: send to ' +
|
|
38
38
|
'metro://<outlook|gmail>/<account>/<email address> to start a new email (subject defaults to the ' +
|
|
39
39
|
'first line of text); the result names the new thread line. Each attachment names EXACTLY ONE ' +
|
|
40
40
|
'source, and the choice matters: `upload` (an `upload_id` from `create_upload`, the ' +
|
|
@@ -42,7 +42,8 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
42
42
|
'`data` (base64 inline -- TINY FILES ONLY, a few KB, because the base64 has to be ' +
|
|
43
43
|
'written out verbatim in the call and longer than that it corrupts); `url` (the daemon ' +
|
|
44
44
|
'fetches it, so the file must already be public); `path` (resolved on the DAEMON host, ' +
|
|
45
|
-
'which is not your machine).
|
|
45
|
+
'which is not your machine). Stage (XMTP) only: `frame` posts an interactive view. At least one ' +
|
|
46
|
+
'of text/attachments/frame is required. ' +
|
|
46
47
|
'The success line names each attachment the station actually delivered; a station that ' +
|
|
47
48
|
'cannot carry a file errors instead of reporting success.',
|
|
48
49
|
inputSchema: {
|
|
@@ -66,6 +67,28 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
66
67
|
description: 'Optional files to attach.',
|
|
67
68
|
items: attachmentItem,
|
|
68
69
|
},
|
|
70
|
+
frame: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
description:
|
|
73
|
+
'Stage (XMTP) only: a frame, a small interactive view that Stage shows as a card (title and ' +
|
|
74
|
+
'description) and opens full screen. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
|
|
75
|
+
'or Basic root, 64K characters at most); a missing `title` or `description` is taken from the ' +
|
|
76
|
+
'widget. A tap comes back to you as a reply to the frame: "Frame action: <type> <payload ' +
|
|
77
|
+
"JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
|
|
78
|
+
'skill lists the widget nodes, with an example.',
|
|
79
|
+
properties: {
|
|
80
|
+
widget: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
description:
|
|
83
|
+
'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
|
|
84
|
+
'{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
|
|
85
|
+
additionalProperties: true,
|
|
86
|
+
},
|
|
87
|
+
title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
|
|
88
|
+
description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
|
|
89
|
+
},
|
|
90
|
+
required: ['widget'],
|
|
91
|
+
},
|
|
69
92
|
},
|
|
70
93
|
required: ['line'],
|
|
71
94
|
},
|
|
@@ -12,7 +12,8 @@ import {
|
|
|
12
12
|
import { convOf, type Account } from './accounts.js';
|
|
13
13
|
import { resolveMsgId } from './wire.js';
|
|
14
14
|
import { emitOutbound } from './emit.js';
|
|
15
|
-
import { PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
|
|
15
|
+
import { FrameCodec, PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
|
|
16
|
+
import { buildFrameContent } from './frames.js';
|
|
16
17
|
import { convHandlers } from './actions-conv.js';
|
|
17
18
|
import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
|
|
18
19
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
@@ -30,10 +31,18 @@ const badArgs = (message: string): TrainError =>
|
|
|
30
31
|
|
|
31
32
|
async function send(id: string, args: Args): Promise<void> {
|
|
32
33
|
const { line, text } = args as { line: string; text: string };
|
|
34
|
+
const built = args.frame === undefined ? undefined : buildFrameContent(args.frame);
|
|
33
35
|
const { acct, conv } = await convOf(line);
|
|
34
36
|
if (!conv) throw noConv(line);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
let messageId: string | undefined;
|
|
38
|
+
if (text || !built) {
|
|
39
|
+
messageId = await conv.sendText(text);
|
|
40
|
+
emitOutbound(acct.cfg.id, line, messageId, text);
|
|
41
|
+
}
|
|
42
|
+
if (built) {
|
|
43
|
+
messageId = await conv.send(new FrameCodec().encode(built.frame));
|
|
44
|
+
emitOutbound(acct.cfg.id, line, messageId, `Frame: ${built.title}`);
|
|
45
|
+
}
|
|
37
46
|
respond(id, { result: { messageId } });
|
|
38
47
|
}
|
|
39
48
|
|
|
@@ -164,6 +164,40 @@ export const SignatureReferenceCodec =
|
|
|
164
164
|
c.signature ? `[Signature] ${c.signature}` : '[Signature]',
|
|
165
165
|
);
|
|
166
166
|
|
|
167
|
+
export const ContentTypeFrame: ContentTypeId = {
|
|
168
|
+
authorityId: 'stage.box',
|
|
169
|
+
typeId: 'frame',
|
|
170
|
+
versionMajor: 1,
|
|
171
|
+
versionMinor: 0,
|
|
172
|
+
};
|
|
173
|
+
export interface FrameContent {
|
|
174
|
+
title?: string;
|
|
175
|
+
description?: string;
|
|
176
|
+
widget: Record<string, unknown>;
|
|
177
|
+
}
|
|
178
|
+
export const frameFallback = (c: FrameContent): string => {
|
|
179
|
+
const head = c.title ? `Frame: ${c.title}` : 'Frame';
|
|
180
|
+
return c.description ? `${head}\n${c.description}` : head;
|
|
181
|
+
};
|
|
182
|
+
export const FrameCodec = makeJsonCodec<FrameContent>(ContentTypeFrame, frameFallback);
|
|
183
|
+
|
|
184
|
+
export const ContentTypeFrameAction: ContentTypeId = {
|
|
185
|
+
authorityId: 'stage.box',
|
|
186
|
+
typeId: 'frameAction',
|
|
187
|
+
versionMajor: 1,
|
|
188
|
+
versionMinor: 0,
|
|
189
|
+
};
|
|
190
|
+
export interface FrameActionContent {
|
|
191
|
+
frameId: string;
|
|
192
|
+
action: { type: string; payload?: Record<string, unknown> };
|
|
193
|
+
label?: string;
|
|
194
|
+
}
|
|
195
|
+
export const frameActionText = (c: FrameActionContent): string => {
|
|
196
|
+
const payload = c.action.payload === undefined ? '' : ` ${JSON.stringify(c.action.payload)}`;
|
|
197
|
+
return `Frame action: ${c.action.type}${payload}`;
|
|
198
|
+
};
|
|
199
|
+
const FrameActionCodec = makeJsonCodec<FrameActionContent>(ContentTypeFrameAction, frameActionText);
|
|
200
|
+
|
|
167
201
|
const ContentTypeDeleteMessage: ContentTypeId = {
|
|
168
202
|
authorityId: 'xmtp.org',
|
|
169
203
|
typeId: 'deleteMessage',
|
|
@@ -195,4 +229,6 @@ export const CODECS = (): ContentCodec[] => [
|
|
|
195
229
|
new PollCodec(),
|
|
196
230
|
new SignatureRequestCodec(),
|
|
197
231
|
new SignatureReferenceCodec(),
|
|
232
|
+
new FrameCodec(),
|
|
233
|
+
new FrameActionCodec(),
|
|
198
234
|
];
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
} from './attachments.js';
|
|
10
10
|
import { reportAttachment } from '@metro-labs/core/stations/train-events';
|
|
11
11
|
import { sentByMe } from './wire.js';
|
|
12
|
+
import { frameActionText, frameFallback, type FrameContent } from './codecs.js';
|
|
13
|
+
import { asFrameAction } from './frames.js';
|
|
12
14
|
|
|
13
15
|
export interface EnvelopeCtx {
|
|
14
16
|
accountId: string;
|
|
@@ -300,6 +302,32 @@ function refEnvelope(
|
|
|
300
302
|
return undefined;
|
|
301
303
|
}
|
|
302
304
|
|
|
305
|
+
function frameEnvelope(
|
|
306
|
+
base: Record<string, unknown>,
|
|
307
|
+
typeId: string,
|
|
308
|
+
c: object,
|
|
309
|
+
): Record<string, unknown> | undefined {
|
|
310
|
+
if (typeId === 'frame') {
|
|
311
|
+
const f = c as Partial<FrameContent>;
|
|
312
|
+
const frame: FrameContent = {
|
|
313
|
+
...(typeof f.title === 'string' ? { title: f.title } : {}),
|
|
314
|
+
...(typeof f.description === 'string' ? { description: f.description } : {}),
|
|
315
|
+
widget: typeof f.widget === 'object' && f.widget !== null ? f.widget : {},
|
|
316
|
+
};
|
|
317
|
+
return { ...base, text: frameFallback(frame), payload: { contentType: typeId, frame: c } };
|
|
318
|
+
}
|
|
319
|
+
const a = typeId === 'frameAction' ? asFrameAction(c) : undefined;
|
|
320
|
+
if (!a) return undefined;
|
|
321
|
+
const label = a.label ? ` (tapped "${a.label}")` : '';
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
text: `${frameActionText(a)}${label}`,
|
|
325
|
+
event: { type: 'reply', replyTo: a.frameId },
|
|
326
|
+
...(sentByMe(a.frameId) ? { reply_to_self: true } : {}),
|
|
327
|
+
payload: { contentType: typeId, frameAction: a, replyTo: a.frameId },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
303
331
|
export function typedEnvelope(
|
|
304
332
|
base: Record<string, unknown>,
|
|
305
333
|
typeId: string | undefined,
|
|
@@ -311,6 +339,7 @@ export function typedEnvelope(
|
|
|
311
339
|
if (typeId === 'poll') return pollPayload(base, typeId, c);
|
|
312
340
|
return (
|
|
313
341
|
attachmentEnvelope(base, typeId ?? '', c, ctx) ??
|
|
314
|
-
refEnvelope(base, typeId, c)
|
|
342
|
+
refEnvelope(base, typeId, c) ??
|
|
343
|
+
frameEnvelope(base, typeId ?? '', c)
|
|
315
344
|
);
|
|
316
345
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
2
|
+
import type { FrameActionContent, FrameContent } from './codecs.js';
|
|
3
|
+
|
|
4
|
+
export const FRAME_MAX_CHARS = 64 * 1024;
|
|
5
|
+
const MAX_TITLE = 200;
|
|
6
|
+
const MAX_DESCRIPTION = 1000;
|
|
7
|
+
const SUMMARY_SCAN = 80;
|
|
8
|
+
const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
|
|
9
|
+
|
|
10
|
+
type Node = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
const isNode = (v: unknown): v is Node =>
|
|
13
|
+
typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
14
|
+
|
|
15
|
+
const bad = (message: string): TrainError => new TrainError('INVALID_ARGS', message);
|
|
16
|
+
|
|
17
|
+
function plain(value: unknown): string {
|
|
18
|
+
if (typeof value !== 'string') return '';
|
|
19
|
+
return value
|
|
20
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
21
|
+
.replace(/^\s*(?:#{1,6}|>)\s*/gm, '')
|
|
22
|
+
.replace(/[*_`~]/g, '')
|
|
23
|
+
.replace(/\s+/g, ' ')
|
|
24
|
+
.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function childrenOf(node: Node): Node[] {
|
|
28
|
+
const c = node.children;
|
|
29
|
+
if (Array.isArray(c)) return c.filter(isNode);
|
|
30
|
+
return isNode(c) ? [c] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function frameSummary(widget: Node): { title?: string; description?: string } {
|
|
34
|
+
const titles: string[] = [];
|
|
35
|
+
const texts: string[] = [];
|
|
36
|
+
const stack: Node[] = [widget];
|
|
37
|
+
for (let seen = 0; stack.length > 0 && seen < SUMMARY_SCAN; seen += 1) {
|
|
38
|
+
const node = stack.pop();
|
|
39
|
+
if (!node) break;
|
|
40
|
+
const value = plain(node.value);
|
|
41
|
+
if (node.type === 'Title' && value) titles.push(value);
|
|
42
|
+
if (typeof node.type === 'string' && TEXT_TYPES.has(node.type) && value) texts.push(value);
|
|
43
|
+
stack.push(...childrenOf(node).reverse());
|
|
44
|
+
}
|
|
45
|
+
const title = titles[0] ?? texts[0];
|
|
46
|
+
return { title, description: texts.find((t) => t !== title) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function clip(value: unknown, max: number, name: string): string | undefined {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
if (typeof value !== 'string') throw bad(`frame ${name} must be a string`);
|
|
52
|
+
const out = value.trim().slice(0, max);
|
|
53
|
+
return out === '' ? undefined : out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseJson(raw: unknown, name: string): unknown {
|
|
57
|
+
if (typeof raw !== 'string') return raw;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
throw bad(`${name} is not valid JSON`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseWidget(raw: unknown): Node {
|
|
66
|
+
const widget = parseJson(raw, 'frame widget');
|
|
67
|
+
if (!isNode(widget) || typeof widget.type !== 'string' || widget.type === '')
|
|
68
|
+
throw bad('frame widget must be a ChatKit widget object with a `type` (Card, ListView or Basic)');
|
|
69
|
+
const chars = JSON.stringify(widget).length;
|
|
70
|
+
if (chars > FRAME_MAX_CHARS)
|
|
71
|
+
throw bad(`frame widget is ${chars} characters; the limit is ${FRAME_MAX_CHARS}`);
|
|
72
|
+
return widget;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function buildFrameContent(raw: unknown): { frame: FrameContent; title: string } {
|
|
76
|
+
const args = parseJson(raw, 'frame');
|
|
77
|
+
if (!isNode(args)) throw bad('frame must be an object: {widget, title?, description?}');
|
|
78
|
+
const widget = parseWidget(args.widget);
|
|
79
|
+
const derived = frameSummary(widget);
|
|
80
|
+
const title = clip(args.title, MAX_TITLE, 'title') ?? derived.title?.slice(0, MAX_TITLE);
|
|
81
|
+
const description = clip(args.description, MAX_DESCRIPTION, 'description') ?? derived.description?.slice(0, MAX_DESCRIPTION);
|
|
82
|
+
const frame: FrameContent = {
|
|
83
|
+
...(title ? { title } : {}),
|
|
84
|
+
...(description && description !== title ? { description } : {}),
|
|
85
|
+
widget,
|
|
86
|
+
};
|
|
87
|
+
return { frame, title: title ?? 'Frame' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function asFrameAction(c: object): FrameActionContent | undefined {
|
|
91
|
+
const v = c as Partial<FrameActionContent>;
|
|
92
|
+
if (typeof v.frameId !== 'string' || !isNode(v.action) || typeof v.action.type !== 'string') return undefined;
|
|
93
|
+
const payload = isNode(v.action.payload) ? v.action.payload : undefined;
|
|
94
|
+
return {
|
|
95
|
+
frameId: v.frameId,
|
|
96
|
+
action: payload ? { type: v.action.type, payload } : { type: v.action.type },
|
|
97
|
+
...(typeof v.label === 'string' && v.label ? { label: v.label } : {}),
|
|
98
|
+
};
|
|
99
|
+
}
|
package/runtime/runtime.json
CHANGED