@lofa199419/waha-v2 2.1.0 → 3.0.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/bin/waha_cli.py CHANGED
@@ -17,6 +17,11 @@ DEFAULT_MAX_CHUNK_LENGTH = 1500
17
17
  SEEN_DELAY_MS = 3000
18
18
  MIN_DELAY_MS = 600
19
19
  MAX_DELAY_MS = 8000
20
+ IDENTITY_CONTACT_PAGE_SIZE = 200
21
+ IDENTITY_CONTACT_MAX_PAGES = 10
22
+ IDENTITY_CHAT_LIMIT = 50
23
+ IDENTITY_MESSAGE_LIMIT = 5
24
+ IDENTITY_NOISY_MESSAGE_TYPES = {"ciphertext", "e2e_notification", "notification_template"}
20
25
 
21
26
 
22
27
  def env(name: str, default: str | None = None) -> str | None:
@@ -31,6 +36,11 @@ def state_dir() -> pathlib.Path:
31
36
  return home / ".openclaw"
32
37
 
33
38
 
39
+ def identity_index_path(session_name: str) -> pathlib.Path:
40
+ safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in (session_name or "default"))
41
+ return state_dir() / "extensions" / OPENCLAW_CHANNEL_ID / "identity" / f"{safe}.json"
42
+
43
+
34
44
  def parse_dotenv(path: pathlib.Path) -> dict[str, str]:
35
45
  values: dict[str, str] = {}
36
46
  if not path.exists():
@@ -201,6 +211,166 @@ def send_presence(endpoint: str, session: str, chat_id: str) -> None:
201
211
  waha_request("POST", endpoint, {"session": session, "chatId": chat_id}, session_name=session)
202
212
 
203
213
 
214
+ def normalize_raw_id(value) -> str | None:
215
+ if value is None:
216
+ return None
217
+ if isinstance(value, str):
218
+ raw = value.strip().lower()
219
+ return raw or None
220
+ if isinstance(value, dict):
221
+ serialized = value.get("_serialized")
222
+ if isinstance(serialized, str):
223
+ raw = serialized.strip().lower()
224
+ return raw or None
225
+ return None
226
+
227
+
228
+ def extract_digits(value: str | None) -> str | None:
229
+ digits = "".join(ch for ch in str(value or "") if ch.isdigit())
230
+ return digits or None
231
+
232
+
233
+ def to_chat_phone_id(value: str | None) -> str | None:
234
+ digits = extract_digits(value)
235
+ return f"{digits}@c.us" if digits else None
236
+
237
+
238
+ def parse_jid(value) -> dict | None:
239
+ raw = normalize_raw_id(value)
240
+ if not raw:
241
+ return None
242
+ left, at, server = raw.partition("@")
243
+ user, colon, device = left.partition(":")
244
+ bare = f"{user}@{server}" if at else raw
245
+ kind = "unknown"
246
+ if server == "g.us":
247
+ kind = "group"
248
+ elif server == "broadcast":
249
+ kind = "broadcast"
250
+ elif colon:
251
+ kind = "device"
252
+ elif server == "lid":
253
+ kind = "lid"
254
+ elif server in {"c.us", "s.whatsapp.net"}:
255
+ kind = "direct"
256
+ return {
257
+ "raw": raw,
258
+ "bare": bare,
259
+ "server": server or None,
260
+ "user": user or None,
261
+ "device": device or None,
262
+ "kind": kind,
263
+ "phoneE164": extract_digits(user) if server in {"c.us", "s.whatsapp.net"} else None,
264
+ "lid": bare if server == "lid" else None,
265
+ }
266
+
267
+
268
+ def add_alias(target: set[str], raw_value) -> None:
269
+ parsed = parse_jid(raw_value)
270
+ if not parsed:
271
+ return
272
+ target.add(parsed["raw"])
273
+ target.add(parsed["bare"])
274
+ if parsed.get("phoneE164"):
275
+ target.add(f"{parsed['phoneE164']}@c.us")
276
+
277
+
278
+ def merge_preferred_text(current: str | None, nxt: str | None) -> str | None:
279
+ nxt = str(nxt or "").strip()
280
+ if not nxt:
281
+ return current
282
+ current = str(current or "").strip()
283
+ return current or nxt
284
+
285
+
286
+ def merge_preferred_phone(current: str | None, nxt: str | None) -> str | None:
287
+ return current or extract_digits(nxt)
288
+
289
+
290
+ def extract_name(record) -> str | None:
291
+ if not isinstance(record, dict):
292
+ return None
293
+ business = record.get("businessProfile")
294
+ candidates = [
295
+ record.get("name"),
296
+ record.get("pushname"),
297
+ record.get("shortName"),
298
+ record.get("verifiedName"),
299
+ business.get("verifiedName") if isinstance(business, dict) else None,
300
+ record.get("subject"),
301
+ ]
302
+ for candidate in candidates:
303
+ text = str(candidate or "").strip()
304
+ if text:
305
+ return text
306
+ return None
307
+
308
+
309
+ def pick_message_body(record: dict) -> str | None:
310
+ direct = str(record.get("body") or "").strip()
311
+ if direct:
312
+ return direct
313
+ nested = record.get("_data")
314
+ if isinstance(nested, dict):
315
+ nested_body = str(nested.get("body") or "").strip()
316
+ if nested_body:
317
+ return nested_body
318
+ return None
319
+
320
+
321
+ def sanitize_message_body(body: str | None, message_type: str | None) -> str | None:
322
+ text = str(body or "").strip()
323
+ if not text:
324
+ return None
325
+ compact = "".join(text.split())
326
+ binary_like = (
327
+ len(compact) > 160
328
+ and len(compact) % 4 == 0
329
+ and all(ch.isalnum() or ch in "+/=" for ch in compact)
330
+ and (compact.startswith("/9j/") or compact.startswith("iVBOR") or compact.startswith("UklGR"))
331
+ )
332
+ if binary_like:
333
+ return None
334
+ if str(message_type or "").strip().lower() in IDENTITY_NOISY_MESSAGE_TYPES:
335
+ return None
336
+ return text
337
+
338
+
339
+ def summarize_recent_messages(values, self_aliases: set[str]) -> list[dict]:
340
+ summaries: list[dict] = []
341
+ if not isinstance(values, list):
342
+ return summaries
343
+ for value in values:
344
+ if not isinstance(value, dict):
345
+ continue
346
+ nested = value.get("_data")
347
+ nested = nested if isinstance(nested, dict) else {}
348
+ raw_from = normalize_raw_id(value.get("from") or nested.get("from"))
349
+ raw_to = normalize_raw_id(value.get("to") or nested.get("to"))
350
+ raw_type = str(value.get("type") or nested.get("type") or "").strip() or None
351
+ timestamp = value.get("timestamp")
352
+ if not isinstance(timestamp, int):
353
+ timestamp = nested.get("t") if isinstance(nested.get("t"), int) else None
354
+ from_me = value.get("fromMe") is True
355
+ direction = "out" if from_me or (raw_from and (raw_from in self_aliases or (parse_jid(raw_from) or {}).get("bare") in self_aliases)) else "in"
356
+ body = sanitize_message_body(pick_message_body(value), raw_type)
357
+ if str(raw_type or "").lower() in IDENTITY_NOISY_MESSAGE_TYPES and not body:
358
+ continue
359
+ summaries.append(
360
+ {
361
+ "id": str(value.get("id") or "").strip() or None,
362
+ "timestamp": timestamp,
363
+ "direction": direction,
364
+ "from": raw_from,
365
+ "to": raw_to,
366
+ "body": body,
367
+ "type": raw_type,
368
+ "hasMedia": value.get("hasMedia") is True,
369
+ }
370
+ )
371
+ return summaries
372
+
373
+
204
374
  def waha_request(method: str, path: str, body: dict | None = None, query: dict | None = None, session_name: str | None = None):
