whatsapp-pi 1.0.65 → 1.0.67

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 CHANGED
@@ -138,7 +138,7 @@ The extension registers the following tools that the Pi agent can call:
138
138
  | `get_wa_conversation_history` | read-only | Get the most recent messages with a given `senderNumber` (accepts `+E164`, raw digits, or a JID). Supports `limit`. |
139
139
  | `check_wa_new_messages` | read-only | List conversations whose most recent message is incoming (i.e. waiting for a reply). Supports `sinceTimestamp` (ms epoch). |
140
140
 
141
- The three read-only tools query the local recents store at `~/.pi/whatsapp-pi/recents/recents.json`. They never touch the network and do not mark messages as read.
141
+ The three read-only tools query the local recents store at `~/.pi/agent/extension/whatsapp-pi/recents/recents.json`. They never touch the network and do not mark messages as read.
142
142
 
143
143
  ## WhatsApp Numbers and JIDs
144
144
 
@@ -209,7 +209,7 @@ npm test
209
209
  - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to connect on startup when credentials already exist.
210
210
  - **Group-Only Mode**: Use `--whatsapp-group <jid>` to bind Pi to a single WhatsApp group. The group must also be present in Allowed Groups.
211
211
  - **Allowed Group Reaction Mode**: Each allowed group can be set to Active or Passive. Passive mode only replies when the bot is directly mentioned with @.
212
- - **Recents Store**: Recent conversations and message history are persisted in `~/.pi/whatsapp-pi/recents/recents.json`.
212
+ - **Recents Store**: Recent conversations and message history are persisted in `~/.pi/agent/extension/whatsapp-pi/recents/recents.json`.
213
213
  - **Message Detail / Reply**: Open a message from history to inspect full content and reply with `R`.
214
214
  - **Media Support**: Images are forwarded for vision analysis, audio is transcribed with Whisper, and PDFs are saved under `./.pi-data/whatsapp/documents/` with local text preview when available.
215
215
  - **Session Handling**: Saved state, allow list, and startup reconnects are restored automatically when available.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -5,13 +5,16 @@ import { writeFile, mkdir } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { existsSync } from 'node:fs';
7
7
  import { homedir } from 'node:os';
8
+ import { createStoragePaths } from './storage-path.js';
8
9
  import { t } from '../i18n.js';
9
10
 
10
11
  const execAsync = promisify(exec);
11
12
 
