Init project
This commit is contained in:
commit
b01f418c07
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.git/
|
||||
.env
|
||||
.wwebjs_auth/
|
||||
.wwebjs_cache/
|
||||
.gitignore
|
||||
README.md
|
||||
22
.env.example
Normal file
22
.env.example
Normal file
@ -0,0 +1,22 @@
|
||||
# WhatsApp groups to monitor (comma-separated)
|
||||
GROUP_NAMES=Group1,Group2
|
||||
|
||||
# TextBee SMS Gateway
|
||||
TEXTBEE_DEVICE_ID=your_device_id
|
||||
TEXTBEE_API_KEY=your_api_key
|
||||
SMS_RECIPIENT=+972501234567
|
||||
|
||||
# Telegram (for QR re-auth notifications)
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# Batch settings
|
||||
BATCH_INTERVAL_MS=900000
|
||||
BATCH_MAX_CHARS=700
|
||||
INCLUDE_OWN_MESSAGES=true
|
||||
OWN_NAME=Me
|
||||
OWN_LAST_NAME=
|
||||
|
||||
# Keep-alive (ping a URL to prevent hosting sleep)
|
||||
KEEP_ALIVE_URL=
|
||||
KEEP_ALIVE_INTERVAL_MS=300000
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
.env
|
||||
node_modules/
|
||||
.wwebjs_auth/
|
||||
.wwebjs_cache/
|
||||
*.session
|
||||
37
Dockerfile
Normal file
37
Dockerfile
Normal file
@ -0,0 +1,37 @@
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
chromium \
|
||||
chromium-sandbox \
|
||||
libnss3 \
|
||||
libnspr4 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
--no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV CHROME_PATH=/usr/bin/chromium \
|
||||
PUPPETEER_SKIP_DOWNLOAD=true \
|
||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "index.js"]
|
||||
107
README.md
Normal file
107
README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# OmegaBaSMS
|
||||
|
||||
Forward WhatsApp group messages to SMS — batched and free.
|
||||
|
||||
## How it works
|
||||
|
||||
- Monitors specified WhatsApp groups via WhatsApp Web
|
||||
- Queues incoming messages for a configurable interval (default 30s)
|
||||
- Flushes all queued messages as a single SMS via **TextBee** (uses your Android phone's mobile plan)
|
||||
- If the WhatsApp session expires, sends the QR code to **Telegram** for easy re-auth
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 18+
|
||||
- Android phone with mobile plan (for TextBee)
|
||||
- Telegram account (for QR re-auth notifications)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. TextBee (SMS Gateway)
|
||||
|
||||
1. Create a free account at [textbee.dev](https://textbee.dev)
|
||||
2. Install the TextBee app on your Android phone
|
||||
3. In the dashboard, register your device (scan QR with the app)
|
||||
4. Copy your **Device ID** and **API key**
|
||||
|
||||
### 2. Telegram Bot (QR re-auth)
|
||||
|
||||
1. Open Telegram, search for `@BotFather`, send `/newbot`
|
||||
2. Choose a name and username, get the **bot token**
|
||||
3. Message your bot once, then visit:
|
||||
`https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates`
|
||||
4. Copy your **chat ID** from the response
|
||||
|
||||
### 3. Configuration
|
||||
|
||||
Copy the example env file and fill in your details:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```env
|
||||
# WhatsApp groups to monitor (comma-separated)
|
||||
GROUP_NAMES=MyGroup,AnotherGroup
|
||||
|
||||
# TextBee
|
||||
TEXTBEE_DEVICE_ID=your_device_id
|
||||
TEXTBEE_API_KEY=your_api_key
|
||||
SMS_RECIPIENT=+972501234567
|
||||
|
||||
# Telegram
|
||||
TELEGRAM_BOT_TOKEN=your_bot_token
|
||||
TELEGRAM_CHAT_ID=your_chat_id
|
||||
|
||||
# Batch settings (30s for testing, 900000 = 15min for production)
|
||||
BATCH_INTERVAL_MS=30000
|
||||
BATCH_MAX_CHARS=700
|
||||
|
||||
# Your name shown in forwarded messages
|
||||
INCLUDE_OWN_MESSAGES=true
|
||||
OWN_NAME=Dvir
|
||||
OWN_LAST_NAME=
|
||||
```
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
On first run, scan the QR code with WhatsApp on your phone. The session is saved for next time.
|
||||
|
||||
## Log format
|
||||
|
||||
```
|
||||
[12:34:56] [INIT] Starting OmegaBaSMS...
|
||||
[12:34:56] [INIT] Launching WhatsApp Web...
|
||||
[12:34:58] [QR] New QR code received — scan with WhatsApp
|
||||
[12:35:10] [READY] WhatsApp connected successfully
|
||||
[13:01:23] [QUEUE] Queue #1 - Message from Dvir, flushed at 13:16
|
||||
[13:16:23] [INFO] Flushed 2 messages
|
||||
```
|
||||
|
||||
## Batch messaging
|
||||
|
||||
Messages are queued and flushed together to avoid SMS spam:
|
||||
|
||||
```
|
||||
[GroupName]
|
||||
Dvir: Hey everyone
|
||||
Dudi: What's up?
|
||||
Dvir: Meeting at 5
|
||||
```
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
OmegaBaSMS/
|
||||
├── index.js # Main — WhatsApp client, queue, logging
|
||||
├── config.js # Reads from .env
|
||||
├── sms.js # TextBee API
|
||||
├── telegram.js # Telegram notification
|
||||
├── .env # Your secrets (git-ignored)
|
||||
├── .env.example # Template
|
||||
└── package.json
|
||||
```
|
||||
31
config.js
Normal file
31
config.js
Normal file
@ -0,0 +1,31 @@
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = {
|
||||
groupNames: (process.env.GROUP_NAMES || '').split(',').map(s => s.trim()).filter(Boolean),
|
||||
|
||||
smsGateway: {
|
||||
deviceId: process.env.TEXTBEE_DEVICE_ID,
|
||||
apiKey: process.env.TEXTBEE_API_KEY,
|
||||
recipientNumber: process.env.SMS_RECIPIENT,
|
||||
},
|
||||
|
||||
telegram: {
|
||||
botToken: process.env.TELEGRAM_BOT_TOKEN,
|
||||
chatId: process.env.TELEGRAM_CHAT_ID,
|
||||
},
|
||||
|
||||
ownerNumber: process.env.SMS_RECIPIENT,
|
||||
|
||||
batch: {
|
||||
intervalMs: parseInt(process.env.BATCH_INTERVAL_MS, 10) || 900000,
|
||||
maxChars: parseInt(process.env.BATCH_MAX_CHARS, 10) || 700,
|
||||
},
|
||||
|
||||
includeOwnMessages: process.env.INCLUDE_OWN_MESSAGES !== 'false',
|
||||
ownName: [process.env.OWN_NAME, process.env.OWN_LAST_NAME].filter(Boolean).join(' ') || 'Me',
|
||||
|
||||
keepAlive: {
|
||||
url: process.env.KEEP_ALIVE_URL || '',
|
||||
intervalMs: parseInt(process.env.KEEP_ALIVE_INTERVAL_MS, 10) || 300000,
|
||||
},
|
||||
};
|
||||
294
index.js
Normal file
294
index.js
Normal file
@ -0,0 +1,294 @@
|
||||
const { Client, LocalAuth } = require('whatsapp-web.js');
|
||||
const qrcode = require('qrcode');
|
||||
const qrcodeTerminal = require('qrcode-terminal');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
const { sendSMS } = require('./sms');
|
||||
const { sendTelegramPhoto } = require('./telegram');
|
||||
|
||||
let messageQueue = [];
|
||||
let flushTimer = null;
|
||||
let flushTimerStart = null;
|
||||
let client = null;
|
||||
let restartDelay = 1000;
|
||||
let starting = false;
|
||||
let restarting = false;
|
||||
let msgCounter = 0;
|
||||
|
||||
function ts() {
|
||||
return new Date().toLocaleString('he-IL', { hour12: false });
|
||||
}
|
||||
|
||||
function log(level, msg) {
|
||||
console.log(`[${ts()}] [${level}] ${msg}`);
|
||||
}
|
||||
|
||||
function flushTime() {
|
||||
if (!flushTimerStart) return '--:--';
|
||||
const t = new Date(Date.now() + Math.max(0, config.batch.intervalMs - (Date.now() - flushTimerStart)));
|
||||
return t.toLocaleTimeString('he-IL', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
function formatBatch(queue) {
|
||||
const groups = {};
|
||||
for (const m of queue) {
|
||||
if (!groups[m.group]) groups[m.group] = [];
|
||||
groups[m.group].push(m);
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
for (const [group, msgs] of Object.entries(groups)) {
|
||||
parts.push(`[${group}]`);
|
||||
for (const m of msgs) {
|
||||
parts.push(`${m.sender}: ${m.text}`);
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function queueSize() {
|
||||
return messageQueue.reduce(
|
||||
(sum, m) => sum + m.group.length + m.sender.length + m.text.length + 6,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (flushTimer) clearTimeout(flushTimer);
|
||||
if (!flushTimerStart) flushTimerStart = Date.now();
|
||||
flushTimer = setTimeout(flushQueue, config.batch.intervalMs);
|
||||
}
|
||||
|
||||
async function flushQueue() {
|
||||
if (restarting) return;
|
||||
if (messageQueue.length === 0) return;
|
||||
|
||||
flushTimer = null;
|
||||
flushTimerStart = null;
|
||||
|
||||
const batch = messageQueue;
|
||||
messageQueue = [];
|
||||
msgCounter = 0;
|
||||
|
||||
const text = formatBatch(batch);
|
||||
|
||||
try {
|
||||
await sendSMS(text);
|
||||
log('INFO', `Flushed ${batch.length} messages`);
|
||||
} catch (err) {
|
||||
log('ERROR', `Flush failed: ${err.message}`);
|
||||
messageQueue = batch.concat(messageQueue);
|
||||
msgCounter = messageQueue.length;
|
||||
scheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
function enqueue(group, sender, text) {
|
||||
msgCounter++;
|
||||
messageQueue.push({ group, sender, text });
|
||||
|
||||
scheduleFlush();
|
||||
log('QUEUE', `Queue #${msgCounter} - Message from ${sender}, flushed at ${flushTime()}`);
|
||||
|
||||
if (queueSize() >= config.batch.maxChars) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
flushTimerStart = null;
|
||||
flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function killClient() {
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
client.removeAllListeners();
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await client.destroy();
|
||||
} catch {}
|
||||
|
||||
/**
|
||||
* IMPORTANT:
|
||||
* Give Chromium time to release Windows file locks
|
||||
*/
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
|
||||
client = null;
|
||||
}
|
||||
|
||||
let keepAliveTimer = null;
|
||||
|
||||
function startKeepAlive() {
|
||||
if (keepAliveTimer) clearInterval(keepAliveTimer);
|
||||
const ka = config.keepAlive;
|
||||
if (!ka || !ka.url) return;
|
||||
|
||||
log('INIT', `Keep-alive ping every ${ka.intervalMs / 1000}s to ${ka.url}`);
|
||||
const ping = () => {
|
||||
fetch(ka.url).catch(() => {});
|
||||
};
|
||||
ping();
|
||||
keepAliveTimer = setInterval(ping, ka.intervalMs);
|
||||
}
|
||||
|
||||
async function startClient() {
|
||||
if (starting) {
|
||||
log('WARN', 'Already starting — skipping duplicate call');
|
||||
return;
|
||||
}
|
||||
|
||||
starting = true;
|
||||
|
||||
try {
|
||||
restarting = true;
|
||||
|
||||
log('INIT', 'Starting OmegaBaSMS...');
|
||||
await killClient();
|
||||
|
||||
log('INIT', `Groups to monitor: ${config.groupNames.join(', ')}`);
|
||||
log('INIT', `Batch interval: ${config.batch.intervalMs / 1000}s / max ${config.batch.maxChars} chars`);
|
||||
log('INIT', `Forwarding SMS to: ${config.smsGateway.recipientNumber}`);
|
||||
|
||||
if (config.telegram.botToken) {
|
||||
log('INIT', 'Telegram notifications enabled');
|
||||
}
|
||||
|
||||
startKeepAlive();
|
||||
|
||||
log('INIT', 'Launching WhatsApp Web...');
|
||||
|
||||
client = new Client({
|
||||
authStrategy: new LocalAuth(),
|
||||
puppeteer: {
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||
}
|
||||
});
|
||||
|
||||
client.on('qr', async (qr) => {
|
||||
log('QR', 'New QR code received — scan with WhatsApp on your phone');
|
||||
qrcodeTerminal.generate(qr, { small: true });
|
||||
|
||||
if (config.telegram.botToken) {
|
||||
try {
|
||||
const buf = await qrcode.toBuffer(qr, { width: 400 });
|
||||
await sendTelegramPhoto(buf, 'WhatsApp re-auth needed - scan this QR\nhttps://wa.me/settings/linked_devices');
|
||||
log('QR', 'QR photo sent to Telegram');
|
||||
} catch (err) {
|
||||
log('ERROR', `Failed to send QR photo: ${err.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.on('ready', () => {
|
||||
restarting = false;
|
||||
restartDelay = 1000;
|
||||
log('READY', 'WhatsApp connected successfully');
|
||||
log('READY', `Monitoring ${config.groupNames.length} group(s): ${config.groupNames.join(', ')}`);
|
||||
log('READY', `Forwarding to ${config.smsGateway.recipientNumber}`);
|
||||
});
|
||||
|
||||
client.on('auth_failure', (msg) => {
|
||||
log('ERROR', `Auth failure: ${msg}`);
|
||||
});
|
||||
|
||||
client.on('disconnected', async (reason) => {
|
||||
if (starting) return;
|
||||
|
||||
log('WARN', `Disconnected: ${reason}. Restarting in ${restartDelay / 1000}s...`);
|
||||
|
||||
restarting = true;
|
||||
|
||||
if (reason === 'LOGOUT') {
|
||||
const authDir = path.join(__dirname, '.wwebjs_auth');
|
||||
if (fs.existsSync(authDir)) {
|
||||
try {
|
||||
fs.rmSync(authDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
maxRetries: 10,
|
||||
retryDelay: 500
|
||||
});
|
||||
|
||||
log('WARN', 'Cleared old session data');
|
||||
} catch (err) {
|
||||
log('ERROR', `Failed clearing auth data: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, restartDelay));
|
||||
restartDelay = Math.min(restartDelay * 2, 30000);
|
||||
startClient();
|
||||
});
|
||||
|
||||
client.on('message_create', async (message) => {
|
||||
try {
|
||||
if (restarting) return;
|
||||
if (message.type !== 'chat') return;
|
||||
|
||||
const chat = await message.getChat();
|
||||
if (!chat.isGroup) return;
|
||||
if (!config.groupNames.includes(chat.name)) return;
|
||||
if (message.fromMe && !config.includeOwnMessages) return;
|
||||
|
||||
const contact = await message.getContact();
|
||||
const sender = message.fromMe
|
||||
? config.ownName
|
||||
: (contact.name || contact.pushname || contact.shortName || contact.number || 'Unknown');
|
||||
|
||||
const body = message.body || (message.hasMedia ? '[Media]' : '');
|
||||
if (!body) return;
|
||||
|
||||
enqueue(chat.name, sender, body);
|
||||
} catch (err) {
|
||||
log('ERROR', `Message handler: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
client.initialize();
|
||||
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 FIX: detect detached frame / WhatsApp Web crashes
|
||||
*/
|
||||
function shouldRestart(err) {
|
||||
const msg = (err && err.message) || String(err);
|
||||
return msg.includes('detached Frame') ||
|
||||
msg.includes('Execution context was destroyed') ||
|
||||
msg.includes('Target closed') ||
|
||||
msg.includes('Session closed') ||
|
||||
msg.includes('Navigation failed') ||
|
||||
msg.includes('Protocol error');
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
if (shouldRestart(err)) {
|
||||
log('WARN', `Recoverable: ${err.message}. Restarting...`);
|
||||
startClient();
|
||||
return;
|
||||
}
|
||||
|
||||
log('FATAL', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
if (shouldRestart(err)) {
|
||||
log('WARN', `Recoverable: ${err.message}. Restarting...`);
|
||||
startClient();
|
||||
return;
|
||||
}
|
||||
|
||||
log('FATAL', `Unhandled rejection: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
startClient();
|
||||
2576
package-lock.json
generated
Normal file
2576
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "omegabasms",
|
||||
"version": "1.0.0",
|
||||
"description": "Forward WhatsApp group messages via SMS",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-terminal": "^0.12.0",
|
||||
"whatsapp-web.js": "^1.25.0"
|
||||
}
|
||||
}
|
||||
42
sms.js
Normal file
42
sms.js
Normal file
@ -0,0 +1,42 @@
|
||||
const https = require('https');
|
||||
const config = require('./config');
|
||||
|
||||
async function sendSMS(text, recipientOverride) {
|
||||
const gw = config.smsGateway;
|
||||
const recipients = recipientOverride ? [recipientOverride] : [gw.recipientNumber];
|
||||
const data = JSON.stringify({
|
||||
recipients,
|
||||
message: text,
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'api.textbee.dev',
|
||||
path: `/api/v1/gateway/devices/${gw.deviceId}/send-sms`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': gw.apiKey,
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
resolve(body);
|
||||
} else {
|
||||
reject(new Error(`TextBee ${res.statusCode}: ${body}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendSMS };
|
||||
26
telegram.js
Normal file
26
telegram.js
Normal file
@ -0,0 +1,26 @@
|
||||
const config = require('./config');
|
||||
|
||||
const tg = config.telegram;
|
||||
if (!tg || !tg.botToken) {
|
||||
module.exports = { sendTelegramMessage: async () => {}, sendTelegramPhoto: async () => {} };
|
||||
} else {
|
||||
const base = `https://api.telegram.org/bot${tg.botToken}`;
|
||||
|
||||
async function sendTelegramMessage(text) {
|
||||
await fetch(`${base}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id: tg.chatId, text }),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTelegramPhoto(buffer, caption) {
|
||||
const form = new FormData();
|
||||
form.append('chat_id', tg.chatId);
|
||||
form.append('photo', new Blob([buffer]), 'qr.png');
|
||||
if (caption) form.append('caption', caption);
|
||||
await fetch(`${base}/sendPhoto`, { method: 'POST', body: form });
|
||||
}
|
||||
|
||||
module.exports = { sendTelegramMessage, sendTelegramPhoto };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user