@noodleseed/one 0.183.1 → 0.185.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.
Files changed (63) hide show
  1. package/dist/commands/solutions-conversations.d.ts.map +1 -1
  2. package/dist/commands/solutions-conversations.js +3 -3
  3. package/dist/commands/solutions-conversations.js.map +1 -1
  4. package/dist/commands/solutions-history.d.ts.map +1 -1
  5. package/dist/commands/solutions-history.js +6 -3
  6. package/dist/commands/solutions-history.js.map +1 -1
  7. package/dist/commands/whatsapp-ops.d.ts +2 -1
  8. package/dist/commands/whatsapp-ops.d.ts.map +1 -1
  9. package/dist/commands/whatsapp-ops.js +63 -27
  10. package/dist/commands/whatsapp-ops.js.map +1 -1
  11. package/dist/commands/whatsapp-output.d.ts +1 -1
  12. package/dist/commands/whatsapp-output.d.ts.map +1 -1
  13. package/dist/commands/whatsapp-output.js +13 -7
  14. package/dist/commands/whatsapp-output.js.map +1 -1
  15. package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
  16. package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +2 -2
  17. package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
  18. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +7 -2
  19. package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +2 -1
  20. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +88 -8
  21. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +42 -30
  22. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +2 -2
  23. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +60 -11
  24. package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +31 -3
  25. package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +2 -1
  26. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +4 -2
  27. package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +4 -3
  28. package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +2 -1
  29. package/node_modules/@noodle-borg/authoring/dist/assistant.js +5 -4
  30. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +72 -51
  31. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
  32. package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
  33. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +16 -4
  34. package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +8 -3
  35. package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
  36. package/node_modules/@noodle-borg/service/dist/application-history-settings.js +6 -2
  37. package/node_modules/@noodle-borg/service/dist/build-info.js +3 -2
  38. package/node_modules/@noodle-borg/service/dist/channels/instagram-meta.js +196 -0
  39. package/node_modules/@noodle-borg/service/dist/channels/instagram-webhook.js +78 -0
  40. package/node_modules/@noodle-borg/service/dist/channels/provider.js +1 -1
  41. package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +24 -0
  42. package/node_modules/@noodle-borg/service/dist/channels/runtime.js +35 -24
  43. package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +5 -5
  44. package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +2 -1
  45. package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +2 -2
  46. package/node_modules/@noodle-borg/service/dist/routes/instagram-meta.js +79 -0
  47. package/node_modules/@noodle-borg/service/dist/routes/meta-webhook.js +36 -0
  48. package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +4 -1
  49. package/node_modules/@noodle-borg/service/dist/routes/whatsapp-meta.js +3 -34
  50. package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +45 -24
  51. package/node_modules/@noodle-borg/service/dist/service.js +1 -1
  52. package/node_modules/@noodle-borg/service/package.json +1 -1
  53. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
  54. package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +1 -0
  55. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
  56. package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
  57. package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
  58. package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
  59. package/node_modules/@noodle-borg/wire-contracts/dist/instagram.d.ts +181 -0
  60. package/node_modules/@noodle-borg/wire-contracts/dist/instagram.js +50 -0
  61. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
  62. package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +3 -0
  63. package/package.json +3 -3
