Init project

This commit is contained in:
elishadavidi 2026-05-28 09:38:42 +03:00
commit b01f418c07
11 changed files with 3162 additions and 0 deletions

7
.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules/
.git/
.env
.wwebjs_auth/
.wwebjs_cache/
.gitignore
README.md

22
.env.example Normal file
View 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
View File

@ -0,0 +1,5 @@
.env
node_modules/
.wwebjs_auth/
.wwebjs_cache/
*.session

37
Dockerfile Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View 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
View 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
View 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 };
}