205
375
  base_url = normalize_base_url(require_resolved("WAHA_URL", session_name))
206
376
  api_key = require_resolved("WAHA_API_KEY", session_name)
@@ -270,6 +440,7 @@ Chats and Messages:
270
440
  waha-get-chats [--session NAME] [--limit N] [--offset N]
271
441
  waha-get-messages --chat-id ID [--session NAME] [--limit N] [--offset N]
272
442
  waha-send-text --chat-id ID --text TEXT [--session NAME]
443
+ waha-rebuild-identity-index [--session NAME]
273
444
 
274
445
  Optional env:
275
446
  WAHA_SESSION_DEFAULT / WAHA_DEFAULT_SESSION
@@ -477,6 +648,203 @@ def cmd_send_text(args):
477
648
  print(f"Chunk {index} Message ID: {message_id}")
478
649
 
479
650
 
651
+ def cmd_rebuild_identity_index(args):
652
+ session = args.session
653
+ lid_rows = waha_request(
654
+ "GET",
655
+ f"/api/{urllib.parse.quote(session, safe='')}/lids",
656
+ session_name=session,
657
+ )
658
+ lid_rows = lid_rows if isinstance(lid_rows, list) else []
659
+ lid_mappings: dict[str, str] = {}
660
+ phone_to_lid: dict[str, str] = {}
661
+ for row in lid_rows:
662
+ if not isinstance(row, dict):
663
+ continue
664
+ lid = normalize_raw_id(row.get("lid"))
665
+ pn = normalize_raw_id(row.get("pn")) or to_chat_phone_id(str(row.get("pn") or ""))
666
+ if not lid or not pn:
667
+ continue
668
+ lid_mappings[lid] = pn
669
+ phone_to_lid[pn] = lid
670
+
671
+ contacts: list[dict] = []
672
+ for page in range(IDENTITY_CONTACT_MAX_PAGES):
673
+ offset = page * IDENTITY_CONTACT_PAGE_SIZE
674
+ batch = waha_request(
675
+ "GET",
676
+ "/api/contacts/all",
677
+ query={"session": session, "limit": IDENTITY_CONTACT_PAGE_SIZE, "offset": offset},
678
+ session_name=session,
679
+ )
680
+ if not isinstance(batch, list) or not batch:
681
+ break
682
+ contacts.extend(item for item in batch if isinstance(item, dict))
683
+ if len(batch) < IDENTITY_CONTACT_PAGE_SIZE:
684
+ break
685
+
686
+ self_aliases: set[str] = set()
687
+ self_display_name = None
688
+ self_phone = None
689
+ for contact in contacts:
690
+ if contact.get("isMe") is not True:
691
+ continue
692
+ add_alias(self_aliases, contact.get("id"))
693
+ self_phone = merge_preferred_phone(self_phone, str(contact.get("number") or ""))
694
+ self_display_name = merge_preferred_text(self_display_name, extract_name(contact))
695
+
696
+ peers: dict[str, dict] = {}
697
+ lookup: dict[str, str] = {}
698
+ canonical: dict[str, str] = {}
699
+
700
+ def upsert_peer(chat_id: str, patch: dict, alias_ids: set[str] | None = None) -> None:
701
+ parsed = parse_jid(chat_id) or {}
702
+ existing = peers.get(chat_id, {
703
+ "chatId": chat_id,
704
+ "kind": parsed.get("kind", "unknown"),
705
+ "aliases": [],
706
+ "recentMessages": [],
707
+ "updatedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
708
+ })
709
+ alias_set = set(existing.get("aliases", []))
710
+ add_alias(alias_set, chat_id)
711
+ for alias in alias_ids or set():
712
+ add_alias(alias_set, alias)
713
+ next_peer = dict(existing)
714
+ next_peer.update(patch)
715
+ next_peer["kind"] = patch.get("kind", existing.get("kind", "unknown"))
716
+ next_peer["displayName"] = merge_preferred_text(existing.get("displayName"), patch.get("displayName"))
717
+ next_peer["verifiedName"] = merge_preferred_text(existing.get("verifiedName"), patch.get("verifiedName"))
718
+ next_peer["phoneE164"] = merge_preferred_phone(existing.get("phoneE164"), patch.get("phoneE164"))
719
+ next_peer["lid"] = merge_preferred_text(existing.get("lid"), patch.get("lid"))
720
+ next_peer["aliases"] = sorted(alias_set)
721
+ next_peer["recentMessages"] = patch.get("recentMessages", existing.get("recentMessages", []))
722
+ next_peer["lastMessageAt"] = patch.get("lastMessageAt", existing.get("lastMessageAt"))
723
+ next_peer["updatedAt"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
724
+ peers[chat_id] = next_peer
725
+ for alias in next_peer["aliases"]:
726
+ lookup[alias] = chat_id
727
+ parsed_alias = parse_jid(alias) or {}
728
+ if parsed_alias.get("kind") == "lid":
729
+ mapped_phone = lid_mappings.get(parsed_alias.get("bare"))
730
+ if mapped_phone:
731
+ canonical[alias] = mapped_phone
732
+ elif parsed_alias.get("kind") == "direct" and parsed_alias.get("phoneE164"):
733
+ canonical[alias] = f"{parsed_alias['phoneE164']}@c.us"
734
+ lookup[chat_id] = chat_id
735
+ if next_peer.get("phoneE164"):
736
+ canonical[f"{next_peer['phoneE164']}@c.us"] = f"{next_peer['phoneE164']}@c.us"
737
+ if next_peer.get("lid") and lid_mappings.get(next_peer["lid"]):
738
+ canonical[next_peer["lid"]] = lid_mappings[next_peer["lid"]]
739
+
740
+ for contact in contacts:
741
+ contact_id = normalize_raw_id(contact.get("id"))
742
+ if not contact_id:
743
+ continue
744
+ parsed = parse_jid(contact_id) or {}
745
+ aliases: set[str] = set()
746
+ add_alias(aliases, contact_id)
747
+ business = contact.get("businessProfile")
748
+ if isinstance(business, dict):
749
+ add_alias(aliases, business.get("id"))
750
+ phone_digits = extract_digits(str(contact.get("number") or "")) if parsed.get("kind") == "direct" else None
751
+ if phone_digits:
752
+ aliases.add(f"{phone_digits}@c.us")
753
+ upsert_peer(
754
+ contact_id,
755
+ {
756
+ "kind": parsed.get("kind", "unknown"),
757
+ "displayName": extract_name(contact),
758
+ "verifiedName": str(contact.get("verifiedName") or "").strip() or None,
759
+ "phoneE164": parsed.get("phoneE164") or phone_digits,
760
+ "lid": parsed.get("lid"),
761
+ },
762
+ aliases,
763
+ )
764
+
765
+ chats = waha_request(
766
+ "GET",
767
+ f"/api/{urllib.parse.quote(session, safe='')}/chats",
768
+ query={"limit": IDENTITY_CHAT_LIMIT, "offset": 0},
769
+ session_name=session,
770
+ )
771
+ chats = chats if isinstance(chats, list) else []
772
+ for chat in chats:
773
+ if not isinstance(chat, dict):
774
+ continue
775
+ chat_id = normalize_raw_id(chat.get("id") or chat.get("chatId"))
776
+ if not chat_id:
777
+ continue
778
+ parsed_chat = parse_jid(chat_id) or {}
779
+ messages = waha_request(
780
+ "GET",
781
+ f"/api/{urllib.parse.quote(session, safe='')}/chats/{urllib.parse.quote(chat_id, safe='')}/messages",
782
+ query={"limit": IDENTITY_MESSAGE_LIMIT},
783
+ session_name=session,
784
+ )
785
+ summaries = summarize_recent_messages(messages if isinstance(messages, list) else [], self_aliases)
786
+ alias_set: set[str] = set()
787
+ add_alias(alias_set, chat_id)
788
+ resolved_phone = parsed_chat.get("phoneE164")
789
+ resolved_lid = parsed_chat.get("lid")
790
+ if parsed_chat.get("lid") and lid_mappings.get(parsed_chat["lid"]):
791
+ resolved_phone = merge_preferred_phone(resolved_phone, lid_mappings.get(parsed_chat["lid"]))
792
+ if parsed_chat.get("kind") == "direct" and parsed_chat.get("phoneE164"):
793
+ direct_chat_id = f"{parsed_chat['phoneE164']}@c.us"
794
+ if direct_chat_id in phone_to_lid:
795
+ resolved_lid = merge_preferred_text(resolved_lid, phone_to_lid[direct_chat_id])
796
+ for message in summaries:
797
+ counterpart_id = message.get("to") if message.get("direction") == "out" else message.get("from")
798
+ counterpart_raw = normalize_raw_id(counterpart_id)
799
+ counterpart_parsed = parse_jid(counterpart_id) or {}
800
+ if counterpart_raw and counterpart_raw not in self_aliases and counterpart_parsed.get("bare") not in self_aliases:
801
+ add_alias(alias_set, counterpart_raw)
802
+ resolved_phone = merge_preferred_phone(resolved_phone, counterpart_parsed.get("phoneE164"))
803
+ resolved_lid = merge_preferred_text(resolved_lid, counterpart_parsed.get("lid"))
804
+ if counterpart_parsed.get("lid") and lid_mappings.get(counterpart_parsed["lid"]):
805
+ resolved_phone = merge_preferred_phone(resolved_phone, lid_mappings.get(counterpart_parsed["lid"]))
806
+ if counterpart_parsed.get("kind") == "direct" and counterpart_parsed.get("phoneE164"):
807
+ direct_counterpart_id = f"{counterpart_parsed['phoneE164']}@c.us"
808
+ if direct_counterpart_id in phone_to_lid:
809
+ resolved_lid = merge_preferred_text(resolved_lid, phone_to_lid[direct_counterpart_id])
810
+ upsert_peer(
811
+ chat_id,
812
+ {
813
+ "kind": "group" if chat.get("isGroup") is True else parsed_chat.get("kind", "unknown"),
814
+ "displayName": extract_name(chat),
815
+ "phoneE164": resolved_phone,
816
+ "lid": resolved_lid,
817
+ "recentMessages": summaries,
818
+ "lastMessageAt": summaries[0]["timestamp"] if summaries else None,
819
+ },
820
+ alias_set,
821
+ )
822
+
823
+ index = {
824
+ "version": 1,
825
+ "accountId": session,
826
+ "session": session,
827
+ "refreshedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
828
+ "stateDir": str(state_dir()),
829
+ "self": {
830
+ "displayName": self_display_name,
831
+ "phoneE164": self_phone,
832
+ "aliases": sorted(self_aliases),
833
+ } if self_aliases or self_display_name or self_phone else None,
834
+ "peers": dict(sorted(peers.items())),
835
+ "lookup": dict(sorted(lookup.items())),
836
+ "canonical": dict(sorted(canonical.items())),
837
+ "lidMappings": dict(sorted(lid_mappings.items())),
838
+ }
839
+ path = identity_index_path(session)
840
+ path.parent.mkdir(parents=True, exist_ok=True)
841
+ path.write_text(json.dumps(index, indent=2, ensure_ascii=False) + "\n")
842
+ print(f"Identity index rebuilt for session '{session}'.")
843
+ print(f"Path: {path}")
844
+ print(f"Peers: {len(peers)}")
845
+ print(f"LID mappings: {len(lid_mappings)}")
846
+
847
+
480
848
  COMMANDS = {
481
849
  "waha-list-sessions": ("List sessions", cmd_list_sessions),
482
850
  "waha-get-session": ("Get session", cmd_get_session),
@@ -493,6 +861,7 @@ COMMANDS = {
493
861
  "waha-list-chats": ("List chats", cmd_get_chats),
494
862
  "waha-get-messages": ("Get messages", cmd_get_messages),
495
863
  "waha-send-text": ("Send text", cmd_send_text),
864
+ "waha-rebuild-identity-index": ("Rebuild identity index", cmd_rebuild_identity_index),
496
865
  }
497
866
 
498
867
 
@@ -518,6 +887,8 @@ def command_parser(command: str):
518
887
  add_session_arg(parser)
519
888
  parser.add_argument("--chat-id", required=True)
520
889
  parser.add_argument("--text", required=True)
890
+ elif command == "waha-rebuild-identity-index":
891
+ add_session_arg(parser)
521
892
  return parser
522
893
 
523
894
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lofa199419/waha-v2",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "private": false,
5
5
  "description": "OpenClaw WAHA v2 channel plugin — independent WhatsApp HTTP API integration",
6
6
  "type": "module",
package/src/client.ts CHANGED
@@ -25,6 +25,11 @@ export type WahaV2PollPayload = {
25
25
  multipleAnswers?: boolean;
26
26
  };
27
27
 
28
+ export type WahaV2LidMapping = {
29
+ lid?: string;
30
+ pn?: string;
31
+ };
32
+
28
33
  export class WahaV2Client {
29
34
  private readonly inner: WahaClient;
30
35
  private readonly _config: WahaV2ClientConfig;
@@ -107,6 +112,59 @@ export class WahaV2Client {
107
112
  }
108
113
  }
109
114
 
115
+ async listLidMappings(session: string): Promise<WahaV2LidMapping[]> {
116
+ const url = `${this._config.baseUrl}/api/${encodeURIComponent(session)}/lids`;
117
+ const headers: Record<string, string> = { Accept: "application/json" };
118
+ if (this._config.apiKey) headers["X-Api-Key"] = this._config.apiKey;
119
+ const response = await fetch(url, { method: "GET", headers });
120
+ if (!response.ok) {
121
+ const body = await response.text().catch(() => "");
122
+ const suffix = body ? ` body=${body.slice(0, 240)}` : "";
123
+ throw new Error(`WAHA /lids lookup failed: ${response.status} ${response.statusText}${suffix}`);
124
+ }
125
+ const json = (await response.json().catch(() => [])) as unknown;
126
+ return Array.isArray(json) ? (json as WahaV2LidMapping[]) : [];
127
+ }
128
+
129
+ async getPhoneNumberByLid(session: string, lid: string): Promise<WahaV2LidMapping | undefined> {
130
+ const url = `${this._config.baseUrl}/api/${encodeURIComponent(session)}/lids/${encodeURIComponent(lid)}`;
131
+ const headers: Record<string, string> = { Accept: "application/json" };
132
+ if (this._config.apiKey) headers["X-Api-Key"] = this._config.apiKey;
133
+ const response = await fetch(url, { method: "GET", headers });
134
+ if (response.status === 404) return undefined;
135
+ if (!response.ok) {
136
+ const body = await response.text().catch(() => "");
137
+ const suffix = body ? ` body=${body.slice(0, 240)}` : "";
138
+ throw new Error(
139
+ `WAHA /lids/{lid} lookup failed: ${response.status} ${response.statusText}${suffix}`,
140
+ );
141
+ }
142
+ return (await response.json().catch(() => undefined)) as WahaV2LidMapping | undefined;
143
+ }
144
+
145
+ async getLidByPhoneNumber(
146
+ session: string,
147
+ phoneNumberOrChatId: string,
148
+ ): Promise<WahaV2LidMapping | undefined> {
149
+ const normalized = String(phoneNumberOrChatId ?? "").trim().toLowerCase();
150
+ const phoneNumber = normalized.endsWith("@c.us")
151
+ ? normalized.slice(0, -5)
152
+ : normalized.replace(/\D+/g, "");
153
+ const url = `${this._config.baseUrl}/api/${encodeURIComponent(session)}/lids/pn/${encodeURIComponent(phoneNumber)}`;
154
+ const headers: Record<string, string> = { Accept: "application/json" };
155
+ if (this._config.apiKey) headers["X-Api-Key"] = this._config.apiKey;
156
+ const response = await fetch(url, { method: "GET", headers });
157
+ if (response.status === 404) return undefined;
158
+ if (!response.ok) {
159
+ const body = await response.text().catch(() => "");
160
+ const suffix = body ? ` body=${body.slice(0, 240)}` : "";
161
+ throw new Error(
162
+ `WAHA /lids/pn/{phone} lookup failed: ${response.status} ${response.statusText}${suffix}`,
163
+ );
164
+ }
165
+ return (await response.json().catch(() => undefined)) as WahaV2LidMapping | undefined;
166
+ }
167
+
110
168
  // ---------------------------------------------------------------------------
111
169
  // Session management
112
170
  // ---------------------------------------------------------------------------
package/src/gateway.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ChannelGatewayAdapter } from "openclaw/plugin-sdk";
2
2
  import { createWahaV2Client } from "./client.js";
3
+ import { warmWahaV2IdentityIndex } from "./identity.js";
3
4
  import { probeWahaV2Session } from "./probe.js";
4
5
  import { getWahaV2Logger } from "./runtime.js";
5
6
  import { removeWahaV2Client, setWahaV2Client } from "./runtime.js";
@@ -132,11 +133,20 @@ export const wahaV2Gateway: ChannelGatewayAdapter<ResolvedWahaV2Account> = {
132
133
  `baseUrl=${account.baseUrl} session=${account.session} connected=${probe.ok}`,
133
134
  );
134
135
 
136
+ if (probe.ok && !abortSignal.aborted) {
137
+ void warmWahaV2IdentityIndex(client, account, "startup").catch(() => {});
138
+ }
139
+
135
140
  // Health monitoring — re-probe every 60 s and update connected state.
141
+ let lastConnected = probe.ok;
136
142
  let healthInterval: ReturnType<typeof setInterval> | null = setInterval(async () => {
137
143
  if (abortSignal.aborted) return;
138
144
  const health = await probeWahaV2Session(client, account.session).catch(() => ({ ok: false }));
139
145
  setStatus({ accountId: account.accountId, running: true, connected: health.ok });
146
+ if (health.ok && !lastConnected) {
147
+ void warmWahaV2IdentityIndex(client, account, "reconnected").catch(() => {});
148
+ }
149
+ lastConnected = health.ok;
140
150
  }, HEALTH_INTERVAL_MS);
141
151
 
142
152
  abortSignal.addEventListener(
@@ -0,0 +1,674 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { WahaV2Client, WahaV2LidMapping } from "./client.js";
5
+ import { getWahaV2Logger } from "./runtime.js";
6
+ import type { ResolvedWahaV2Account } from "./types.js";
7
+
8
+ const IDENTITY_INDEX_VERSION = 1;
9
+ const CONTACT_PAGE_SIZE = 200;
10
+ const CONTACT_MAX_PAGES = 10;
11
+ const CHAT_SYNC_LIMIT = 50;
12
+ const RECENT_MESSAGE_LIMIT = 5;
13
+ const NOISY_MESSAGE_TYPES = new Set(["ciphertext", "e2e_notification", "notification_template"]);
14
+
15
+ type WahaPeerKind = "direct" | "group" | "lid" | "device" | "broadcast" | "unknown";
16
+
17
+ type WahaParsedJid = {
18
+ raw: string;
19
+ bare: string;
20
+ server?: string;
21
+ user?: string;
22
+ device?: string;
23
+ kind: WahaPeerKind;
24
+ phoneE164?: string;
25
+ lid?: string;
26
+ };
27
+
28
+ export type WahaV2IdentityMessageSummary = {
29
+ id?: string;
30
+ timestamp?: number;
31
+ direction: "in" | "out";
32
+ from?: string;
33
+ to?: string;
34
+ body?: string;
35
+ type?: string;
36
+ hasMedia?: boolean;
37
+ };
38
+
39
+ export type WahaV2IdentityPeerRecord = {
40
+ chatId: string;
41
+ kind: WahaPeerKind;
42
+ displayName?: string;
43
+ verifiedName?: string;
44
+ phoneE164?: string;
45
+ lid?: string;
46
+ aliases: string[];
47
+ recentMessages: WahaV2IdentityMessageSummary[];
48
+ lastMessageAt?: number;
49
+ updatedAt: string;
50
+ };
51
+
52
+ export type WahaV2IdentityIndex = {
53
+ version: number;
54
+ accountId: string;
55
+ session: string;
56
+ refreshedAt: string;
57
+ stateDir: string;
58
+ self?: {
59
+ displayName?: string;
60
+ phoneE164?: string;
61
+ aliases: string[];
62
+ };
63
+ peers: Record<string, WahaV2IdentityPeerRecord>;
64
+ lookup: Record<string, string>;
65
+ canonical: Record<string, string>;
66
+ lidMappings: Record<string, string>;
67
+ };
68
+
69
+ export type WahaV2ResolvedIdentity = {
70
+ chat?: WahaV2IdentityPeerRecord;
71
+ sender?: WahaV2IdentityPeerRecord;
72
+ canonicalChatId?: string;
73
+ canonicalSenderId?: string;
74
+ indexPath: string;
75
+ };
76
+
77
+ const identityCache = new Map<string, WahaV2IdentityIndex>();
78
+ const warmInFlight = new Map<string, Promise<WahaV2IdentityIndex>>();
79
+
80
+ function cacheKey(account: { accountId: string; session: string }): string {
81
+ return `${account.accountId}:${account.session}`;
82
+ }
83
+
84
+ function sanitizeFileComponent(value: string): string {
85
+ const cleaned = String(value ?? "").trim();
86
+ return (cleaned || "default").replace(/[^a-z0-9._-]+/gi, "_");
87
+ }
88
+
89
+ function extractDigits(value: string | undefined): string | undefined {
90
+ const digits = String(value ?? "").replace(/\D+/g, "");
91
+ return digits || undefined;
92
+ }
93
+
94
+ function normalizeRawId(value: unknown): string | undefined {
95
+ if (!value) return undefined;
96
+ if (typeof value === "string") {
97
+ const normalized = value.trim().toLowerCase();
98
+ return normalized || undefined;
99
+ }
100
+ if (typeof value === "object") {
101
+ const serialized = (value as { _serialized?: unknown })._serialized;
102
+ if (typeof serialized === "string") {
103
+ const normalized = serialized.trim().toLowerCase();
104
+ return normalized || undefined;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ function toChatPhoneId(value: string | undefined): string | undefined {
111
+ const digits = extractDigits(value);
112
+ return digits ? `${digits}@c.us` : undefined;
113
+ }
114
+
115
+ function parseJid(rawValue: unknown): WahaParsedJid | undefined {
116
+ const raw = normalizeRawId(rawValue);
117
+ if (!raw) return undefined;
118
+
119
+ const [left, server] = raw.split("@", 2);
120
+ const [user, device] = String(left ?? "").split(":", 2);
121
+ const bare = server ? `${user}@${server}` : raw;
122
+ const baseDigits = extractDigits(user);
123
+
124
+ let kind: WahaPeerKind = "unknown";
125
+ if (server === "g.us") kind = "group";
126
+ else if (server === "broadcast") kind = "broadcast";
127
+ else if (device) kind = "device";
128
+ else if (server === "lid") kind = "lid";
129
+ else if (server === "c.us" || server === "s.whatsapp.net") kind = "direct";
130
+
131
+ return {
132
+ raw,
133
+ bare,
134
+ server,
135
+ user: user || undefined,
136
+ device: device || undefined,
137
+ kind,
138
+ phoneE164: server === "c.us" || server === "s.whatsapp.net" ? baseDigits : undefined,
139
+ lid: server === "lid" ? bare : undefined,
140
+ };
141
+ }
142
+
143
+ function extractName(value: unknown): string | undefined {
144
+ if (!value || typeof value !== "object") return undefined;
145
+ const record = value as Record<string, unknown>;
146
+ const candidates = [
147
+ record.name,
148
+ record.pushname,
149
+ record.shortName,
150
+ record.verifiedName,
151
+ (record.businessProfile as Record<string, unknown> | undefined)?.verifiedName,
152
+ record.subject,
153
+ ];
154
+ for (const candidate of candidates) {
155
+ const text = String(candidate ?? "").trim();
156
+ if (text) return text;
157
+ }
158
+ return undefined;
159
+ }
160
+
161
+ function pickMessageBody(value: Record<string, unknown>): string | undefined {
162
+ const direct = String(value.body ?? "").trim();
163
+ if (direct) return direct;
164
+ const nested = value._data as Record<string, unknown> | undefined;
165
+ const nestedBody = String(nested?.body ?? "").trim();
166
+ if (nestedBody) return nestedBody;
167
+ return undefined;
168
+ }
169
+
170
+ function sanitizeMessageBody(body: string | undefined, type?: string): string | undefined {
171
+ const text = String(body ?? "").trim();
172
+ if (!text) return undefined;
173
+ const compact = text.replace(/\s+/g, "");
174
+ const binaryLike =
175
+ compact.length > 160 &&
176
+ compact.length % 4 === 0 &&
177
+ /^[A-Za-z0-9+/=]+$/.test(compact) &&
178
+ (compact.startsWith("/9j/") || compact.startsWith("iVBOR") || compact.startsWith("UklGR"));
179
+ if (binaryLike) return undefined;
180
+ if (NOISY_MESSAGE_TYPES.has(String(type ?? "").trim().toLowerCase())) return undefined;
181
+ return text;
182
+ }
183
+
184
+ function summarizeRecentMessages(
185
+ values: unknown[],
186
+ selfAliases: Set<string>,
187
+ ): WahaV2IdentityMessageSummary[] {
188
+ if (!Array.isArray(values)) return [];
189
+ const summaries: WahaV2IdentityMessageSummary[] = [];
190
+ for (const value of values) {
191
+ if (!value || typeof value !== "object") continue;
192
+ const record = value as Record<string, unknown>;
193
+ const nested = record._data as Record<string, unknown> | undefined;
194
+ const rawFrom = normalizeRawId(record.from ?? nested?.from);
195
+ const rawTo = normalizeRawId(record.to ?? nested?.to);
196
+ const fromMe = record.fromMe === true;
197
+ const timestamp =
198
+ typeof record.timestamp === "number"
199
+ ? record.timestamp
200
+ : typeof nested?.t === "number"
201
+ ? (nested.t as number)
202
+ : undefined;
203
+ const rawType = String(record.type ?? nested?.type ?? "").trim() || undefined;
204
+ const direction: "in" | "out" =
205
+ fromMe || (rawFrom ? selfAliases.has(rawFrom) || selfAliases.has(parseJid(rawFrom)?.bare ?? "") : false)
206
+ ? "out"
207
+ : "in";
208
+ const sanitizedBody = sanitizeMessageBody(pickMessageBody(record), rawType);
209
+ if (NOISY_MESSAGE_TYPES.has(String(rawType ?? "").toLowerCase()) && !sanitizedBody) {
210
+ continue;
211
+ }
212
+
213
+ summaries.push({
214
+ id: String(record.id ?? "").trim() || undefined,
215
+ timestamp,
216
+ direction,
217
+ from: rawFrom,
218
+ to: rawTo,
219
+ body: sanitizedBody,
220
+ type: rawType,
221
+ hasMedia: record.hasMedia === true,
222
+ });
223
+ }
224
+ return summaries;
225
+ }
226
+
227
+ function addAlias(target: Set<string>, rawValue: unknown): void {
228
+ const parsed = parseJid(rawValue);
229
+ if (!parsed) return;
230
+ target.add(parsed.raw);
231
+ target.add(parsed.bare);
232
+ if (parsed.phoneE164) {
233
+ target.add(`${parsed.phoneE164}@c.us`);
234
+ }
235
+ }
236
+
237
+ function normalizeLidMapping(value: WahaV2LidMapping | undefined): WahaV2LidMapping | undefined {
238
+ if (!value) return undefined;
239
+ const lid = normalizeRawId(value.lid);
240
+ const pn = normalizeRawId(value.pn) ?? toChatPhoneId(String(value.pn ?? ""));
241
+ if (!lid && !pn) return undefined;
242
+ return { lid, pn };
243
+ }
244
+
245
+ function resolveStateDir(): string {
246
+ const configured = String(process.env.OPENCLAW_STATE_DIR ?? "").trim();
247
+ return configured || join(homedir(), ".openclaw");
248
+ }
249
+
250
+ function resolveIdentityIndexPath(account: { session: string }): string {
251
+ return join(
252
+ resolveStateDir(),
253
+ "extensions",
254
+ "waha-v2",
255
+ "identity",
256
+ `${sanitizeFileComponent(account.session)}.json`,
257
+ );
258
+ }
259
+
260
+ async function writeIdentityIndex(index: WahaV2IdentityIndex): Promise<void> {
261
+ const path = resolveIdentityIndexPath(index);
262
+ await mkdir(dirname(path), { recursive: true });
263
+ await writeFile(path, `${JSON.stringify(index, null, 2)}\n`, "utf8");
264
+ }
265
+
266
+ function formatRecentMessageForAgent(message: WahaV2IdentityMessageSummary): string {
267
+ const direction = message.direction === "out" ? "out" : "in";
268
+ const timestamp = message.timestamp
269
+ ? new Date(message.timestamp * 1000).toISOString()
270
+ : "unknown-time";
271
+ const body = String(message.body ?? "").replace(/\s+/g, " ").trim();
272
+ const summary = body || `[${message.type ?? (message.hasMedia ? "media" : "message")}]`;
273
+ return `- ${direction} ${timestamp} ${summary.slice(0, 180)}`;
274
+ }
275
+
276
+ function mergePreferredText(current: string | undefined, next: string | undefined): string | undefined {
277
+ const candidate = String(next ?? "").trim();
278
+ if (!candidate) return current;
279
+ const existing = String(current ?? "").trim();
280
+ return existing || candidate;
281
+ }
282
+
283
+ function mergePreferredPhone(current: string | undefined, next: string | undefined): string | undefined {
284
+ return current ?? extractDigits(next);
285
+ }
286
+
287
+ async function buildIdentityIndex(
288
+ client: WahaV2Client,
289
+ account: ResolvedWahaV2Account,
290
+ ): Promise<WahaV2IdentityIndex> {
291
+ const lidMappingEntries = await client.listLidMappings(account.session).catch((err) => {
292
+ getWahaV2Logger().warn(
293
+ `waha-v2: identity lid sync failed for session "${account.session}": ${String(err)}`,
294
+ );
295
+ return [];
296
+ });
297
+ const lidMappings = new Map<string, string>();
298
+ const phoneToLid = new Map<string, string>();
299
+ for (const entry of lidMappingEntries) {
300
+ const normalized = normalizeLidMapping(entry);
301
+ if (!normalized?.lid || !normalized.pn) continue;
302
+ lidMappings.set(normalized.lid, normalized.pn);
303
+ phoneToLid.set(normalized.pn, normalized.lid);
304
+ }
305
+
306
+ const contacts: unknown[] = [];
307
+ for (let page = 0; page < CONTACT_MAX_PAGES; page += 1) {
308
+ const offset = page * CONTACT_PAGE_SIZE;
309
+ const current = await client.listContacts(account.session, CONTACT_PAGE_SIZE, offset).catch((err) => {
310
+ getWahaV2Logger().warn(
311
+ `waha-v2: identity contact sync failed for session "${account.session}" at offset ${offset}: ${String(err)}`,
312
+ );
313
+ return [];
314
+ });
315
+ if (!Array.isArray(current) || current.length === 0) break;
316
+ contacts.push(...current);
317
+ if (current.length < CONTACT_PAGE_SIZE) break;
318
+ }
319
+
320
+ const selfAliasSet = new Set<string>();
321
+ const selfDisplayNames: string[] = [];
322
+ let selfPhoneE164: string | undefined;
323
+
324
+ for (const contact of contacts) {
325
+ if (!contact || typeof contact !== "object") continue;
326
+ const record = contact as Record<string, unknown>;
327
+ if (record.isMe !== true) continue;
328
+ addAlias(selfAliasSet, record.id);
329
+ selfPhoneE164 = mergePreferredPhone(selfPhoneE164, String(record.number ?? ""));
330
+ const displayName = extractName(record);
331
+ if (displayName) selfDisplayNames.push(displayName);
332
+ }
333
+
334
+ const peers = new Map<string, WahaV2IdentityPeerRecord>();
335
+ const lookup = new Map<string, string>();
336
+ const canonical = new Map<string, string>();
337
+
338
+ const upsertPeer = (chatId: string, patch: Partial<WahaV2IdentityPeerRecord>, aliasIds?: Iterable<string>) => {
339
+ const parsed = parseJid(chatId);
340
+ const existing =
341
+ peers.get(chatId) ??
342
+ ({
343
+ chatId,
344
+ kind: parsed?.kind ?? "unknown",
345
+ aliases: [],
346
+ recentMessages: [],
347
+ updatedAt: new Date().toISOString(),
348
+ } satisfies WahaV2IdentityPeerRecord);
349
+
350
+ const aliasSet = new Set(existing.aliases);
351
+ addAlias(aliasSet, chatId);
352
+ if (aliasIds) {
353
+ for (const alias of aliasIds) {
354
+ addAlias(aliasSet, alias);
355
+ }
356
+ }
357
+
358
+ const next: WahaV2IdentityPeerRecord = {
359
+ ...existing,
360
+ ...patch,
361
+ kind: patch.kind ?? existing.kind,
362
+ displayName: mergePreferredText(existing.displayName, patch.displayName),
363
+ verifiedName: mergePreferredText(existing.verifiedName, patch.verifiedName),
364
+ phoneE164: mergePreferredPhone(existing.phoneE164, patch.phoneE164),
365
+ lid: mergePreferredText(existing.lid, patch.lid),
366
+ aliases: Array.from(aliasSet).sort(),
367
+ recentMessages: patch.recentMessages ?? existing.recentMessages,
368
+ lastMessageAt: patch.lastMessageAt ?? existing.lastMessageAt,
369
+ updatedAt: new Date().toISOString(),
370
+ };
371
+ peers.set(chatId, next);
372
+ for (const alias of next.aliases) {
373
+ lookup.set(alias, chatId);
374
+ }
375
+ lookup.set(chatId, chatId);
376
+ if (next.phoneE164) {
377
+ canonical.set(`${next.phoneE164}@c.us`, `${next.phoneE164}@c.us`);
378
+ }
379
+ if (next.lid) {
380
+ const mappedPhone = lidMappings.get(next.lid);
381
+ if (mappedPhone) canonical.set(next.lid, mappedPhone);
382
+ }
383
+ for (const alias of next.aliases) {
384
+ const parsedAlias = parseJid(alias);
385
+ if (parsedAlias?.kind === "lid") {
386
+ const mappedPhone = lidMappings.get(parsedAlias.bare);
387
+ if (mappedPhone) canonical.set(alias, mappedPhone);
388
+ } else if (parsedAlias?.kind === "direct" && parsedAlias.phoneE164) {
389
+ canonical.set(alias, `${parsedAlias.phoneE164}@c.us`);
390
+ }
391
+ }
392
+ };
393
+
394
+ for (const contact of contacts) {
395
+ if (!contact || typeof contact !== "object") continue;
396
+ const record = contact as Record<string, unknown>;
397
+ const contactId = normalizeRawId(record.id);
398
+ if (!contactId) continue;
399
+ const parsed = parseJid(contactId);
400
+ const aliases = new Set<string>();
401
+ addAlias(aliases, contactId);
402
+ const businessId = normalizeRawId((record.businessProfile as Record<string, unknown> | undefined)?.id);
403
+ if (businessId) addAlias(aliases, businessId);
404
+ const phoneDigits =
405
+ parsed?.kind === "direct" ? extractDigits(String(record.number ?? "")) : undefined;
406
+ if (phoneDigits) aliases.add(`${phoneDigits}@c.us`);
407
+ upsertPeer(
408
+ contactId,
409
+ {
410
+ kind: parsed?.kind ?? "unknown",
411
+ displayName: extractName(record),
412
+ verifiedName: String(record.verifiedName ?? "").trim() || undefined,
413
+ phoneE164: parsed?.phoneE164 ?? phoneDigits,
414
+ lid: parsed?.lid,
415
+ },
416
+ aliases,
417
+ );
418
+ }
419
+
420
+ const chats = await client.listChats(account.session, CHAT_SYNC_LIMIT, 0).catch((err) => {
421
+ getWahaV2Logger().warn(
422
+ `waha-v2: identity chat sync failed for session "${account.session}": ${String(err)}`,
423
+ );
424
+ return [];
425
+ });
426
+
427
+ if (Array.isArray(chats)) {
428
+ for (const chat of chats) {
429
+ if (!chat || typeof chat !== "object") continue;
430
+ const record = chat as Record<string, unknown>;
431
+ const chatId = normalizeRawId(record.id ?? record.chatId);
432
+ if (!chatId) continue;
433
+ const parsedChat = parseJid(chatId);
434
+ const recentMessages = await client
435
+ .getChatMessages(account.session, chatId, RECENT_MESSAGE_LIMIT, false)
436
+ .catch((err) => {
437
+ getWahaV2Logger().warn(
438
+ `waha-v2: identity message sync failed for ${chatId} (${account.session}): ${String(err)}`,
439
+ );
440
+ return [];
441
+ });
442
+
443
+ const summaries = summarizeRecentMessages(recentMessages, selfAliasSet);
444
+ const aliasSet = new Set<string>();
445
+ addAlias(aliasSet, chatId);
446
+
447
+ let resolvedPhone = parsedChat?.phoneE164;
448
+ let resolvedLid = parsedChat?.lid;
449
+ if (parsedChat?.lid && lidMappings.has(parsedChat.lid)) {
450
+ resolvedPhone = mergePreferredPhone(resolvedPhone, lidMappings.get(parsedChat.lid));
451
+ }
452
+ if (parsedChat?.kind === "direct" && parsedChat.phoneE164) {
453
+ const directChatId = `${parsedChat.phoneE164}@c.us`;
454
+ if (phoneToLid.has(directChatId)) {
455
+ resolvedLid = mergePreferredText(resolvedLid, phoneToLid.get(directChatId));
456
+ }
457
+ }
458
+ if (summaries.length > 0) {
459
+ for (const message of summaries) {
460
+ const counterpartId = message.direction === "out" ? message.to : message.from;
461
+ const counterpartRaw = normalizeRawId(counterpartId);
462
+ if (
463
+ counterpartRaw &&
464
+ !selfAliasSet.has(counterpartRaw) &&
465
+ !selfAliasSet.has(parseJid(counterpartRaw)?.bare ?? "")
466
+ ) {
467
+ addAlias(aliasSet, counterpartRaw);
468
+ }
469
+ const parsedCounterpart = parseJid(counterpartId);
470
+ resolvedPhone = mergePreferredPhone(resolvedPhone, parsedCounterpart?.phoneE164);
471
+ resolvedLid = mergePreferredText(resolvedLid, parsedCounterpart?.lid);
472
+ if (parsedCounterpart?.lid && lidMappings.has(parsedCounterpart.lid)) {
473
+ resolvedPhone = mergePreferredPhone(resolvedPhone, lidMappings.get(parsedCounterpart.lid));
474
+ }
475
+ if (parsedCounterpart?.kind === "direct" && parsedCounterpart.phoneE164) {
476
+ const directCounterpartId = `${parsedCounterpart.phoneE164}@c.us`;
477
+ if (phoneToLid.has(directCounterpartId)) {
478
+ resolvedLid = mergePreferredText(resolvedLid, phoneToLid.get(directCounterpartId));
479
+ }
480
+ }
481
+ }
482
+ }
483
+
484
+ upsertPeer(
485
+ chatId,
486
+ {
487
+ kind: record.isGroup === true ? "group" : (parsedChat?.kind ?? "unknown"),
488
+ displayName: extractName(record),
489
+ phoneE164: resolvedPhone,
490
+ lid: resolvedLid,
491
+ recentMessages: summaries,
492
+ lastMessageAt: summaries[0]?.timestamp,
493
+ },
494
+ aliasSet,
495
+ );
496
+ }
497
+ }
498
+
499
+ const index: WahaV2IdentityIndex = {
500
+ version: IDENTITY_INDEX_VERSION,
501
+ accountId: account.accountId,
502
+ session: account.session,
503
+ refreshedAt: new Date().toISOString(),
504
+ stateDir: resolveStateDir(),
505
+ self:
506
+ selfAliasSet.size > 0 || selfPhoneE164 || selfDisplayNames.length > 0
507
+ ? {
508
+ displayName: selfDisplayNames[0],
509
+ phoneE164: selfPhoneE164,
510
+ aliases: Array.from(selfAliasSet).sort(),
511
+ }
512
+ : undefined,
513
+ peers: Object.fromEntries(Array.from(peers.entries()).sort(([a], [b]) => a.localeCompare(b))),
514
+ lookup: Object.fromEntries(Array.from(lookup.entries()).sort(([a], [b]) => a.localeCompare(b))),
515
+ canonical: Object.fromEntries(
516
+ Array.from(canonical.entries()).sort(([a], [b]) => a.localeCompare(b)),
517
+ ),
518
+ lidMappings: Object.fromEntries(
519
+ Array.from(lidMappings.entries()).sort(([a], [b]) => a.localeCompare(b)),
520
+ ),
521
+ };
522
+ return index;
523
+ }
524
+
525
+ async function loadIdentityIndex(account: ResolvedWahaV2Account): Promise<WahaV2IdentityIndex | undefined> {
526
+ const key = cacheKey(account);
527
+ const cached = identityCache.get(key);
528
+ if (cached) return cached;
529
+
530
+ try {
531
+ const raw = await readFile(resolveIdentityIndexPath(account), "utf8");
532
+ const parsed = JSON.parse(raw) as Partial<WahaV2IdentityIndex>;
533
+ const hydrated = {
534
+ version: parsed.version ?? IDENTITY_INDEX_VERSION,
535
+ accountId: parsed.accountId ?? account.accountId,
536
+ session: parsed.session ?? account.session,
537
+ refreshedAt: parsed.refreshedAt ?? new Date(0).toISOString(),
538
+ stateDir: parsed.stateDir ?? resolveStateDir(),
539
+ self: parsed.self,
540
+ peers: parsed.peers ?? {},
541
+ lookup: parsed.lookup ?? {},
542
+ canonical: parsed.canonical ?? {},
543
+ lidMappings: parsed.lidMappings ?? {},
544
+ } satisfies WahaV2IdentityIndex;
545
+ identityCache.set(key, hydrated);
546
+ return hydrated;
547
+ } catch {
548
+ return undefined;
549
+ }
550
+ }
551
+
552
+ export async function warmWahaV2IdentityIndex(
553
+ client: WahaV2Client,
554
+ account: ResolvedWahaV2Account,
555
+ reason: string,
556
+ ): Promise<WahaV2IdentityIndex> {
557
+ const key = cacheKey(account);
558
+ const existing = warmInFlight.get(key);
559
+ if (existing) return existing;
560
+
561
+ const task = (async () => {
562
+ getWahaV2Logger().info(
563
+ `waha-v2: identity warmup started for account "${account.accountId}" session="${account.session}" reason=${reason}`,
564
+ );
565
+ const index = await buildIdentityIndex(client, account);
566
+ await writeIdentityIndex(index);
567
+ identityCache.set(key, index);
568
+ getWahaV2Logger().info(
569
+ `waha-v2: identity warmup completed for account "${account.accountId}" session="${account.session}" peers=${Object.keys(index.peers).length}`,
570
+ );
571
+ return index;
572
+ })()
573
+ .catch((err) => {
574
+ getWahaV2Logger().warn(
575
+ `waha-v2: identity warmup failed for account "${account.accountId}" session="${account.session}": ${String(err)}`,
576
+ );
577
+ throw err;
578
+ })
579
+ .finally(() => {
580
+ warmInFlight.delete(key);
581
+ });
582
+
583
+ warmInFlight.set(key, task);
584
+ return task;
585
+ }
586
+
587
+ export async function resolveWahaV2Identity(
588
+ account: ResolvedWahaV2Account,
589
+ params: { chatId?: string; senderId?: string },
590
+ ): Promise<WahaV2ResolvedIdentity> {
591
+ const index = await loadIdentityIndex(account);
592
+ const path = resolveIdentityIndexPath(account);
593
+ if (!index) return { indexPath: path };
594
+
595
+ const chatId = normalizeRawId(params.chatId);
596
+ const senderId = normalizeRawId(params.senderId);
597
+ const resolvePeer = (rawValue?: string): WahaV2IdentityPeerRecord | undefined => {
598
+ const parsed = parseJid(rawValue);
599
+ if (!parsed) return undefined;
600
+ const candidates = [parsed.raw, parsed.bare];
601
+ if (parsed.phoneE164) candidates.push(`${parsed.phoneE164}@c.us`);
602
+ for (const candidate of candidates) {
603
+ const chatKey = index.lookup[candidate];
604
+ if (chatKey && index.peers[chatKey]) return index.peers[chatKey];
605
+ }
606
+ return undefined;
607
+ };
608
+ const resolveCanonical = (rawValue?: string): string | undefined => {
609
+ const parsed = parseJid(rawValue);
610
+ if (!parsed) return undefined;
611
+ const candidates = [parsed.raw, parsed.bare];
612
+ if (parsed.phoneE164) candidates.push(`${parsed.phoneE164}@c.us`);
613
+ for (const candidate of candidates) {
614
+ const canonicalId = index.canonical[candidate];
615
+ if (canonicalId) return canonicalId;
616
+ }
617
+ if (parsed.kind === "direct" && parsed.phoneE164) return `${parsed.phoneE164}@c.us`;
618
+ if (parsed.kind === "group") return parsed.raw;
619
+ return undefined;
620
+ };
621
+
622
+ return {
623
+ chat: resolvePeer(chatId),
624
+ sender: resolvePeer(senderId),
625
+ canonicalChatId: resolveCanonical(chatId),
626
+ canonicalSenderId: resolveCanonical(senderId),
627
+ indexPath: path,
628
+ };
629
+ }
630
+
631
+ export function buildWahaV2IdentityContext(params: {
632
+ chatId?: string;
633
+ senderId?: string;
634
+ isGroup: boolean;
635
+ resolved: WahaV2ResolvedIdentity;
636
+ }): string | undefined {
637
+ const lines: string[] = [];
638
+ const chatId = normalizeRawId(params.chatId);
639
+ const senderId = normalizeRawId(params.senderId);
640
+ const chat = params.resolved.chat;
641
+ const sender = params.resolved.sender;
642
+
643
+ if (!chatId && !senderId && !chat && !sender) return undefined;
644
+
645
+ lines.push("[WHATSAPP IDENTITY]");
646
+ if (chatId) lines.push(`ChatId: ${chatId}`);
647
+ if (params.resolved.canonicalChatId) lines.push(`CanonicalChatId: ${params.resolved.canonicalChatId}`);
648
+ if (chat?.displayName) lines.push(`ChatName: ${chat.displayName}`);
649
+ if (params.isGroup) {
650
+ if (senderId) lines.push(`SenderId: ${senderId}`);
651
+ if (params.resolved.canonicalSenderId) {
652
+ lines.push(`CanonicalSenderId: ${params.resolved.canonicalSenderId}`);
653
+ }
654
+ if (sender?.displayName) lines.push(`SenderName: ${sender.displayName}`);
655
+ if (sender?.phoneE164) lines.push(`SenderPhone: ${sender.phoneE164}`);
656
+ } else {
657
+ if (senderId) lines.push(`PeerId: ${senderId}`);
658
+ if (params.resolved.canonicalSenderId) {
659
+ lines.push(`CanonicalPeerId: ${params.resolved.canonicalSenderId}`);
660
+ }
661
+ if ((sender ?? chat)?.displayName) lines.push(`PeerName: ${(sender ?? chat)?.displayName}`);
662
+ if ((sender ?? chat)?.phoneE164) lines.push(`PeerPhone: ${(sender ?? chat)?.phoneE164}`);
663
+ }
664
+ if ((sender ?? chat)?.lid) lines.push(`PeerLid: ${(sender ?? chat)?.lid}`);
665
+ const recent = chat?.recentMessages ?? [];
666
+ if (recent.length > 0) {
667
+ lines.push("RecentMessages:");
668
+ for (const message of recent.slice(0, RECENT_MESSAGE_LIMIT)) {
669
+ lines.push(formatRecentMessageForAgent(message));
670
+ }
671
+ }
672
+ lines.push(`IdentityIndex: ${params.resolved.indexPath}`);
673
+ return `${lines.join("\n")}\n\n`;
674
+ }
package/src/webhook.ts CHANGED
@@ -7,6 +7,7 @@ import { readJsonBodyWithLimit, sleep } from "openclaw/plugin-sdk";
7
7
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
8
  import { resolveWahaV2Account, resolveWahaV2AccountBySession } from "./accounts.js";
9
9
  import { calcTypingDelayMs, chunkWahaMessage } from "./deliver.js";
10
+ import { buildWahaV2IdentityContext, resolveWahaV2Identity } from "./identity.js";
10
11
  import { getWahaV2Logger, getWahaV2Runtime, requireWahaV2Client } from "./runtime.js";
11
12
  import {
12
13
  WAHA_V2_CHANNEL_ID,
@@ -280,10 +281,9 @@ async function applyLabelDirectivesToChat(
280
281
  );
281
282
  }
282
283
 
283
- /** Normalize E.164-ish numbers to `<digits>@c.us`. */
284
+ /** Preserve WA JIDs as-is while normalizing case/whitespace. */
284
285
  function normalizeSenderId(raw: string): string {
285
- if (raw.includes("@")) return raw;
286
- return `${raw.replace(/\D+/g, "")}@c.us`;
286
+ return String(raw ?? "").trim().toLowerCase();
287
287
  }
288
288
 
289
289
  function matchesOwnerPauseWord(text: string, words?: string[]): string | undefined {
@@ -533,10 +533,31 @@ async function processWahaV2EventImmediate(
533
533
  // Build a descriptive body when there's media but no caption text.
534
534
  const mediaLabel = mediaPath ? mediaTypeLabel(payload.type, mediaMime) : undefined;
535
535
  const bodyForAgent = text || (mediaLabel ? `[${mediaLabel}]` : "");
536
+ const resolvedIdentity = await resolveWahaV2Identity(account, { chatId, senderId }).catch((err) => {
537
+ getWahaV2Logger().warn(`waha-v2: identity resolution failed (${chatId}): ${String(err)}`);
538
+ return {
539
+ chat: undefined,
540
+ sender: undefined,
541
+ canonicalChatId: undefined,
542
+ canonicalSenderId: undefined,
543
+ indexPath: "unavailable",
544
+ };
545
+ });
546
+ const canonicalChatId =
547
+ isGroup ? chatId : (resolvedIdentity.canonicalChatId ?? resolvedIdentity.canonicalSenderId ?? chatId);
548
+ const canonicalSenderId = isGroup
549
+ ? (resolvedIdentity.canonicalSenderId ?? senderId)
550
+ : (resolvedIdentity.canonicalSenderId ?? resolvedIdentity.canonicalChatId ?? senderId);
551
+ const identityPrefix = buildWahaV2IdentityContext({
552
+ chatId,
553
+ senderId,
554
+ isGroup,
555
+ resolved: resolvedIdentity,
556
+ });
536
557
  const policyPrefix = policyInstruction
537
558
  ? `[CHAT POLICY]\nLabels: ${labelSummary}\nInstruction: ${policyInstruction}\n\n`
538
559
  : "";
539
- const bodyForAgentWithPolicy = `${policyPrefix}${bodyForAgent}`.trim();
560
+ const bodyForAgentWithPolicy = `${identityPrefix ?? ""}${policyPrefix}${bodyForAgent}`.trim();
540
561
 
541
562
  // If we have both caption text and media, surface both in Body.
542
563
  const combinedBody = text && mediaLabel ? `${text}\n[${mediaLabel}]` : bodyForAgent;
@@ -545,7 +566,7 @@ async function processWahaV2EventImmediate(
545
566
  cfg,
546
567
  channel: WAHA_V2_CHANNEL_ID,
547
568
  accountId: account.accountId,
548
- peer: { kind: isGroup ? "group" : "direct", id: chatId },
569
+ peer: { kind: isGroup ? "group" : "direct", id: canonicalChatId },
549
570
  });
550
571
 
551
572
  const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
@@ -557,14 +578,23 @@ async function processWahaV2EventImmediate(
557
578
  BodyForAgent: bodyForAgentWithPolicy,
558
579
  RawBody: text,
559
580
  CommandBody: text,
560
- From: `${WAHA_V2_CHANNEL_ID}:${senderId}`,
581
+ From: `${WAHA_V2_CHANNEL_ID}:${canonicalSenderId}`,
561
582
  To: `${WAHA_V2_CHANNEL_ID}:${account.session}`,
562
583
  SessionKey: route.sessionKey,
563
584
  AccountId: route.accountId,
564
585
  ChatType: isGroup ? ("group" as const) : ("direct" as const),
565
586
  Provider: WAHA_V2_CHANNEL_ID,
566
587
  Surface: WAHA_V2_CHANNEL_ID,
567
- SenderId: senderId,
588
+ SenderId: canonicalSenderId,
589
+ WahaChatId: chatId,
590
+ WahaSenderId: senderId,
591
+ WahaCanonicalChatId: canonicalChatId,
592
+ WahaCanonicalSenderId: canonicalSenderId,
593
+ WahaChatName: resolvedIdentity.chat?.displayName,
594
+ WahaChatPhone: resolvedIdentity.chat?.phoneE164,
595
+ WahaSenderName: resolvedIdentity.sender?.displayName ?? resolvedIdentity.chat?.displayName,
596
+ WahaSenderPhone: resolvedIdentity.sender?.phoneE164 ?? resolvedIdentity.chat?.phoneE164,
597
+ WahaIdentityIndexPath: resolvedIdentity.indexPath,
568
598
  MessageSid: payload.id,
569
599
  Timestamp: payload.timestamp ? payload.timestamp * 1000 : Date.now(),
570
600
  // Media fields — consumed by the agent framework to send the file to the AI.