@andreprado/agentkit 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +68 -6
- package/docs/guides/add-channel.md +189 -7
- package/docs/guides/add-knowledge.md +144 -0
- package/docs/guides/add-managed-composio.md +163 -0
- package/docs/guides/add-tool.md +1 -1
- package/docs/guides/channel-security.md +128 -32
- package/docs/guides/connect-discord.md +178 -0
- package/docs/guides/connect-slack.md +126 -0
- package/docs/guides/connect-telegram.md +78 -1
- package/docs/guides/connect-whatsapp-evolution.md +121 -0
- package/docs/guides/connect-whatsapp-uazapi.md +126 -0
- package/docs/guides/connect-whatsapp-zapster.md +112 -8
- package/docs/guides/create-agent.md +45 -4
- package/docs/guides/debug-channel.md +147 -0
- package/docs/guides/improve-from-production.md +151 -0
- package/docs/guides/prepare-deploy.md +47 -17
- package/docs/guides/replay-production-traces.md +72 -0
- package/docs/guides/run-evals.md +147 -20
- package/docs/guides/security-rules.md +7 -6
- package/docs/guides/send-feedback.md +135 -0
- package/docs/guides/use-provider.md +27 -3
- package/docs/llms-full.txt +348 -55
- package/docs/llms.txt +62 -7
- package/package.json +2 -5
- package/src/cli/args.ts +57 -0
- package/src/cli/cloud-client.ts +377 -0
- package/src/cli/commands/channels.ts +1586 -0
- package/src/cli/commands/feedback.ts +438 -0
- package/src/cli/commands/knowledge.ts +136 -0
- package/src/cli/commands/transcribe.ts +171 -0
- package/src/cli/constants.ts +4 -0
- package/src/cli/deploy-chat-ui.ts +535 -0
- package/src/cli/deploy-readiness.ts +481 -0
- package/src/cli/flags.ts +162 -0
- package/src/cli/help.ts +236 -0
- package/src/cli/index.ts +1167 -1005
- package/src/cli/process.ts +31 -0
- package/src/cloud/artifact.ts +139 -0
- package/src/cloud/client.ts +80 -0
- package/src/cloud/contracts.ts +63 -0
- package/src/cloud/index.ts +3 -0
- package/src/create-project.ts +21 -6
- package/src/index.ts +517 -8
- package/src/providers/pi.ts +70 -16
- package/src/providers/test.ts +88 -1
- package/src/providers/types.ts +7 -0
- package/src/runtime/channel-buffer.ts +30 -0
- package/src/runtime/channel-test-harness.ts +21 -1
- package/src/runtime/channels/discord.ts +896 -0
- package/src/runtime/channels/generic-webhook.ts +225 -0
- package/src/runtime/channels/slack.ts +646 -0
- package/src/runtime/channels/telegram.ts +466 -23
- package/src/runtime/channels/whatsapp-evolution.ts +1357 -0
- package/src/runtime/channels/whatsapp-meta.ts +9 -0
- package/src/runtime/channels/whatsapp-uazapi.ts +1327 -0
- package/src/runtime/channels/whatsapp-zapster.ts +677 -40
- package/src/runtime/channels.ts +87 -4
- package/src/runtime/chat.ts +130 -38
- package/src/runtime/config.ts +519 -19
- package/src/runtime/core/manifest.ts +103 -5
- package/src/runtime/core/targets.ts +5 -5
- package/src/runtime/database.ts +93 -2
- package/src/runtime/db-commands.ts +9 -0
- package/src/runtime/deploy-readiness.ts +46 -4
- package/src/runtime/deploy.ts +1 -1
- package/src/runtime/dev-server.ts +779 -45
- package/src/runtime/env.ts +8 -3
- package/src/runtime/evals.ts +589 -43
- package/src/runtime/improve.ts +868 -0
- package/src/runtime/inspect.ts +194 -4
- package/src/runtime/integrations/composio.ts +423 -0
- package/src/runtime/knowledge/chunk.ts +333 -0
- package/src/runtime/knowledge/config.ts +135 -0
- package/src/runtime/knowledge/embeddings.ts +133 -0
- package/src/runtime/knowledge/ingest.ts +521 -0
- package/src/runtime/knowledge/prompt-policy.ts +30 -0
- package/src/runtime/knowledge/retrieve.ts +303 -0
- package/src/runtime/knowledge/schema.ts +100 -0
- package/src/runtime/knowledge/tool.ts +64 -0
- package/src/runtime/knowledge/vector.ts +258 -0
- package/src/runtime/prompt-context.ts +141 -0
- package/src/runtime/runtime-contract.ts +86 -8
- package/src/runtime/skills.ts +95 -0
- package/src/runtime/spec.ts +152 -0
- package/src/runtime/sync.ts +144 -0
- package/src/runtime/targets/cloudflare/build.ts +1468 -203
- package/src/runtime/targets/container/server.ts +1 -1
- package/src/runtime/targets/vps/deploy.ts +26 -9
- package/src/runtime/tool-runner.ts +9 -1
- package/src/runtime/tools.ts +128 -2
- package/src/runtime/traces.ts +41 -0
- package/src/runtime/transcription.ts +483 -0
- package/src/storage/sqlite.ts +149 -3
- package/src/templates/blank.ts +76 -17
- package/src/templates/dentista.ts +1011 -0
- package/src/templates/index.ts +2 -0
- package/src/templates/skills/agentkit-build-agent/SKILL.md +52 -0
- package/src/templates/skills/agentkit-build-agent/templates/appointment-intake.instructions.md +21 -0
- package/src/templates/skills/agentkit-build-agent/templates/sales-qualifier.instructions.md +17 -0
- package/src/templates/skills/agentkit-build-agent/templates/support-agent.instructions.md +16 -0
- package/src/templates/skills/agentkit-capsule/SKILL.md +70 -0
- package/src/templates/skills/agentkit-capsule/references/docs-router.md +15 -0
- package/src/templates/skills/agentkit-channels/SKILL.md +127 -0
- package/src/templates/skills/agentkit-channels/references/channel-buffering.md +65 -0
- package/src/templates/skills/agentkit-channels/references/channel-debugging.md +66 -0
- package/src/templates/skills/agentkit-channels/references/discord.md +93 -0
- package/src/templates/skills/agentkit-channels/references/slack.md +56 -0
- package/src/templates/skills/agentkit-channels/references/telegram.md +72 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-evolution.md +57 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-uazapi.md +61 -0
- package/src/templates/skills/agentkit-channels/references/whatsapp-zapster.md +77 -0
- package/src/templates/skills/agentkit-database/SKILL.md +45 -0
- package/src/templates/skills/agentkit-database/templates/appointments.schema.sql +15 -0
- package/src/templates/skills/agentkit-database/templates/leads.schema.sql +17 -0
- package/src/templates/skills/agentkit-deploy/SKILL.md +50 -0
- package/src/templates/skills/agentkit-evals/SKILL.md +109 -0
- package/src/templates/skills/agentkit-evals/templates/multi-turn.eval.md +29 -0
- package/src/templates/skills/agentkit-evals/templates/no-leak.eval.md +18 -0
- package/src/templates/skills/agentkit-evals/templates/smoke.eval.md +18 -0
- package/src/templates/skills/agentkit-evals/templates/tool-call.eval.md +27 -0
- package/src/templates/skills/agentkit-improve/SKILL.md +86 -0
- package/src/templates/skills/agentkit-improve/references/replay-side-effects.md +18 -0
- package/src/templates/skills/agentkit-improve/references/trace-packets.md +22 -0
- package/src/templates/skills/agentkit-improve/templates/regression.eval.md +18 -0
- package/src/templates/skills/agentkit-integrations/SKILL.md +76 -0
- package/src/templates/skills/agentkit-knowledge/SKILL.md +43 -0
- package/src/templates/skills/agentkit-knowledge/templates/faq.md +14 -0
- package/src/templates/skills/agentkit-knowledge/templates/policies.md +14 -0
- package/src/templates/skills/agentkit-knowledge/templates/prices.csv +3 -0
- package/src/templates/skills/agentkit-prompts/SKILL.md +47 -0
- package/src/templates/skills/agentkit-prompts/templates/knowledge-grounded-faq.instructions.md +11 -0
- package/src/templates/skills/agentkit-provider/SKILL.md +60 -0
- package/src/templates/skills/agentkit-security/SKILL.md +56 -0
- package/src/templates/skills/agentkit-tools/SKILL.md +37 -0
- package/src/templates/skills/agentkit-tools/examples/database-write.tool.md +35 -0
- package/src/templates/skills/agentkit-tools/examples/eval-safe-external-action.tool.md +37 -0
- package/src/templates/skills/agentkit-tools/examples/lookup-order.tool.md +46 -0
- package/src/templates/skills/agentkit-troubleshooting/SKILL.md +76 -0
- package/src/templates/support.ts +77 -18
- package/docs/guides/channels-production-handoff.md +0 -99
- package/docs/portable-deploy-release-checklist.md +0 -41
- package/src/runtime/targets/cloudflare/deploy.ts +0 -5475
|
@@ -0,0 +1,1327 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelMediaDownloadInput,
|
|
4
|
+
ChannelMediaDownloadResult,
|
|
5
|
+
ChannelSendInput,
|
|
6
|
+
ChannelSendResult,
|
|
7
|
+
ChannelStatusInput,
|
|
8
|
+
ChannelStatusResult,
|
|
9
|
+
NormalizedChannelEvent,
|
|
10
|
+
RawWebhookEvent,
|
|
11
|
+
WebhookVerificationInput,
|
|
12
|
+
WebhookVerificationResult,
|
|
13
|
+
} from "../channels";
|
|
14
|
+
import { AgentKitError } from "../errors";
|
|
15
|
+
|
|
16
|
+
type UazapiFetch = (url: string, init?: RequestInit) => Promise<Response>;
|
|
17
|
+
|
|
18
|
+
type UazapiSendTextRequest = {
|
|
19
|
+
method: "POST";
|
|
20
|
+
url: string;
|
|
21
|
+
body: {
|
|
22
|
+
number: string;
|
|
23
|
+
text: string;
|
|
24
|
+
readchat: boolean;
|
|
25
|
+
async: boolean;
|
|
26
|
+
track_source: "agentkit";
|
|
27
|
+
track_id: string;
|
|
28
|
+
};
|
|
29
|
+
idempotencyKey: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type UazapiMessage = {
|
|
33
|
+
id?: string;
|
|
34
|
+
messageId?: string;
|
|
35
|
+
chatId?: string;
|
|
36
|
+
sender?: string;
|
|
37
|
+
senderName?: string;
|
|
38
|
+
fromMe?: boolean;
|
|
39
|
+
wasSentByApi?: boolean;
|
|
40
|
+
messageType?: string;
|
|
41
|
+
timestamp?: number;
|
|
42
|
+
text?: string;
|
|
43
|
+
fileUrl?: string;
|
|
44
|
+
mimeType?: string;
|
|
45
|
+
durationSeconds?: number;
|
|
46
|
+
sizeBytes?: number;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const UAZAPI_MAX_DOWNLOAD_BYTES = 25_000_000;
|
|
50
|
+
const UAZAPI_MEDIA_DOWNLOAD_BODY_OVERHEAD_BYTES = 64_000;
|
|
51
|
+
const UAZAPI_MESSAGE_EVENTS = new Set(["messages", "message", "message.received", "newsletter_messages"]);
|
|
52
|
+
const UAZAPI_UPDATE_EVENTS = new Set(["messages_update", "message.status", "message.update"]);
|
|
53
|
+
const UAZAPI_IGNORED_EVENTS = new Set(["connection", "presence", "history"]);
|
|
54
|
+
|
|
55
|
+
export const uazapiWhatsappChannelAdapter: ChannelAdapter = {
|
|
56
|
+
type: "whatsapp",
|
|
57
|
+
provider: "uazapi",
|
|
58
|
+
requiredSecrets: ["UAZAPI_BASE_URL", "UAZAPI_TOKEN"],
|
|
59
|
+
verifyWebhook(input) {
|
|
60
|
+
const optionalTokenResult = verifyOptionalWebhookToken(input);
|
|
61
|
+
|
|
62
|
+
if (!optionalTokenResult.ok) {
|
|
63
|
+
return optionalTokenResult;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
parseJsonObject(input);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
code: "channel_payload_invalid",
|
|
72
|
+
message: error instanceof Error ? error.message : String(error),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: true };
|
|
77
|
+
},
|
|
78
|
+
normalizeInbound(input) {
|
|
79
|
+
const body = parseJsonObject(input);
|
|
80
|
+
const eventType = readEventType(body);
|
|
81
|
+
|
|
82
|
+
if (UAZAPI_IGNORED_EVENTS.has(eventType)) {
|
|
83
|
+
return [unsupportedUazapiEvent(input, body, eventType, "unsupported_event_type")];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (UAZAPI_UPDATE_EVENTS.has(eventType)) {
|
|
87
|
+
return [normalizeUazapiStatusEvent(input, body, eventType)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!UAZAPI_MESSAGE_EVENTS.has(eventType)) {
|
|
91
|
+
return [unsupportedUazapiEvent(input, body, eventType, "unsupported_event_type")];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const message = normalizeUazapiMessage(body);
|
|
95
|
+
const providerEventId = providerEventIdFor(body, message);
|
|
96
|
+
|
|
97
|
+
if (!providerEventId) {
|
|
98
|
+
throw new AgentKitError("channel_payload_invalid", "UAZAPI payload is missing messageid, event id, or chat timestamp.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const contactId = stripWhatsappJid(message.sender ?? message.chatId ?? "unknown");
|
|
102
|
+
const instanceId = readString(body, "instance") ?? readString(body, "instance_id") ?? "default";
|
|
103
|
+
const messageType = (message.messageType ?? (message.text ? "text" : "unknown")).toLowerCase();
|
|
104
|
+
const base = baseUazapiEvent(input, {
|
|
105
|
+
providerEventId,
|
|
106
|
+
instanceId,
|
|
107
|
+
contactId,
|
|
108
|
+
eventType,
|
|
109
|
+
messageType,
|
|
110
|
+
senderName: message.senderName,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (message.fromMe || message.wasSentByApi) {
|
|
114
|
+
return [
|
|
115
|
+
{
|
|
116
|
+
...base,
|
|
117
|
+
kind: "unsupported",
|
|
118
|
+
unsupportedReason: message.wasSentByApi ? "ignored_message:was_sent_by_api" : "ignored_message:from_me",
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isUazapiAudioMessage(messageType, message)) {
|
|
124
|
+
return [
|
|
125
|
+
{
|
|
126
|
+
...base,
|
|
127
|
+
kind: "message",
|
|
128
|
+
message: {
|
|
129
|
+
role: "user",
|
|
130
|
+
contentType: "audio",
|
|
131
|
+
content: "",
|
|
132
|
+
providerMessageId: message.messageId ?? message.id,
|
|
133
|
+
audio: {
|
|
134
|
+
providerMediaId: message.messageId ?? message.id ?? providerEventId,
|
|
135
|
+
kind: messageType === "ptt" || messageType === "myaudio" ? "voice" : "audio",
|
|
136
|
+
...(message.mimeType ? { mimeType: message.mimeType } : {}),
|
|
137
|
+
filename: defaultUazapiAudioFilename(message),
|
|
138
|
+
...(message.durationSeconds !== undefined ? { durationSeconds: message.durationSeconds } : {}),
|
|
139
|
+
...(message.sizeBytes !== undefined ? { sizeBytes: message.sizeBytes } : {}),
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!isUazapiTextMessage(messageType, message)) {
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
...base,
|
|
150
|
+
kind: "unsupported",
|
|
151
|
+
unsupportedReason: `unsupported_message_type:${messageType}`,
|
|
152
|
+
},
|
|
153
|
+
];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const text = message.text?.trim();
|
|
157
|
+
|
|
158
|
+
if (!text) {
|
|
159
|
+
return [
|
|
160
|
+
{
|
|
161
|
+
...base,
|
|
162
|
+
kind: "unsupported",
|
|
163
|
+
unsupportedReason: "unsupported_message_type:empty_text",
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
...base,
|
|
171
|
+
kind: "message",
|
|
172
|
+
message: {
|
|
173
|
+
role: "user",
|
|
174
|
+
contentType: "text",
|
|
175
|
+
content: text,
|
|
176
|
+
providerMessageId: message.messageId ?? message.id,
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
},
|
|
181
|
+
async downloadMedia(input) {
|
|
182
|
+
return downloadUazapiMedia(input);
|
|
183
|
+
},
|
|
184
|
+
async sendMessage(input) {
|
|
185
|
+
return sendUazapiMessage(input);
|
|
186
|
+
},
|
|
187
|
+
async getStatus(input) {
|
|
188
|
+
return getUazapiStatus(input);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export function buildUazapiSendTextRequest(input: ChannelSendInput): UazapiSendTextRequest {
|
|
193
|
+
return buildUazapiSendTextRequestInternal(input, "<redacted>", "<redacted>");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function sendUazapiMessage(
|
|
197
|
+
input: ChannelSendInput,
|
|
198
|
+
fetcher: UazapiFetch = fetch,
|
|
199
|
+
): Promise<ChannelSendResult> {
|
|
200
|
+
if (input.secrets.AGENTKIT_CHANNEL_SEND_DRY_RUN === "1") {
|
|
201
|
+
const request = buildUazapiSendTextRequest(input);
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
ok: true,
|
|
205
|
+
status: "stubbed",
|
|
206
|
+
providerRequestId: request.idempotencyKey,
|
|
207
|
+
providerMetadata: {
|
|
208
|
+
method: request.method,
|
|
209
|
+
url: request.url,
|
|
210
|
+
body: request.body,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const token = input.secrets.UAZAPI_TOKEN;
|
|
216
|
+
const baseUrl = input.secrets.UAZAPI_BASE_URL;
|
|
217
|
+
|
|
218
|
+
if (!token || !baseUrl) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
retryable: false,
|
|
222
|
+
code: "channel_secret_missing",
|
|
223
|
+
message: "Secret UAZAPI_BASE_URL or UAZAPI_TOKEN is missing.",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let request: UazapiSendTextRequest;
|
|
228
|
+
let actualRequest: UazapiSendTextRequest;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
request = buildUazapiSendTextRequest(input);
|
|
232
|
+
actualRequest = buildUazapiSendTextRequestInternal(input, baseUrl, "actual");
|
|
233
|
+
} catch (error) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
retryable: false,
|
|
237
|
+
code: "channel_send_failed",
|
|
238
|
+
message: error instanceof Error ? error.message : String(error),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let response: Response;
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
response = await fetcher(actualRequest.url, {
|
|
246
|
+
method: actualRequest.method,
|
|
247
|
+
headers: {
|
|
248
|
+
token,
|
|
249
|
+
"Content-Type": "application/json",
|
|
250
|
+
Accept: "application/json",
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify(actualRequest.body),
|
|
253
|
+
});
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const message = redactString(error instanceof Error ? error.message : String(error), [token, baseUrl]);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
ok: false,
|
|
259
|
+
retryable: true,
|
|
260
|
+
code: "channel_provider_unavailable",
|
|
261
|
+
message: `UAZAPI sendText request failed before a response: ${message}`,
|
|
262
|
+
providerRequestId: request.idempotencyKey,
|
|
263
|
+
providerMetadata: {
|
|
264
|
+
method: request.method,
|
|
265
|
+
url: request.url,
|
|
266
|
+
body: request.body,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const payload = await readUazapiResponseBody(response);
|
|
272
|
+
const redactedPayload = redactValue(payload, [token, baseUrl]);
|
|
273
|
+
const messageId = readUazapiProviderMessageId(payload);
|
|
274
|
+
|
|
275
|
+
if (!response.ok) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
retryable: isRetryableUazapiSendFailure(response.status, payload),
|
|
279
|
+
code: "channel_send_failed",
|
|
280
|
+
message: redactString(readUazapiErrorMessage(payload, response.status), [token, baseUrl]),
|
|
281
|
+
providerRequestId: request.idempotencyKey,
|
|
282
|
+
providerMetadata: {
|
|
283
|
+
method: request.method,
|
|
284
|
+
url: request.url,
|
|
285
|
+
status: response.status,
|
|
286
|
+
body: redactedPayload,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!messageId) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
retryable: false,
|
|
295
|
+
code: "channel_send_failed",
|
|
296
|
+
message: "UAZAPI sendText did not return messageid or id.",
|
|
297
|
+
providerRequestId: request.idempotencyKey,
|
|
298
|
+
providerMetadata: {
|
|
299
|
+
method: request.method,
|
|
300
|
+
url: request.url,
|
|
301
|
+
status: response.status,
|
|
302
|
+
body: redactedPayload,
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
ok: true,
|
|
309
|
+
status: "sent",
|
|
310
|
+
providerRequestId: request.idempotencyKey,
|
|
311
|
+
providerMessageId: messageId,
|
|
312
|
+
providerMetadata: {
|
|
313
|
+
method: request.method,
|
|
314
|
+
url: request.url,
|
|
315
|
+
status: response.status,
|
|
316
|
+
body: redactedPayload,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function downloadUazapiMedia(
|
|
322
|
+
input: ChannelMediaDownloadInput,
|
|
323
|
+
fetcher: UazapiFetch = fetch,
|
|
324
|
+
): Promise<ChannelMediaDownloadResult> {
|
|
325
|
+
const token = input.secrets.UAZAPI_TOKEN;
|
|
326
|
+
const baseUrl = input.secrets.UAZAPI_BASE_URL;
|
|
327
|
+
|
|
328
|
+
if (!token || !baseUrl) {
|
|
329
|
+
return {
|
|
330
|
+
ok: false,
|
|
331
|
+
retryable: false,
|
|
332
|
+
code: "channel_secret_missing",
|
|
333
|
+
message: "Secret UAZAPI_BASE_URL or UAZAPI_TOKEN is missing.",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const providerMessageId = input.message.providerMessageId ?? input.message.audio?.providerMediaId;
|
|
338
|
+
|
|
339
|
+
if (!providerMessageId) {
|
|
340
|
+
return {
|
|
341
|
+
ok: false,
|
|
342
|
+
retryable: false,
|
|
343
|
+
code: "channel_audio_download_unavailable",
|
|
344
|
+
message: "UAZAPI audio payload did not include a provider message id.",
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let url: string;
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
url = uazapiApiUrl(baseUrl, "/message/download");
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return {
|
|
354
|
+
ok: false,
|
|
355
|
+
retryable: false,
|
|
356
|
+
code: "channel_audio_download_unavailable",
|
|
357
|
+
message: error instanceof Error ? error.message : String(error),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let response: Response;
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
response = await fetcher(url, {
|
|
365
|
+
method: "POST",
|
|
366
|
+
headers: {
|
|
367
|
+
token,
|
|
368
|
+
"Content-Type": "application/json",
|
|
369
|
+
Accept: "application/json",
|
|
370
|
+
},
|
|
371
|
+
body: JSON.stringify({
|
|
372
|
+
id: providerMessageId,
|
|
373
|
+
return_base64: true,
|
|
374
|
+
return_link: false,
|
|
375
|
+
generate_mp3: false,
|
|
376
|
+
transcribe: false,
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
} catch (error) {
|
|
380
|
+
return {
|
|
381
|
+
ok: false,
|
|
382
|
+
retryable: true,
|
|
383
|
+
code: "channel_provider_unavailable",
|
|
384
|
+
message: `UAZAPI media download failed before a response: ${redactString(
|
|
385
|
+
error instanceof Error ? error.message : String(error),
|
|
386
|
+
[token, baseUrl],
|
|
387
|
+
)}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const maxBytes = input.maxBytes ?? UAZAPI_MAX_DOWNLOAD_BYTES;
|
|
392
|
+
let payload: unknown;
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
payload = await readUazapiResponseBody(response, {
|
|
396
|
+
maxBytes: maxUazapiMediaResponseBytes(maxBytes),
|
|
397
|
+
});
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error instanceof UazapiResponseBodyTooLargeError) {
|
|
400
|
+
return {
|
|
401
|
+
ok: false,
|
|
402
|
+
retryable: false,
|
|
403
|
+
code: "channel_audio_too_large",
|
|
404
|
+
message: `UAZAPI media download response exceeds the configured ${maxBytes} byte audio limit before decode.`,
|
|
405
|
+
providerMetadata: {
|
|
406
|
+
status: response.status,
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
418
|
+
code: response.status === 429 || response.status >= 500
|
|
419
|
+
? "channel_provider_unavailable"
|
|
420
|
+
: "channel_media_download_failed",
|
|
421
|
+
message: `UAZAPI media download returned HTTP ${response.status}.`,
|
|
422
|
+
providerMetadata: {
|
|
423
|
+
status: response.status,
|
|
424
|
+
body: redactValue(payload, [token, baseUrl]),
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (!isRecord(payload) || typeof payload.base64Data !== "string") {
|
|
430
|
+
return {
|
|
431
|
+
ok: false,
|
|
432
|
+
retryable: false,
|
|
433
|
+
code: "channel_audio_download_unavailable",
|
|
434
|
+
message: "UAZAPI media download did not return base64Data.",
|
|
435
|
+
providerMetadata: {
|
|
436
|
+
status: response.status,
|
|
437
|
+
body: redactValue(payload, [token, baseUrl]),
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const base64Data = normalizeBase64(payload.base64Data);
|
|
443
|
+
const estimatedBytes = estimateBase64DecodedBytes(base64Data);
|
|
444
|
+
|
|
445
|
+
if (estimatedBytes === undefined) {
|
|
446
|
+
return {
|
|
447
|
+
ok: false,
|
|
448
|
+
retryable: false,
|
|
449
|
+
code: "channel_media_download_failed",
|
|
450
|
+
message: "UAZAPI media download returned invalid base64Data.",
|
|
451
|
+
providerMetadata: {
|
|
452
|
+
status: response.status,
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (estimatedBytes > maxBytes) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
retryable: false,
|
|
461
|
+
code: "channel_audio_too_large",
|
|
462
|
+
message: `Audio response contains approximately ${estimatedBytes} bytes, which exceeds the configured ${maxBytes} byte limit.`,
|
|
463
|
+
providerMetadata: {
|
|
464
|
+
status: response.status,
|
|
465
|
+
sizeBytes: estimatedBytes,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let audio: Uint8Array;
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
audio = decodeBase64(base64Data);
|
|
474
|
+
} catch {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
retryable: false,
|
|
478
|
+
code: "channel_media_download_failed",
|
|
479
|
+
message: "UAZAPI media download returned invalid base64Data.",
|
|
480
|
+
providerMetadata: {
|
|
481
|
+
status: response.status,
|
|
482
|
+
},
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (audio.byteLength > maxBytes) {
|
|
487
|
+
return {
|
|
488
|
+
ok: false,
|
|
489
|
+
retryable: false,
|
|
490
|
+
code: "channel_audio_too_large",
|
|
491
|
+
message: `Audio response contains ${audio.byteLength} bytes, which exceeds the configured ${maxBytes} byte limit.`,
|
|
492
|
+
providerMetadata: {
|
|
493
|
+
status: response.status,
|
|
494
|
+
sizeBytes: audio.byteLength,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
ok: true,
|
|
501
|
+
audio,
|
|
502
|
+
filename: input.message.audio?.filename ?? `${providerMessageId}.ogg`,
|
|
503
|
+
...(typeof payload.mimetype === "string" ? { mimeType: payload.mimetype } : input.message.audio?.mimeType ? { mimeType: input.message.audio.mimeType } : {}),
|
|
504
|
+
...(input.message.audio?.durationSeconds !== undefined ? { durationSeconds: input.message.audio.durationSeconds } : {}),
|
|
505
|
+
sizeBytes: audio.byteLength,
|
|
506
|
+
providerMetadata: {
|
|
507
|
+
status: response.status,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export async function getUazapiStatus(
|
|
513
|
+
input: ChannelStatusInput,
|
|
514
|
+
fetcher: UazapiFetch = fetch,
|
|
515
|
+
): Promise<ChannelStatusResult> {
|
|
516
|
+
const missingSecrets = ["UAZAPI_BASE_URL", "UAZAPI_TOKEN"].filter((secret) => !input.secrets[secret]);
|
|
517
|
+
|
|
518
|
+
if (missingSecrets.length > 0) {
|
|
519
|
+
return {
|
|
520
|
+
status: "needs_setup",
|
|
521
|
+
checkedAt: new Date().toISOString(),
|
|
522
|
+
message: `UAZAPI channel secrets are missing: ${missingSecrets.join(", ")}.`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
let url: string;
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
url = uazapiApiUrl(input.secrets.UAZAPI_BASE_URL, "/instance/status");
|
|
530
|
+
} catch (error) {
|
|
531
|
+
return {
|
|
532
|
+
status: "failed",
|
|
533
|
+
checkedAt: new Date().toISOString(),
|
|
534
|
+
message: error instanceof Error ? error.message : String(error),
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
let response: Response;
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
response = await fetcher(url, {
|
|
542
|
+
method: "GET",
|
|
543
|
+
headers: {
|
|
544
|
+
token: input.secrets.UAZAPI_TOKEN,
|
|
545
|
+
Accept: "application/json",
|
|
546
|
+
},
|
|
547
|
+
});
|
|
548
|
+
} catch (error) {
|
|
549
|
+
return {
|
|
550
|
+
status: "failed",
|
|
551
|
+
checkedAt: new Date().toISOString(),
|
|
552
|
+
message: `UAZAPI instance status failed before a response: ${redactString(
|
|
553
|
+
error instanceof Error ? error.message : String(error),
|
|
554
|
+
[input.secrets.UAZAPI_TOKEN, input.secrets.UAZAPI_BASE_URL],
|
|
555
|
+
)}`,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const payload = await readUazapiResponseBody(response);
|
|
560
|
+
|
|
561
|
+
if (!response.ok) {
|
|
562
|
+
return {
|
|
563
|
+
status: "failed",
|
|
564
|
+
checkedAt: new Date().toISOString(),
|
|
565
|
+
message: redactString(
|
|
566
|
+
readUazapiErrorMessage(payload, response.status),
|
|
567
|
+
[input.secrets.UAZAPI_TOKEN, input.secrets.UAZAPI_BASE_URL],
|
|
568
|
+
),
|
|
569
|
+
providerMetadata: {
|
|
570
|
+
status: response.status,
|
|
571
|
+
body: redactValue(payload, [input.secrets.UAZAPI_TOKEN, input.secrets.UAZAPI_BASE_URL]),
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const instance = isRecord(payload) ? readRecord(payload, "instance") : {};
|
|
577
|
+
const status = isRecord(payload) ? readRecord(payload, "status") : {};
|
|
578
|
+
const instanceStatus = readString(instance, "status");
|
|
579
|
+
const connected = readBoolean(status, "connected") === true || instanceStatus === "connected";
|
|
580
|
+
|
|
581
|
+
if (connected) {
|
|
582
|
+
return {
|
|
583
|
+
status: "connected",
|
|
584
|
+
checkedAt: new Date().toISOString(),
|
|
585
|
+
message: "UAZAPI instance is connected.",
|
|
586
|
+
providerMetadata: {
|
|
587
|
+
instanceStatus: instanceStatus ?? "connected",
|
|
588
|
+
...(readString(instance, "name") ? { instanceName: readString(instance, "name") } : {}),
|
|
589
|
+
},
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
status: "needs_setup",
|
|
595
|
+
checkedAt: new Date().toISOString(),
|
|
596
|
+
message: `UAZAPI instance is ${instanceStatus ?? "not connected"}. Connect it with QR code or pairing code in UAZAPI.`,
|
|
597
|
+
providerMetadata: {
|
|
598
|
+
...(instanceStatus ? { instanceStatus } : {}),
|
|
599
|
+
},
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export function buildUazapiWebhookSetupPayload(webhookUrl: string): Record<string, unknown> {
|
|
604
|
+
return {
|
|
605
|
+
enabled: true,
|
|
606
|
+
url: webhookUrl,
|
|
607
|
+
events: ["messages", "messages_update", "connection"],
|
|
608
|
+
excludeMessages: ["wasSentByApi"],
|
|
609
|
+
addUrlEvents: false,
|
|
610
|
+
addUrlTypesMessages: false,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export function uazapiApiUrl(baseUrl: string, path: string): string {
|
|
615
|
+
const base = validateUazapiBaseUrl(baseUrl);
|
|
616
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
617
|
+
return new URL(normalizedPath, `${base}/`).href;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function verifyOptionalWebhookToken(input: WebhookVerificationInput): WebhookVerificationResult {
|
|
621
|
+
const expectsToken = input.channel.secrets.includes("UAZAPI_WEBHOOK_TOKEN");
|
|
622
|
+
|
|
623
|
+
if (!expectsToken) {
|
|
624
|
+
return { ok: true };
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const expectedToken = input.secrets.UAZAPI_WEBHOOK_TOKEN;
|
|
628
|
+
|
|
629
|
+
if (!expectedToken) {
|
|
630
|
+
return {
|
|
631
|
+
ok: false,
|
|
632
|
+
code: "channel_secret_missing",
|
|
633
|
+
message: "Secret UAZAPI_WEBHOOK_TOKEN is not set for this channel.",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
let actualToken: string | null = null;
|
|
638
|
+
|
|
639
|
+
try {
|
|
640
|
+
actualToken = new URL(input.url).searchParams.get("token");
|
|
641
|
+
} catch {
|
|
642
|
+
actualToken = null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (actualToken !== expectedToken) {
|
|
646
|
+
return {
|
|
647
|
+
ok: false,
|
|
648
|
+
code: "channel_signature_invalid",
|
|
649
|
+
message: "UAZAPI webhook token did not match.",
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return { ok: true };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function normalizeUazapiStatusEvent(
|
|
657
|
+
input: RawWebhookEvent,
|
|
658
|
+
body: Record<string, unknown>,
|
|
659
|
+
eventType: string,
|
|
660
|
+
): NormalizedChannelEvent {
|
|
661
|
+
const message = normalizeUazapiMessage(body);
|
|
662
|
+
const providerEventId = providerEventIdFor(body, message) ?? `status:${Date.now()}`;
|
|
663
|
+
const status = (message.messageType ?? readString(body, "status") ?? "sent").toLowerCase();
|
|
664
|
+
const deliveryStatus = status.includes("read")
|
|
665
|
+
? "delivered"
|
|
666
|
+
: status.includes("deliver")
|
|
667
|
+
? "delivered"
|
|
668
|
+
: status.includes("fail")
|
|
669
|
+
? "failed"
|
|
670
|
+
: "sent";
|
|
671
|
+
|
|
672
|
+
return {
|
|
673
|
+
...baseUazapiEvent(input, {
|
|
674
|
+
providerEventId,
|
|
675
|
+
instanceId: readString(body, "instance") ?? readString(body, "instance_id") ?? "default",
|
|
676
|
+
contactId: stripWhatsappJid(message.sender ?? message.chatId ?? "unknown"),
|
|
677
|
+
eventType,
|
|
678
|
+
messageType: status,
|
|
679
|
+
senderName: message.senderName,
|
|
680
|
+
}),
|
|
681
|
+
kind: "delivery_status",
|
|
682
|
+
deliveryStatus,
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function unsupportedUazapiEvent(
|
|
687
|
+
input: RawWebhookEvent,
|
|
688
|
+
body: Record<string, unknown>,
|
|
689
|
+
eventType: string,
|
|
690
|
+
reason: string,
|
|
691
|
+
): NormalizedChannelEvent {
|
|
692
|
+
const message = normalizeUazapiMessage(body);
|
|
693
|
+
const providerEventId = providerEventIdFor(body, message) ?? `${eventType}:${Date.now()}`;
|
|
694
|
+
|
|
695
|
+
return {
|
|
696
|
+
...baseUazapiEvent(input, {
|
|
697
|
+
providerEventId,
|
|
698
|
+
instanceId: readString(body, "instance") ?? readString(body, "instance_id") ?? "default",
|
|
699
|
+
contactId: stripWhatsappJid(message.sender ?? message.chatId ?? "unknown"),
|
|
700
|
+
eventType,
|
|
701
|
+
messageType: message.messageType ?? "unknown",
|
|
702
|
+
senderName: message.senderName,
|
|
703
|
+
}),
|
|
704
|
+
kind: "unsupported",
|
|
705
|
+
unsupportedReason: `${reason}:${eventType}`,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function baseUazapiEvent(
|
|
710
|
+
input: RawWebhookEvent,
|
|
711
|
+
values: {
|
|
712
|
+
providerEventId: string;
|
|
713
|
+
instanceId: string;
|
|
714
|
+
contactId: string;
|
|
715
|
+
eventType: string;
|
|
716
|
+
messageType: string;
|
|
717
|
+
senderName?: string;
|
|
718
|
+
},
|
|
719
|
+
): Omit<NormalizedChannelEvent, "kind"> {
|
|
720
|
+
return {
|
|
721
|
+
type: "whatsapp",
|
|
722
|
+
provider: "uazapi",
|
|
723
|
+
providerEventId: values.providerEventId,
|
|
724
|
+
dedupeKey: `whatsapp:uazapi:${values.instanceId}:${values.providerEventId}`,
|
|
725
|
+
externalIdentity: {
|
|
726
|
+
key: `whatsapp:uazapi:contact:${values.contactId}`,
|
|
727
|
+
display: values.senderName ?? values.contactId,
|
|
728
|
+
redactedDisplay: values.senderName
|
|
729
|
+
? `${values.senderName} (${redactPhone(values.contactId)})`
|
|
730
|
+
: `whatsapp:uazapi:contact:${redactPhone(values.contactId)}`,
|
|
731
|
+
},
|
|
732
|
+
receivedAt: input.receivedAt,
|
|
733
|
+
providerMetadata: {
|
|
734
|
+
instanceId: values.instanceId,
|
|
735
|
+
eventType: values.eventType,
|
|
736
|
+
messageType: values.messageType,
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function normalizeUazapiMessage(body: Record<string, unknown>): UazapiMessage {
|
|
742
|
+
const data = readRecord(body, "data");
|
|
743
|
+
const nestedMessage = readRecord(body, "message");
|
|
744
|
+
const dataMessage = readRecord(data, "message");
|
|
745
|
+
const message = Object.keys(nestedMessage).length > 0
|
|
746
|
+
? nestedMessage
|
|
747
|
+
: Object.keys(dataMessage).length > 0
|
|
748
|
+
? dataMessage
|
|
749
|
+
: Object.keys(data).length > 0
|
|
750
|
+
? data
|
|
751
|
+
: body;
|
|
752
|
+
const content = readRecord(message, "content");
|
|
753
|
+
const textRecord = readRecord(message, "text");
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
id: readString(message, "id"),
|
|
757
|
+
messageId: readString(message, "messageid") ?? readString(message, "messageId") ?? readString(message, "message_id"),
|
|
758
|
+
chatId: readString(message, "chatid") ?? readString(message, "chatId") ?? readString(message, "chat_id") ?? readString(message, "from"),
|
|
759
|
+
sender:
|
|
760
|
+
readString(message, "sender") ??
|
|
761
|
+
readString(message, "from") ??
|
|
762
|
+
readString(data, "from") ??
|
|
763
|
+
readString(message, "chatid") ??
|
|
764
|
+
readString(message, "chatId"),
|
|
765
|
+
senderName: readString(message, "senderName") ?? readString(message, "pushName") ?? readString(message, "name"),
|
|
766
|
+
fromMe: readBoolean(message, "fromMe"),
|
|
767
|
+
wasSentByApi: readBoolean(message, "wasSentByApi"),
|
|
768
|
+
messageType:
|
|
769
|
+
readString(message, "messageType") ??
|
|
770
|
+
readString(message, "type") ??
|
|
771
|
+
readString(message, "message_type") ??
|
|
772
|
+
readString(content, "type"),
|
|
773
|
+
timestamp: readNumber(message, "messageTimestamp") ?? readNumber(message, "timestamp") ?? readNumber(data, "timestamp"),
|
|
774
|
+
text:
|
|
775
|
+
readString(message, "text") ??
|
|
776
|
+
readString(textRecord, "body") ??
|
|
777
|
+
readString(content, "text") ??
|
|
778
|
+
readString(content, "conversation") ??
|
|
779
|
+
readString(data, "body"),
|
|
780
|
+
fileUrl: readString(message, "fileURL") ?? readString(message, "fileUrl") ?? readString(message, "url"),
|
|
781
|
+
mimeType: readString(message, "mimetype") ?? readString(message, "mimeType"),
|
|
782
|
+
durationSeconds: readNumber(message, "duration") ?? readNumber(content, "duration"),
|
|
783
|
+
sizeBytes: readNumber(message, "file_size") ?? readNumber(message, "size") ?? readNumber(content, "size"),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function providerEventIdFor(body: Record<string, unknown>, message: UazapiMessage): string | undefined {
|
|
788
|
+
return (
|
|
789
|
+
message.messageId ??
|
|
790
|
+
readString(body, "event_id") ??
|
|
791
|
+
readString(body, "eventId") ??
|
|
792
|
+
readString(body, "id") ??
|
|
793
|
+
message.id ??
|
|
794
|
+
fallbackEventId(message)
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function fallbackEventId(message: UazapiMessage): string | undefined {
|
|
799
|
+
if (!message.chatId || message.timestamp === undefined) {
|
|
800
|
+
return undefined;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return `fallback:${stripWhatsappJid(message.chatId)}:${message.timestamp}`;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function readEventType(body: Record<string, unknown>): string {
|
|
807
|
+
const value =
|
|
808
|
+
readString(body, "EventType") ??
|
|
809
|
+
readString(body, "eventType") ??
|
|
810
|
+
readString(body, "event") ??
|
|
811
|
+
readString(body, "type") ??
|
|
812
|
+
"messages";
|
|
813
|
+
return value.toLowerCase();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function isUazapiTextMessage(messageType: string, message: UazapiMessage): boolean {
|
|
817
|
+
return messageType === "text" || messageType === "conversation" || Boolean(message.text);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isUazapiAudioMessage(messageType: string, message: UazapiMessage): boolean {
|
|
821
|
+
return (
|
|
822
|
+
messageType === "audio" ||
|
|
823
|
+
messageType === "ptt" ||
|
|
824
|
+
messageType === "myaudio" ||
|
|
825
|
+
Boolean(message.mimeType?.toLowerCase().startsWith("audio/"))
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function defaultUazapiAudioFilename(message: UazapiMessage): string {
|
|
830
|
+
const id = message.messageId ?? message.id ?? "uazapi-audio";
|
|
831
|
+
const extension = message.mimeType?.includes("mpeg") ? "mp3" : "ogg";
|
|
832
|
+
return `${id}.${extension}`;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function buildUazapiSendTextRequestInternal(
|
|
836
|
+
input: ChannelSendInput,
|
|
837
|
+
baseUrl: string,
|
|
838
|
+
mode: "actual" | "<redacted>",
|
|
839
|
+
): UazapiSendTextRequest {
|
|
840
|
+
if (input.message.contentType !== "text") {
|
|
841
|
+
throw new Error("UAZAPI sendText supports only text channel messages.");
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const to = contactFromExternalIdentity(input.externalIdentity.key);
|
|
845
|
+
const redactedTo = redactPhone(to);
|
|
846
|
+
const trackId = `${input.channelId}:${input.conversationId}`;
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
method: "POST",
|
|
850
|
+
url: mode === "actual" ? uazapiApiUrl(baseUrl, "/send/text") : "https://<redacted>/send/text",
|
|
851
|
+
body: {
|
|
852
|
+
number: mode === "actual" ? to : redactedTo,
|
|
853
|
+
text: input.message.content,
|
|
854
|
+
readchat: true,
|
|
855
|
+
async: false,
|
|
856
|
+
track_source: "agentkit",
|
|
857
|
+
track_id: trackId,
|
|
858
|
+
},
|
|
859
|
+
idempotencyKey: `whatsapp:uazapi:${input.channelId}:${input.conversationId}:${redactedTo}`,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function contactFromExternalIdentity(identityKey: string): string {
|
|
864
|
+
const prefix = "whatsapp:uazapi:contact:";
|
|
865
|
+
|
|
866
|
+
if (!identityKey.startsWith(prefix)) {
|
|
867
|
+
throw new Error("UAZAPI WhatsApp external identity must start with whatsapp:uazapi:contact:.");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const contact = identityKey.slice(prefix.length);
|
|
871
|
+
|
|
872
|
+
if (!contact) {
|
|
873
|
+
throw new Error("UAZAPI WhatsApp external identity is missing a contact id.");
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return contact;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function readUazapiProviderMessageId(payload: unknown): string | undefined {
|
|
880
|
+
if (!isRecord(payload)) {
|
|
881
|
+
return undefined;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
const response = readRecord(payload, "response");
|
|
885
|
+
const message = readRecord(payload, "message");
|
|
886
|
+
|
|
887
|
+
return (
|
|
888
|
+
readString(payload, "messageid") ??
|
|
889
|
+
readString(payload, "messageId") ??
|
|
890
|
+
readString(payload, "id") ??
|
|
891
|
+
readString(message, "messageid") ??
|
|
892
|
+
readString(message, "id") ??
|
|
893
|
+
readString(response, "messageid") ??
|
|
894
|
+
readString(response, "id")
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function isRetryableUazapiSendFailure(status: number, payload: unknown): boolean {
|
|
899
|
+
if (status === 429 || status >= 500) {
|
|
900
|
+
const errorKey = isRecord(payload) ? readString(payload, "error_key") : undefined;
|
|
901
|
+
|
|
902
|
+
if (errorKey === "WHATSAPP_REACHOUT_TIMELOCK") {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
return true;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
class UazapiResponseBodyTooLargeError extends Error {
|
|
913
|
+
constructor() {
|
|
914
|
+
super("UAZAPI response body exceeded the configured limit.");
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
async function readUazapiResponseBody(response: Response, options: { maxBytes?: number } = {}): Promise<unknown> {
|
|
919
|
+
const text = await readUazapiResponseText(response, options.maxBytes);
|
|
920
|
+
|
|
921
|
+
if (!text) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
try {
|
|
926
|
+
return JSON.parse(text);
|
|
927
|
+
} catch {
|
|
928
|
+
return text;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function readUazapiResponseText(response: Response, maxBytes?: number): Promise<string> {
|
|
933
|
+
if (maxBytes !== undefined) {
|
|
934
|
+
const contentLength = readContentLength(response.headers);
|
|
935
|
+
|
|
936
|
+
if (contentLength !== undefined && contentLength > maxBytes) {
|
|
937
|
+
throw new UazapiResponseBodyTooLargeError();
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (maxBytes !== undefined && response.body) {
|
|
942
|
+
const reader = response.body.getReader();
|
|
943
|
+
const decoder = new TextDecoder();
|
|
944
|
+
let text = "";
|
|
945
|
+
let bytesRead = 0;
|
|
946
|
+
|
|
947
|
+
try {
|
|
948
|
+
while (true) {
|
|
949
|
+
const chunk = await reader.read();
|
|
950
|
+
|
|
951
|
+
if (chunk.done) {
|
|
952
|
+
break;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
bytesRead += chunk.value.byteLength;
|
|
956
|
+
|
|
957
|
+
if (bytesRead > maxBytes) {
|
|
958
|
+
throw new UazapiResponseBodyTooLargeError();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
text += decoder.decode();
|
|
965
|
+
return text;
|
|
966
|
+
} finally {
|
|
967
|
+
reader.releaseLock();
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
const text = await response.text();
|
|
972
|
+
|
|
973
|
+
if (maxBytes !== undefined && utf8ByteLength(text) > maxBytes) {
|
|
974
|
+
throw new UazapiResponseBodyTooLargeError();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
return text;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function readContentLength(headers: Headers): number | undefined {
|
|
981
|
+
const value = headers.get("content-length");
|
|
982
|
+
|
|
983
|
+
if (!value) {
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const parsed = Number(value);
|
|
988
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function maxUazapiMediaResponseBytes(maxAudioBytes: number): number {
|
|
992
|
+
return Math.ceil(maxAudioBytes / 3) * 4 + UAZAPI_MEDIA_DOWNLOAD_BODY_OVERHEAD_BYTES;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function readUazapiErrorMessage(payload: unknown, status: number): string {
|
|
996
|
+
if (isRecord(payload)) {
|
|
997
|
+
return (
|
|
998
|
+
readString(payload, "message") ??
|
|
999
|
+
readString(payload, "error") ??
|
|
1000
|
+
readString(payload, "provider_message") ??
|
|
1001
|
+
`UAZAPI request returned HTTP ${status}.`
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return typeof payload === "string" && payload ? payload : `UAZAPI request returned HTTP ${status}.`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function parseJsonObject(input: RawWebhookEvent): Record<string, unknown> {
|
|
1009
|
+
let parsed: unknown;
|
|
1010
|
+
|
|
1011
|
+
try {
|
|
1012
|
+
parsed = JSON.parse(input.rawBody);
|
|
1013
|
+
} catch {
|
|
1014
|
+
throw new AgentKitError("channel_payload_invalid", "UAZAPI payload must be valid JSON.");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
if (!isRecord(parsed)) {
|
|
1018
|
+
throw new AgentKitError("channel_payload_invalid", "UAZAPI payload must be a JSON object.");
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
return parsed;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function validateUazapiBaseUrl(rawBaseUrl: string): string {
|
|
1025
|
+
let url: URL;
|
|
1026
|
+
|
|
1027
|
+
try {
|
|
1028
|
+
url = new URL(rawBaseUrl);
|
|
1029
|
+
} catch {
|
|
1030
|
+
throw new Error("UAZAPI_BASE_URL must be a valid HTTPS URL.");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
if (url.protocol !== "https:") {
|
|
1034
|
+
throw new Error("UAZAPI_BASE_URL must use HTTPS.");
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (url.username || url.password) {
|
|
1038
|
+
throw new Error("UAZAPI_BASE_URL must not include credentials.");
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
if (isBlockedHostname(url.hostname)) {
|
|
1042
|
+
throw new Error("UAZAPI_BASE_URL host is not allowed.");
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
1046
|
+
url.search = "";
|
|
1047
|
+
url.hash = "";
|
|
1048
|
+
|
|
1049
|
+
return url.href.replace(/\/$/, "");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function isBlockedHostname(hostname: string): boolean {
|
|
1053
|
+
const host = normalizeUrlHostname(hostname);
|
|
1054
|
+
|
|
1055
|
+
if (
|
|
1056
|
+
host === "localhost" ||
|
|
1057
|
+
host.endsWith(".localhost") ||
|
|
1058
|
+
host === "metadata.google.internal" ||
|
|
1059
|
+
host === "169.254.169.254"
|
|
1060
|
+
) {
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const ipv4 = parseIpv4Octets(host);
|
|
1065
|
+
|
|
1066
|
+
if (ipv4) {
|
|
1067
|
+
return isBlockedIpv4Octets(ipv4);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (host.includes(":")) {
|
|
1071
|
+
return isBlockedIpv6Hostname(host);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return false;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function normalizeUrlHostname(hostname: string): string {
|
|
1078
|
+
const host = hostname.toLowerCase().replace(/\.$/, "");
|
|
1079
|
+
return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function isBlockedIpv4Octets(parts: number[]): boolean {
|
|
1083
|
+
const [a, b] = parts;
|
|
1084
|
+
|
|
1085
|
+
return (
|
|
1086
|
+
a === 10 ||
|
|
1087
|
+
a === 127 ||
|
|
1088
|
+
(a === 169 && b === 254) ||
|
|
1089
|
+
(a === 172 && b !== undefined && b >= 16 && b <= 31) ||
|
|
1090
|
+
(a === 192 && b === 168) ||
|
|
1091
|
+
a === 0
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function isBlockedIpv6Hostname(host: string): boolean {
|
|
1096
|
+
if (host.includes("%")) {
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const segments = parseIpv6Segments(host);
|
|
1101
|
+
|
|
1102
|
+
if (!segments) {
|
|
1103
|
+
return false;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
const first = segments[0] ?? 0;
|
|
1107
|
+
const isUnspecified = segments.every((segment) => segment === 0);
|
|
1108
|
+
const isLoopback = segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1;
|
|
1109
|
+
const isUniqueLocal = (first & 0xfe00) === 0xfc00;
|
|
1110
|
+
const isLinkLocal = (first & 0xffc0) === 0xfe80;
|
|
1111
|
+
const isIpv4Mapped = segments.slice(0, 5).every((segment) => segment === 0) && segments[5] === 0xffff;
|
|
1112
|
+
const isIpv4Compatible = segments.slice(0, 6).every((segment) => segment === 0) && (segments[6] !== 0 || segments[7] !== 0);
|
|
1113
|
+
|
|
1114
|
+
if (isUnspecified || isLoopback || isUniqueLocal || isLinkLocal) {
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if (isIpv4Mapped || isIpv4Compatible) {
|
|
1119
|
+
return isBlockedIpv4Octets([
|
|
1120
|
+
(segments[6] >> 8) & 255,
|
|
1121
|
+
segments[6] & 255,
|
|
1122
|
+
(segments[7] >> 8) & 255,
|
|
1123
|
+
segments[7] & 255,
|
|
1124
|
+
]);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
return false;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
function parseIpv6Segments(host: string): number[] | undefined {
|
|
1131
|
+
if (!host.includes(":")) {
|
|
1132
|
+
return undefined;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if ((host.match(/::/g) ?? []).length > 1) {
|
|
1136
|
+
return undefined;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
const [left = "", right = ""] = host.split("::");
|
|
1140
|
+
const leftSegments = parseIpv6SegmentSide(left);
|
|
1141
|
+
const rightSegments = parseIpv6SegmentSide(right);
|
|
1142
|
+
|
|
1143
|
+
if (!leftSegments || !rightSegments) {
|
|
1144
|
+
return undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
if (!host.includes("::")) {
|
|
1148
|
+
return leftSegments.length === 8 ? leftSegments : undefined;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const missing = 8 - leftSegments.length - rightSegments.length;
|
|
1152
|
+
|
|
1153
|
+
if (missing < 1) {
|
|
1154
|
+
return undefined;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return [...leftSegments, ...Array.from({ length: missing }, () => 0), ...rightSegments];
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function parseIpv6SegmentSide(value: string): number[] | undefined {
|
|
1161
|
+
if (!value) {
|
|
1162
|
+
return [];
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const parts = value.split(":");
|
|
1166
|
+
const segments: number[] = [];
|
|
1167
|
+
|
|
1168
|
+
for (const [index, part] of parts.entries()) {
|
|
1169
|
+
if (!part) {
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (part.includes(".")) {
|
|
1174
|
+
if (index !== parts.length - 1) {
|
|
1175
|
+
return undefined;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
const ipv4 = parseIpv4Octets(part);
|
|
1179
|
+
|
|
1180
|
+
if (!ipv4) {
|
|
1181
|
+
return undefined;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
segments.push((ipv4[0] << 8) | ipv4[1], (ipv4[2] << 8) | ipv4[3]);
|
|
1185
|
+
continue;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (!/^[0-9a-f]{1,4}$/i.test(part)) {
|
|
1189
|
+
return undefined;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
segments.push(Number.parseInt(part, 16));
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return segments;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function parseIpv4Octets(host: string): number[] | undefined {
|
|
1199
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
1200
|
+
return undefined;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
const parts = host.split(".").map(Number);
|
|
1204
|
+
|
|
1205
|
+
if (parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
1206
|
+
return undefined;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
return parts;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function stripWhatsappJid(value: string): string {
|
|
1213
|
+
return value
|
|
1214
|
+
.replace(/^whatsapp:/, "")
|
|
1215
|
+
.replace(/@s\.whatsapp\.net$/i, "")
|
|
1216
|
+
.replace(/@c\.us$/i, "")
|
|
1217
|
+
.replace(/@lid$/i, "")
|
|
1218
|
+
.replace(/@g\.us$/i, "");
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function redactPhone(value: string): string {
|
|
1222
|
+
const digits = value.replace(/\D/g, "");
|
|
1223
|
+
|
|
1224
|
+
if (digits.length < 8) {
|
|
1225
|
+
return value;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return `${digits.slice(0, 4)}******${digits.slice(-4)}`;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function redactString(value: string, secrets: Array<string | undefined>): string {
|
|
1232
|
+
let redacted = value;
|
|
1233
|
+
|
|
1234
|
+
for (const secret of secrets) {
|
|
1235
|
+
if (secret) {
|
|
1236
|
+
redacted = redacted.split(secret).join("<redacted>");
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return redacted;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function redactValue(value: unknown, secrets: Array<string | undefined>): unknown {
|
|
1244
|
+
if (typeof value === "string") {
|
|
1245
|
+
return redactString(value, secrets);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (Array.isArray(value)) {
|
|
1249
|
+
return value.map((entry) => redactValue(entry, secrets));
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (isRecord(value)) {
|
|
1253
|
+
const redacted: Record<string, unknown> = {};
|
|
1254
|
+
|
|
1255
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1256
|
+
redacted[key] = redactValue(entry, secrets);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
return redacted;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return value;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function decodeBase64(value: string): Uint8Array {
|
|
1266
|
+
if (typeof Buffer !== "undefined") {
|
|
1267
|
+
return new Uint8Array(Buffer.from(value, "base64"));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const binary = atob(value);
|
|
1271
|
+
const bytes = new Uint8Array(binary.length);
|
|
1272
|
+
|
|
1273
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
1274
|
+
bytes[index] = binary.charCodeAt(index);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
return bytes;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function normalizeBase64(value: string): string {
|
|
1281
|
+
return value.replace(/\s/g, "");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function estimateBase64DecodedBytes(value: string): number | undefined {
|
|
1285
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(value) || value.length % 4 === 1) {
|
|
1286
|
+
return undefined;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
if (value.includes("=") && !/={1,2}$/.test(value)) {
|
|
1290
|
+
return undefined;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
1294
|
+
return Math.floor((value.length * 3) / 4) - padding;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function utf8ByteLength(value: string): number {
|
|
1298
|
+
if (typeof Buffer !== "undefined") {
|
|
1299
|
+
return Buffer.byteLength(value);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return new TextEncoder().encode(value).byteLength;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function readRecord(value: Record<string, unknown>, key: string): Record<string, unknown> {
|
|
1306
|
+
const entry = value[key];
|
|
1307
|
+
return isRecord(entry) ? entry : {};
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function readString(value: Record<string, unknown>, key: string): string | undefined {
|
|
1311
|
+
const entry = value[key];
|
|
1312
|
+
return typeof entry === "string" && entry.length > 0 ? entry : undefined;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function readBoolean(value: Record<string, unknown>, key: string): boolean | undefined {
|
|
1316
|
+
const entry = value[key];
|
|
1317
|
+
return typeof entry === "boolean" ? entry : undefined;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function readNumber(value: Record<string, unknown>, key: string): number | undefined {
|
|
1321
|
+
const entry = value[key];
|
|
1322
|
+
return typeof entry === "number" && Number.isFinite(entry) ? entry : undefined;
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1326
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
1327
|
+
}
|