@laburen/openclaw-plugin-whatsapp-api 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,21 +1,47 @@
1
1
  {
2
2
  "name": "@laburen/openclaw-plugin-whatsapp-api",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "WhatsApp API channel plugin for OpenClaw",
6
+ "main": "index.js",
6
7
  "files": [
7
- "index.ts",
8
- "src",
8
+ "index.js",
9
+ "index.d.ts",
9
10
  "openclaw.plugin.json",
10
- "README.md"
11
+ "README.md",
12
+ "LICENSE"
11
13
  ],
14
+ "scripts": {
15
+ "build": "tsdown",
16
+ "test": "node --test --import tsx/esm tests/*.test.ts",
17
+ "lint": "eslint src/ index.ts",
18
+ "lint:fix": "eslint src/ index.ts --fix"
19
+ },
12
20
  "license": "MIT",
21
+ "peerDependencies": {
22
+ "openclaw": ">=2026.3.22"
23
+ },
13
24
  "devDependencies": {
14
- "openclaw": "*"
25
+ "@types/node": "^22.0.0",
26
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
27
+ "@typescript-eslint/parser": "^8.56.1",
28
+ "eslint": "^9.39.3",
29
+ "tsdown": "^0.21.4",
30
+ "tsx": "^4.19.0",
31
+ "typescript": "^5.9.3",
32
+ "openclaw": "^2026.3.22"
15
33
  },
16
34
  "openclaw": {
17
35
  "extensions": [
18
- "./index.ts"
19
- ]
36
+ "./index.js"
37
+ ],
38
+ "channel": {
39
+ "id": "whatsapp-api",
40
+ "label": "WhatsApp API",
41
+ "selectionLabel": "WhatsApp API (Cloud)",
42
+ "docsPath": "/channels/whatsapp-api",
43
+ "blurb": "WhatsApp Cloud API channel with router-fed inbound webhook and direct Meta Graph API outbound.",
44
+ "order": 10
45
+ }
20
46
  }
21
47
  }
