@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.
- package/dist/commands/solutions-conversations.d.ts.map +1 -1
- package/dist/commands/solutions-conversations.js +3 -3
- package/dist/commands/solutions-conversations.js.map +1 -1
- package/dist/commands/solutions-history.d.ts.map +1 -1
- package/dist/commands/solutions-history.js +6 -3
- package/dist/commands/solutions-history.js.map +1 -1
- package/dist/commands/whatsapp-ops.d.ts +2 -1
- package/dist/commands/whatsapp-ops.d.ts.map +1 -1
- package/dist/commands/whatsapp-ops.js +63 -27
- package/dist/commands/whatsapp-ops.js.map +1 -1
- package/dist/commands/whatsapp-output.d.ts +1 -1
- package/dist/commands/whatsapp-output.d.ts.map +1 -1
- package/dist/commands/whatsapp-output.js +13 -7
- package/dist/commands/whatsapp-output.js.map +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +1 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-embedded-assistant-ref.js +2 -2
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-model-context.js +7 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-turn-context.d.ts +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.d.ts +88 -8
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-coordinator.js +42 -30
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-spend.js +2 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.d.ts +60 -11
- package/node_modules/@noodle-borg/assistant-gateway/dist/channel-types.js +31 -3
- package/node_modules/@noodle-borg/assistant-gateway/dist/model-request.d.ts +2 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.d.ts +4 -2
- package/node_modules/@noodle-borg/assistant-gateway/dist/public-surface.js +4 -3
- package/node_modules/@noodle-borg/authoring/dist/assistant.d.ts +2 -1
- package/node_modules/@noodle-borg/authoring/dist/assistant.js +5 -4
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-channels.js +72 -51
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-conversations.js +1 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-solution-history.js +1 -0
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.d.ts +16 -4
- package/node_modules/@noodle-borg/compiler/dist/manifest/schema.js +8 -3
- package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +1 -1
- package/node_modules/@noodle-borg/service/dist/application-history-settings.js +6 -2
- package/node_modules/@noodle-borg/service/dist/build-info.js +3 -2
- package/node_modules/@noodle-borg/service/dist/channels/instagram-meta.js +196 -0
- package/node_modules/@noodle-borg/service/dist/channels/instagram-webhook.js +78 -0
- package/node_modules/@noodle-borg/service/dist/channels/provider.js +1 -1
- package/node_modules/@noodle-borg/service/dist/channels/reply-bound.js +24 -0
- package/node_modules/@noodle-borg/service/dist/channels/runtime.js +35 -24
- package/node_modules/@noodle-borg/service/dist/conversation-history/capture.js +5 -5
- package/node_modules/@noodle-borg/service/dist/conversation-history/contracts.js +2 -1
- package/node_modules/@noodle-borg/service/dist/conversation-history/operator.js +2 -2
- package/node_modules/@noodle-borg/service/dist/routes/instagram-meta.js +79 -0
- package/node_modules/@noodle-borg/service/dist/routes/meta-webhook.js +36 -0
- package/node_modules/@noodle-borg/service/dist/routes/service-probes.js +4 -1
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp-meta.js +3 -34
- package/node_modules/@noodle-borg/service/dist/routes/whatsapp.js +45 -24
- package/node_modules/@noodle-borg/service/dist/service.js +1 -1
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.d.ts +7 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/application-history-settings.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.d.ts +12 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/conversation-history.js +1 -1
- package/node_modules/@noodle-borg/wire-contracts/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/index.js +1 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/instagram.d.ts +181 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/instagram.js +50 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.d.ts +2 -0
- package/node_modules/@noodle-borg/wire-contracts/dist/service-info.js +3 -0
- 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
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
|
197
|
-
* message body and reply, keeping content-free controls.
|
|
198
|
-
*
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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,
|
|
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 =
|
|
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),
|
|
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,
|
|
124
|
-
await this.store.append({ id, tenant: turn.tenant, channel:
|
|
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
|
|
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
|
|
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
|