@gtchakama/wa-tui 1.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.
@@ -0,0 +1,30 @@
1
+ const state = {
2
+ screen: 'loading', // 'loading', 'qr', 'chats', 'chatDetail'
3
+ qr: null,
4
+ chats: [],
5
+ currentChatId: null,
6
+ currentChatName: null,
7
+ currentRawChat: null,
8
+ currentMessages: [],
9
+ /** Saved input per chat id when leaving the thread */
10
+ chatDrafts: {},
11
+ /** absolute paths from Ctrl+D download, keyed by message id */
12
+ mediaPaths: {},
13
+ /** Reply quote target for next send */
14
+ replyTo: null,
15
+ /** Index into currentMessages for quote selection */
16
+ replyPickIndex: null,
17
+ filter: 'all', // 'all', 'direct', 'groups'
18
+ /** 'recent' | 'unread' | 'alpha' */
19
+ chatSort: 'recent',
20
+ unreadOnly: false,
21
+ page: 1,
22
+ pageSize: 10,
23
+ loading: true,
24
+ error: null,
25
+ unreadCount: 0,
26
+ /** When opening Settings (F2), where to return: chats | chatDetail */
27
+ settingsReturnScreen: null
28
+ };
29
+
30
+ module.exports = state;
@@ -0,0 +1,54 @@
1
+ function formatTimestamp(timestamp) {
2
+ const date = new Date(timestamp * 1000);
3
+ const now = new Date();
4
+
5
+ if (date.toDateString() === now.toDateString()) {
6
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
7
+ }
8
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
9
+ }
10
+
11
+ function truncate(str, len = 40) {
12
+ if (!str) return '';
13
+ if (str.length <= len) return str;
14
+ return str.substring(0, len) + '...';
15
+ }
16
+
17
+ /** Compare chat JIDs when WA uses @c.us vs @s.whatsapp.net (or casing differs). */
18
+ function canonicalChatIdForCompare(id) {
19
+ if (!id || typeof id !== 'string') return '';
20
+ const lower = id.toLowerCase();
21
+ const at = lower.lastIndexOf('@');
22
+ if (at === -1) return lower;
23
+ const user = lower.slice(0, at);
24
+ const host = lower.slice(at + 1);
25
+ if (host === 'g.us') return `${user}@g.us`;
26
+ if (host === 'c.us' || host === 's.whatsapp.net') return `${user}@c.us`;
27
+ return lower;
28
+ }
29
+
30
+ function chatIdsMatch(a, b) {
31
+ if (!a || !b) return false;
32
+ if (a === b) return true;
33
+ return canonicalChatIdForCompare(a) === canonicalChatIdForCompare(b);
34
+ }
35
+
36
+ /** Readable fallback when pushname is missing (e.g. +263… instead of raw JID). */
37
+ function formatPeerLabel(jidOrName) {
38
+ if (jidOrName == null || jidOrName === '') return '';
39
+ const s = String(jidOrName);
40
+ if (!s.includes('@')) return s;
41
+ const at = s.lastIndexOf('@');
42
+ const user = s.slice(0, at);
43
+ const host = s.slice(at + 1).toLowerCase();
44
+ if (host === 'g.us') return s;
45
+ if (/^\d+$/.test(user)) return `+${user}`;
46
+ return user;
47
+ }
48
+
49
+ module.exports = {
50
+ formatTimestamp,
51
+ truncate,
52
+ chatIdsMatch,
53
+ formatPeerLabel
54
+ };
@@ -0,0 +1,53 @@
1
+ const { MessageTypes } = require('whatsapp-web.js');
2
+ const { truncate } = require('./format');
3
+
4
+ function mediaBracketLabel(type, hasMedia) {
5
+ if (!hasMedia) return null;
6
+ switch (type) {
7
+ case MessageTypes.IMAGE:
8
+ case MessageTypes.ALBUM:
9
+ return 'image';
10
+ case MessageTypes.VIDEO:
11
+ return 'video';
12
+ case MessageTypes.VOICE:
13
+ return 'voice';
14
+ case MessageTypes.AUDIO:
15
+ return 'audio';
16
+ case MessageTypes.DOCUMENT:
17
+ return 'doc';
18
+ case MessageTypes.STICKER:
19
+ return 'sticker';
20
+ case MessageTypes.LOCATION:
21
+ return 'location';
22
+ default:
23
+ return 'media';
24
+ }
25
+ }
26
+
27
+ /** Primary line body: caption/text or [kind] for media-only. */
28
+ function displayBodyForParts(type, hasMedia, body) {
29
+ const text = body != null ? String(body).trim() : '';
30
+ if (text) return text;
31
+ const kind = mediaBracketLabel(type, hasMedia);
32
+ return kind ? `[${kind}]` : '';
33
+ }
34
+
35
+ /** Plain text augmentation (renderer applies colors). */
36
+ function augmentDisplayPlain(row) {
37
+ const base =
38
+ displayBodyForParts(row.type, row.hasMedia, row.body) || '—';
39
+ let out = base;
40
+ if (row.hasQuotedMsg && row.quotedSnippet) {
41
+ out += ` (re: ${row.quotedSnippet})`;
42
+ }
43
+ if (row.localPath) {
44
+ out += ` @ ${truncate(row.localPath, 56)}`;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ module.exports = {
50
+ mediaBracketLabel,
51
+ displayBodyForParts,
52
+ augmentDisplayPlain
53
+ };
@@ -0,0 +1,72 @@
1
+ const { spawn } = require('child_process');
2
+ const fs = require('fs');
3
+
4
+ function trySpawn(cmd, args, extra = {}) {
5
+ try {
6
+ const child = spawn(cmd, args, {
7
+ stdio: 'ignore',
8
+ detached: true,
9
+ ...extra
10
+ });
11
+ child.unref();
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Short notification when an incoming message arrives elsewhere in the UI.
20
+ * Set WA_TUI_NO_SOUND=1 to disable.
21
+ */
22
+ function playIncomingMessageSound() {
23
+ if (process.env.WA_TUI_NO_SOUND === '1') return;
24
+
25
+ const platform = process.platform;
26
+
27
+ if (platform === 'darwin') {
28
+ const custom = process.env.WA_TUI_SOUND && String(process.env.WA_TUI_SOUND).trim();
29
+ if (custom && fs.existsSync(custom) && trySpawn('afplay', [custom])) return;
30
+ const ping = '/System/Library/Sounds/Ping.aiff';
31
+ if (fs.existsSync(ping) && trySpawn('afplay', [ping])) return;
32
+ }
33
+
34
+ if (platform === 'win32') {
35
+ if (
36
+ trySpawn(
37
+ 'powershell.exe',
38
+ [
39
+ '-NoProfile',
40
+ '-NonInteractive',
41
+ '-Command',
42
+ '[console]::Beep(1050, 45); [console]::Beep(1400, 65)'
43
+ ],
44
+ { windowsHide: true }
45
+ )
46
+ ) {
47
+ return;
48
+ }
49
+ }
50
+
51
+ if (platform === 'linux') {
52
+ const custom = process.env.WA_TUI_SOUND && String(process.env.WA_TUI_SOUND).trim();
53
+ if (custom && fs.existsSync(custom)) {
54
+ if (trySpawn('paplay', [custom])) return;
55
+ if (trySpawn('aplay', ['-q', custom])) return;
56
+ }
57
+ const complete = '/usr/share/sounds/freedesktop/stereo/complete.oga';
58
+ if (fs.existsSync(complete) && trySpawn('paplay', [complete])) return;
59
+ const oga = '/usr/share/sounds/freedesktop/stereo/message.oga';
60
+ if (fs.existsSync(oga) && trySpawn('paplay', [oga])) return;
61
+ const wav = '/usr/share/sounds/sound-icons/echocancel.wav';
62
+ if (fs.existsSync(wav) && trySpawn('aplay', ['-q', wav])) return;
63
+ }
64
+
65
+ try {
66
+ process.stdout.write('\x07');
67
+ } catch {
68
+ // ignore
69
+ }
70
+ }
71
+
72
+ module.exports = { playIncomingMessageSound };
@@ -0,0 +1,19 @@
1
+ function paginate(items, page = 1, pageSize = 10) {
2
+ const totalItems = items.length;
3
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
4
+ const safePage = Math.min(Math.max(page, 1), totalPages);
5
+ const start = (safePage - 1) * pageSize;
6
+ const paginatedItems = items.slice(start, start + pageSize);
7
+
8
+ return {
9
+ items: paginatedItems,
10
+ page: safePage,
11
+ totalPages,
12
+ totalItems,
13
+ pageSize
14
+ };
15
+ }
16
+
17
+ module.exports = {
18
+ paginate
19
+ };
@@ -0,0 +1,264 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const {
5
+ Client,
6
+ LocalAuth,
7
+ MessageTypes
8
+ } = require('whatsapp-web.js');
9
+ const EventEmitter = require('events');
10
+ const { formatPeerLabel, truncate } = require('../utils/format');
11
+ const { displayBodyForParts } = require('../utils/messageFormat');
12
+
13
+ async function resolveIncomingAuthor(msg, chat) {
14
+ const isGroup = Boolean(chat && chat.isGroup);
15
+ const peerTitle = (chat && (chat.name || chat.formattedTitle || '').trim()) || '';
16
+ if (!isGroup) {
17
+ if (peerTitle) return peerTitle;
18
+ return formatPeerLabel(msg.author || msg.from);
19
+ }
20
+ try {
21
+ const c = await msg.getContact();
22
+ const n = (c.pushname || c.name || c.shortName || '').trim();
23
+ if (n) return n;
24
+ } catch (_) {}
25
+ return formatPeerLabel(msg.author || msg.from);
26
+ }
27
+
28
+ const SUPPRESSED_MESSAGE_TYPES = new Set([
29
+ MessageTypes.E2E_NOTIFICATION,
30
+ MessageTypes.PROTOCOL,
31
+ MessageTypes.GP2,
32
+ MessageTypes.CIPHERTEXT,
33
+ MessageTypes.REACTION,
34
+ MessageTypes.DEBUG,
35
+ MessageTypes.BROADCAST_NOTIFICATION,
36
+ MessageTypes.REVOKED
37
+ ]);
38
+
39
+ function shouldEmitUserMessage(msg) {
40
+ if (!msg || msg.isStatus || !msg.id?._serialized) return false;
41
+ if (msg.broadcast) return false;
42
+ if (SUPPRESSED_MESSAGE_TYPES.has(msg.type)) return false;
43
+ const body = msg.body != null ? String(msg.body).trim() : '';
44
+ if (!body && !msg.hasMedia) return false;
45
+ return true;
46
+ }
47
+
48
+ async function quotedSnippetFrom(msg) {
49
+ if (!msg.hasQuotedMsg) return '';
50
+ try {
51
+ const q = await msg.getQuotedMessage();
52
+ if (!q) return '';
53
+ const qb =
54
+ q.body != null && String(q.body).trim()
55
+ ? String(q.body).trim()
56
+ : displayBodyForParts(q.type, Boolean(q.hasMedia), q.body);
57
+ return truncate(qb, 40);
58
+ } catch (_) {
59
+ return '';
60
+ }
61
+ }
62
+
63
+ async function rowFromClientMessage(msg, chat) {
64
+ const author = msg.fromMe ? 'You' : await resolveIncomingAuthor(msg, chat);
65
+ const quotedSnippet = await quotedSnippetFrom(msg);
66
+ return {
67
+ id: msg.id._serialized,
68
+ body: msg.body,
69
+ displayBody: displayBodyForParts(msg.type, msg.hasMedia, msg.body),
70
+ fromMe: msg.fromMe,
71
+ author,
72
+ timestamp: msg.timestamp,
73
+ type: msg.type,
74
+ hasMedia: Boolean(msg.hasMedia),
75
+ hasQuotedMsg: Boolean(msg.hasQuotedMsg),
76
+ quotedSnippet
77
+ };
78
+ }
79
+
80
+ class WhatsAppService extends EventEmitter {
81
+ constructor() {
82
+ super();
83
+ this.client = new Client({
84
+ authStrategy: new LocalAuth(),
85
+ puppeteer: {
86
+ headless: true,
87
+ args: ['--no-sandbox', '--disable-setuid-sandbox']
88
+ }
89
+ });
90
+ this.ready = false;
91
+ }
92
+
93
+ initialize(onQr, onReady, onAuth) {
94
+ this.client.on('qr', (qr) => {
95
+ onQr(qr);
96
+ });
97
+
98
+ this.client.on('ready', () => {
99
+ this.ready = true;
100
+ onReady();
101
+ });
102
+
103
+ this.client.on('authenticated', () => {
104
+ if (onAuth) onAuth();
105
+ });
106
+
107
+ this.client.on('message_create', (msg) => {
108
+ void (async () => {
109
+ if (!shouldEmitUserMessage(msg)) return;
110
+
111
+ const remote =
112
+ msg.id &&
113
+ msg.id.remote &&
114
+ msg.id.remote !== 'status@broadcast'
115
+ ? String(msg.id.remote)
116
+ : '';
117
+ const chatId = remote || (msg.fromMe ? msg.to : msg.from);
118
+
119
+ let author = 'You';
120
+ if (!msg.fromMe) {
121
+ try {
122
+ const chat = await msg.getChat();
123
+ author = await resolveIncomingAuthor(msg, chat);
124
+ } catch (_) {
125
+ author = formatPeerLabel(msg.author || msg.from);
126
+ }
127
+ }
128
+
129
+ const quotedSnippet = await quotedSnippetFrom(msg);
130
+
131
+ this.emit('message', {
132
+ id: msg.id._serialized,
133
+ chatId,
134
+ body: msg.body,
135
+ displayBody: displayBodyForParts(msg.type, msg.hasMedia, msg.body),
136
+ timestamp: msg.timestamp,
137
+ author,
138
+ fromMe: msg.fromMe,
139
+ type: msg.type,
140
+ hasMedia: Boolean(msg.hasMedia),
141
+ hasQuotedMsg: Boolean(msg.hasQuotedMsg),
142
+ quotedSnippet
143
+ });
144
+ })();
145
+ });
146
+
147
+ this.client.on('auth_failure', (msg) => {
148
+ console.error('Authentication failure:', msg);
149
+ });
150
+
151
+ return this.client.initialize();
152
+ }
153
+
154
+ async getChats() {
155
+ const chats = await this.client.getChats();
156
+ const sorted = chats.sort((a, b) => b.timestamp - a.timestamp);
157
+
158
+ return sorted.map((chat) => {
159
+ const title =
160
+ chat.name || chat.formattedTitle || (chat.id && chat.id.user) || 'Unknown';
161
+
162
+ return {
163
+ id: chat.id._serialized,
164
+ name: title,
165
+ isGroup: chat.isGroup,
166
+ unreadCount: chat.unreadCount || 0,
167
+ timestamp: chat.timestamp || 0,
168
+ lastMessage: chat.lastMessage ? chat.lastMessage.body : '',
169
+ raw: chat
170
+ };
171
+ });
172
+ }
173
+
174
+ async getMessages(chatId, limit = 40, rawChat = null) {
175
+ try {
176
+ let chat = rawChat;
177
+
178
+ if (!chat) {
179
+ const chats = await this.getChats();
180
+ const found = chats.find((c) => c.id === chatId);
181
+ if (found) chat = found.raw;
182
+ }
183
+
184
+ if (!chat) {
185
+ chat = await this.client.getChatById(chatId).catch(() => null);
186
+ }
187
+
188
+ if (!chat || !chat.fetchMessages) return [];
189
+
190
+ const messages = await chat.fetchMessages({ limit });
191
+ const filtered = messages.filter((msg) => shouldEmitUserMessage(msg));
192
+ return Promise.all(filtered.map((msg) => rowFromClientMessage(msg, chat)));
193
+ } catch (err) {
194
+ console.error('Error in getMessages:', err);
195
+ return [];
196
+ }
197
+ }
198
+
199
+ async sendMessage(chatId, text, rawChat = null, sendOptions = {}) {
200
+ try {
201
+ const hasQuote = Boolean(sendOptions && sendOptions.quotedMessageId);
202
+ if (hasQuote) {
203
+ return this.client.sendMessage(chatId, text, sendOptions);
204
+ }
205
+ if (rawChat && rawChat.sendMessage) {
206
+ return rawChat.sendMessage(text);
207
+ }
208
+ if (chatId == null || chatId === '') {
209
+ throw new Error('No chat selected');
210
+ }
211
+ return this.client.sendMessage(chatId, text);
212
+ } catch (err) {
213
+ console.error('Error in sendMessage:', err);
214
+ throw err;
215
+ }
216
+ }
217
+
218
+ /** Saves media to ~/Downloads/wa-tui/, returns absolute file path. */
219
+ async downloadMessageMedia(messageId, chatId, rawChat = null) {
220
+ let chat = rawChat;
221
+ if (!chat) {
222
+ const chats = await this.getChats();
223
+ const found = chats.find((c) => c.id === chatId);
224
+ if (found) chat = found.raw;
225
+ }
226
+ if (!chat) {
227
+ chat = await this.client.getChatById(chatId).catch(() => null);
228
+ }
229
+ if (!chat || !chat.fetchMessages) {
230
+ throw new Error('Chat not available');
231
+ }
232
+ const messages = await chat.fetchMessages({ limit: 80 });
233
+ const msg = messages.find((m) => m.id._serialized === messageId);
234
+ if (!msg) throw new Error('Message not found');
235
+ if (!msg.hasMedia) throw new Error('This message has no media to download');
236
+ const media = await msg.downloadMedia();
237
+ if (!media || !media.data) throw new Error('Download failed');
238
+
239
+ const dir = path.join(os.homedir(), 'Downloads', 'wa-tui');
240
+ fs.mkdirSync(dir, { recursive: true });
241
+ const ext =
242
+ (media.mimetype && media.mimetype.split('/')[1] && media.mimetype.split('/')[1].split(';')[0]) ||
243
+ 'bin';
244
+ const safe = String(messageId).replace(/[^a-z0-9]+/gi, '_').slice(-24);
245
+ const fname = `${Date.now()}_${safe}.${ext}`;
246
+ const fpath = path.join(dir, fname);
247
+ fs.writeFileSync(fpath, media.data, 'base64');
248
+ return fpath;
249
+ }
250
+
251
+ async logoutSession() {
252
+ this.ready = false;
253
+ try {
254
+ await this.client.logout();
255
+ } catch (err) {
256
+ console.error('Logout:', err.message || err);
257
+ try {
258
+ await this.client.destroy();
259
+ } catch (_) {}
260
+ }
261
+ }
262
+ }
263
+
264
+ module.exports = new WhatsAppService();