@@ -0,0 +1,196 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { ChannelError } from '@noodle-borg/assistant-gateway/portable';
3
+ import { guardedFetch } from '@noodle-borg/connector-http';
4
+ import { z } from 'zod';
5
+ import { boundedJson, } from './provider.js';
6
+ /** The one callback every Instagram binding shares; bindings are found by Instagram account id. */
7
+ export const INSTAGRAM_META_CALLBACK_PATH = '/v1/channels/instagram/meta';
8
+ /** Instagram rejects text over 1000 UTF-8 bytes. */
9
+ export const INSTAGRAM_TEXT_BYTES = 1000;
10
+ const node = /^\d{1,32}$/;
11
+ const PAGE_TOKEN_TTL_MS = 600_000;
12
+ /** Derived Page tokens, in memory only and keyed by a digest: never logged, stored or returned. */
13
+ const pageTokens = new Map();
14
+ /** A 4xx that judges the payload itself; auth, throttling and timeouts are no reason to resend. */
15
+ function rejectedPayload(status) {
16
+ return status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
17
+ }
18
+ /**
19
+ * One Instagram professional account reached through its linked Facebook Page on the Messenger
20
+ * Platform (Facebook Login route). Sources, read 2026-09-26:
21
+ * - Instagram Messaging calls use the Page access token of the linked Page
22
+ * (https://developers.facebook.com/docs/messenger-platform/instagram/get-started/); a system-user
23
+ * token with Page access reads it from GET /{page-id}?fields=access_token.
24
+ * - send: POST /{page-id}/messages with `recipient.id` = the Instagram-scoped id and
25
+ * `message.text`, answered `{recipient_id, message_id}`
26
+ * (https://developers.facebook.com/docs/messenger-platform/instagram/features/send-message/);
27
+ * text is at most 1000 bytes; quick replies are at most 13 with titles up to 20 characters.
28
+ * - webhooks: GET | POST /{page-id}/subscribed_apps, `subscribed_fields=messages`
29
+ * (https://developers.facebook.com/docs/messenger-platform/webhooks/).
30
+ * - asset: GET /{page-id}?fields=instagram_business_account names the linked account.
31
+ */
32
+ export class InstagramMeta {
33
+ token;
34
+ asset;
35
+ app;
36
+ fetcher;
37
+ cacheKey;
38
+ constructor(token, asset, app, fetcher = (url, init) => guardedFetch(new URL(url), init)) {
39
+ this.token = token;
40
+ this.asset = asset;
41
+ this.app = app;
42
+ this.fetcher = fetcher;
43
+ if (!/^v\d{1,3}\.\d{1,2}$/.test(app.graphVersion) ||
44
+ !node.test(app.appId) ||
45
+ !node.test(asset.pageId) ||
46
+ !node.test(asset.instagramAccountId))
47
+ throw new ChannelError('provider_configuration_invalid');
48
+ this.cacheKey = createHash('sha256')
49
+ .update(JSON.stringify([token, asset.pageId]))
50
+ .digest('hex');
51
+ }
52
+ request(path, token, method = 'GET', body) {
53
+ return this.fetcher(`https://graph.facebook.com/${this.app.graphVersion}/${this.asset.pageId}${path}`, {
54
+ method,
55
+ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
56
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
57
+ redirect: 'manual',
58
+ signal: AbortSignal.timeout(15_000),
59
+ });
60
+ }
61
+ async pageToken() {
62
+ const cached = pageTokens.get(this.cacheKey);
63
+ if (cached && cached.until > Date.now())
64
+ return cached.token;
65
+ const response = await this.request('?fields=access_token', this.token);
66
+ if (!response.ok) {
67
+ await response.body?.cancel();
68
+ throw new ChannelError(response.status >= 500 ? 'provider_unavailable' : 'provider_page_access_denied');
69
+ }
70
+ const parsed = z
71
+ .object({ access_token: z.string().min(1).max(2048) })
72
+ .safeParse(await boundedJson(response));
73
+ if (!parsed.success)
74
+ throw new ChannelError('provider_page_access_denied');
75
+ if (pageTokens.size >= 100)
76
+ pageTokens.clear();
77
+ pageTokens.set(this.cacheKey, {
78
+ token: parsed.data.access_token,
79
+ until: Date.now() + PAGE_TOKEN_TTL_MS,
80
+ });
81
+ return parsed.data.access_token;
82
+ }
83
+ async health() {
84
+ const response = await this.request('?fields=instagram_business_account', await this.pageToken());
85
+ if (!response.ok) {
86
+ await response.body?.cancel();
87
+ throw new ChannelError('provider_unavailable');
88
+ }
89
+ const parsed = z
90
+ .object({ instagram_business_account: z.object({ id: z.string().max(64) }).optional() })
91
+ .safeParse(await boundedJson(response));
92
+ if (!parsed.success)
93
+ throw new ChannelError('provider_response_invalid');
94
+ const linked = parsed.data.instagram_business_account?.id;
95
+ if (!linked)
96
+ throw new ChannelError('provider_instagram_unlinked');
97
+ // Messaging permission shows only on a send; readiness proves the Page and account link.
98
+ return { assetId: linked, canSend: true, status: 'AVAILABLE' };
99
+ }
100
+ async setBlocked() {
101
+ return { state: 'error', code: 'provider_block_unsupported' };
102
+ }
103
+ async send(input) {
104
+ if (!input.text || Buffer.byteLength(input.text, 'utf8') > INSTAGRAM_TEXT_BYTES)
105
+ return { state: 'failed', code: 'reply_invalid' };
106
+ const buttons = input.buttons ?? [];
107
+ const quick = buttons.length > 0 &&
108
+ buttons.length <= 13 &&
109
+ buttons.every((button) => button.title.length <= 20 && button.id.length <= 1000);
110
+ const text = { recipient: { id: input.to.value }, message: { text: input.text } };
111
+ try {
112
+ const token = await this.pageToken();
113
+ let code;
114
+ let response = quick
115
+ ? await this.request('/messages', token, 'POST', {
116
+ recipient: text.recipient,
117
+ message: {
118
+ text: input.text,
119
+ quick_replies: buttons.map((button) => ({
120
+ content_type: 'text',
121
+ title: button.title,
122
+ payload: button.id,
123
+ })),
124
+ },
125
+ })
126
+ : undefined;
127
+ if (response !== undefined && !response.ok && rejectedPayload(response.status)) {
128
+ await response.body?.cancel();
129
+ code = 'quick_replies_rejected';
130
+ response = undefined;
131
+ }
132
+ response ??= await this.request('/messages', token, 'POST', text);
133
+ if (!response.ok) {
134
+ await response.body?.cancel();
135
+ return {
136
+ state: response.status >= 500 || response.status === 408 ? 'unknown' : 'failed',
137
+ code: `provider_http_${response.status}`,
138
+ };
139
+ }
140
+ const parsed = z
141
+ .object({ message_id: z.string().min(1).max(512) })
142
+ .safeParse(await boundedJson(response));
143
+ return parsed.success
144
+ ? {
145
+ state: 'accepted',
146
+ providerMessageId: parsed.data.message_id,
147
+ ...(code === undefined ? {} : { code }),
148
+ }
149
+ : { state: 'unknown', code: 'provider_receipt_invalid' };
150
+ }
151
+ catch (error) {
152
+ // The Page token was refused before anything was sent, so nothing reached the participant.
153
+ if (error instanceof ChannelError && error.code === 'provider_page_access_denied')
154
+ return { state: 'failed', code: error.code };
155
+ return { state: 'unknown', code: 'send_outcome_unknown' };
156
+ }
157
+ }
158
+ async subscribed(token) {
159
+ const response = await this.request('/subscribed_apps', token);
160
+ if (!response.ok) {
161
+ await response.body?.cancel();
162
+ throw new ChannelError('provider_unavailable');
163
+ }
164
+ const parsed = z
165
+ .object({
166
+ data: z
167
+ .array(z.object({
168
+ id: z.string().max(64),
169
+ subscribed_fields: z.array(z.string().max(128)).max(100).optional(),
170
+ }))
171
+ .max(100),
172
+ })
173
+ .safeParse(await boundedJson(response));
174
+ if (!parsed.success)
175
+ throw new ChannelError('provider_response_invalid');
176
+ return parsed.data.data.some((app) => app.id === this.app.appId && (app.subscribed_fields ?? []).includes('messages'));
177
+ }
178
+ /** The app-level callback URL is set once in the Meta App Dashboard and is not read here. */
179
+ async inspectWebhook(_url) {
180
+ return { matches: await this.subscribed(await this.pageToken()), authenticated: true };
181
+ }
182
+ async configureWebhook(_url) {
183
+ const token = await this.pageToken();
184
+ if (await this.subscribed(token))
185
+ return;
186
+ const response = await this.request('/subscribed_apps?subscribed_fields=messages', token, 'POST');
187
+ const parsed = response.ok
188
+ ? z.object({ success: z.literal(true) }).safeParse(await boundedJson(response))
189
+ : undefined;
190
+ if (!response.ok)
191
+ await response.body?.cancel();
192
+ if (!parsed?.success)
193
+ throw new ChannelError('webhook_configuration_failed');
194
+ }
195
+ }
196
+ //# sourceMappingURL=instagram-meta.js.map
@@ -0,0 +1,78 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * One `entry[].messaging[]` item of an `object: "instagram"` webhook on the Messenger Platform
4
+ * (https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook/, read
5
+ * 2026-09-26). Everything but a new text, a quick-reply tap or a bare attachment is dropped.
6
+ */
7
+ const scoped = z.object({ id: z.string().max(64) });
8
+ const item = z.object({
9
+ sender: scoped,
10
+ recipient: scoped,
11
+ timestamp: z.number().int().positive(),
12
+ message: z
13
+ .object({
14
+ mid: z.string().min(1).max(512),
15
+ text: z.string().max(4096).optional(),
16
+ is_echo: z.boolean().optional(),
17
+ is_deleted: z.boolean().optional(),
18
+ is_unsupported: z.boolean().optional(),
19
+ quick_reply: z.object({ payload: z.string().min(1).max(1000) }).optional(),
20
+ attachments: z
21
+ .array(z.object({ type: z.string().max(64) }))
22
+ .max(20)
23
+ .optional(),
24
+ })
25
+ .optional(),
26
+ });
27
+ /**
28
+ * The inbound messages addressed to `accountId`. Echoes of our own replies and of staff replies from
29
+ * the Instagram inbox arrive with `is_echo` (or our account as sender) and are dropped; so are edits,
30
+ * deletions, reactions, reads, story mentions and unsupported types. A story reply with text is text;
31
+ * a bare attachment arrives without text and gets the existing text-only reply.
32
+ */
33
+ export function parseInstagramMessaging(items, accountId) {
34
+ const messages = [];
35
+ const skipped = [];
36
+ for (const raw of items) {
37
+ const parsed = item.safeParse(raw);
38
+ if (!parsed.success) {
39
+ skipped.push('webhook_invalid');
40
+ continue;
41
+ }
42
+ const { sender, recipient, timestamp, message } = parsed.data;
43
+ if (!message || message.is_deleted || message.is_unsupported) {
44
+ skipped.push('not_a_message');
45
+ continue;
46
+ }
47
+ if (message.is_echo || sender.id === accountId) {
48
+ skipped.push('echo');
49
+ continue;
50
+ }
51
+ if (recipient.id !== accountId) {
52
+ skipped.push('recipient_mismatch');
53
+ continue;
54
+ }
55
+ if (!/^\d{1,64}$/.test(sender.id)) {
56
+ skipped.push('sender_invalid');
57
+ continue;
58
+ }
59
+ const attachments = message.attachments ?? [];
60
+ if (!message.text && attachments.some((attachment) => attachment.type === 'story_mention')) {
61
+ skipped.push('not_a_message');
62
+ continue;
63
+ }
64
+ messages.push({
65
+ providerId: message.mid,
66
+ address: { kind: 'opaque', value: sender.id },
67
+ // Meta documents milliseconds; a seconds value is normalised rather than read as 1970.
68
+ eventAt: timestamp < 1e12 ? timestamp * 1000 : timestamp,
69
+ ...(message.quick_reply
70
+ ? { button: { id: message.quick_reply.payload, title: message.text ?? '' } }
71
+ : message.text
72
+ ? { text: message.text }
73
+ : {}),
74
+ });
75
+ }
76
+ return { messages, skipped };
77
+ }
78
+ //# sourceMappingURL=instagram-webhook.js.map
@@ -75,7 +75,7 @@ export async function cloudApiHealth(response) {
75
75
  return errors.length > 0 && errors.every((error) => PAYMENT_BLOCKS.has(error.error_code));
76
76
  });