12
13
  export class AudioService {
13
- private readonly mediaDir = join(homedir(), '.pi', 'whatsapp-medias');
14
- private readonly whisperPath = process.platform === 'win32' ? 'python -m whisper' : join(homedir(), '.local', 'bin', 'whisper');
14
+ private readonly mediaDir = createStoragePaths().mediaDir;
15
+ private readonly whisperCommands = process.platform === 'win32'
16
+ ? ['whisper', 'py -m whisper', 'python -m whisper']
17
+ : [join(homedir(), '.local', 'bin', 'whisper'), 'whisper', 'python3 -m whisper', 'python -m whisper'];
15
18
 
16
19
  constructor() {
17
20
  if (!existsSync(this.mediaDir)) {
@@ -35,9 +38,7 @@ export class AudioService {
35
38
 
36
39
  // Transcribe using Whisper
37
40
  // Using small model for better accuracy
38
- const command = `${this.whisperPath} "${inputPath}" --model small --language pt --output_format txt --output_dir "${this.mediaDir}" --fp16 False`;
39
-
40
- await execAsync(command);
41
+ await this.runWhisper(inputPath);
41
42
 
42
43
  const txtPath = join(this.mediaDir, `${filename}.txt`);
43
44
  if (existsSync(txtPath)) {
@@ -52,4 +53,38 @@ export class AudioService {
52
53
  return t('audio.transcriptionErrorResult', { error: error instanceof Error ? error.message : String(error) });
53
54
  }
54
55
  }
56
+
57
+ private async runWhisper(inputPath: string): Promise<void> {
58
+ const commandArgs = `"${inputPath}" --model small --language pt --output_format txt --output_dir "${this.mediaDir}" --fp16 False`;
59
+ let lastError: unknown;
60
+
61
+ for (const whisperCommand of this.whisperCommands) {
62
+ const command = `${whisperCommand} ${commandArgs}`;
63
+
64
+ try {
65
+ await execAsync(command);
66
+ return;
67
+ } catch (error) {
68
+ lastError = error;
69
+ if (!this.isMissingWhisperCommand(error)) {
70
+ throw error;
71
+ }
72
+ }
73
+ }
74
+
75
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
76
+ }
77
+
78
+ private isMissingWhisperCommand(error: unknown): boolean {
79
+ if (!(error instanceof Error)) {
80
+ return false;
81
+ }
82
+
83
+ const anyError = error as Error & { code?: number | string; stderr?: string };
84
+ const message = `${anyError.message}\n${anyError.stderr ?? ''}`;
85
+
86
+ return anyError.code === 127
87
+ || anyError.code === 9009
88
+ || /not found|not recognized/i.test(message);
89
+ }
55
90
  }
@@ -2,10 +2,9 @@ import { WhatsAppService } from './whatsapp.service.js';
2
2
  import { MessageRequest, MessageResult, WhatsAppError } from '../models/whatsapp.types.js';
3
3
  import { t } from '../i18n.js';
4
4
  import { appendFileSync } from 'fs';
5
- import { join } from 'path';
6
- import { homedir } from 'os';
5
+ import { createStoragePaths } from './storage-path.js';
7
6
 
8
- const LOG_FILE = join(homedir(), '.pi', 'whatsapp-pi', 'whatsapp-pi.log');
7
+ const LOG_FILE = createStoragePaths().logPath;
9
8
  function fileLog(msg: string) {
10
9
  try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [MessageSender] ${msg}\n`); } catch {
11
10
  // File logging is best-effort.
@@ -1,6 +1,5 @@
1
- import { homedir } from 'os';
2
- import { join } from 'path';
3
- import { mkdir, readFile, writeFile } from 'fs/promises';
1
+ import { readFile, writeFile } from 'fs/promises';
2
+ import { createStoragePaths, ensureStorageDirectories as ensureStorageRoots, migrateLegacyStorage } from './storage-path.js';
4
3
  import type {
5
4
  MessageDirection,
6
5
  RecentConversationMessage,
@@ -19,9 +18,9 @@ export interface RecentsMessageInput {
19
18
  }
20
19
 
21
20
  export class RecentsService {
22
- private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
23
- private readonly dataDir = join(this.baseDir, 'recents');
24
- private readonly storePath = join(this.dataDir, 'recents.json');
21
+ private readonly storagePaths = createStoragePaths();
22
+ private readonly dataDir = this.storagePaths.recentsDir;
23
+ private readonly storePath = this.storagePaths.recentsPath;
25
24
  private store: RecentsStore = {
26
25
  conversations: [],
27
26
  messagesBySender: {},
@@ -31,7 +30,16 @@ export class RecentsService {
31
30
  constructor(private readonly sessionManager: SessionManager) {}
32
31
 
33
32
  async ensureInitialized() {
34
- await mkdir(this.dataDir, { recursive: true });
33
+ await ensureStorageRoots({
34
+ root: this.storagePaths.root,
35
+ authStateDir: this.storagePaths.authStateDir,
36
+ recentsDir: this.dataDir,
37
+ logDir: this.storagePaths.logDir
38
+ });
39
+ await migrateLegacyStorage({
40
+ root: this.storagePaths.root,
41
+ legacyRoot: this.storagePaths.legacyRoot
42
+ });
35
43
  await this.loadStore();
36
44
  }
37
45
 
@@ -125,12 +133,12 @@ export class RecentsService {
125
133
  });
126
134
  }
127
135
 
128
- private stripSpecialCharacters(text: string): string {
129
- return text
130
- .replace(/\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200D|\uFE0F/gu, '')
131
- .replace(/\s+/g, ' ')
132
- .trim();
133
- }
136
+ private stripSpecialCharacters(text: string): string {
137
+ return text
138
+ .replace(/\p{Extended_Pictographic}|\p{Emoji_Modifier}|\p{Regional_Indicator}|\u200D|\uFE0F/gu, '')
139
+ .replace(/\s+/g, ' ')
140
+ .trim();
141
+ }
134
142
 
135
143
  private buildPreview(text: string): string {
136
144
  const normalized = this.stripSpecialCharacters(text);
@@ -1,59 +1,77 @@
1
1
  import { useMultiFileAuthState } from 'baileys';
2
2
  import { basename, join } from 'path';
3
3
  import { readFile, writeFile, mkdir, rm, rename, readdir } from 'fs/promises';
4
- import { homedir } from 'os';
5
- import { SessionStatus } from '../models/whatsapp.types.js';
6
- import { t } from '../i18n.js';
7
-
8
- export interface Contact {
9
- number: string;
10
- name?: string;
11
- sendNumber?: string;
12
- }
13
-
14
- export class SessionManager {
15
- // Data is stored in the user's home directory to persist across updates
16
- private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
17
- private authStateDir = join(this.baseDir, 'auth');
18
- private readonly configPath = join(this.baseDir, 'config.json');
19
-
20
- static isGroupJid(jid: string): boolean {
21
- return jid.endsWith('@g.us');
22
- }
23
-
24
- /**
25
- * Sets a group-specific auth directory so each agent bound to a group
26
- * registers as its own WhatsApp linked device.
27
- */
28
- setGroupJidForAuth(groupJid: string) {
29
- const sanitized = groupJid.replace(/[^a-zA-Z0-9]/g, '_');
30
- this.authStateDir = join(this.baseDir, `auth-${sanitized}`);
31
- }
32
-
33
- private status: SessionStatus = 'logged-out';
34
- private allowList: Contact[] = [];
35
- private allowedGroups: Contact[] = [];
36
- private ignoredNumbers: Contact[] = [];
4
+ import { SessionStatus } from '../models/whatsapp.types.js';
5
+ import { t } from '../i18n.js';
6
+ import {
7
+ getDefaultLegacyStorageRoot,
8
+ getDefaultStorageRoot,
9
+ createStoragePaths,
10
+ ensureStorageDirectories as ensureStorageRoots,
11
+ migrateLegacyStorage,
12
+ type StoragePaths
13
+ } from './storage-path.js';
14
+
15
+ export interface Contact {
16
+ number: string;
17
+ name?: string;
18
+ sendNumber?: string;
19
+ }
20
+
21
+ export class SessionManager {
22
+ private readonly storagePaths: StoragePaths;
23
+ private authStateDir: string;
24
+ private readonly configPath: string;
25
+
26
+ static isGroupJid(jid: string): boolean {
27
+ return jid.endsWith('@g.us');
28
+ }
29
+
30
+ /**
31
+ * Sets a group-specific auth directory so each agent bound to a group
32
+ * registers as its own WhatsApp linked device.
33
+ */
34
+ setGroupJidForAuth(groupJid: string) {
35
+ const sanitized = groupJid.replace(/[^a-zA-Z0-9]/g, '_');
36
+ this.authStateDir = join(this.storagePaths.root, `auth-${sanitized}`);
37
+ }
38
+
39
+ private status: SessionStatus = 'logged-out';
40
+ private allowList: Contact[] = [];
41
+ private allowedGroups: Contact[] = [];
42
+ private ignoredNumbers: Contact[] = [];
37
43
  private hasAuthState = false;
38
44
  private openaiKey: string = '';
39
45
  private visionModel: string = 'gpt-4o';
40
46
  private operatorJid: string = '';
41
47
  private configLoaded = false;
42
-
43
- constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
44
- this.baseDir = baseDir;
45
- this.authStateDir = join(this.baseDir, 'auth');
46
- this.configPath = join(this.baseDir, 'config.json');
47
- }
48
-
48
+
49
+ constructor(baseDir = getDefaultStorageRoot(), legacyBaseDir = baseDir === getDefaultStorageRoot() ? getDefaultLegacyStorageRoot() : baseDir) {
50
+ this.storagePaths = createStoragePaths(baseDir, legacyBaseDir);
51
+ this.authStateDir = this.storagePaths.authStateDir;
52
+ this.configPath = this.storagePaths.configPath;
53
+ }
54
+
49
55
  private async ensureStorageDirectories() {
50
- await mkdir(this.baseDir, { recursive: true });
51
- await mkdir(this.authStateDir, { recursive: true });
56
+ await ensureStorageRoots({
57
+ root: this.storagePaths.root,
58
+ authStateDir: this.authStateDir,
59
+ recentsDir: this.storagePaths.recentsDir,
60
+ logDir: this.storagePaths.logDir
61
+ });
62
+ }
63
+
64
+ private async migrateLegacyStorageIfNeeded() {
65
+ await migrateLegacyStorage({
66
+ root: this.storagePaths.root,
67
+ legacyRoot: this.storagePaths.legacyRoot
68
+ });
52
69
  }
53
70
 
54
71
  public async ensureInitialized() {
55
72
  try {
56
73
  await this.ensureStorageDirectories();
74
+ await this.migrateLegacyStorageIfNeeded();
57
75
  await this.cleanupStaleConfigTempFiles();
58
76
  if (!this.configLoaded) {
59
77
  await this.loadConfig();
@@ -64,119 +82,119 @@ export class SessionManager {
64
82
  // Initialization is best-effort; callers can continue with defaults.
65
83
  }
66
84
  }
67
-
68
- private async loadConfig() {
69
- try {
70
- const data = await readFile(this.configPath, 'utf-8');
71
- const { config, recovered } = this.parseConfig(data);
72
-
73
- const cleanContact = (item: any): Contact | null => {
74
- if (typeof item === 'string') return { number: item };
75
- if (item && typeof item === 'object') {
76
- let num = item.number;
77
- // Unroll nested objects if any
78
- while (num && typeof num === 'object' && num.number) {
79
- num = num.number;
80
- }
81
- if (typeof num === 'string') {
82
- const sendNumber = typeof item.sendNumber === 'string' ? item.sendNumber : undefined;
83
- return { number: num, name: item.name, sendNumber };
84
- }
85
- }
86
- return null;
87
- };
88
-
89
- const loadedAllowList = (config.allowList || []).map(cleanContact).filter(Boolean) as Contact[];
90
- const loadedAllowedGroups = (config.allowedGroups || []).map(cleanContact).filter(Boolean) as Contact[];
91
- const migratedGroups = loadedAllowList.filter(c => SessionManager.isGroupJid(c.number));
92
- this.allowList = loadedAllowList.filter(c => !SessionManager.isGroupJid(c.number));
93
- this.allowedGroups = this.mergeContacts(loadedAllowedGroups, migratedGroups);
94
- this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
95
- this.status = config.status || 'logged-out';
96
- this.hasAuthState = Boolean(config.hasAuthState);
97
- this.openaiKey = config.openaiKey || '';
98
- this.visionModel = config.visionModel || 'gpt-4o';
99
- this.operatorJid = config.operatorJid || '';
100
-
101
- if (recovered) {
102
- await this.saveConfig();
103
- }
104
- } catch {
105
- // File not found is fine
106
- }
107
- }
108
-
109
- private parseConfig(data: string): { config: any; recovered: boolean } {
110
- try {
111
- return { config: JSON.parse(data), recovered: false };
112
- } catch (error) {
113
- const objectEnd = this.findFirstJsonObjectEnd(data);
114
- if (objectEnd < 0) {
115
- throw error;
116
- }
117
-
118
- return {
119
- config: JSON.parse(data.slice(0, objectEnd + 1)),
120
- recovered: true
121
- };
122
- }
123
- }
124
-
125
- private findFirstJsonObjectEnd(data: string): number {
126
- let depth = 0;
127
- let inString = false;
128
- let escaped = false;
129
-
130
- for (let i = 0; i < data.length; i++) {
131
- const char = data[i];
132
-
133
- if (inString) {
134
- if (escaped) {
135
- escaped = false;
136
- } else if (char === '\\') {
137
- escaped = true;
138
- } else if (char === '"') {
139
- inString = false;
140
- }
141
- continue;
142
- }
143
-
144
- if (char === '"') {
145
- inString = true;
146
- continue;
147
- }
148
-
149
- if (char === '{') {
150
- depth++;
151
- } else if (char === '}') {
152
- depth--;
153
- if (depth === 0) {
154
- return i;
155
- }
156
- }
157
- }
158
-
159
- return -1;
160
- }
161
-
162
- public async saveConfig() {
163
- const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
164
- try {
165
- this.hasAuthState = this.hasAuthState || await this.hasCredentialsFile();
166
- const config = {
167
- allowList: this.allowList,
168
- allowedGroups: this.allowedGroups,
169
- ignoredNumbers: this.ignoredNumbers,
170
- status: this.status,
171
- hasAuthState: this.hasAuthState,
172
- openaiKey: this.openaiKey,
173
- visionModel: this.visionModel,
174
- operatorJid: this.operatorJid
175
- };
176
- await mkdir(this.baseDir, { recursive: true });
177
- const serialized = JSON.stringify(config, null, 2);
178
- await writeFile(tempPath, serialized);
179
- try {
85
+
86
+ private async loadConfig() {
87
+ try {
88
+ const data = await readFile(this.configPath, 'utf-8');
89
+ const { config, recovered } = this.parseConfig(data);
90
+
91
+ const cleanContact = (item: any): Contact | null => {
92
+ if (typeof item === 'string') return { number: item };
93
+ if (item && typeof item === 'object') {
94
+ let num = item.number;
95
+ // Unroll nested objects if any
96
+ while (num && typeof num === 'object' && num.number) {
97
+ num = num.number;
98
+ }
99
+ if (typeof num === 'string') {
100
+ const sendNumber = typeof item.sendNumber === 'string' ? item.sendNumber : undefined;
101
+ return { number: num, name: item.name, sendNumber };
102
+ }
103
+ }
104
+ return null;
105
+ };
106
+
107
+ const loadedAllowList = (config.allowList || []).map(cleanContact).filter(Boolean) as Contact[];
108
+ const loadedAllowedGroups = (config.allowedGroups || []).map(cleanContact).filter(Boolean) as Contact[];
109
+ const migratedGroups = loadedAllowList.filter(c => SessionManager.isGroupJid(c.number));
110
+ this.allowList = loadedAllowList.filter(c => !SessionManager.isGroupJid(c.number));
111
+ this.allowedGroups = this.mergeContacts(loadedAllowedGroups, migratedGroups);
112
+ this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
113
+ this.status = config.status || 'logged-out';
114
+ this.hasAuthState = Boolean(config.hasAuthState);
115
+ this.openaiKey = config.openaiKey || '';
116
+ this.visionModel = config.visionModel || 'gpt-4o';
117
+ this.operatorJid = config.operatorJid || '';
118
+
119
+ if (recovered) {
120
+ await this.saveConfig();
121
+ }
122
+ } catch {
123
+ // File not found is fine
124
+ }
125
+ }
126
+
127
+ private parseConfig(data: string): { config: any; recovered: boolean } {
128
+ try {
129
+ return { config: JSON.parse(data), recovered: false };
130
+ } catch (error) {
131
+ const objectEnd = this.findFirstJsonObjectEnd(data);
132
+ if (objectEnd < 0) {
133
+ throw error;
134
+ }
135
+
136
+ return {
137
+ config: JSON.parse(data.slice(0, objectEnd + 1)),
138
+ recovered: true
139
+ };
140
+ }
141
+ }
142
+
143
+ private findFirstJsonObjectEnd(data: string): number {
144
+ let depth = 0;
145
+ let inString = false;
146
+ let escaped = false;
147
+
148
+ for (let i = 0; i < data.length; i++) {
149
+ const char = data[i];
150
+
151
+ if (inString) {
152
+ if (escaped) {
153
+ escaped = false;
154
+ } else if (char === '\\') {
155
+ escaped = true;
156
+ } else if (char === '"') {
157
+ inString = false;
158
+ }
159
+ continue;
160
+ }
161
+
162
+ if (char === '"') {
163
+ inString = true;
164
+ continue;
165
+ }
166
+
167
+ if (char === '{') {
168
+ depth++;
169
+ } else if (char === '}') {
170
+ depth--;
171
+ if (depth === 0) {
172
+ return i;
173
+ }
174
+ }
175
+ }
176
+
177
+ return -1;
178
+ }
179
+
180
+ public async saveConfig() {
181
+ const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
182
+ try {
183
+ this.hasAuthState = this.hasAuthState || await this.hasCredentialsFile();
184
+ const config = {
185
+ allowList: this.allowList,
186
+ allowedGroups: this.allowedGroups,
187
+ ignoredNumbers: this.ignoredNumbers,
188
+ status: this.status,
189
+ hasAuthState: this.hasAuthState,
190
+ openaiKey: this.openaiKey,
191
+ visionModel: this.visionModel,
192
+ operatorJid: this.operatorJid
193
+ };
194
+ await mkdir(this.storagePaths.root, { recursive: true });
195
+ const serialized = JSON.stringify(config, null, 2);
196
+ await writeFile(tempPath, serialized);
197
+ try {
180
198
  await rename(tempPath, this.configPath);
181
199
  } catch {
182
200
  // Windows EPERM: atomic rename failed (file locked). Fall back to direct write.
@@ -194,14 +212,14 @@ export class SessionManager {
194
212
  let files: string[];
195
213
 
196
214
  try {
197
- files = await readdir(this.baseDir);
215
+ files = await readdir(this.storagePaths.root);
198
216
  } catch {
199
217
  return;
200
218
  }
201
219
 
202
220
  await Promise.all(files
203
221
  .filter(fileName => fileName.startsWith(`${configFileName}.`) && fileName.endsWith('.tmp'))
204
- .map(fileName => this.removeConfigTempFile(join(this.baseDir, fileName))));
222
+ .map(fileName => this.removeConfigTempFile(join(this.storagePaths.root, fileName))));
205
223
  }
206
224
 
207
225
  private async removeConfigTempFile(tempPath: string) {
@@ -218,278 +236,278 @@ export class SessionManager {
218
236
  getAllowList(): Contact[] {
219
237
  return this.allowList;
220
238
  }
221
-
222
- getAllowedContact(number: string): Contact | undefined {
223
- return this.allowList.find(c => c.number === number);
224
- }
225
-
226
- getAllowedGroups(): Contact[] {
227
- return this.allowedGroups;
228
- }
229
-
230
- getAllowedGroup(groupJid: string): Contact | undefined {
231
- return this.allowedGroups.find(c => c.number === groupJid);
232
- }
233
-
234
- getIgnoredNumbers(): Contact[] {
235
- return this.ignoredNumbers;
236
- }
237
-
238
- async removeIgnoredNumber(number: string) {
239
- this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== number);
240
- await this.saveConfig();
241
- }
242
-
243
- async addNumber(number: any, name?: string) {
244
- // Handle potential nested objects from legacy bugs
245
- let cleanNumber = number;
246
- while (cleanNumber && typeof cleanNumber === 'object' && cleanNumber.number) {
247
- cleanNumber = cleanNumber.number;
248
- }
249
-
250
- if (typeof cleanNumber !== 'string') {
251
- console.warn(t('session.manager.invalidNumber'), cleanNumber);
252
- return;
253
- }
254
-
255
- const existing = this.allowList.find(c => c.number === cleanNumber);
256
- if (!existing) {
257
- if (SessionManager.isGroupJid(cleanNumber)) {
258
- await this.addAllowedGroup(cleanNumber, name);
259
- return;
260
- }
261
- this.allowList.push({ number: cleanNumber, name });
262
- this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== cleanNumber);
263
- await this.saveConfig();
264
- return;
265
- }
266
-
267
- if (name && !existing.name) {
268
- existing.name = name;
269
- await this.saveConfig();
270
- }
271
- }
272
-
273
- async removeNumber(number: string) {
274
- this.allowList = this.allowList.filter(c => c.number !== number);
275
- await this.saveConfig();
276
- }
277
-
278
- async addAllowedGroup(groupJid: string, name?: string) {
279
- if (!SessionManager.isGroupJid(groupJid)) {
280
- console.warn(t('session.manager.invalidNumber'), groupJid);
281
- return;
282
- }
283
-
284
- const existing = this.allowedGroups.find(c => c.number === groupJid);
285
- if (!existing) {
286
- this.allowedGroups.push({ number: groupJid, name });
287
- this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== groupJid);
288
- await this.saveConfig();
289
- return;
290
- }
291
-
292
- if (name && !existing.name) {
293
- existing.name = name;
294
- await this.saveConfig();
295
- }
296
- }
297
-
298
- async removeAllowedGroup(groupJid: string) {
299
- this.allowedGroups = this.allowedGroups.filter(c => c.number !== groupJid);
300
- await this.saveConfig();
301
- }
302
-
303
- async setAllowedContactAlias(number: string, alias: string) {
304
- const trimmedAlias = alias.trim();
305
- if (!trimmedAlias) {
306
- return;
307
- }
308
-
309
- const contact = this.getAllowedContact(number);
310
- if (!contact) {
311
- return;
312
- }
313
-
314
- contact.name = trimmedAlias;
315
- await this.saveConfig();
316
- }
317
-
318
- async removeAllowedContactAlias(number: string) {
319
- const contact = this.getAllowedContact(number);
320
- if (!contact || !contact.name) {
321
- return;
322
- }
323
-
324
- delete contact.name;
325
- await this.saveConfig();
326
- }
327
-
328
- async setContactSendNumber(number: string, sendNumber: string) {
329
- const contact = this.getAllowedContact(number);
330
- if (!contact) return;
331
- contact.sendNumber = sendNumber.trim();
332
- await this.saveConfig();
333
- }
334
-
335
- async removeContactSendNumber(number: string) {
336
- const contact = this.getAllowedContact(number);
337
- if (!contact) return;
338
- delete contact.sendNumber;
339
- await this.saveConfig();
340
- }
341
-
342
- async setAllowedGroupAlias(groupJid: string, alias: string) {
343
- const trimmedAlias = alias.trim();
344
- if (!trimmedAlias) {
345
- return;
346
- }
347
-
348
- const group = this.getAllowedGroup(groupJid);
349
- if (!group) {
350
- return;
351
- }
352
-
353
- group.name = trimmedAlias;
354
- await this.saveConfig();
355
- }
356
-
357
- async removeAllowedGroupAlias(groupJid: string) {
358
- const group = this.getAllowedGroup(groupJid);
359
- if (!group || !group.name) {
360
- return;
361
- }
362
-
363
- delete group.name;
364
- await this.saveConfig();
365
- }
366
-
367
- isAllowed(number: string): boolean {
368
- return this.allowList.some(c => c.number === number);
369
- }
370
-
371
- isAllowedGroup(groupJid: string): boolean {
372
- return this.allowedGroups.some(c => c.number === groupJid);
373
- }
374
-
375
- isConversationAllowed(sender: string): boolean {
376
- return SessionManager.isGroupJid(sender)
377
- ? this.isAllowedGroup(sender)
378
- : this.isAllowed(sender);
379
- }
380
-
381
- async trackIgnoredNumber(number: string, name?: string) {
382
- // Only track if not already allowed or ignored.
383
- if (!this.isConversationAllowed(number) &&
384
- !this.ignoredNumbers.find(c => c.number === number)) {
385
- this.ignoredNumbers.push({ number, name });
386
- await this.saveConfig();
387
- }
388
- }
389
-
390
- private mergeContacts(primary: Contact[], secondary: Contact[]): Contact[] {
391
- const merged = [...primary];
392
- for (const contact of secondary) {
393
- const existing = merged.find(c => c.number === contact.number);
394
- if (!existing) {
395
- merged.push(contact);
396
- } else {
397
- if (!existing.name && contact.name) {
398
- existing.name = contact.name;
399
- }
400
- }
401
- }
402
- return merged;
403
- }
404
-
405
- public async isRegistered(): Promise<boolean> {
406
- await this.syncAuthStateFromDisk();
407
- return this.hasAuthState;
408
- }
409
-
410
- async markAuthStateAvailable() {
411
- if (!this.hasAuthState) {
412
- this.hasAuthState = true;
413
- await this.saveConfig();
414
- }
415
- }
416
-
417
- async getAuthState() {
418
- await this.ensureStorageDirectories();
419
- return await useMultiFileAuthState(this.authStateDir);
420
- }
421
-
422
- private async syncAuthStateFromDisk() {
423
- const nextHasAuthState = await this.hasCredentialsFile();
424
- const nextStatus = nextHasAuthState || this.status !== 'connected'
425
- ? this.status
426
- : 'disconnected';
427
-
428
- if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
429
- this.hasAuthState = nextHasAuthState;
430
- this.status = nextStatus;
431
- await this.saveConfig();
432
- }
433
- }
434
-
435
- private async hasCredentialsFile(): Promise<boolean> {
436
- try {
437
- await readFile(join(this.authStateDir, 'creds.json'));
438
- return true;
439
- } catch {
440
- return false;
441
- }
442
- }
443
-
444
- async deleteAuthState() {
445
- try {
446
- await rm(this.authStateDir, { recursive: true, force: true });
447
- await mkdir(this.authStateDir, { recursive: true });
448
- this.status = 'logged-out';
449
- this.hasAuthState = false;
450
- await this.saveConfig();
451
- } catch (error) {
452
- console.error(t('session.manager.failedDeleteAuthState'), error);
453
- }
454
- }
455
-
456
- getStatus(): SessionStatus {
457
- return this.status;
458
- }
459
-
460
- async setStatus(status: SessionStatus) {
461
- this.status = status;
462
- await this.saveConfig();
463
- }
464
-
465
- getOpenaiKey(): string {
466
- return this.openaiKey;
467
- }
468
-
469
- async setOpenaiKey(key: string) {
470
- this.openaiKey = key;
471
- await this.saveConfig();
472
- }
473
-
474
- getVisionModel(): string {
475
- return this.visionModel;
476
- }
477
-
478
- async setVisionModel(model: string) {
479
- this.visionModel = model;
480
- await this.saveConfig();
481
- }
482
-
483
- getOperatorJid(): string {
484
- return this.operatorJid;
485
- }
486
-
487
- async setOperatorJid(jid: string) {
488
- this.operatorJid = jid;
489
- await this.saveConfig();
490
- }
491
-
492
- getAuthStateDir(): string {
493
- return this.authStateDir;
494
- }
495
- }
239
+
240
+ getAllowedContact(number: string): Contact | undefined {
241
+ return this.allowList.find(c => c.number === number);
242
+ }
243
+
244
+ getAllowedGroups(): Contact[] {
245
+ return this.allowedGroups;
246
+ }
247
+
248
+ getAllowedGroup(groupJid: string): Contact | undefined {
249
+ return this.allowedGroups.find(c => c.number === groupJid);
250
+ }
251
+
252
+ getIgnoredNumbers(): Contact[] {
253
+ return this.ignoredNumbers;
254
+ }
255
+
256
+ async removeIgnoredNumber(number: string) {
257
+ this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== number);
258
+ await this.saveConfig();
259
+ }
260
+
261
+ async addNumber(number: any, name?: string) {
262
+ // Handle potential nested objects from legacy bugs
263
+ let cleanNumber = number;
264
+ while (cleanNumber && typeof cleanNumber === 'object' && cleanNumber.number) {
265
+ cleanNumber = cleanNumber.number;
266
+ }
267
+
268
+ if (typeof cleanNumber !== 'string') {
269
+ console.warn(t('session.manager.invalidNumber'), cleanNumber);
270
+ return;
271
+ }
272
+
273
+ const existing = this.allowList.find(c => c.number === cleanNumber);
274
+ if (!existing) {
275
+ if (SessionManager.isGroupJid(cleanNumber)) {
276
+ await this.addAllowedGroup(cleanNumber, name);
277
+ return;
278
+ }
279
+ this.allowList.push({ number: cleanNumber, name });
280
+ this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== cleanNumber);
281
+ await this.saveConfig();
282
+ return;
283
+ }
284
+
285
+ if (name && !existing.name) {
286
+ existing.name = name;
287
+ await this.saveConfig();
288
+ }
289
+ }
290
+
291
+ async removeNumber(number: string) {
292
+ this.allowList = this.allowList.filter(c => c.number !== number);
293
+ await this.saveConfig();
294
+ }
295
+
296
+ async addAllowedGroup(groupJid: string, name?: string) {
297
+ if (!SessionManager.isGroupJid(groupJid)) {
298
+ console.warn(t('session.manager.invalidNumber'), groupJid);
299
+ return;
300
+ }
301
+
302
+ const existing = this.allowedGroups.find(c => c.number === groupJid);
303
+ if (!existing) {
304
+ this.allowedGroups.push({ number: groupJid, name });
305
+ this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== groupJid);
306
+ await this.saveConfig();
307
+ return;
308
+ }
309
+
310
+ if (name && !existing.name) {
311
+ existing.name = name;
312
+ await this.saveConfig();
313
+ }
314
+ }
315
+
316
+ async removeAllowedGroup(groupJid: string) {
317
+ this.allowedGroups = this.allowedGroups.filter(c => c.number !== groupJid);
318
+ await this.saveConfig();
319
+ }
320
+
321
+ async setAllowedContactAlias(number: string, alias: string) {
322
+ const trimmedAlias = alias.trim();
323
+ if (!trimmedAlias) {
324
+ return;
325
+ }
326
+
327
+ const contact = this.getAllowedContact(number);
328
+ if (!contact) {
329
+ return;
330
+ }
331
+
332
+ contact.name = trimmedAlias;
333
+ await this.saveConfig();
334
+ }
335
+
336
+ async removeAllowedContactAlias(number: string) {
337
+ const contact = this.getAllowedContact(number);
338
+ if (!contact || !contact.name) {
339
+ return;
340
+ }
341
+
342
+ delete contact.name;
343
+ await this.saveConfig();
344
+ }
345
+
346
+ async setContactSendNumber(number: string, sendNumber: string) {
347
+ const contact = this.getAllowedContact(number);
348
+ if (!contact) return;
349
+ contact.sendNumber = sendNumber.trim();
350
+ await this.saveConfig();
351
+ }
352
+
353
+ async removeContactSendNumber(number: string) {
354
+ const contact = this.getAllowedContact(number);
355
+ if (!contact) return;
356
+ delete contact.sendNumber;
357
+ await this.saveConfig();
358
+ }
359
+
360
+ async setAllowedGroupAlias(groupJid: string, alias: string) {
361
+ const trimmedAlias = alias.trim();
362
+ if (!trimmedAlias) {
363
+ return;
364
+ }
365
+
366
+ const group = this.getAllowedGroup(groupJid);
367
+ if (!group) {
368
+ return;
369
+ }
370
+
371
+ group.name = trimmedAlias;
372
+ await this.saveConfig();
373
+ }
374
+
375
+ async removeAllowedGroupAlias(groupJid: string) {
376
+ const group = this.getAllowedGroup(groupJid);
377
+ if (!group || !group.name) {
378
+ return;
379
+ }
380
+
381
+ delete group.name;
382
+ await this.saveConfig();
383
+ }
384
+
385
+ isAllowed(number: string): boolean {
386
+ return this.allowList.some(c => c.number === number);
387
+ }
388
+
389
+ isAllowedGroup(groupJid: string): boolean {
390
+ return this.allowedGroups.some(c => c.number === groupJid);
391
+ }
392
+
393
+ isConversationAllowed(sender: string): boolean {
394
+ return SessionManager.isGroupJid(sender)
395
+ ? this.isAllowedGroup(sender)
396
+ : this.isAllowed(sender);
397
+ }
398
+
399
+ async trackIgnoredNumber(number: string, name?: string) {
400
+ // Only track if not already allowed or ignored.
401
+ if (!this.isConversationAllowed(number) &&
402
+ !this.ignoredNumbers.find(c => c.number === number)) {
403
+ this.ignoredNumbers.push({ number, name });
404
+ await this.saveConfig();
405
+ }
406
+ }
407
+
408
+ private mergeContacts(primary: Contact[], secondary: Contact[]): Contact[] {
409
+ const merged = [...primary];
410
+ for (const contact of secondary) {
411
+ const existing = merged.find(c => c.number === contact.number);
412
+ if (!existing) {
413
+ merged.push(contact);
414
+ } else {
415
+ if (!existing.name && contact.name) {
416
+ existing.name = contact.name;
417
+ }
418
+ }
419
+ }
420
+ return merged;
421
+ }
422
+
423
+ public async isRegistered(): Promise<boolean> {
424
+ await this.syncAuthStateFromDisk();
425
+ return this.hasAuthState;
426
+ }
427
+
428
+ async markAuthStateAvailable() {
429
+ if (!this.hasAuthState) {
430
+ this.hasAuthState = true;
431
+ await this.saveConfig();
432
+ }
433
+ }
434
+
435
+ async getAuthState() {
436
+ await this.ensureStorageDirectories();
437
+ return await useMultiFileAuthState(this.authStateDir);
438
+ }
439
+
440
+ private async syncAuthStateFromDisk() {
441
+ const nextHasAuthState = await this.hasCredentialsFile();
442
+ const nextStatus = nextHasAuthState || this.status !== 'connected'
443
+ ? this.status
444
+ : 'disconnected';
445
+
446
+ if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
447
+ this.hasAuthState = nextHasAuthState;
448
+ this.status = nextStatus;
449
+ await this.saveConfig();
450
+ }
451
+ }
452
+
453
+ private async hasCredentialsFile(): Promise<boolean> {
454
+ try {
455
+ await readFile(join(this.authStateDir, 'creds.json'));
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
462
+ async deleteAuthState() {
463
+ try {
464
+ await rm(this.authStateDir, { recursive: true, force: true });
465
+ await mkdir(this.authStateDir, { recursive: true });
466
+ this.status = 'logged-out';
467
+ this.hasAuthState = false;
468
+ await this.saveConfig();
469
+ } catch (error) {
470
+ console.error(t('session.manager.failedDeleteAuthState'), error);
471
+ }
472
+ }
473
+
474
+ getStatus(): SessionStatus {
475
+ return this.status;
476
+ }
477
+
478
+ async setStatus(status: SessionStatus) {
479
+ this.status = status;
480
+ await this.saveConfig();
481
+ }
482
+
483
+ getOpenaiKey(): string {
484
+ return this.openaiKey;
485
+ }
486
+
487
+ async setOpenaiKey(key: string) {
488
+ this.openaiKey = key;
489
+ await this.saveConfig();
490
+ }
491
+
492
+ getVisionModel(): string {
493
+ return this.visionModel;
494
+ }
495
+
496
+ async setVisionModel(model: string) {
497
+ this.visionModel = model;
498
+ await this.saveConfig();
499
+ }
500
+
501
+ getOperatorJid(): string {
502
+ return this.operatorJid;
503
+ }
504
+
505
+ async setOperatorJid(jid: string) {
506
+ this.operatorJid = jid;
507
+ await this.saveConfig();
508
+ }
509
+
510
+ getAuthStateDir(): string {
511
+ return this.authStateDir;
512
+ }
513
+ }
@@ -0,0 +1,90 @@
1
+ import { access, mkdir, readdir, cp, stat } from 'fs/promises';
2
+ import { homedir } from 'os';
3
+ import { join } from 'path';
4
+
5
+ export function getDefaultStorageRoot(): string {
6
+ return join(homedir(), '.pi', 'agent', 'extension', 'whatsapp-pi');
7
+ }
8
+
9
+ export function getDefaultLegacyStorageRoot(): string {
10
+ return join(homedir(), '.pi', 'whatsapp-pi');
11
+ }
12
+
13
+ export interface StoragePaths {
14
+ root: string;
15
+ legacyRoot: string;
16
+ authStateDir: string;
17
+ configPath: string;
18
+ recentsDir: string;
19
+ recentsPath: string;
20
+ logDir: string;
21
+ logPath: string;
22
+ mediaDir: string;
23
+ }
24
+
25
+ export function createStoragePaths(root = getDefaultStorageRoot(), legacyRoot = getDefaultLegacyStorageRoot()): StoragePaths {
26
+ return {
27
+ root,
28
+ legacyRoot,
29
+ authStateDir: join(root, 'auth'),
30
+ configPath: join(root, 'config.json'),
31
+ recentsDir: join(root, 'recents'),
32
+ recentsPath: join(root, 'recents', 'recents.json'),
33
+ logDir: root,
34
+ logPath: join(root, 'whatsapp-pi.log'),
35
+ mediaDir: join(root, 'whatsapp-medias')
36
+ };
37
+ }
38
+
39
+ export async function ensureStorageDirectories(paths: Pick<StoragePaths, 'root' | 'authStateDir' | 'recentsDir' | 'logDir'>) {
40
+ await mkdir(paths.root, { recursive: true });
41
+ await mkdir(paths.authStateDir, { recursive: true });
42
+ await mkdir(paths.recentsDir, { recursive: true });
43
+ await mkdir(paths.logDir, { recursive: true });
44
+ }
45
+
46
+ export async function pathExists(targetPath: string): Promise<boolean> {
47
+ try {
48
+ await access(targetPath);
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function copyEntry(source: string, target: string) {
56
+ const sourceStats = await stat(source);
57
+ if (sourceStats.isDirectory()) {
58
+ await mkdir(target, { recursive: true });
59
+ const entries = await readdir(source, { withFileTypes: true });
60
+ for (const entry of entries) {
61
+ await copyEntry(join(source, entry.name), join(target, entry.name));
62
+ }
63
+ return;
64
+ }
65
+
66
+ if (await pathExists(target)) {
67
+ return;
68
+ }
69
+
70
+ await cp(source, target, { force: false, preserveTimestamps: true });
71
+ }
72
+
73
+ export async function migrateLegacyStorage(paths: Pick<StoragePaths, 'root' | 'legacyRoot'>): Promise<boolean> {
74
+ if (paths.root === paths.legacyRoot) {
75
+ return false;
76
+ }
77
+
78
+ if (!(await pathExists(paths.legacyRoot))) {
79
+ return false;
80
+ }
81
+
82
+ await mkdir(paths.root, { recursive: true });
83
+ const entries = await readdir(paths.legacyRoot, { withFileTypes: true });
84
+
85
+ for (const entry of entries) {
86
+ await copyEntry(join(paths.legacyRoot, entry.name), join(paths.root, entry.name));
87
+ }
88
+
89
+ return true;
90
+ }
@@ -1,16 +1,15 @@
1
1
  import { appendFileSync, mkdirSync } from 'fs';
2
- import { join } from 'path';
3
- import { homedir } from 'os';
2
+ import { createStoragePaths } from './storage-path.js';
4
3
 
5
4
  export class WhatsAppPiLogger {
6
5
  private logFile: string;
7
6
 
8
7
  constructor(private verbose = false) {
9
- const logDir = join(homedir(), '.pi', 'whatsapp-pi');
8
+ const { logDir, logPath } = createStoragePaths();
10
9
  try { mkdirSync(logDir, { recursive: true }); } catch {
11
10
  // File logging is best-effort.
12
11
  }
13
- this.logFile = join(logDir, 'whatsapp-pi.log');
12
+ this.logFile = logPath;
14
13
  }
15
14
 
16
15
  setVerbose(verbose: boolean) {
@@ -11,10 +11,9 @@ import { MessageSender } from './message.sender.js';
11
11
  import { installBaileysConsoleFilter } from './baileys-console-filter.js';
12
12
  import { t } from '../i18n.js';
13
13
  import { appendFileSync } from 'fs';
14
- import { join } from 'path';
15
- import { homedir } from 'os';
14
+ import { createStoragePaths } from './storage-path.js';
16
15
 
17
- const LOG_FILE = join(homedir(), '.pi', 'whatsapp-pi', 'whatsapp-pi.log');
16
+ const LOG_FILE = createStoragePaths().logPath;
18
17
  function fileLog(msg: string) {
19
18
  try { appendFileSync(LOG_FILE, `[${new Date().toISOString()}] [WhatsApp-Pi] ${msg}\n`); } catch {
20
19
  // File logging is best-effort.
@@ -185,27 +184,27 @@ export class WhatsAppService {
185
184
  return value;
186
185
  }
187
186
 
188
- private normalizeRecipientJid(jid: string): string {
189
- if (jid.includes('@')) return jid;
190
- const digits = jid.startsWith('+') ? jid.slice(1) : jid;
191
- return `${digits}@s.whatsapp.net`;
192
- }
193
-
194
- public resolveOutboundRecipientJid(recipient: string): string {
195
- if (SessionManager.isGroupJid(recipient)) {
196
- return recipient;
197
- }
198
-
199
- const senderNumber = this.normalizeContactNumber(recipient.split('@')[0]);
200
- const allowedContact = this.sessionManager.getAllowedContact(recipient)
201
- ?? this.sessionManager.getAllowedContact(senderNumber);
202
-
203
- if (allowedContact?.sendNumber) {
204
- return this.normalizeRecipientJid(allowedContact.sendNumber);
205
- }
206
-
207
- return this.normalizeRecipientJid(recipient);
208
- }
187
+ private normalizeRecipientJid(jid: string): string {
188
+ if (jid.includes('@')) return jid;
189
+ const digits = jid.startsWith('+') ? jid.slice(1) : jid;
190
+ return `${digits}@s.whatsapp.net`;
191
+ }
192
+
193
+ public resolveOutboundRecipientJid(recipient: string): string {
194
+ if (SessionManager.isGroupJid(recipient)) {
195
+ return recipient;
196
+ }
197
+
198
+ const senderNumber = this.normalizeContactNumber(recipient.split('@')[0]);
199
+ const allowedContact = this.sessionManager.getAllowedContact(recipient)
200
+ ?? this.sessionManager.getAllowedContact(senderNumber);
201
+
202
+ if (allowedContact?.sendNumber) {
203
+ return this.normalizeRecipientJid(allowedContact.sendNumber);
204
+ }
205
+
206
+ return this.normalizeRecipientJid(recipient);
207
+ }
209
208
 
210
209
  private normalizeJidForComparison(jid: string): string {
211
210
  const [localPart, domain = ''] = jid.split('@');
@@ -669,29 +668,29 @@ export class WhatsAppService {
669
668
  }
670
669
  }
671
670
 
672
- async sendMessage(jid: string, text: string) {
673
- const recipientJid = this.resolveOutboundRecipientJid(jid);
674
-
675
- // Ensure we show the typing indicator before sending
676
- await this.sendPresence(recipientJid, 'composing');
677
-
678
- const result = await this.messageSender.send({
679
- recipientJid,
680
- text: text
681
- });
682
-
683
- // After sending, we can stop the typing indicator
684
- await this.sendPresence(recipientJid, 'paused');
685
-
686
- if (!result.success) {
687
- console.error(t('service.whatsapp.failedSendMessage', { jid: recipientJid, error: result.error ?? t('message.sender.unknownError') }));
688
- }
689
-
690
- return result;
691
- }
692
-
693
- async sendMenuMessage(jid: string, text: string) {
694
- const normalizedJid = this.resolveOutboundRecipientJid(jid);
671
+ async sendMessage(jid: string, text: string) {
672
+ const recipientJid = this.resolveOutboundRecipientJid(jid);
673
+
674
+ // Ensure we show the typing indicator before sending
675
+ await this.sendPresence(recipientJid, 'composing');
676
+
677
+ const result = await this.messageSender.send({
678
+ recipientJid,
679
+ text: text
680
+ });
681
+
682
+ // After sending, we can stop the typing indicator
683
+ await this.sendPresence(recipientJid, 'paused');
684
+
685
+ if (!result.success) {
686
+ console.error(t('service.whatsapp.failedSendMessage', { jid: recipientJid, error: result.error ?? t('message.sender.unknownError') }));
687
+ }
688
+
689
+ return result;
690
+ }
691
+
692
+ async sendMenuMessage(jid: string, text: string) {
693
+ const normalizedJid = this.resolveOutboundRecipientJid(jid);
695
694
  const socket = this.getActiveSocket();
696
695
 
697
696
  if (!socket) {