@vellumai/assistant 0.12.0-staging.1 → 0.12.0-staging.3

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.
Files changed (92) hide show
  1. package/Dockerfile +5 -0
  2. package/docs/architecture/turn-actor.md +9 -0
  3. package/knip.json +1 -0
  4. package/node_modules/@vellumai/app-icons/package.json +18 -0
  5. package/node_modules/@vellumai/app-icons/src/index.test.ts +85 -0
  6. package/node_modules/@vellumai/app-icons/src/index.ts +387 -0
  7. package/node_modules/@vellumai/app-icons/tsconfig.json +20 -0
  8. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  9. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  10. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  11. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  12. package/node_modules/@vellumai/gateway-client/src/__tests__/plugin-admission-denied-contract.test.ts +10 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  14. package/node_modules/@vellumai/gateway-client/src/plugin-admission-denied-contract.ts +15 -2
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +31 -0
  16. package/node_modules/@vellumai/service-contracts/src/channels.ts +28 -3
  17. package/openapi.yaml +628 -0
  18. package/package.json +3 -1
  19. package/scripts/smoke-container-workspace-dependencies.ts +19 -0
  20. package/src/__tests__/app-builder-icon-names.test.ts +29 -0
  21. package/src/__tests__/assistant-attachment-directive.test.ts +4 -0
  22. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -0
  23. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  24. package/src/__tests__/conversation-agent-loop.test.ts +2 -0
  25. package/src/__tests__/conversation-attachments.test.ts +142 -0
  26. package/src/__tests__/conversation-delete-activation-progress.test.ts +145 -0
  27. package/src/__tests__/conversation-event-sink.test.ts +50 -0
  28. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +10 -2
  29. package/src/__tests__/conversation-queue.test.ts +297 -0
  30. package/src/__tests__/credential-routes.test.ts +185 -6
  31. package/src/__tests__/drain-kick-guard.test.ts +2 -0
  32. package/src/__tests__/drain-requeue-on-contention.test.ts +6 -0
  33. package/src/__tests__/messaging-send-tool.test.ts +42 -0
  34. package/src/__tests__/oauth-commands-routes.test.ts +47 -0
  35. package/src/__tests__/subagent-manager-notify.test.ts +25 -4
  36. package/src/activation/progress-store.test.ts +1564 -0
  37. package/src/activation/progress-store.ts +1296 -0
  38. package/src/activation/turn-hooks.test.ts +246 -0
  39. package/src/activation/turn-hooks.ts +126 -0
  40. package/src/api/events/subagent-status-changed.ts +7 -5
  41. package/src/api/responses/activation.ts +136 -0
  42. package/src/apps/app-store.ts +8 -0
  43. package/src/cli/__tests__/catalog-search-help.test.ts +7 -5
  44. package/src/cli/commands/__tests__/cli-test-harness.ts +12 -3
  45. package/src/cli/commands/channels/__tests__/channels.test.ts +2 -0
  46. package/src/cli/commands/channels/__tests__/request.test.ts +191 -0
  47. package/src/cli/commands/channels/index.help.ts +70 -18
  48. package/src/cli/commands/channels/index.ts +17 -6
  49. package/src/cli/commands/channels/request.ts +66 -0
  50. package/src/cli/commands/oauth/request.test.ts +2 -0
  51. package/src/cli/commands/oauth/request.ts +227 -186
  52. package/src/config/bundled-skills/app-builder/SKILL.md +3 -1
  53. package/src/config/bundled-skills/app-builder/TOOLS.json +2 -2
  54. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +8 -4
  55. package/src/config/feature-flag-registry.json +35 -5
  56. package/src/daemon/assistant-attachments.ts +11 -0
  57. package/src/daemon/conversation-agent-loop-handlers.ts +5 -0
  58. package/src/daemon/conversation-agent-loop.ts +71 -3
  59. package/src/daemon/conversation-attachments.ts +42 -1
  60. package/src/daemon/conversation-event-sink.ts +25 -0
  61. package/src/daemon/conversation-process.ts +69 -21
  62. package/src/daemon/conversation-store.ts +4 -3
  63. package/src/daemon/conversation-surfaces.ts +19 -4
  64. package/src/daemon/message-types/sync.ts +2 -0
  65. package/src/ipc/assistant-server.ts +2 -0
  66. package/src/ipc/routes/__tests__/activation-sync-ipc-routes.test.ts +51 -0
  67. package/src/ipc/routes/activation-sync-ipc-routes.ts +42 -0
  68. package/src/notifications/AGENTS.md +1 -1
  69. package/src/notifications/__tests__/proactive-home-thread.test.ts +111 -0
  70. package/src/notifications/conversation-pairing.ts +47 -5
  71. package/src/notifications/delivered-post-record.ts +3 -0
  72. package/src/persistence/__tests__/slack-thread-root-evidence.test.ts +102 -0
  73. package/src/persistence/conversation-crud.ts +39 -0
  74. package/src/persistence/delivery-crud.ts +13 -0
  75. package/src/plugins/AGENTS.md +1 -0
  76. package/src/runtime/auth/__tests__/route-policy.test.ts +37 -0
  77. package/src/runtime/routes/__tests__/user-routes-notices.test.ts +248 -0
  78. package/src/runtime/routes/activation-routes.test.ts +415 -0
  79. package/src/runtime/routes/activation-routes.ts +172 -0
  80. package/src/runtime/routes/credential-routes.ts +46 -3
  81. package/src/runtime/routes/index.ts +2 -0
  82. package/src/runtime/routes/oauth-commands-routes.ts +21 -8
  83. package/src/runtime/routes/platform-managed-credentials.ts +48 -0
  84. package/src/runtime/routes/secret-routes.ts +3 -12
  85. package/src/runtime/routes/user-route-resolution.ts +21 -0
  86. package/src/runtime/routes/user-routes.ts +112 -9
  87. package/src/runtime/sync/activation-sidecar-publish.test.ts +78 -0
  88. package/src/runtime/sync/documents-sidecar-publish.test.ts +3 -0
  89. package/src/runtime/sync/resource-sync-events.ts +23 -0
  90. package/src/runtime/sync/worker-daemon-notify.test.ts +36 -0
  91. package/src/runtime/sync/worker-daemon-notify.ts +39 -1
  92. package/src/tools/apps/executors.ts +8 -8