77
77
  return {
78
- phoneNumberId: parsed.data.id,
78
+ assetId: parsed.data.id,
79
79
  // LIMITED meets provider messaging requirements; provider limits still govern each send.
80
80
  canSend: aggregate !== 'BLOCKED',
81
81
  status: aggregate,
@@ -0,0 +1,24 @@
1
+ const EXPAND = '\n\nAsk me to expand on any part.';
2
+ /** Instagram rejects text over 1000 UTF-8 bytes; WhatsApp bodies stay well under its 4096 limit. */
3
+ const INSTAGRAM_BYTES = 1000;
4
+ /** One reply within its channel's limit, cut on a character boundary and inviting a follow-up. */
5
+ export function boundReply(channel, answer) {
6
+ if (channel === 'whatsapp')
7
+ return answer.length <= 3500
8
+ ? answer
9
+ : `${answer.slice(0, 3350).replace(/[\uD800-\uDBFF]$/u, '')}${EXPAND}`;
10
+ if (Buffer.byteLength(answer, 'utf8') <= INSTAGRAM_BYTES)
11
+ return answer;
12
+ const budget = INSTAGRAM_BYTES - Buffer.byteLength(EXPAND, 'utf8');
13
+ let kept = '';
14
+ let bytes = 0;
15
+ for (const character of answer) {
16
+ const size = Buffer.byteLength(character, 'utf8');
17
+ if (bytes + size > budget)
18
+ break;
19
+ kept += character;
20
+ bytes += size;
21
+ }
22
+ return `${kept}${EXPAND}`;
23
+ }
24
+ //# sourceMappingURL=reply-bound.js.map
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { beginChannelProviderBlock, CHANNEL_RETENTION_MS, ChannelCoordinator, ChannelError, channelDigest, channelProviderBlocks, collectionSpecFor, finishChannelProviderBlock, messagingSurfaceOf, projectArtifactForSurface, resolveInvocationContextSnapshot, WHATSAPP_CONFIRMATION_EXPIRY_MS, withAssistantTurnExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
2
+ import { beginChannelProviderBlock, CHANNEL_KINDS, CHANNEL_RETENTION_MS, ChannelCoordinator, ChannelError, channelDigest, channelProviderBlocks, collectionSpecFor, finishChannelProviderBlock, messagingSurfaceOf, projectArtifactForSurface, resolveInvocationContextSnapshot, WHATSAPP_CONFIRMATION_EXPIRY_MS, withAssistantTurnExecutionAuthority, } from '@noodle-borg/assistant-gateway/portable';
3
3
  import { guardedFetch } from '@noodle-borg/connector-http';
4
4
  import { executePreparedTool, prepareToolForConfirmation } from '@noodle-borg/runtime';
5
5
  import { historyNoticeSentence, messagingSurfaceHistory } from '../conversation-history/capture.js';
@@ -12,8 +12,10 @@ import { Dialog360, dialog360Provider, verifyWhatsAppWebhookSecret } from './360
12
12
  import { capabilityReport } from './capability-report.js';
13
13
  import { collectionCustody, messagingAdmissionDigest, redactPrivate, runCollectionTurn, } from './collection-turn.js';
14
14
  import { verifiedInferenceBound, withChannelInferenceGuard } from './inference-guard.js';
15
+ import { INSTAGRAM_META_CALLBACK_PATH, InstagramMeta } from './instagram-meta.js';
15
16
  import { messagingProjectionIneligibility } from './messaging-eligibility.js';
16
17
  import { MetaCloud, WHATSAPP_META_CALLBACK_PATH } from './meta-cloud.js';
18
+ import { boundReply } from './reply-bound.js';
17
19
  import { reviewButtonBinding } from './reply-buttons.js';
18
20
  export class WhatsAppRuntime {
19
21
  options;
@@ -37,12 +39,14 @@ export class WhatsAppRuntime {
37
39
  const meta = this.options.meta;
38
40
  if (!meta)
39
41
  throw new ChannelError('meta_platform_unconfigured');
40
- if (!apiKey || !binding.wabaId)
42
+ if (!apiKey || (binding.channel === 'whatsapp' && !binding.wabaId))
41
43
  throw new ChannelError('channel_credentials_missing');
42
44
  // Only the tenant's business token is binding state; the platform app secret is not.
43
45
  await this.channels.checkCredentials(binding.id, channelDigest(JSON.stringify([apiKey])));
44
46
  return {
45
- adapter: new MetaCloud(apiKey, { phoneNumberId: binding.phoneNumberId, wabaId: binding.wabaId }, meta, this.options.providerFetch),
47
+ adapter: binding.channel === 'instagram'
48
+ ? new InstagramMeta(apiKey, { pageId: binding.pageId, instagramAccountId: binding.instagramAccountId }, meta, this.options.providerFetch)
49
+ : new MetaCloud(apiKey, { phoneNumberId: binding.phoneNumberId, wabaId: binding.wabaId }, meta, this.options.providerFetch),
46
50
  };
47
51
  }
48
52
  const webhookSecret = binding.webhookSecret ? values[binding.webhookSecret] : undefined;
@@ -59,6 +63,8 @@ export class WhatsAppRuntime {
59
63
  }
60
64
  webhookUrl(req, binding) {
61
65
  const base = this.deps.serviceBase(req).replace(/\/$/, '');
66
+ if (binding.channel === 'instagram')
67
+ return `${base}${INSTAGRAM_META_CALLBACK_PATH}`;
62
68
  return binding.provider === 'meta'
63
69
  ? `${base}${WHATSAPP_META_CALLBACK_PATH}`
64
70
  : `${base}/v1/channels/whatsapp/webhooks/${binding.id}`;
@@ -67,7 +73,7 @@ export class WhatsAppRuntime {
67
73
  const target = await activeAssistantTarget(this.deps, binding.tenant);
68
74
  if (!target || target.deploymentId !== binding.deploymentId)
69
75
  throw new ChannelError('deployment_changed');
70
- const surface = messagingSurfaceOf(target.served.artifact.server.assistant);
76
+ const surface = messagingSurfaceOf(target.served.artifact.server.assistant, binding.channel);
71
77
  if (!surface)
72
78
  throw new ChannelError('messaging_surface_missing');
73
79
  if (binding.capabilities.some((cap) => !surface.capabilities.some((authored) => authored.kind === cap.kind && authored.name === cap.name)))
@@ -107,7 +113,7 @@ export class WhatsAppRuntime {
107
113
  target = await this.target(binding);
108
114
  });
109
115
  await check('model_cost', async () => {
110
- const model = await resolveAssistantModelBinding(await this.target(binding), binding.tenant, binding.deploymentId, this.deps, 'whatsapp');
116
+ const model = await resolveAssistantModelBinding(await this.target(binding), binding.tenant, binding.deploymentId, this.deps, binding.channel);
111
117
  if (!model)
112
118
  throw new ChannelError('model_not_configured');
113
119
  verifiedInferenceBound(model, this.channels.now());
@@ -123,7 +129,8 @@ export class WhatsAppRuntime {
123
129
  });
124
130
  await check('provider_asset', async () => {
125
131
  const health = await (await this.provider(binding)).adapter.health();
126
- if (health.phoneNumberId !== binding.phoneNumberId)
132
+ const asset = binding.channel === 'instagram' ? binding.instagramAccountId : binding.phoneNumberId;
133
+ if (health.assetId !== asset)
127
134
  throw new ChannelError('asset_mismatch');
128
135
  if (!health.canSend)
129
136
  throw new ChannelError(health.reason === 'payment_method_required'
@@ -166,7 +173,7 @@ export class WhatsAppRuntime {
166
173
  await (await this.provider(binding)).adapter.configureWebhook(this.webhookUrl(req, binding));
167
174
  }
168
175
  async projection(tenant) {
169
- const binding = await this.channels.get(tenant);
176
+ const binding = await this.channels.get(tenant, 'whatsapp');
170
177
  if (!binding)
171
178
  return {
172
179
  status: 'unavailable',
@@ -193,18 +200,23 @@ export class WhatsAppRuntime {
193
200
  }
194
201
  }
195
202
  /**
196
- * Erases one participant's working memory on the tenant's binding (ADR 0241): their context and every
197
- * message body and reply, keeping content-free controls. False when the tenant has no binding or the
198
- * reference cannot name a participant, which never reaches the channel store.
203
+ * Erases one participant's working memory on the tenant's bindings (ADR 0241): their context and every
204
+ * message body and reply, keeping content-free controls. A participant id is scoped to one binding, so
205
+ * every channel's binding is searched. False when the tenant has no binding or the reference cannot
206
+ * name a participant, which never reaches the channel store.
199
207
  */
200
208
  async forgetParticipant(tenant, participantId, actor) {
201
209
  if (!/^p_[a-f0-9]{64}$/.test(participantId))
202
210
  return false;
203
- const binding = await this.channels.get(tenant);
204
- if (!binding)
205
- return false;
206
- await this.channels.forget(binding.id, participantId, actor, `history-forget:${randomUUID()}`);
207
- return true;
211
+ let found = false;
212
+ for (const channel of CHANNEL_KINDS) {
213
+ const binding = await this.channels.get(tenant, channel);
214
+ if (!binding)
215
+ continue;
216
+ found = true;
217
+ await this.channels.forget(binding.id, participantId, actor, `history-forget:${randomUUID()}`);
218
+ }
219
+ return found;
208
220
  }
209
221
  async providerBlock(id, participantId, blocked, actor, key, protectedPhone) {
210
222
  const operation = await beginChannelProviderBlock(this.channels.store, id, participantId, blocked ? 'blocked' : 'unblocked', actor, key, this.channels.now(), protectedPhone);
@@ -339,13 +351,13 @@ export class WhatsAppRuntime {
339
351
  try {
340
352
  const target = await this.target(binding);
341
353
  const artifact = target.served.artifact;
342
- const model = await resolveAssistantModelBinding(target, binding.tenant, binding.deploymentId, this.deps, 'whatsapp');
354
+ const model = await resolveAssistantModelBinding(target, binding.tenant, binding.deploymentId, this.deps, binding.channel);
343
355
  if (!model)
344
356
  throw new ChannelError('model_not_configured');
345
357
  const guarded = withChannelInferenceGuard(model, this.channels, binding.id, event.id, beforeStep);
346
358
  const session = {
347
359
  kind: 'messaging',
348
- channel: 'whatsapp',
360
+ channel: binding.channel,
349
361
  id: event.id,
350
362
  bindingId: binding.id,
351
363
  participantId: participant.id,
@@ -400,7 +412,7 @@ export class WhatsAppRuntime {
400
412
  invocationId: proposalId,
401
413
  publicAdmission: {
402
414
  messaging: messagingAdmissionDigest(binding.id, participant.id),
403
- provenance: { kind: 'messaging', channel: 'whatsapp' },
415
+ provenance: { kind: 'messaging', channel: binding.channel },
404
416
  },
405
417
  });
406
418
  },
@@ -446,9 +458,9 @@ export class WhatsAppRuntime {
446
458
  throw new ChannelError(failure ?? 'answer_unavailable');
447
459
  // WhatsApp has no footer, so a recorded channel states its window here (ADR 0241 decision 17),
448
460
  // in the author's `historyNotice` language when one is declared.
449
- const surface = messagingSurfaceHistory(artifact.server.assistant);
461
+ const surface = messagingSurfaceHistory(artifact.server.assistant, binding.channel);
450
462
  const days = participant.history.length === 0
451
- ? ((await this.deps.conversations?.retentionDays(binding.tenant, 'whatsapp', surface)) ?? 0)
463
+ ? ((await this.deps.conversations?.retentionDays(binding.tenant, binding.channel, surface)) ?? 0)
452
464
  : 0;
453
465
  const kept = days > 0 ? ` ${historyNoticeSentence(days, surface.noticeTemplate)}` : '';
454
466
  const disclosure = participant.history.length === 0
@@ -456,20 +468,19 @@ export class WhatsAppRuntime {
456
468
  : '';
457
469
  answer = disclosure + text.trim();
458
470
  }
459
- const bounded = answer.length <= 3500
460
- ? answer
461
- : `${answer.slice(0, 3350).replace(/[\uD800-\uDBFF]$/u, '')}\n\nAsk me to expand on any part.`;
471
+ const bounded = boundReply(binding.channel, answer);
462
472
  // Marked-private values never persist: history and the event row keep redacted text, and the
463
473
  // full review outlives only its own delivery.
464
474
  const scrub = (text) => ledger !== undefined && spec !== undefined ? redactPrivate(spec, ledger, text) : text;
465
475
  await this.channels.complete(binding.id, event.id, event.lease, bounded, historyRetention, undefined, { user: scrub(heard), assistant: scrub(bounded) }, outcome.kind === 'reply' ? outcome.buttons : undefined);
466
476
  await this.deps.conversations?.recordChannelTurn({
467
477
  tenant: binding.tenant,
478
+ channel: binding.channel,
468
479
  participantId: participant.id,
469
480
  user: scrub(heard),
470
481
  assistant: scrub(bounded),
471
482
  receivedAt: event.receivedAt,
472
- }, messagingSurfaceHistory(artifact.server.assistant));
483
+ }, messagingSurfaceHistory(artifact.server.assistant, binding.channel));
473
484
  }
474
485
  catch (error) {
475
486
  const code = error instanceof ChannelError ? error.code : 'answer_failed';
@@ -27,9 +27,9 @@ export function websiteSurfaceHistory(assistant, binding) {
27
27
  : undefined;
28
28
  return { historyDisabled: surface?.history === false, ...noticeTemplateOf(assistant) };
29
29
  }
30
- export function messagingSurfaceHistory(assistant) {
30
+ export function messagingSurfaceHistory(assistant, channel) {
31
31
  return {
32
- historyDisabled: messagingSurfaceOf(assistant)?.history === false,
32
+ historyDisabled: messagingSurfaceOf(assistant, channel)?.history === false,
33
33
  ...noticeTemplateOf(assistant),
34
34
  };
35
35
  }
@@ -115,13 +115,13 @@ export class ConversationCapture {
115
115
  if (surface.historyDisabled)
116
116
  return Promise.resolve();
117
117
  return this.#guard(turn.tenant, async () => {
118
- const days = effectiveConversationDays(await this.policy(turn.tenant), 'whatsapp');
118
+ const days = effectiveConversationDays(await this.policy(turn.tenant), turn.channel);
119
119
  if (days === 0)
120
120
  return;
121
121
  const now = this.#now();
122
122
  const subject = { kind: 'participant', ref: turn.participantId };
123
- const id = (await this.store.findRecent(turn.tenant, 'whatsapp', subject, turn.receivedAt - CHANNEL_CONVERSATION_GAP_MS)) ?? `cv_${randomUUID()}`;
124
- await this.store.append({ id, tenant: turn.tenant, channel: 'whatsapp', subject }, [
123
+ const id = (await this.store.findRecent(turn.tenant, turn.channel, subject, turn.receivedAt - CHANNEL_CONVERSATION_GAP_MS)) ?? `cv_${randomUUID()}`;
124
+ await this.store.append({ id, tenant: turn.tenant, channel: turn.channel, subject }, [
125
125
  { kind: 'message', role: 'user', text: maskPaymentCards(turn.user), at: turn.receivedAt },
126
126
  { kind: 'message', role: 'assistant', text: maskPaymentCards(turn.assistant), at: now },
127
127
  ].filter((item) => item.text.trim()), days);
@@ -3,12 +3,13 @@
3
3
  * from the model's short working memory. It is never read back into a prompt, log, audit or analytics.
4
4
  */
5
5
  export const CONVERSATION_DAY_MS = 86_400_000;
6
- /** A WhatsApp conversation ends after this much silence, matching the provider's service window. */
6
+ /** A messaging conversation ends after this much silence, matching the providers' reply window. */
7
7
  export const CHANNEL_CONVERSATION_GAP_MS = CONVERSATION_DAY_MS;
8
8
  export const ALL_CONVERSATION_SOURCES = {
9
9
  website_visitors: true,
10
10
  signed_in_customers: true,
11
11
  whatsapp: true,
12
+ instagram: true,
12
13
  };
13
14
  /**
14
15
  * The latest expiry a shorter window allows an item from `at`; Off (0 days) ends every item now. The
@@ -1,6 +1,6 @@
1
1
  import { createHmac } from 'node:crypto';
2
2
  import { canonicalJson } from '@noodle-borg/compiler';
3
- import { ConversationExportResponseSchema, ConversationForgetRequestSchema, ConversationForgetResponseSchema, ConversationIdSchema, ConversationListResponseSchema, ConversationReviewRequestSchema, ConversationReviewStatusSchema, ConversationShowResponseSchema, } from '@noodle-borg/wire-contracts';
3
+ import { ConversationChannelSchema, ConversationExportResponseSchema, ConversationForgetRequestSchema, ConversationForgetResponseSchema, ConversationIdSchema, ConversationListResponseSchema, ConversationReviewRequestSchema, ConversationReviewStatusSchema, ConversationShowResponseSchema, } from '@noodle-borg/wire-contracts';
4
4
  import { maskPaymentCards } from '../payment-card.js';
5
5
  import { CONVERSATION_DAY_MS, CONVERSATION_NOTE_LIMIT, } from './contracts.js';
6
6
  const runLocal = (operation) => operation();
@@ -174,7 +174,7 @@ export class ApplicationConversations {
174
174
  if (!Number.isInteger(limit) ||
175
175
  limit < 1 ||
176
176
  limit > bounds.maximum ||
177
- (channel !== undefined && channel !== 'website' && channel !== 'whatsapp') ||
177
+ (channel !== undefined && !ConversationChannelSchema.safeParse(channel).success) ||
178
178
  (status !== undefined && !ConversationReviewStatusSchema.safeParse(status).success) ||
179
179
  (cursor !== undefined && (!cursor || cursor.length > 2048)))
180
180
  throw invalid();
@@ -0,0 +1,79 @@
1
+ import { ChannelError } from '@noodle-borg/assistant-gateway/portable';
2
+ import { readBodyBuffer, sendJson } from '@noodle-borg/transport-http';
3
+ import { z } from 'zod';
4
+ import { parseInstagramMessaging } from '../channels/instagram-webhook.js';
5
+ import { handshake, META_RETRYABLE, META_SIGNATURE_HEADER, verifyMetaSignature, } from './meta-webhook.js';
6
+ const envelope = z.object({
7
+ object: z.literal('instagram'),
8
+ entry: z
9
+ .array(z.object({
10
+ id: z.string().regex(/^\d{1,32}$/),
11
+ messaging: z.array(z.unknown()).max(100).optional(),
12
+ }))
13
+ .max(100),
14
+ });
15
+ /**
16
+ * The shared Instagram callback of Noodle Seed's Meta app, signed like WhatsApp's with the app secret.
17
+ * Each entry names the Instagram account it is for; only the one Instagram binding that owns that
18
+ * account receives it, and every message must also be addressed to it.
19
+ */
20
+ export async function instagramWebhook(req, res, url, runtime) {
21
+ const meta = runtime.options.meta;
22
+ if (!meta)
23
+ return sendJson(res, 404, { error: 'not_found' });
24
+ if (req.method === 'GET')
25
+ return handshake(res, url, meta.verifyToken);
26
+ if (req.method !== 'POST')
27
+ return sendJson(res, 405, { error: 'method_not_allowed' });
28
+ const body = await readBodyBuffer(req, 1 << 20);
29
+ if (!body.ok)
30
+ return sendJson(res, 413, { error: 'webhook_invalid' });
31
+ // The signature covers the exact bytes Meta sent; nothing is parsed before it verifies.
32
+ if (!verifyMetaSignature(body.buffer, req.headers[META_SIGNATURE_HEADER], meta.appSecret))
33
+ return sendJson(res, 401, { error: 'unauthorized' });
34
+ // Content-free: a code and never a header, body, sender, message or account id.
35
+ const skip = (code) => runtime.deps.logger?.warn('assistant.channel.webhook', { channel: 'instagram', code });
36
+ let parsed;
37
+ try {
38
+ const result = envelope.safeParse(JSON.parse(body.buffer.toString('utf8')));
39
+ if (result.success)
40
+ parsed = result.data;
41
+ }
42
+ catch {
43
+ parsed = undefined;
44
+ }
45
+ if (!parsed) {
46
+ skip('webhook_invalid');
47
+ return sendJson(res, 200, { ok: true });
48
+ }
49
+ const items = parsed.entry.reduce((total, entry) => total + (entry.messaging?.length ?? 0), 0);
50
+ if (items > 100) {
51
+ skip('batch_too_large');
52
+ return sendJson(res, 200, { ok: true });
53
+ }
54
+ for (const entry of parsed.entry) {
55
+ try {
56
+ const id = await runtime.channels.bindingForAsset('instagram', entry.id);
57
+ if (!id)
58
+ throw new ChannelError('binding_unknown');
59
+ const binding = await runtime.channels.internal(id);
60
+ if (binding.channel !== 'instagram' || binding.instagramAccountId !== entry.id)
61
+ throw new ChannelError('asset_mismatch');
62
+ const { messages, skipped } = parseInstagramMessaging(entry.messaging ?? [], binding.instagramAccountId);
63
+ // Echoes of our own and staff replies are expected traffic, not a finding.
64
+ for (const code of new Set(skipped))
65
+ if (code !== 'echo')
66
+ skip(code);
67
+ if (messages.length > 0)
68
+ await runtime.channels.receive(id, messages, await runtime.retention(binding));
69
+ }
70
+ catch (error) {
71
+ if (!(error instanceof ChannelError) || META_RETRYABLE.has(error.code))
72
+ throw error;
73
+ skip(error.code);
74
+ }
75
+ }
76
+ sendJson(res, 200, { ok: true });
77
+ runtime.options.worker.wake();
78
+ }
79
+ //# sourceMappingURL=instagram-meta.js.map