package/index.ts DELETED
@@ -1,16 +0,0 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { createWhatsAppApiChannel } from "./src/channel.js";
3
- import { setPluginApi } from "./src/runtime.js";
4
-
5
- const plugin = {
6
- id: "whatsapp-api",
7
- name: "WhatsApp API",
8
- description: "WhatsApp API channel plugin with inbound webhook and direct Meta outbound",
9
- register(api: OpenClawPluginApi) {
10
- setPluginApi(api);
11
- api.registerChannel({ plugin: createWhatsAppApiChannel(api) });
12
- api.logger.info("[whatsapp-api] plugin registered");
13
- },
14
- };
15
-
16
- export default plugin;
package/src/accounts.ts DELETED
@@ -1,102 +0,0 @@
1
- import type { ResolvedWhatsAppApiAccount } from "./types.js";
2
-
3
- const CHANNEL_ID = "whatsapp-api";
4
- const DEFAULT_ACCOUNT_ID = "default";
5
- const DEFAULT_WEBHOOK_PATH = "/webhook/whatsapp-api";
6
- const DEFAULT_API_VERSION = "v22.0";
7
- const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
8
- const DEFAULT_MAX_RETRIES = 2;
9
- const DEFAULT_RETRY_BACKOFF_MS = 500;
10
- const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000;
11
- const DEFAULT_MAX_BODY_BYTES = 512 * 1024;
12
- const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
13
- const DEFAULT_MEDIA_REQUEST_TIMEOUT_MS = 15_000;
14
-
15
- function getChannelConfig(cfg: Record<string, unknown>): Record<string, unknown> {
16
- const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
17
- return (channels[CHANNEL_ID] as Record<string, unknown> | undefined) ?? {};
18
- }
19
-
20
- export function listAccountIds(cfg: Record<string, unknown>): string[] {
21
- const channelCfg = getChannelConfig(cfg);
22
- const ids = new Set<string>();
23
- const accounts =
24
- (channelCfg.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
25
-
26
- if (Object.keys(accounts).length > 0) {
27
- for (const id of Object.keys(accounts)) {
28
- ids.add(id);
29
- }
30
- } else {
31
- ids.add(DEFAULT_ACCOUNT_ID);
32
- }
33
-
34
- return [...ids];
35
- }
36
-
37
- export function resolveAccount(
38
- cfg: Record<string, unknown>,
39
- accountId?: string | null,
40
- ): ResolvedWhatsAppApiAccount {
41
- const channelCfg = getChannelConfig(cfg);
42
- const id = accountId ?? DEFAULT_ACCOUNT_ID;
43
- const accounts =
44
- (channelCfg.accounts as Record<string, Record<string, unknown>> | undefined) ?? {};
45
- const accountCfg = accounts[id] ?? {};
46
-
47
- return {
48
- accountId: id,
49
- enabled: (accountCfg.enabled as boolean | undefined) ?? true,
50
- webhookPath:
51
- (accountCfg.webhookPath as string | undefined) ??
52
- (channelCfg.webhookPath as string | undefined) ??
53
- DEFAULT_WEBHOOK_PATH,
54
- inboundSharedSecret:
55
- (accountCfg.inboundSharedSecret as string | undefined) ??
56
- (channelCfg.inboundSharedSecret as string | undefined),
57
- inboundAccessToken:
58
- (accountCfg.inboundAccessToken as string | undefined) ??
59
- (channelCfg.inboundAccessToken as string | undefined),
60
- outboundPhoneNumberId:
61
- (accountCfg.outboundPhoneNumberId as string | undefined) ??
62
- (channelCfg.outboundPhoneNumberId as string | undefined),
63
- outboundAccessToken:
64
- (accountCfg.outboundAccessToken as string | undefined) ??
65
- (channelCfg.outboundAccessToken as string | undefined),
66
- outboundApiVersion:
67
- (accountCfg.outboundApiVersion as string | undefined) ??
68
- (channelCfg.outboundApiVersion as string | undefined) ??
69
- DEFAULT_API_VERSION,
70
- requestTimeoutMs:
71
- (accountCfg.requestTimeoutMs as number | undefined) ??
72
- (channelCfg.requestTimeoutMs as number | undefined) ??
73
- DEFAULT_REQUEST_TIMEOUT_MS,
74
- maxRetries:
75
- (accountCfg.maxRetries as number | undefined) ??
76
- (channelCfg.maxRetries as number | undefined) ??
77
- DEFAULT_MAX_RETRIES,
78
- retryBackoffMs:
79
- (accountCfg.retryBackoffMs as number | undefined) ??
80
- (channelCfg.retryBackoffMs as number | undefined) ??
81
- DEFAULT_RETRY_BACKOFF_MS,
82
- dedupeTtlMs:
83
- (accountCfg.dedupeTtlMs as number | undefined) ??
84
- (channelCfg.dedupeTtlMs as number | undefined) ??
85
- DEFAULT_DEDUPE_TTL_MS,
86
- maxBodyBytes:
87
- (accountCfg.maxBodyBytes as number | undefined) ??
88
- (channelCfg.maxBodyBytes as number | undefined) ??
89
- DEFAULT_MAX_BODY_BYTES,
90
- mediaMaxBytes:
91
- (accountCfg.mediaMaxBytes as number | undefined) ??
92
- (channelCfg.mediaMaxBytes as number | undefined) ??
93
- DEFAULT_MEDIA_MAX_BYTES,
94
- mediaRequestTimeoutMs:
95
- (accountCfg.mediaRequestTimeoutMs as number | undefined) ??
96
- (channelCfg.mediaRequestTimeoutMs as number | undefined) ??
97
- DEFAULT_MEDIA_REQUEST_TIMEOUT_MS,
98
- mediaTempDir:
99
- (accountCfg.mediaTempDir as string | undefined) ??
100
- (channelCfg.mediaTempDir as string | undefined),
101
- };
102
- }
package/src/channel.ts DELETED
@@ -1,253 +0,0 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { listAccountIds, resolveAccount } from "./accounts.js";
3
- import { sendWhatsAppApiReplyPayload, sendWhatsAppApiText } from "./outbound.js";
4
- import { getPluginRuntime } from "./runtime.js";
5
- import { downloadInboundMediaAttachment } from "./utils/media/inbound.js";
6
- import { handleWhatsAppApiWebhook } from "./webhook.js";
7
- import type { WhatsAppApiInboundMessage } from "./types.js";
8
-
9
- const CHANNEL_ID = "whatsapp-api";
10
-
11
- function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
12
- return new Promise((resolve) => {
13
- const done = () => {
14
- onAbort?.();
15
- resolve();
16
- };
17
- if (!signal) {
18
- return;
19
- }
20
- if (signal.aborted) {
21
- done();
22
- return;
23
- }
24
- signal.addEventListener("abort", done, { once: true });
25
- });
26
- }
27
-
28
- function buildInboundBody(rt: any, message: WhatsAppApiInboundMessage): string {
29
- if (typeof rt?.channel?.reply?.formatInboundEnvelope === "function") {
30
- return rt.channel.reply.formatInboundEnvelope({
31
- channel: "WhatsApp API",
32
- from: message.senderName || message.from,
33
- timestamp: message.timestamp,
34
- body: message.text,
35
- chatType: "direct",
36
- sender: {
37
- name: message.senderName,
38
- id: message.from,
39
- },
40
- });
41
- }
42
- return message.text;
43
- }
44
-
45
- async function dispatchInboundToAgent(params: {
46
- accountId: string;
47
- message: WhatsAppApiInboundMessage;
48
- log?: { info?: (msg: string) => void; warn?: (msg: string) => void; error?: (msg: string) => void };
49
- onOutbound?: () => void;
50
- }): Promise<void> {
51
- const { accountId, message, log, onOutbound } = params;
52
- const rt = getPluginRuntime() as any;
53
- const cfg = await rt.config.loadConfig();
54
- const account = resolveAccount(cfg, accountId);
55
- const route = rt.channel.routing.resolveAgentRoute({
56
- cfg,
57
- channel: CHANNEL_ID,
58
- accountId,
59
- peer: {
60
- kind: "direct",
61
- id: message.from,
62
- },
63
- });
64
- const body = buildInboundBody(rt, message);
65
- const to = `whatsapp-api:${message.from}`;
66
- const mediaAttachment = await downloadInboundMediaAttachment({
67
- account,
68
- message,
69
- log: params.log,
70
- });
71
- const ctxPayload = rt.channel.reply.finalizeInboundContext({
72
- Body: body,
73
- BodyForAgent: message.text,
74
- RawBody: message.text,
75
- CommandBody: message.text,
76
- From: `whatsapp-api:${message.from}`,
77
- To: to,
78
- SessionKey: route.sessionKey,
79
- AccountId: route.accountId,
80
- ChatType: "direct",
81
- ConversationLabel: message.senderName || message.from,
82
- SenderName: message.senderName,
83
- SenderId: message.from,
84
- Provider: CHANNEL_ID,
85
- Surface: CHANNEL_ID,
86
- MessageSid: message.messageId,
87
- ReplyToId: message.replyToId,
88
- ReplyToBody: message.replyToBody,
89
- ReplyToSender: message.replyToSender,
90
- MediaPath: mediaAttachment.mediaPath,
91
- MediaType: mediaAttachment.mediaType,
92
- OriginatingChannel: CHANNEL_ID,
93
- OriginatingTo: to,
94
- });
95
-
96
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
97
- ctx: ctxPayload,
98
- cfg,
99
- dispatcherOptions: {
100
- deliver: async (payload: unknown) => {
101
- const account = resolveAccount(cfg, accountId);
102
- const sent = await sendWhatsAppApiReplyPayload({
103
- to: message.from,
104
- payload,
105
- account,
106
- });
107
- if (sent.length === 0) {
108
- log?.info?.(
109
- `[${CHANNEL_ID}] skipping outbound: reply payload empty for messageId=${message.messageId}`,
110
- );
111
- return;
112
- }
113
- log?.info?.(
114
- `[${CHANNEL_ID}] sent ${sent.length} outbound message(s) to ${message.from} for inbound=${message.messageId}`,
115
- );
116
- onOutbound?.();
117
- },
118
- onError: (err: unknown, info: { kind?: string }) => {
119
- log?.error?.(
120
- `[${CHANNEL_ID}] ${info?.kind ?? "reply"} dispatch failed for account=${accountId} messageId=${message.messageId}: ${String(err)}`,
121
- );
122
- },
123
- },
124
- });
125
- }
126
-
127
- export function createWhatsAppApiChannel(api: OpenClawPluginApi) {
128
- return {
129
- id: CHANNEL_ID,
130
- meta: {
131
- id: CHANNEL_ID,
132
- label: "WhatsApp API",
133
- selectionLabel: "WhatsApp API (Cloud)",
134
- detailLabel: "WhatsApp API (Cloud)",
135
- docsPath: "/channels/whatsapp-api",
136
- blurb: "WhatsApp Cloud API channel with router-fed inbound webhook",
137
- order: 5,
138
- },
139
- capabilities: {
140
- chatTypes: ["direct" as const],
141
- media: true,
142
- threads: false,
143
- reactions: false,
144
- edit: false,
145
- unsend: false,
146
- reply: false,
147
- effects: false,
148
- blockStreaming: false,
149
- },
150
- config: {
151
- listAccountIds: (cfg: Record<string, unknown>) => listAccountIds(cfg),
152
- resolveAccount: (cfg: Record<string, unknown>, accountId?: string | null) =>
153
- resolveAccount(cfg, accountId),
154
- defaultAccountId: () => "default",
155
- },
156
- outbound: {
157
- deliveryMode: "gateway" as const,
158
- sendText: async ({
159
- to,
160
- text,
161
- cfg,
162
- accountId,
163
- }: {
164
- to: string;
165
- text: string;
166
- cfg: Record<string, unknown>;
167
- accountId?: string;
168
- }) => {
169
- const account = resolveAccount(cfg, accountId ?? null);
170
- const messageId = await sendWhatsAppApiText({
171
- to,
172
- text,
173
- account,
174
- });
175
- return {
176
- channel: CHANNEL_ID,
177
- chatId: to,
178
- messageId,
179
- };
180
- },
181
- },
182
- gateway: {
183
- startAccount: async (ctx: any) => {
184
- const account = resolveAccount(ctx.cfg, ctx.accountId);
185
-
186
- if (!account.enabled) {
187
- ctx.log?.info?.(`[${CHANNEL_ID}] account ${account.accountId} disabled, skipping route`);
188
- return waitUntilAbort(ctx.abortSignal);
189
- }
190
-
191
- const routePath = account.webhookPath;
192
- ctx.setStatus?.({
193
- accountId: account.accountId,
194
- running: true,
195
- lastStartAt: Date.now(),
196
- lastError: null,
197
- });
198
- api.registerHttpRoute({
199
- path: routePath,
200
- auth: "plugin",
201
- replaceExisting: true,
202
- handler: async (req, res) => {
203
- return await handleWhatsAppApiWebhook({
204
- req,
205
- res,
206
- account,
207
- log: ctx.log,
208
- onMessage: async (message) => {
209
- ctx.setStatus?.({
210
- accountId: account.accountId,
211
- lastInboundAt: Date.now(),
212
- });
213
- try {
214
- await dispatchInboundToAgent({
215
- accountId: account.accountId,
216
- message,
217
- log: ctx.log,
218
- onOutbound: () => {
219
- ctx.setStatus?.({
220
- accountId: account.accountId,
221
- lastOutboundAt: Date.now(),
222
- });
223
- },
224
- });
225
- } catch (err) {
226
- ctx.setStatus?.({
227
- accountId: account.accountId,
228
- lastError: String(err),
229
- });
230
- throw err;
231
- }
232
- },
233
- });
234
- },
235
- });
236
-
237
- ctx.log?.info?.(`[${CHANNEL_ID}] registered HTTP route: ${routePath}`);
238
-
239
- return waitUntilAbort(ctx.abortSignal, () => {
240
- ctx.log?.info?.(`[${CHANNEL_ID}] stopped account ${account.accountId}`);
241
- ctx.setStatus?.({
242
- accountId: account.accountId,
243
- running: false,
244
- lastStopAt: Date.now(),
245
- });
246
- });
247
- },
248
- stopAccount: async (ctx: { accountId: string; log?: { info?: (msg: string) => void } }) => {
249
- ctx.log?.info?.(`[${CHANNEL_ID}] stopAccount called for ${ctx.accountId}`);
250
- },
251
- },
252
- };
253
- }
package/src/inbound.ts DELETED
@@ -1,233 +0,0 @@
1
- import type {
2
- WhatsAppApiInboundContact,
3
- WhatsAppApiInboundLocation,
4
- WhatsAppApiInboundMessage,
5
- } from "./types.js";
6
- import { asArray, asRecord, asTrimmedString } from "./utils/common.js";
7
-
8
- function readTextPayload(msg: Record<string, unknown>): {
9
- text: string;
10
- media?: WhatsAppApiInboundMessage["media"];
11
- location?: WhatsAppApiInboundLocation;
12
- contacts?: WhatsAppApiInboundContact[];
13
- } {
14
- const readLocationText = (location: WhatsAppApiInboundLocation): string => {
15
- const coord = `${location.latitude.toFixed(6)}, ${location.longitude.toFixed(6)}`;
16
- const parts = [location.name, location.address].filter(
17
- (value): value is string => typeof value === "string" && Boolean(value.trim()),
18
- );
19
- const suffix = parts.length > 0 ? `; ${parts.join(" - ")}` : "";
20
- return `<location: ${coord}${suffix}>`;
21
- };
22
-
23
- const readContactLabel = (contact: WhatsAppApiInboundContact): string | undefined => {
24
- const primaryPhone = contact.phones[0];
25
- const fields = [contact.name, primaryPhone].filter(
26
- (value): value is string => typeof value === "string" && Boolean(value.trim()),
27
- );
28
- return fields.length > 0 ? fields.join(", ") : undefined;
29
- };
30
-
31
- const readContactsText = (contacts: WhatsAppApiInboundContact[]): string => {
32
- if (contacts.length === 0) {
33
- return "<contacts>";
34
- }
35
- if (contacts.length === 1) {
36
- const label = readContactLabel(contacts[0]);
37
- return label ? `<contact: ${label}>` : "<contact>";
38
- }
39
- const labels = contacts
40
- .map((contact) => readContactLabel(contact))
41
- .filter((value): value is string => Boolean(value));
42
- if (labels.length === 0) {
43
- return `<contacts: ${contacts.length} contacts>`;
44
- }
45
- const shown = labels.slice(0, 3);
46
- const remaining = Math.max(contacts.length - shown.length, 0);
47
- const suffix = remaining > 0 ? ` (+${remaining} more)` : "";
48
- return `<contacts: ${shown.join(", ")}${suffix}>`;
49
- };
50
-
51
- const msgType = typeof msg.type === "string" ? msg.type : "";
52
- if (msgType === "text") {
53
- const text = asRecord(msg.text);
54
- return {
55
- text: typeof text?.body === "string" ? text.body.trim() : "",
56
- };
57
- }
58
-
59
- if (
60
- msgType === "image" ||
61
- msgType === "video" ||
62
- msgType === "audio" ||
63
- msgType === "document" ||
64
- msgType === "sticker"
65
- ) {
66
- const rawMedia = asRecord(msg[msgType]);
67
- const mediaId = asTrimmedString(rawMedia?.id);
68
- const mimeType = asTrimmedString(rawMedia?.mime_type);
69
- const fileName = asTrimmedString(rawMedia?.filename);
70
- const caption = asTrimmedString(rawMedia?.caption);
71
- const placeholder = `<media:${msgType}>`;
72
- return {
73
- text: caption ? `${caption}\n\n${placeholder}` : placeholder,
74
- media: mediaId
75
- ? {
76
- id: mediaId,
77
- kind: msgType,
78
- mimeType,
79
- fileName,
80
- }
81
- : undefined,
82
- };
83
- }
84
- if (msgType === "location") {
85
- const rawLocation = asRecord(msg.location);
86
- const latitude = Number(rawLocation?.latitude);
87
- const longitude = Number(rawLocation?.longitude);
88
- if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
89
- return { text: "<location>" };
90
- }
91
- const location: WhatsAppApiInboundLocation = {
92
- latitude,
93
- longitude,
94
- name: asTrimmedString(rawLocation?.name),
95
- address: asTrimmedString(rawLocation?.address),
96
- url: asTrimmedString(rawLocation?.url),
97
- };
98
- return {
99
- text: readLocationText(location),
100
- location,
101
- };
102
- }
103
- if (msgType === "contacts") {
104
- const contacts: WhatsAppApiInboundContact[] = [];
105
- for (const contactRaw of asArray(msg.contacts)) {
106
- const contact = asRecord(contactRaw);
107
- if (!contact) {
108
- continue;
109
- }
110
- const nameRecord = asRecord(contact.name);
111
- const name =
112
- asTrimmedString(nameRecord?.formatted_name) ??
113
- asTrimmedString(nameRecord?.first_name) ??
114
- asTrimmedString(nameRecord?.last_name);
115
- const phones = asArray(contact.phones)
116
- .map((phoneRaw) => asTrimmedString(asRecord(phoneRaw)?.phone))
117
- .filter((value): value is string => Boolean(value));
118
- contacts.push({ name, phones });
119
- }
120
- return {
121
- text: readContactsText(contacts),
122
- contacts,
123
- };
124
- }
125
- if (msgType === "reaction") return { text: "<reaction>" };
126
- if (msgType === "button") return { text: "<button>" };
127
- if (msgType === "interactive") return { text: "<interactive>" };
128
-
129
- return { text: "" };
130
- }
131
-
132
- export function parseWhatsAppCloudInbound(params: {
133
- payload: unknown;
134
- accountId: string;
135
- }): WhatsAppApiInboundMessage[] {
136
- const root = asRecord(params.payload);
137
- if (!root) {
138
- return [];
139
- }
140
-
141
- const result: WhatsAppApiInboundMessage[] = [];
142
- const entries = asArray(root.entry);
143
- for (const entryRaw of entries) {
144
- const entry = asRecord(entryRaw);
145
- if (!entry) {
146
- continue;
147
- }
148
-
149
- const changes = asArray(entry.changes);
150
- for (const changeRaw of changes) {
151
- const change = asRecord(changeRaw);
152
- if (!change) {
153
- continue;
154
- }
155
- if (change.field !== "messages") {
156
- continue;
157
- }
158
-
159
- const value = asRecord(change.value);
160
- if (!value) {
161
- continue;
162
- }
163
- const metadata = asRecord(value.metadata) ?? {};
164
- const to =
165
- (typeof metadata.display_phone_number === "string" &&
166
- metadata.display_phone_number.trim()) ||
167
- (typeof metadata.phone_number_id === "string" && metadata.phone_number_id.trim()) ||
168
- "";
169
-
170
- const contactNames = new Map<string, string>();
171
- for (const contactRaw of asArray(value.contacts)) {
172
- const contact = asRecord(contactRaw);
173
- if (!contact) {
174
- continue;
175
- }
176
- const waId = typeof contact.wa_id === "string" ? contact.wa_id.trim() : "";
177
- const profile = asRecord(contact.profile);
178
- const name = typeof profile?.name === "string" ? profile.name.trim() : "";
179
- if (waId && name) {
180
- contactNames.set(waId, name);
181
- }
182
- }
183
-
184
- const messages = asArray(value.messages);
185
- for (const messageRaw of messages) {
186
- const msg = asRecord(messageRaw);
187
- if (!msg) {
188
- continue;
189
- }
190
- const from = typeof msg.from === "string" ? msg.from.trim() : "";
191
- const messageId = typeof msg.id === "string" ? msg.id.trim() : "";
192
- if (!from || !messageId) {
193
- continue;
194
- }
195
-
196
- const parsed = readTextPayload(msg);
197
- if (!parsed.text) {
198
- continue;
199
- }
200
-
201
- const context = asRecord(msg.context);
202
- const replyToId = asTrimmedString(context?.id) ?? asTrimmedString(context?.message_id);
203
- const replyToSender = asTrimmedString(context?.from);
204
- const replyToBody =
205
- asTrimmedString(context?.body) ??
206
- asTrimmedString(asRecord(context?.text)?.body) ??
207
- asTrimmedString(asRecord(context?.quoted_message)?.body);
208
- const timestampRaw = typeof msg.timestamp === "string" ? Number(msg.timestamp) : NaN;
209
- const timestamp =
210
- Number.isFinite(timestampRaw) && timestampRaw > 0 ? timestampRaw * 1000 : undefined;
211
-
212
- result.push({
213
- accountId: params.accountId,
214
- from,
215
- to,
216
- messageId,
217
- text: parsed.text,
218
- chatType: "direct",
219
- timestamp,
220
- senderName: contactNames.get(from),
221
- replyToId,
222
- replyToBody,
223
- replyToSender,
224
- media: parsed.media,
225
- location: parsed.location,
226
- contacts: parsed.contacts,
227
- });
228
- }
229
- }
230
- }
231
-
232
- return result;
233
- }