package/Dockerfile CHANGED
@@ -108,6 +108,7 @@ RUN ln -sf /usr/local/bin/bun /usr/local/bin/bunx
108
108
  COPY --link --from=builder /app /app
109
109
 
110
110
  # Copy source after installing dependencies for better layer caching.
111
+ COPY packages/app-icons /app/packages/app-icons
111
112
  COPY packages/avatar-catalog /app/packages/avatar-catalog
112
113
  COPY packages/avatar-manifest /app/packages/avatar-manifest
113
114
  COPY packages/ces-client /app/packages/ces-client
@@ -122,6 +123,10 @@ COPY packages/twilio-client /app/packages/twilio-client
122
123
 
123
124
  COPY assistant /app/assistant
124
125
 
126
+ # Resolve production workspace dependencies from the filesystem layout shipped
127
+ # in the image.
128
+ RUN bun run scripts/smoke-container-workspace-dependencies.ts
129
+
125
130
  # The repo-root .dockerignore decides which skills (and files) ship.
126
131
  COPY skills /app/skills
127
132
 
@@ -60,6 +60,15 @@ and restore the prior value afterwards, each guarding the restore so a turn that
60
60
  started in between is not clobbered. They are supplying the acting actor for
61
61
  their run, and are covered by this contract.
62
62
 
63
+ A queued message commits to a run at its drain, not at its enqueue. The drains
64
+ (`drainSingleMessage` and `drainBatch` in `conversation-process.ts`) stamp the
65
+ queued sender the way `processMessage` stamps its committing actor, and re-scope
66
+ the resident history to them, so a turn drained behind another actor's turn
67
+ runs as its sender everywhere the resting actor is read, not only in the
68
+ per-turn snapshot. A steered drain skips the re-scope: its sender owned the
69
+ turn it cut off, and the resident history may carry the in-memory repair of the
70
+ abandoned `tool_use`.
71
+
63
72
  `call-controller` keeps its own `trustContext` on its own object and never reads
