@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.
- package/README.md +79 -0
- package/bin/wa-tui.js +3 -0
- package/package.json +49 -0
- package/src/config/palettes.js +87 -0
- package/src/config/userSettings.js +35 -0
- package/src/index.js +26 -0
- package/src/ui/renderer.js +1095 -0
- package/src/ui/state.js +30 -0
- package/src/utils/format.js +54 -0
- package/src/utils/messageFormat.js +53 -0
- package/src/utils/notifySound.js +72 -0
- package/src/utils/pager.js +19 -0
- package/src/whatsapp/service.js +264 -0
package/src/ui/state.js
ADDED
|
@@ -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();
|