whatsapp-pi 1.0.43 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -27,6 +27,7 @@
27
27
  "author": "Rapha",
28
28
  "license": "MIT",
29
29
  "scripts": {
30
+ "lint": "eslint whatsapp-pi.ts \"src/**/*.ts\" \"tests/**/*.ts\"",
30
31
  "test": "vitest run",
31
32
  "typecheck": "tsc --noEmit"
32
33
  },
@@ -36,10 +37,14 @@
36
37
  "qrcode-terminal": "^0.12.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "@eslint/js": "^9.39.4",
39
41
  "@mariozechner/pi-coding-agent": "latest",
40
42
  "@types/node": "^20.11.0",
41
43
  "@types/qrcode-terminal": "^0.12.2",
44
+ "@typescript-eslint/eslint-plugin": "^8.58.1",
45
+ "@typescript-eslint/parser": "^8.58.1",
42
46
  "@vitest/coverage-v8": "^1.6.1",
47
+ "eslint": "^9.39.4",
43
48
  "ts-node": "^10.9.2",
44
49
  "tsx": "^4.7.0",
45
50
  "typescript": "^5.3.0",
@@ -102,7 +102,7 @@ export class IncomingMediaService {
102
102
  }
103
103
 
104
104
  private async saveDocument(fileName: string, buffer: Buffer): Promise<string> {
105
- const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
105
+ const sanitized = fileName.replace(/[^a-z0-9._-]/gi, '_');
106
106
  const savedFileName = `${Date.now()}_${sanitized}`;
107
107
  const documentDir = join(process.cwd(), '.pi-data', 'whatsapp', 'documents');
108
108
  const absolutePath = join(documentDir, savedFileName);
@@ -1,27 +1,33 @@
1
- import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
- import { join } from 'path';
3
- import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises';
1
+ import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
+ import { join } from 'path';
3
+ import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
4
4
  import { homedir } from 'os';
5
5
  import { SessionStatus } from '../models/whatsapp.types.js';
6
6
 
7
- export interface Contact {
8
- number: string;
9
- name?: string;
10
- }
11
-
12
- export class SessionManager {
13
- // Data is stored in the user's home directory to persist across updates
14
- private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
15
- private readonly authStateDir = join(this.baseDir, 'auth');
16
- private readonly configPath = join(this.baseDir, 'config.json');
7
+ export interface Contact {
8
+ number: string;
9
+ name?: string;
10
+ }
11
+
12
+ export class SessionManager {
13
+ // Data is stored in the user's home directory to persist across updates
14
+ private readonly baseDir: string;
15
+ private readonly authStateDir: string;
16
+ private readonly configPath: string;
17
17
 
18
18
  private status: SessionStatus = 'logged-out';
19
19
  private allowList: Contact[] = [];
20
20
  private blockList: Contact[] = [];
21
21
  private ignoredNumbers: Contact[] = [];
22
22
  private hasAuthState = false;
23
- private openaiKey: string = '';
24
- private visionModel: string = 'gpt-4o';
23
+ private openaiKey: string = '';
24
+ private visionModel: string = 'gpt-4o';
25
+
26
+ constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
27
+ this.baseDir = baseDir;
28
+ this.authStateDir = join(this.baseDir, 'auth');
29
+ this.configPath = join(this.baseDir, 'config.json');
30
+ }
25
31
 
26
32
  private async ensureStorageDirectories() {
27
33
  await mkdir(this.baseDir, { recursive: true });
@@ -33,15 +39,17 @@ export class SessionManager {
33
39
  await this.ensureStorageDirectories();
34
40
  await this.loadConfig();
35
41
  await this.syncAuthStateFromDisk();
36
- } catch (error) {}
42
+ } catch {
43
+ // Initialization is best-effort; callers can continue with defaults.
44
+ }
37
45
  }
38
46
 
39
- private async loadConfig() {
40
- try {
41
- const data = await readFile(this.configPath, 'utf-8');
42
- const config = JSON.parse(data);
43
-
44
- const cleanContact = (item: any): Contact | null => {
47
+ private async loadConfig() {
48
+ try {
49
+ const data = await readFile(this.configPath, 'utf-8');
50
+ const { config, recovered } = this.parseConfig(data);
51
+
52
+ const cleanContact = (item: any): Contact | null => {
45
53
  if (typeof item === 'string') return { number: item };
46
54
  if (item && typeof item === 'object') {
47
55
  let num = item.number;
@@ -60,30 +68,92 @@ export class SessionManager {
60
68
  this.blockList = (config.blockList || []).map(cleanContact).filter(Boolean) as Contact[];
61
69
  this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
62
70
  this.status = config.status || 'logged-out';
63
- this.hasAuthState = Boolean(config.hasAuthState);
64
- this.openaiKey = config.openaiKey || '';
65
- this.visionModel = config.visionModel || 'gpt-4o';
66
- } catch (error) {
67
- // File not found is fine
68
- }
69
- }
70
-
71
- public async saveConfig() {
72
- try {
73
- const config = {
74
- allowList: this.allowList,
75
- blockList: this.blockList,
76
- ignoredNumbers: this.ignoredNumbers,
71
+ this.hasAuthState = Boolean(config.hasAuthState);
72
+ this.openaiKey = config.openaiKey || '';
73
+ this.visionModel = config.visionModel || 'gpt-4o';
74
+
75
+ if (recovered) {
76
+ await this.saveConfig();
77
+ }
78
+ } catch {
79
+ // File not found is fine
80
+ }
81
+ }
82
+
83
+ private parseConfig(data: string): { config: any; recovered: boolean } {
84
+ try {
85
+ return { config: JSON.parse(data), recovered: false };
86
+ } catch (error) {
87
+ const objectEnd = this.findFirstJsonObjectEnd(data);
88
+ if (objectEnd < 0) {
89
+ throw error;
90
+ }
91
+
92
+ return {
93
+ config: JSON.parse(data.slice(0, objectEnd + 1)),
94
+ recovered: true
95
+ };
96
+ }
97
+ }
98
+
99
+ private findFirstJsonObjectEnd(data: string): number {
100
+ let depth = 0;
101
+ let inString = false;
102
+ let escaped = false;
103
+
104
+ for (let i = 0; i < data.length; i++) {
105
+ const char = data[i];
106
+
107
+ if (inString) {
108
+ if (escaped) {
109
+ escaped = false;
110
+ } else if (char === '\\') {
111
+ escaped = true;
112
+ } else if (char === '"') {
113
+ inString = false;
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (char === '"') {
119
+ inString = true;
120
+ continue;
121
+ }
122
+
123
+ if (char === '{') {
124
+ depth++;
125
+ } else if (char === '}') {
126
+ depth--;
127
+ if (depth === 0) {
128
+ return i;
129
+ }
130
+ }
131
+ }
132
+
133
+ return -1;
134
+ }
135
+
136
+ public async saveConfig() {
137
+ const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
138
+ try {
139
+ this.hasAuthState = this.hasAuthState || await this.hasCredentialsFile();
140
+ const config = {
141
+ allowList: this.allowList,
142
+ blockList: this.blockList,
143
+ ignoredNumbers: this.ignoredNumbers,
77
144
  status: this.status,
78
145
  hasAuthState: this.hasAuthState,
79
- openaiKey: this.openaiKey,
80
- visionModel: this.visionModel
81
- };
82
- await writeFile(this.configPath, JSON.stringify(config, null, 2));
83
- } catch (error) {
84
- console.error('Failed to save config:', error);
85
- }
86
- }
146
+ openaiKey: this.openaiKey,
147
+ visionModel: this.visionModel
148
+ };
149
+ await mkdir(this.baseDir, { recursive: true });
150
+ await writeFile(tempPath, JSON.stringify(config, null, 2));
151
+ await rename(tempPath, this.configPath);
152
+ } catch (error) {
153
+ await rm(tempPath, { force: true }).catch(() => {});
154
+ console.error('Failed to save config:', error);
155
+ }
156
+ }
87
157
 
88
158
  getAllowList(): Contact[] {
89
159
  return this.allowList;
@@ -205,17 +275,10 @@ export class SessionManager {
205
275
  }
206
276
  }
207
277
 
208
- public async isRegistered(): Promise<boolean> {
209
- try {
210
- const credsPah = join(this.authStateDir, 'creds.json');
211
- await readFile(credsPah);
212
- this.hasAuthState = true;
213
- return true;
214
- } catch {
215
- await this.syncAuthStateFromDisk();
216
- return this.hasAuthState;
217
- }
218
- }
278
+ public async isRegistered(): Promise<boolean> {
279
+ await this.syncAuthStateFromDisk();
280
+ return this.hasAuthState;
281
+ }
219
282
 
220
283
  async markAuthStateAvailable() {
221
284
  if (!this.hasAuthState) {
@@ -229,19 +292,27 @@ export class SessionManager {
229
292
  return await useMultiFileAuthState(this.authStateDir);
230
293
  }
231
294
 
232
- private async syncAuthStateFromDisk() {
233
- try {
234
- const entries = await readdir(this.authStateDir);
235
- if (entries.length > 0) {
236
- if (!this.hasAuthState) {
237
- this.hasAuthState = true;
238
- await this.saveConfig();
239
- }
240
- }
241
- } catch {
242
- // Ignore missing directory / empty auth state
243
- }
244
- }
295
+ private async syncAuthStateFromDisk() {
296
+ const nextHasAuthState = await this.hasCredentialsFile();
297
+ const nextStatus = nextHasAuthState || this.status !== 'connected'
298
+ ? this.status
299
+ : 'disconnected';
300
+
301
+ if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
302
+ this.hasAuthState = nextHasAuthState;
303
+ this.status = nextStatus;
304
+ await this.saveConfig();
305
+ }
306
+ }
307
+
308
+ private async hasCredentialsFile(): Promise<boolean> {
309
+ try {
310
+ await readFile(join(this.authStateDir, 'creds.json'));
311
+ return true;
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
245
316
 
246
317
  async deleteAuthState() {
247
318
  try {
@@ -71,12 +71,16 @@ interface BoomLikeError {
71
71
  message?: string;
72
72
  }
73
73
 
74
- export class WhatsAppService {
75
- private socket?: WhatsAppSocketLike;
76
- private sessionManager: SessionManager;
77
- private messageSender: MessageSender;
78
- private isReconnecting = false;
79
- private verboseMode = false;
74
+ export class WhatsAppService {
75
+ private static readonly INITIAL_RECONNECT_DELAY_MS = 5_000;
76
+ private static readonly MAX_RECONNECT_DELAY_MS = 120_000;
77
+
78
+ private socket?: WhatsAppSocketLike;
79
+ private sessionManager: SessionManager;
80
+ private messageSender: MessageSender;
81
+ private isReconnecting = false;
82
+ private reconnectAttempts = 0;
83
+ private verboseMode = false;
80
84
  private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
81
85
  private saveCreds?: () => Promise<void>;
82
86
  private restoreBaileysConsoleFilter?: () => void;
@@ -164,12 +168,17 @@ export class WhatsAppService {
164
168
  return '';
165
169
  }
166
170
 
167
- private clearReconnectTimeout() {
168
- if (this.reconnectTimeout) {
169
- clearTimeout(this.reconnectTimeout);
170
- this.reconnectTimeout = undefined;
171
- }
172
- }
171
+ private clearReconnectTimeout() {
172
+ if (this.reconnectTimeout) {
173
+ clearTimeout(this.reconnectTimeout);
174
+ this.reconnectTimeout = undefined;
175
+ }
176
+ }
177
+
178
+ private getReconnectDelayMs(): number {
179
+ const delay = WhatsAppService.INITIAL_RECONNECT_DELAY_MS * (2 ** Math.max(0, this.reconnectAttempts - 1));
180
+ return Math.min(delay, WhatsAppService.MAX_RECONNECT_DELAY_MS);
181
+ }
173
182
 
174
183
  private cleanupSocket() {
175
184
  this.clearReconnectTimeout();
@@ -295,7 +304,7 @@ export class WhatsAppService {
295
304
  private async handlePairingQr(qr: string) {
296
305
  this.sessionManager.setStatus('pairing');
297
306
  this.onQRCode?.(qr);
298
- this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
307
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
299
308
  }
300
309
 
301
310
  private async handleConnectionOpen() {
@@ -303,10 +312,11 @@ export class WhatsAppService {
303
312
  console.log('WhatsApp connection successfully opened');
304
313
  }
305
314
 
306
- this.isReconnecting = false;
307
- this.clearReconnectTimeout();
308
- await this.saveCreds?.();
309
- await this.sessionManager.markAuthStateAvailable();
315
+ this.isReconnecting = false;
316
+ this.reconnectAttempts = 0;
317
+ this.clearReconnectTimeout();
318
+ await this.saveCreds?.();
319
+ await this.sessionManager.markAuthStateAvailable();
310
320
  this.sessionManager.setStatus('connected');
311
321
  this.onStatusUpdate?.('| WhatsApp: Connected');
312
322
  }
@@ -339,56 +349,58 @@ export class WhatsAppService {
339
349
  console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
340
350
  }
341
351
 
342
- if (shouldTreatAsLoggedOut) {
343
- if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
344
- if (this.verboseMode) {
345
- console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
346
- }
347
- await this.sessionManager.deleteAuthState();
348
- this.cleanupSocket();
349
- this.socket = undefined;
350
- this.isReconnecting = false;
351
- await this.start({ allowPairingOnAuthFailure: false });
352
- return;
353
- }
354
-
355
- if (this.verboseMode) {
356
- console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
357
- }
358
- if (isBadMac) {
359
- if (this.verboseMode) {
360
- console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
361
- console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
362
- }
363
- this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
364
- }
365
- this.sessionManager.setStatus('logged-out');
366
- if (!isBadMac) {
367
- this.onStatusUpdate?.('| WhatsApp: Logged out');
368
- }
369
- return;
370
- }
371
-
372
- if (statusCode === DisconnectReason.connectionReplaced) {
373
- if (this.verboseMode) {
374
- console.error('Connection replaced - another instance connected');
375
- }
376
- this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
377
- return;
378
- }
379
-
380
- if (shouldReconnect && !this.isReconnecting) {
381
- this.isReconnecting = true;
382
- this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
383
- this.clearReconnectTimeout();
384
- this.reconnectTimeout = setTimeout(() => {
385
- this.isReconnecting = false;
386
- void this.start(options);
387
- }, 3000);
388
- } else if (!shouldReconnect) {
389
- this.sessionManager.setStatus('logged-out');
390
- this.onStatusUpdate?.('| WhatsApp: Disconnected');
391
- }
352
+ if (shouldTreatAsLoggedOut) {
353
+ if (this.verboseMode) {
354
+ console.error(`Session rejected [${statusCode}] - preserving auth state`);
355
+ }
356
+ if (isBadMac) {
357
+ if (this.verboseMode) {
358
+ console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
359
+ console.error('[WhatsApp-Pi] Use Logoff (Delete Session) only if reconnect keeps failing.');
360
+ }
361
+ this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
362
+ } else if (isAuthRejected && allowPairingOnAuthFailure) {
363
+ this.onStatusUpdate?.('| WhatsApp: Session Preserved (Reconnect Failed)');
364
+ }
365
+ this.cleanupSocket();
366
+ this.isReconnecting = false;
367
+ this.reconnectAttempts = 0;
368
+ await this.sessionManager.setStatus('disconnected');
369
+ if (!isBadMac) {
370
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
371
+ }
372
+ return;
373
+ }
374
+
375
+ if (statusCode === DisconnectReason.connectionReplaced) {
376
+ if (this.verboseMode) {
377
+ console.error('Connection replaced - another instance connected');
378
+ }
379
+ this.cleanupSocket();
380
+ this.isReconnecting = false;
381
+ this.reconnectAttempts = 0;
382
+ await this.sessionManager.setStatus('disconnected');
383
+ this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
384
+ return;
385
+ }
386
+
387
+ if (shouldReconnect && !this.isReconnecting) {
388
+ this.isReconnecting = true;
389
+ this.reconnectAttempts++;
390
+ const reconnectDelayMs = this.getReconnectDelayMs();
391
+ this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
392
+ this.clearReconnectTimeout();
393
+ await this.saveCreds?.();
394
+ this.cleanupSocket();
395
+ this.reconnectTimeout = setTimeout(() => {
396
+ this.isReconnecting = false;
397
+ void this.start(options);
398
+ }, reconnectDelayMs);
399
+ } else if (!shouldReconnect) {
400
+ this.reconnectAttempts = 0;
401
+ this.sessionManager.setStatus('logged-out');
402
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
403
+ }
392
404
  }
393
405
 
394
406
  private extractText(message: IncomingMessageContent | undefined): string {
@@ -63,13 +63,14 @@ export class MenuHandler {
63
63
  await this.whatsappService.stop();
64
64
  ctx.ui.notify('WhatsApp Agent Disconnected', 'warning');
65
65
  break;
66
- case 'Logoff (Delete Session)':
67
- const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
- if (confirmLogoff) {
69
- await this.whatsappService.logout();
70
- ctx.ui.notify('Logged off and credentials deleted', 'info');
71
- }
72
- break;
66
+ case 'Logoff (Delete Session)': {
67
+ const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
+ if (confirmLogoff) {
69
+ await this.whatsappService.logout();
70
+ ctx.ui.notify('Logged off and credentials deleted', 'info');
71
+ }
72
+ break;
73
+ }
73
74
  case 'Allowed Numbers':
74
75
  await this.manageAllowList(ctx);
75
76
  break;
@@ -29,11 +29,10 @@ export class MessageDetailView {
29
29
  ) {
30
30
  this.props.onClose();
31
31
  }
32
- }
33
-
34
- render(width: number): string[] {
35
- const title = this.props.title.trim() || 'Message Details';
36
- const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
32
+ }
33
+
34
+ render(width: number): string[] {
35
+ const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
37
36
 
38
37
  const availableWidth = Math.max(20, width - 4);
39
38
  const rawHeaderLines = [
package/whatsapp-pi.ts CHANGED
@@ -60,7 +60,7 @@ export default function (pi: ExtensionAPI) {
60
60
  };
61
61
 
62
62
  // Initial status setup
63
- pi.on("session_start", async (event, ctx) => {
63
+ pi.on("session_start", async (_event, ctx) => {
64
64
  _ctx = ctx;
65
65
  // Check verbose mode
66
66
  const isVerboseFlagSet = process.argv.includes("--verbose");
@@ -100,12 +100,13 @@ export default function (pi: ExtensionAPI) {
100
100
  const savedStateEntry = [...ctx.sessionManager.getEntries()]
101
101
  .reverse()
102
102
  .find(entry => entry.type === "custom" && entry.customType === "whatsapp-state");
103
- const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
103
+ const isWhatsappPiOn = pi.getFlag("whatsapp-pi-online") === true;
104
+ const registered = await sessionManager.isRegistered();
104
105
 
105
106
  if (savedStateEntry) {
106
107
  const data = (savedStateEntry as { data?: any }).data;
107
108
  if (data.status) {
108
- const restoredStatus = data.status === 'connected' && !isWhatsappPiOn
109
+ const restoredStatus = data.status === 'connected' && !(isWhatsappPiOn && registered)
109
110
  ? 'disconnected'
110
111
  : data.status;
111
112
  await sessionManager.setStatus(restoredStatus);
@@ -119,9 +120,6 @@ export default function (pi: ExtensionAPI) {
119
120
  }
120
121
  }
121
122
 
122
- // Check whatsapp flag — only auto-connect on initial startup, not reloads/new sessions
123
- const registered = await sessionManager.isRegistered();
124
-
125
123
  if (isWhatsappPiOn && registered) {
126
124
  ctx.ui.setStatus('whatsapp', '| WhatsApp: Auto-connecting...');
127
125
 
@@ -133,7 +131,7 @@ export default function (pi: ExtensionAPI) {
133
131
  attempts++;
134
132
  try {
135
133
  await whatsappService.start({ allowPairingOnAuthFailure: false });
136
- } catch (error) {
134
+ } catch {
137
135
  if (attempts < maxAttempts) {
138
136
  ctx.ui.notify(`WhatsApp: Connection attempt ${attempts} failed. Retrying...`, 'warning');
139
137
  setTimeout(tryConnect, 3000);
@@ -145,6 +143,8 @@ export default function (pi: ExtensionAPI) {
145
143
  };
146
144
 
147
145
  await tryConnect();
146
+ } else if (isWhatsappPiOn) {
147
+ ctx.ui.notify('WhatsApp: Auto-connect requested, but no saved WhatsApp credentials were found. Use Connect WhatsApp once to scan the QR code.', 'warning');
148
148
  } else {
149
149
  ctx.ui.notify('WhatsApp: Use Connect / Reconnect WhatsApp. QR code will appear only if pairing is needed.', 'info');
150
150
  }
@@ -157,7 +157,7 @@ export default function (pi: ExtensionAPI) {
157
157
  if (code !== 0 && code !== 99) { // 99 is a common exit code for -v in some versions
158
158
  throw new Error(`pdftotext returned code ${code}`);
159
159
  }
160
- } catch (e) {
160
+ } catch {
161
161
  ctx.ui.notify('WhatsApp: pdftotext not found. PDF document support will be limited to storage only.', 'warning');
162
162
  logger.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
163
163
  }
@@ -333,7 +333,7 @@ export default function (pi: ExtensionAPI) {
333
333
  } else {
334
334
  ctx.ui.notify(`Failed to send WhatsApp reply`, 'error');
335
335
  }
336
- } catch (error) {
336
+ } catch {
337
337
  ctx.ui.notify(`Failed to send WhatsApp reply`, 'error');
338
338
  }
339
339
  }