64
73
  the conversation's. It is outside this contract.
65
74
 
package/knip.json CHANGED
@@ -15,6 +15,7 @@
15
15
  "ignore": ["src/config/preloaded-apps/**"],
16
16
  "ignoreDependencies": [
17
17
  "@microsoft/api-extractor",
18
+ "@vellumai/app-icons",
18
19
  "@vellumai/avatar-catalog",
19
20
  "@vellumai/avatar-manifest",
20
21
  "@vellumai/ces-client",
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@vellumai/app-icons",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "scripts": {
11
+ "typecheck": "bunx tsc --noEmit",
12
+ "test": "bun test src/"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "1.3.10",
16
+ "typescript": "5.9.3"
17
+ }
18
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ APP_ICON_NAMES,
5
+ bridgeEmojiAppIcon,
6
+ EMOJI_ICON_NAMES,
7
+ isAppIconName,
8
+ isEmojiAppIcon,
9
+ normalizeAppIcon,
10
+ } from "./index.js";
11
+
12
+ describe("APP_ICON_NAMES", () => {
13
+ test("is kebab-case and free of duplicates", () => {
14
+ expect(new Set(APP_ICON_NAMES).size).toBe(APP_ICON_NAMES.length);
15
+ for (const name of APP_ICON_NAMES) {
16
+ expect(name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
17
+ }
18
+ expect(isAppIconName("calculator")).toBe(true);
19
+ expect(isAppIconName("not-an-icon")).toBe(false);
20
+ });
21
+
22
+ test("every emoji maps to a registry name", () => {
23
+ for (const name of Object.values(EMOJI_ICON_NAMES)) {
24
+ expect(isAppIconName(name)).toBe(true);
25
+ }
26
+ });
27
+ });
28
+
29
+ describe("isEmojiAppIcon", () => {
30
+ test("accepts an emoji, a sequence, and a keycap", () => {
31
+ expect(isEmojiAppIcon("☕")).toBe(true);
32
+ expect(isEmojiAppIcon("☕️")).toBe(true);
33
+ expect(isEmojiAppIcon("👨‍💻")).toBe(true);
34
+ expect(isEmojiAppIcon("1️⃣")).toBe(true);
35
+ expect(isEmojiAppIcon("👍🏽")).toBe(true);
36
+ });
37
+
38
+ test("rejects text, text around an emoji, and bare marks", () => {
39
+ expect(isEmojiAppIcon("coffee")).toBe(false);
40
+ expect(isEmojiAppIcon("coffee ☕")).toBe(false);
41
+ expect(isEmojiAppIcon("☕ coffee")).toBe(false);
42
+ expect(isEmojiAppIcon("\u200D")).toBe(false);
43
+ expect(isEmojiAppIcon("")).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("bridgeEmojiAppIcon", () => {
48
+ test("maps a known emoji to its name, with or without the selector", () => {
49
+ expect(bridgeEmojiAppIcon("🔢")).toBe("calculator");
50
+ expect(bridgeEmojiAppIcon("☕️")).toBe("coffee");
51
+ });
52
+
53
+ test("passes everything else through, prototype names included", () => {
54
+ expect(bridgeEmojiAppIcon("calculator")).toBe("calculator");
55
+ expect(bridgeEmojiAppIcon("1️⃣")).toBe("1️⃣");
56
+ expect(bridgeEmojiAppIcon("constructor")).toBe("constructor");
57
+ expect(bridgeEmojiAppIcon(undefined)).toBeUndefined();
58
+ });
59
+ });
60
+
61
+ describe("normalizeAppIcon", () => {
62
+ test("keeps a registry name, normalised to its kebab-case key", () => {
63
+ expect(normalizeAppIcon("calculator")).toBe("calculator");
64
+ expect(normalizeAppIcon(" Calculator ")).toBe("calculator");
65
+ expect(normalizeAppIcon("List-Todo")).toBe("list-todo");
66
+ });
67
+
68
+ test("maps a known emoji and keeps an unknown one", () => {
69
+ expect(normalizeAppIcon("☕")).toBe("coffee");
70
+ expect(normalizeAppIcon("🔢")).toBe("calculator");
71
+ expect(normalizeAppIcon("1️⃣")).toBe("1️⃣");
72
+ });
73
+
74
+ test("drops URLs, text around an emoji, unknown names, empties, non-strings", () => {
75
+ expect(normalizeAppIcon("https://example.com/icon.png")).toBeUndefined();
76
+ expect(normalizeAppIcon("http://x/y.svg")).toBeUndefined();
77
+ expect(normalizeAppIcon("coffee ☕")).toBeUndefined();
78
+ expect(normalizeAppIcon("not-an-icon")).toBeUndefined();
79
+ expect(normalizeAppIcon("constructor")).toBeUndefined();
80
+ expect(normalizeAppIcon("")).toBeUndefined();
81
+ expect(normalizeAppIcon(" ")).toBeUndefined();
82
+ expect(normalizeAppIcon(42)).toBeUndefined();
83
+ expect(normalizeAppIcon(undefined)).toBeUndefined();
84
+ });
85
+ });
@@ -0,0 +1,387 @@
1
+ /**
2
+ * The icons an app can wear, shared by the assistant (which picks and stores
3
+ * one on an app's manifest) and the web client (which draws it).
4
+ *
5
+ * An app's `icon` is a Lucide kebab-case name from {@link APP_ICON_NAMES}.
6
+ * The assistant chooses it at `app_create`; the web client keeps the
7
+ * name-to-glyph map, typed against {@link AppIconName} so a name added here
8
+ * without a glyph there fails its typecheck. A manifest may also carry an
9
+ * emoji in place of a name; {@link EMOJI_ICON_NAMES} maps the common ones to
10
+ * the name each stands for, and both sides apply the map.
11
+ */
12
+
13
+ export const APP_ICON_NAMES = [
14
+ "calculator",
15
+ "calendar",
16
+ "list-todo",
17
+ "list-checks",
18
+ "square-check",
19
+ "timer",
20
+ "clock",
21
+ "alarm-clock",
22
+ "notebook-pen",
23
+ "sticky-note",
24
+ "pencil",
25
+ "file-text",
26
+ "clipboard-list",
27
+ "bookmark",
28
+ "book",
29
+ "book-open",
30
+ "chart-bar",
31
+ "chart-line",
32
+ "chart-pie",
33
+ "table",
34
+ "square-kanban",
35
+ "database",
36
+ "gauge",
37
+ "activity",
38
+ "target",
39
+ "flag",
40
+ "trophy",
41
+ "wallet",
42
+ "dollar-sign",
43
+ "piggy-bank",
44
+ "credit-card",
45
+ "receipt",
46
+ "percent",
47
+ "shopping-cart",
48
+ "package",
49
+ "gift",
50
+ "ticket",
51
+ "mail",
52
+ "inbox",
53
+ "message-square",
54
+ "phone",
55
+ "bell",
56
+ "users",
57
+ "contact",
58
+ "music",
59
+ "headphones",
60
+ "mic",
61
+ "video",
62
+ "film",
63
+ "tv",
64
+ "play",
65
+ "image",
66
+ "camera",
67
+ "gamepad-2",
68
+ "puzzle",
69
+ "party-popper",
70
+ "smile",
71
+ "map",
72
+ "map-pin",
73
+ "compass",
74
+ "globe",
75
+ "plane",
76
+ "car",
77
+ "bus",
78
+ "bike",
79
+ "ship",
80
+ "truck",
81
+ "house",
82
+ "bed",
83
+ "briefcase",
84
+ "graduation-cap",
85
+ "languages",
86
+ "brain",
87
+ "lightbulb",
88
+ "heart",
89
+ "heart-pulse",
90
+ "dumbbell",
91
+ "pill",
92
+ "stethoscope",
93
+ "baby",
94
+ "paw-print",
95
+ "utensils",
96
+ "coffee",
97
+ "wine",
98
+ "beer",
99
+ "cake",
100
+ "apple",
101
+ "carrot",
102
+ "salad",
103
+ "egg",
104
+ "fish",
105
+ "cloud",
106
+ "sun",
107
+ "moon",
108
+ "umbrella",
109
+ "snowflake",
110
+ "thermometer",
111
+ "droplets",
112
+ "flame",
113
+ "leaf",
114
+ "mountain",
115
+ "code",
116
+ "terminal",
117
+ "cpu",
118
+ "bot",
119
+ "wifi",
120
+ "lock",
121
+ "key",
122
+ "shield",
123
+ "settings",
124
+ "wrench",
125
+ "plug",
126
+ "battery",
127
+ "search",
128
+ "link",
129
+ "hash",
130
+ "layers",
131
+ "folder-open",
132
+ "palette",
133
+ "pen-tool",
134
+ "ruler",
135
+ "scale",
136
+ "scissors",
137
+ "shirt",
138
+ "newspaper",
139
+ "repeat",
140
+ "shuffle",
141
+ "volume-2",
142
+ "speaker",
143
+ "star",
144
+ "sparkles",
145
+ "zap",
146
+ "rocket",
147
+ "home",
148
+ ] as const;
149
+
150
+ export type AppIconName = (typeof APP_ICON_NAMES)[number];
151
+
152
+ const APP_ICON_NAME_SET: ReadonlySet<string> = new Set(APP_ICON_NAMES);
153
+
154
+ export function isAppIconName(value: string): value is AppIconName {
155
+ return APP_ICON_NAME_SET.has(value);
156
+ }
157
+
158
+ /**
159
+ * Emoji a manifest may carry as its icon, each mapped to the registry name
160
+ * it stands for. Keys carry no variation selector; the lookup strips it.
161
+ */
162
+ export const EMOJI_ICON_NAMES: Readonly<Record<string, AppIconName>> = {
163
+ "🔢": "calculator",
164
+ "🧮": "calculator",
165
+ "📅": "calendar",
166
+ "🗓": "calendar",
167
+ "✅": "list-todo",
168
+ "☑": "list-checks",
169
+ "⏱": "timer",
170
+ "⏲": "timer",
171
+ "⏰": "alarm-clock",
172
+ "🕐": "clock",
173
+ "🕒": "clock",
174
+ "📝": "notebook-pen",
175
+ "🗒": "sticky-note",
176
+ "✏": "pencil",
177
+ "📄": "file-text",
178
+ "📋": "clipboard-list",
179
+ "🔖": "bookmark",
180
+ "📚": "book",
181
+ "📖": "book-open",
182
+ "📔": "notebook-pen",
183
+ "📊": "chart-bar",
184
+ "📈": "chart-line",
185
+ "📉": "chart-line",
186
+ "🥧": "chart-pie",
187
+ "🗄": "database",
188
+ "🎯": "target",
189
+ "🚩": "flag",
190
+ "🏆": "trophy",
191
+ "🏁": "flag",
192
+ "💰": "wallet",
193
+ "💵": "dollar-sign",
194
+ "💲": "dollar-sign",
195
+ "🐷": "piggy-bank",
196
+ "💳": "credit-card",
197
+ "🧾": "receipt",
198
+ "🛒": "shopping-cart",
199
+ "🛍": "shopping-cart",
200
+ "📦": "package",
201
+ "🎁": "gift",
202
+ "🎟": "ticket",
203
+ "🎫": "ticket",
204
+ "✉": "mail",
205
+ "📧": "mail",
206
+ "📨": "mail",
207
+ "📥": "inbox",
208
+ "💬": "message-square",
209
+ "🗨": "message-square",
210
+ "📞": "phone",
211
+ "☎": "phone",
212
+ "🔔": "bell",
213
+ "👥": "users",
214
+ "👤": "contact",
215
+ "🎵": "music",
216
+ "🎶": "music",
217
+ "🎧": "headphones",
218
+ "🎤": "mic",
219
+ "🎙": "mic",
220
+ "📹": "video",
221
+ "🎬": "film",
222
+ "🎥": "film",
223
+ "📺": "tv",
224
+ "▶": "play",
225
+ "🖼": "image",
226
+ "📷": "camera",
227
+ "📸": "camera",
228
+ "🎮": "gamepad-2",
229
+ "🕹": "gamepad-2",
230
+ "🧩": "puzzle",
231
+ "🎉": "party-popper",
232
+ "🎊": "party-popper",
233
+ "😀": "smile",
234
+ "🙂": "smile",
235
+ "🗺": "map",
236
+ "📍": "map-pin",
237
+ "🧭": "compass",
238
+ "🌍": "globe",
239
+ "🌎": "globe",
240
+ "🌏": "globe",
241
+ "✈": "plane",
242
+ "🚗": "car",
243
+ "🚌": "bus",
244
+ "🚲": "bike",
245
+ "🚢": "ship",
246
+ "🚚": "truck",
247
+ "🏠": "house",
248
+ "🏡": "house",
249
+ "🛏": "bed",
250
+ "💼": "briefcase",
251
+ "🎓": "graduation-cap",
252
+ "🌐": "languages",
253
+ "🧠": "brain",
254
+ "💡": "lightbulb",
255
+ "❤": "heart",
256
+ "💗": "heart-pulse",
257
+ "🏋": "dumbbell",
258
+ "💪": "dumbbell",
259
+ "💊": "pill",
260
+ "🩺": "stethoscope",
261
+ "👶": "baby",
262
+ "🐾": "paw-print",
263
+ "🐶": "paw-print",
264
+ "🐱": "paw-print",
265
+ "🍽": "utensils",
266
+ "🍴": "utensils",
267
+ "☕": "coffee",
268
+ "🍷": "wine",
269
+ "🍺": "beer",
270
+ "🍰": "cake",
271
+ "🎂": "cake",
272
+ "🍎": "apple",
273
+ "🥕": "carrot",
274
+ "🥗": "salad",
275
+ "🥚": "egg",
276
+ "🐟": "fish",
277
+ "☁": "cloud",
278
+ "🌤": "cloud",
279
+ "☀": "sun",
280
+ "🌞": "sun",
281
+ "🌙": "moon",
282
+ "☂": "umbrella",
283
+ "🌧": "umbrella",
284
+ "❄": "snowflake",
285
+ "🌡": "thermometer",
286
+ "💧": "droplets",
287
+ "🔥": "flame",
288
+ "🌱": "leaf",
289
+ "🍃": "leaf",
290
+ "🏔": "mountain",
291
+ "⛰": "mountain",
292
+ "💻": "code",
293
+ "👨‍💻": "code",
294
+ "🖥": "terminal",
295
+ "⌨": "terminal",
296
+ "🤖": "bot",
297
+ "📡": "wifi",
298
+ "🔒": "lock",
299
+ "🔐": "lock",
300
+ "🔑": "key",
301
+ "🛡": "shield",
302
+ "⚙": "settings",
303
+ "🔧": "wrench",
304
+ "🛠": "wrench",
305
+ "🔌": "plug",
306
+ "🔋": "battery",
307
+ "🔍": "search",
308
+ "🔎": "search",
309
+ "🔗": "link",
310
+ "#️⃣": "hash",
311
+ "📁": "folder-open",
312
+ "📂": "folder-open",
313
+ "🎨": "palette",
314
+ "🖌": "pen-tool",
315
+ "📏": "ruler",
316
+ "⚖": "scale",
317
+ "✂": "scissors",
318
+ "👕": "shirt",
319
+ "📰": "newspaper",
320
+ "🔁": "repeat",
321
+ "🔀": "shuffle",
322
+ "🔊": "volume-2",
323
+ "⭐": "star",
324
+ "🌟": "star",
325
+ "✨": "sparkles",
326
+ "⚡": "zap",
327
+ "🚀": "rocket",
328
+ };
329
+
330
+ const VARIATION_SELECTORS = /[\uFE0E\uFE0F]/gu;
331
+
332
+ /* A value that is nothing but emoji: pictographs, emoji-presentation
333
+ characters, skin-tone modifiers, the joiner that composes a sequence, the
334
+ variation selectors, and keycap sequences (a digit, `#` or `*` with the
335
+ keycap mark). Anchored, so text with an emoji in it is not an emoji. */
336
+ const EMOJI_ONLY =
337
+ /^(?:\p{Extended_Pictographic}|\p{Emoji_Presentation}|\p{Emoji_Modifier}|\u200D|\uFE0E|\uFE0F|[0-9#*]\uFE0F?\u20E3)+$/u;
338
+ /* At least one glyph-bearing character, so a bare joiner or selector is not
339
+ an emoji either. */
340
+ const EMOJI_GLYPH = /\p{Extended_Pictographic}|\p{Emoji_Presentation}|\u20E3/u;
341
+
342
+ /** True when `value` is an emoji (or emoji sequence) and nothing else. */
343
+ export function isEmojiAppIcon(value: string): boolean {
344
+ return EMOJI_ONLY.test(value) && EMOJI_GLYPH.test(value);
345
+ }
346
+
347
+ /**
348
+ * A stored icon as a client should read it: an emoji the map knows becomes
349
+ * its registry name, and anything else passes through untouched, so reading
350
+ * a manifest never loses an icon the map cannot place.
351
+ */
352
+ export function bridgeEmojiAppIcon(
353
+ icon: string | undefined,
354
+ ): string | undefined {
355
+ if (!icon) {
356
+ return icon;
357
+ }
358
+ const key = icon.trim().replace(VARIATION_SELECTORS, "");
359
+ /* An own-property check: a bare index would find `constructor` and its
360
+ kin on the object's prototype. */
361
+ return Object.hasOwn(EMOJI_ICON_NAMES, key) ? EMOJI_ICON_NAMES[key] : icon;
362
+ }
363
+
364
+ /**
365
+ * The icon to persist on an app's manifest for a value the model passed: a
366
+ * registry name (any case) as its kebab-case key, an emoji as the name it
367
+ * maps to (or as itself when the map has none), and `undefined` for anything
368
+ * else. URLs and text around an emoji are dropped: they would render as raw
369
+ * strings, and an image icon is `app_generate_icon`'s job.
370
+ */
371
+ export function normalizeAppIcon(raw: unknown): string | undefined {
372
+ if (typeof raw !== "string") {
373
+ return undefined;
374
+ }
375
+ const trimmed = raw.trim();
376
+ if (trimmed === "" || /^https?:\/\//i.test(trimmed)) {
377
+ return undefined;
378
+ }
379
+ const name = trimmed.toLowerCase();
380
+ if (isAppIconName(name)) {
381
+ return name;
382
+ }
383
+ if (isEmojiAppIcon(trimmed)) {
384
+ return bridgeEmojiAppIcon(trimmed);
385
+ }
386
+ return undefined;
387
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "resolveJsonModule": true,
11
+ "declaration": true,
12
+ "declarationMap": true,
13
+ "sourceMap": true,
14
+ "outDir": "./dist",
15
+ "rootDir": "./src",
16
+ "types": ["bun"]
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ botProviderForChannel,
4
5
  CHANNEL_BOT_PROVIDER,
5
6
  CHANNEL_IDS,
7
+ channelForBotProvider,
6
8
  isChannelBotProvider,
7
9
  isChannelUserIntegration,
8
10
  isChannelId,
@@ -41,6 +43,35 @@ describe("isChannelId", () => {
41
43
  });
42
44
  });
43
45
 
46
+ describe("botProviderForChannel and channelForBotProvider", () => {
47
+ test("are inverses over every entry of the map", () => {
48
+ for (const [channelId, botProviderKey] of Object.entries(
49
+ CHANNEL_BOT_PROVIDER,
50
+ )) {
51
+ expect(botProviderForChannel(channelId)).toBe(botProviderKey);
52
+ expect(channelForBotProvider(botProviderKey)).toBe(
53
+ channelId as keyof typeof CHANNEL_BOT_PROVIDER,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
+ expect(botProviderForChannel("phone")).toBeUndefined();
60
+ expect(botProviderForChannel("vellum")).toBeUndefined();
61
+ expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
+ // `slack` is the person's integration standing beside the bot's key.
63
+ expect(channelForBotProvider("slack")).toBeUndefined();
64
+ expect(channelForBotProvider("google")).toBeUndefined();
65
+ });
66
+
67
+ test("never resolve an inherited object property as a channel or a key", () => {
68
+ for (const name of ["constructor", "toString", "__proto__"]) {
69
+ expect(botProviderForChannel(name)).toBeUndefined();
70
+ expect(channelForBotProvider(name)).toBeUndefined();
71
+ }
72
+ });
73
+ });
74
+
44
75
  describe("isChannelUserIntegration", () => {
45
76
  test("names the grant standing beside a bot of the same brand", () => {
46
77
  expect(isChannelUserIntegration("slack")).toBe(true);
@@ -93,9 +93,34 @@ export const CHANNEL_BOT_PROVIDER = {
93
93
  * connected" question, for any provider key.
94
94
  */
95
95
  export function isChannelBotProvider(providerKey: string): boolean {
96
- return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
97
- providerKey,
98
- );
96
+ return channelForBotProvider(providerKey) !== undefined;
97
+ }
98
+
99
+ /**
100
+ * The provider key holding a channel's bot credential, or `undefined` when
101
+ * the channel has none, or is not a channel at all. Walks the map's own
102
+ * entries, so an inherited object property name is never a channel.
103
+ */
104
+ export function botProviderForChannel(channelId: string): string | undefined {
105
+ return Object.entries(CHANNEL_BOT_PROVIDER).find(
106
+ ([id]) => id === channelId,
107
+ )?.[1];
108
+ }
109
+
110
+ /**
111
+ * The channel whose bot credential a provider key holds, or `undefined` when
112
+ * the key is not a bot's. The inverse of `botProviderForChannel`, for a
113
+ * surface that has only the provider key in hand and needs to speak in the
114
+ * channel's vocabulary.
115
+ */
116
+ export function channelForBotProvider(
117
+ providerKey: string,
118
+ ): keyof typeof CHANNEL_BOT_PROVIDER | undefined {
119
+ return (
120
+ Object.entries(CHANNEL_BOT_PROVIDER) as Array<
121
+ [keyof typeof CHANNEL_BOT_PROVIDER, string]
122
+ >
123
+ ).find(([, key]) => key === providerKey)?.[0];
99
124
  }
100
125
 
101
126
  /**
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ botProviderForChannel,
4
5
  CHANNEL_BOT_PROVIDER,
5
6
  CHANNEL_IDS,
7
+ channelForBotProvider,
6
8
  isChannelBotProvider,
7
9
  isChannelUserIntegration,
8
10
  isChannelId,
@@ -41,6 +43,35 @@ describe("isChannelId", () => {
41
43
  });
42
44
  });
43
45
 
46
+ describe("botProviderForChannel and channelForBotProvider", () => {
47
+ test("are inverses over every entry of the map", () => {
48
+ for (const [channelId, botProviderKey] of Object.entries(
49
+ CHANNEL_BOT_PROVIDER,
50
+ )) {
51
+ expect(botProviderForChannel(channelId)).toBe(botProviderKey);
52
+ expect(channelForBotProvider(botProviderKey)).toBe(
53
+ channelId as keyof typeof CHANNEL_BOT_PROVIDER,
54
+ );
55
+ }
56
+ });
57
+
58
+ test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
+ expect(botProviderForChannel("phone")).toBeUndefined();
60
+ expect(botProviderForChannel("vellum")).toBeUndefined();
61
+ expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
+ // `slack` is the person's integration standing beside the bot's key.
63
+ expect(channelForBotProvider("slack")).toBeUndefined();
64
+ expect(channelForBotProvider("google")).toBeUndefined();
65
+ });
66
+
67
+ test("never resolve an inherited object property as a channel or a key", () => {
68
+ for (const name of ["constructor", "toString", "__proto__"]) {
69
+ expect(botProviderForChannel(name)).toBeUndefined();
70
+ expect(channelForBotProvider(name)).toBeUndefined();
71
+ }
72
+ });
73
+ });
74
+
44
75
  describe("isChannelUserIntegration", () => {
45
76
  test("names the grant standing beside a bot of the same brand", () => {
46
77
  expect(isChannelUserIntegration("slack")).toBe(true);