feat: n8n initial commit — JavaScript (Node.js) n8n workflow automation + axios/cheerio/puppeteer/xlsx
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
// create_whatsapp_workflow.js — Prisa Yachts WhatsApp Bot for n8n
|
||||
const https = require('https');
|
||||
|
||||
const N8N_BASE = process.env.N8N_BASE_URL || 'https://n8n.crewinghunters.com';
|
||||
const API_KEY = process.env.N8N_API_KEY;
|
||||
if (!API_KEY) { console.error('ERROR: N8N_API_KEY environment variable is required'); process.exit(1); }
|
||||
|
||||
/* ─────────────────────────────────────────────────────────── helpers */
|
||||
function apiCall(method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const opts = {
|
||||
hostname: 'n8n.crewinghunters.com',
|
||||
path: '/api/v1' + path,
|
||||
method,
|
||||
headers: {
|
||||
'X-N8N-API-KEY': API_KEY,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...(data ? { 'Content-Length': Buffer.byteLength(data) } : {})
|
||||
}
|
||||
};
|
||||
const req = https.request(opts, res => {
|
||||
let buf = '';
|
||||
res.on('data', c => buf += c);
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode, data: JSON.parse(buf) }); }
|
||||
catch { resolve({ status: res.statusCode, data: buf }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────── state-machine code (runs inside n8n) */
|
||||
const STATE_MACHINE_CODE = `
|
||||
const WELCOME = \`👋 Welcome to Prisa Yachts LLC!
|
||||
Bienvenido a Prisa Yachts LLC
|
||||
|
||||
Safe Command ♦ Luxury Maintenance and Care
|
||||
|
||||
Select your service / Selecciona tu servicio:
|
||||
|
||||
1️⃣ Engines & Mechanical
|
||||
2️⃣ Electrical & Electronics / NMEA
|
||||
3️⃣ Teak Deck Recovery
|
||||
4️⃣ Captaining & Crewing
|
||||
5️⃣ Yacht Care & Detailing
|
||||
6️⃣ Crew Placement & Staffing
|
||||
7️⃣ Other / Otro
|
||||
|
||||
Reply 1–7 / Responde 1–7\`;
|
||||
|
||||
const CAT_MSGS = {
|
||||
1:\`🔧 Engines & Mechanical
|
||||
|
||||
1. Vessel name & type?
|
||||
2. Engine brand & approx. year?
|
||||
3. Briefly describe the issue:
|
||||
(noise, leak, won't start, overheating,
|
||||
routine service, fuel system, etc.)
|
||||
4. Current location / Marina?
|
||||
5. When do you need service?
|
||||
· Flexible · This week · ASAP
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our specialist will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
2:\`⚡ Electrical & Electronics / NMEA
|
||||
|
||||
1. Vessel name & type?
|
||||
2. Briefly describe the issue or installation:
|
||||
(wiring, shore power, panel, NMEA 2000,
|
||||
AIS, chartplotter, VHF, autopilot, etc.)
|
||||
3. Is it a new installation or a repair?
|
||||
· New install · Repair · Diagnosis
|
||||
4. Current location / Marina?
|
||||
5. When do you need service?
|
||||
· Flexible · This week · ASAP
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our specialist will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
3:\`🪵 Teak Deck Recovery
|
||||
|
||||
1. Vessel name & LOA (feet)?
|
||||
2. Teak condition:
|
||||
· Cleaning needed · Caulking damaged
|
||||
· Planks cracked · Full replacement
|
||||
3. Which deck areas?
|
||||
(cockpit, flybridge, side decks, all)
|
||||
4. Briefly describe the current condition:
|
||||
5. Current location / Marina?
|
||||
6. When do you need service?
|
||||
· Flexible · This week · ASAP
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our specialist will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
4:\`⚓ Captaining & Crewing
|
||||
|
||||
1. Vessel name, type & LOA (feet)?
|
||||
2. Service needed:
|
||||
· Day trip · Charter · Vessel delivery
|
||||
· Offshore passage · Event crew
|
||||
3. Departure & destination?
|
||||
4. Date & duration?
|
||||
5. Number of crew needed?
|
||||
6. Any special requirements?
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our captain will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
5:\`🛥️ Yacht Care & Detailing
|
||||
|
||||
1. Vessel name, type & LOA (feet)?
|
||||
2. Service needed:
|
||||
· Full wash & wax · Hull polish
|
||||
· Interior cleaning · Brightwork
|
||||
· Antifouling prep · Full detail
|
||||
3. Briefly describe current condition:
|
||||
4. Current location / Marina?
|
||||
5. When do you need service?
|
||||
· Flexible · This week · ASAP
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our team will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
6:\`👥 Crew Placement & Staffing
|
||||
|
||||
1. Vessel name, type & LOA (feet)?
|
||||
2. Position(s) needed:
|
||||
· Captain · Chief Mate / Deck Officer
|
||||
· Deckhand · Chief Engineer
|
||||
· Stewardess / Interior crew
|
||||
· Chef · Other
|
||||
3. Contract type:
|
||||
· Permanent · Seasonal · One-way delivery
|
||||
· Day work · Relief / Temporary
|
||||
4. Start date & duration?
|
||||
5. Vessel flag & trading area?
|
||||
6. Any certifications required?
|
||||
(STCW, license type, experience level)
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
- Company (if applicable):
|
||||
─────────────────
|
||||
Thank you! Our crewing team will
|
||||
contact you within 2 hours. 🛥️\`,
|
||||
|
||||
7:\`📋 Other / Otro
|
||||
|
||||
1. Vessel name & type?
|
||||
2. Briefly describe what you need:
|
||||
3. Current location / Marina?
|
||||
4. When do you need service?
|
||||
· Flexible · This week · ASAP
|
||||
|
||||
─────────────────
|
||||
Your contact info:
|
||||
- Full name:
|
||||
- Phone:
|
||||
- Email:
|
||||
─────────────────
|
||||
Thank you! Our team will
|
||||
contact you within 2 hours. 🛥️\`
|
||||
};
|
||||
|
||||
const CONFIRMATION = \`✅ Request received! / ¡Solicitud recibida!
|
||||
|
||||
We have all your information and a
|
||||
Prisa Yachts specialist will contact
|
||||
you within 2 hours.
|
||||
|
||||
📞 (786) 396-3315
|
||||
📧 info@prisayachts.com
|
||||
🌐 prisayachts.com
|
||||
📸 @prisayachts
|
||||
|
||||
Safe Command ♦ Luxury Maintenance and Care\`;
|
||||
|
||||
const CAT_NAMES = {
|
||||
1:'Engines & Mechanical', 2:'Electrical & Electronics / NMEA',
|
||||
3:'Teak Deck Recovery', 4:'Captaining & Crewing',
|
||||
5:'Yacht Care & Detailing', 6:'Crew Placement & Staffing', 7:'Other / Otro'
|
||||
};
|
||||
|
||||
const ROUTING = {1:'alvaro',2:'alvaro',3:'federico',4:'federico',5:'federico',6:'federico',7:'both'};
|
||||
const ALVARO = '19546554084';
|
||||
const FEDERICO = '17542093375';
|
||||
const RESET_WORDS = ['menu','start','hola','hello','hi'];
|
||||
const TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ── parse webhook payload ──────────────────────────────────────────
|
||||
const raw = $input.first().json;
|
||||
|
||||
// Meta GET verification
|
||||
const q = raw.query || {};
|
||||
if (q['hub.challenge']) {
|
||||
return [{ json: { action: 'webhook_verify', challenge: String(q['hub.challenge']) } }];
|
||||
}
|
||||
|
||||
const body = raw.body || raw;
|
||||
const msg = body?.entry?.[0]?.changes?.[0]?.value?.messages?.[0];
|
||||
if (!msg || msg.type !== 'text') {
|
||||
return [{ json: { action: 'ignore' } }];
|
||||
}
|
||||
|
||||
const from = msg.from;
|
||||
const msgText = msg.text.body.trim();
|
||||
const msgLow = msgText.toLowerCase();
|
||||
|
||||
// ── state machine ─────────────────────────────────────────────────
|
||||
const sd = $getWorkflowStaticData('global');
|
||||
if (!sd.states) sd.states = {};
|
||||
|
||||
const now = Date.now();
|
||||
let state = sd.states[from] || { step: 'new', lastActivity: 0, data: {} };
|
||||
if (now - state.lastActivity > TIMEOUT_MS) state = { step: 'new', lastActivity: now, data: {} };
|
||||
state.lastActivity = now;
|
||||
|
||||
const reply = (messageText, extra={}) => {
|
||||
sd.states[from] = state;
|
||||
return [{ json: { action: 'send_to_client', to: from, messageText, notifySpecialist: false, ...extra } }];
|
||||
};
|
||||
|
||||
if (RESET_WORDS.includes(msgLow) || state.step === 'new') {
|
||||
state.step = 'waiting_category'; state.data = {};
|
||||
return reply(WELCOME);
|
||||
}
|
||||
|
||||
if (state.step === 'waiting_category') {
|
||||
const cat = parseInt(msgText, 10);
|
||||
if (cat >= 1 && cat <= 7) {
|
||||
state.step = 'collecting_info'; state.data.category = cat;
|
||||
return reply(CAT_MSGS[cat]);
|
||||
}
|
||||
return reply(WELCOME);
|
||||
}
|
||||
|
||||
if (state.step === 'collecting_info') {
|
||||
const cat = state.data.category;
|
||||
const ts = new Date().toLocaleString('en-US', { timeZone: 'America/New_York' });
|
||||
const specMsg = \`🔔 NEW SERVICE REQUEST — Prisa Yachts
|
||||
|
||||
📋 Category: \${CAT_NAMES[cat]}
|
||||
👤 Client WhatsApp: +\${from}
|
||||
|
||||
📝 Info received:
|
||||
\${msgText}
|
||||
|
||||
🕐 Received: \${ts} ET\`;
|
||||
|
||||
state.step = 'complete';
|
||||
return reply(CONFIRMATION, {
|
||||
notifySpecialist: true,
|
||||
routing: ROUTING[cat],
|
||||
specialistMsg: specMsg,
|
||||
alvaroPhone: ALVARO,
|
||||
federicoPhone: FEDERICO
|
||||
});
|
||||
}
|
||||
|
||||
// complete → restart
|
||||
state.step = 'waiting_category'; state.data = {};
|
||||
return reply(WELCOME);
|
||||
`;
|
||||
|
||||
/* ────────────────────────────────────────── build workflow JSON */
|
||||
function buildWorkflow(credId) {
|
||||
const credential = { httpHeaderAuth: { id: String(credId), name: 'WhatsApp_Prisa' } };
|
||||
const waUrl = 'https://graph.facebook.com/v18.0/PLACEHOLDER_PHONE_NUMBER_ID/messages';
|
||||
|
||||
const ifStrNode = (id, name, pos, left, right) => ({
|
||||
id, name,
|
||||
type: 'n8n-nodes-base.if', typeVersion: 2,
|
||||
position: pos,
|
||||
parameters: {
|
||||
conditions: { combinator:'and', conditions:[{
|
||||
leftValue: left, rightValue: right,
|
||||
operator: { type:'string', operation:'equals' }
|
||||
}]},
|
||||
options: {}
|
||||
}
|
||||
});
|
||||
|
||||
const httpNode = (id, name, pos, jsonBody) => ({
|
||||
id, name,
|
||||
type: 'n8n-nodes-base.httpRequest', typeVersion: 4.2,
|
||||
position: pos,
|
||||
credentials: credential,
|
||||
parameters: {
|
||||
method: 'POST', url: waUrl,
|
||||
authentication: 'genericCredentialType',
|
||||
genericAuthType: 'httpHeaderAuth',
|
||||
sendBody: true, specifyBody: 'json',
|
||||
jsonBody, options: {}
|
||||
}
|
||||
});
|
||||
|
||||
const clientBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.to,type:"text",text:{body:$json.messageText}}) }}`;
|
||||
const alvaroBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.alvaroPhone,type:"text",text:{body:$json.specialistMsg}}) }}`;
|
||||
const fedBody = `={{ JSON.stringify({messaging_product:"whatsapp",to:$json.federicoPhone,type:"text",text:{body:$json.specialistMsg}}) }}`;
|
||||
|
||||
return {
|
||||
name: 'Prisa Yachts — WhatsApp Bot',
|
||||
nodes: [
|
||||
// 1 Webhook
|
||||
{
|
||||
id: 'n-wh', name: 'WhatsApp Webhook',
|
||||
type: 'n8n-nodes-base.webhook', typeVersion: 2,
|
||||
position: [200, 300],
|
||||
parameters: { httpMethod:'POST', path:'prisa-whatsapp', responseMode:'onReceived', options:{} }
|
||||
},
|
||||
// 2 State machine
|
||||
{
|
||||
id: 'n-sm', name: 'State Machine',
|
||||
type: 'n8n-nodes-base.code', typeVersion: 2,
|
||||
position: [450, 300],
|
||||
parameters: { mode:'runOnceForAllItems', jsCode: STATE_MACHINE_CODE }
|
||||
},
|
||||
// 3 IF: is webhook_verify?
|
||||
ifStrNode('n-ifv', 'Is Verify?', [700, 300], '={{ $json.action }}', 'webhook_verify'),
|
||||
// 4 Respond with challenge
|
||||
{
|
||||
id: 'n-respond', name: 'Respond — Verify',
|
||||
type: 'n8n-nodes-base.respondToWebhook', typeVersion: 1.1,
|
||||
position: [950, 140],
|
||||
parameters: {
|
||||
respondWith: 'text',
|
||||
responseBody: '={{ $json.challenge }}',
|
||||
options: { responseCode: 200 }
|
||||
}
|
||||
},
|
||||
// 5 IF: is send_to_client?
|
||||
ifStrNode('n-ifs', 'Is Send?', [950, 380], '={{ $json.action }}', 'send_to_client'),
|
||||
// 6 Send to client
|
||||
httpNode('n-hsc', 'Send WhatsApp — Client', [1200, 280], clientBody),
|
||||
// 7 Ignore (no-op)
|
||||
{ id:'n-nop', name:'Ignore', type:'n8n-nodes-base.noOp', typeVersion:1, position:[1200,520], parameters:{} },
|
||||
// 8 IF notifySpecialist
|
||||
{
|
||||
id: 'n-ifn', name: 'Notify Specialist?',
|
||||
type: 'n8n-nodes-base.if', typeVersion: 2,
|
||||
position: [1450, 280],
|
||||
parameters: {
|
||||
conditions: { combinator:'and', conditions:[{
|
||||
leftValue:'={{ $json.notifySpecialist }}', rightValue: true,
|
||||
operator:{ type:'boolean', operation:'equals' }
|
||||
}]},
|
||||
options: {}
|
||||
}
|
||||
},
|
||||
// 9 IF routing === 'both' → true: both, false: check alvaro/fed
|
||||
ifStrNode('n-ifb', 'Is Both?', [1700, 200], '={{ $json.routing }}', 'both'),
|
||||
// 10 IF routing === 'alvaro' → true: alvaro only, false: federico only
|
||||
ifStrNode('n-ifa', 'Is Alvaro?', [1700, 420], '={{ $json.routing }}', 'alvaro'),
|
||||
// 11,12 HTTP nodes
|
||||
httpNode('n-hal', 'Send WhatsApp — Alvaro', [1950, 180], alvaroBody),
|
||||
httpNode('n-hfe', 'Send WhatsApp — Federico', [1950, 420], fedBody),
|
||||
],
|
||||
connections: {
|
||||
'WhatsApp Webhook': { main: [[{ node:'State Machine', type:'main', index:0 }]] },
|
||||
'State Machine': { main: [[{ node:'Is Verify?', type:'main', index:0 }]] },
|
||||
'Is Verify?': { main: [
|
||||
[{ node:'Respond — Verify', type:'main', index:0 }], // true
|
||||
[{ node:'Is Send?', type:'main', index:0 }] // false
|
||||
]},
|
||||
'Is Send?': { main: [
|
||||
[{ node:'Send WhatsApp — Client', type:'main', index:0 }], // true
|
||||
[{ node:'Ignore', type:'main', index:0 }] // false
|
||||
]},
|
||||
'Send WhatsApp — Client': { main: [[{ node:'Notify Specialist?', type:'main', index:0 }]] },
|
||||
'Notify Specialist?': { main: [
|
||||
[{ node:'Is Both?', type:'main', index:0 }], // true → route
|
||||
[] // false → end
|
||||
]},
|
||||
'Is Both?': { main: [
|
||||
// true → send to BOTH simultaneously
|
||||
[{ node:'Send WhatsApp — Alvaro', type:'main', index:0 }, { node:'Send WhatsApp — Federico', type:'main', index:0 }],
|
||||
[{ node:'Is Alvaro?', type:'main', index:0 }] // false → check which one
|
||||
]},
|
||||
'Is Alvaro?': { main: [
|
||||
[{ node:'Send WhatsApp — Alvaro', type:'main', index:0 }], // true
|
||||
[{ node:'Send WhatsApp — Federico', type:'main', index:0 }] // false (must be federico)
|
||||
]}
|
||||
},
|
||||
settings: { executionOrder: 'v1' }
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────── main */
|
||||
async function cleanup() {
|
||||
const wfs = await apiCall('GET', '/workflows?limit=50');
|
||||
for (const wf of (wfs.data?.data || [])) {
|
||||
if (wf.name && wf.name.includes('Prisa Yachts')) {
|
||||
await apiCall('DELETE', `/workflows/${wf.id}`);
|
||||
console.log(` 🗑 Deleted workflow: ${wf.id}`);
|
||||
}
|
||||
}
|
||||
const creds = await apiCall('GET', '/credentials?limit=50');
|
||||
for (const c of (creds.data?.data || [])) {
|
||||
if (c.name === 'WhatsApp_Prisa') {
|
||||
await apiCall('DELETE', `/credentials/${c.id}`);
|
||||
console.log(` 🗑 Deleted credential: ${c.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n🚀 Prisa Yachts — WhatsApp Bot Deployment\n');
|
||||
|
||||
console.log('0/4 Cleaning up previous attempts...');
|
||||
await cleanup();
|
||||
console.log(' ✅ Clean');
|
||||
|
||||
// 1. Credential
|
||||
console.log('1/4 Creating WhatsApp credential...');
|
||||
const credRes = await apiCall('POST', '/credentials', {
|
||||
name: 'WhatsApp_Prisa',
|
||||
type: 'httpHeaderAuth',
|
||||
data: { name: 'Authorization', value: 'Bearer PLACEHOLDER_PENDING_META_APPROVAL' }
|
||||
});
|
||||
if (![200,201].includes(credRes.status)) {
|
||||
console.error(' ❌ Credential error:', JSON.stringify(credRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
const credId = credRes.data.id;
|
||||
console.log(` ✅ Credential created id=${credId}`);
|
||||
|
||||
// 2. Workflow
|
||||
console.log('2/4 Creating workflow...');
|
||||
const wfRes = await apiCall('POST', '/workflows', buildWorkflow(credId));
|
||||
if (![200,201].includes(wfRes.status)) {
|
||||
console.error(' ❌ Workflow error:', JSON.stringify(wfRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
const wfId = wfRes.data.id;
|
||||
console.log(` ✅ Workflow created id=${wfId}`);
|
||||
|
||||
// 3. Activate
|
||||
console.log('3/4 Activating workflow...');
|
||||
const actRes = await apiCall('POST', `/workflows/${wfId}/activate`);
|
||||
if (actRes.status !== 200) {
|
||||
console.warn(' ⚠️ Activation returned', actRes.status, JSON.stringify(actRes.data));
|
||||
} else {
|
||||
console.log(' ✅ Workflow ACTIVE');
|
||||
}
|
||||
|
||||
// 4. Confirm
|
||||
console.log('4/4 Verifying...');
|
||||
const chk = await apiCall('GET', `/workflows/${wfId}`);
|
||||
console.log(` ✅ active = ${chk.data?.active}`);
|
||||
|
||||
console.log(`
|
||||
══════════════════════════════════════════════════════
|
||||
✅ DEPLOYMENT COMPLETE
|
||||
══════════════════════════════════════════════════════
|
||||
|
||||
Webhook URL → https://n8n.crewinghunters.com/webhook/prisa-whatsapp
|
||||
Workflow ID → ${wfId}
|
||||
Credential → WhatsApp_Prisa (id=${credId})
|
||||
|
||||
══════════════════════════════════════════════════════
|
||||
NEXT STEPS — after Meta approves your WhatsApp account
|
||||
══════════════════════════════════════════════════════
|
||||
|
||||
1. n8n → Settings → Credentials → WhatsApp_Prisa
|
||||
Change value from PLACEHOLDER_PENDING_META_APPROVAL
|
||||
to Bearer <YOUR_ACCESS_TOKEN>
|
||||
|
||||
2. Open workflow "Prisa Yachts — WhatsApp Bot"
|
||||
In these 3 nodes, change the URL:
|
||||
• Send WhatsApp — Client
|
||||
• Send WhatsApp — Alvaro
|
||||
• Send WhatsApp — Federico
|
||||
Replace PLACEHOLDER_PHONE_NUMBER_ID
|
||||
With <YOUR_PHONE_NUMBER_ID>
|
||||
|
||||
3. Meta Developer Portal → WhatsApp → Configuration
|
||||
Webhook URL : https://n8n.crewinghunters.com/webhook/prisa-whatsapp
|
||||
Verify Token : any string (e.g. "prisayachts2025")
|
||||
Subscribe to : messages
|
||||
|
||||
4. Send "hola" to +17863963315 from any WhatsApp to test.
|
||||
|
||||
══════════════════════════════════════════════════════
|
||||
ROUTING
|
||||
══════════════════════════════════════════════════════
|
||||
1 Engines & Mechanical → Alvaro +19546554084
|
||||
2 Electrical & Electronics → Alvaro +19546554084
|
||||
3 Teak Deck Recovery → Federico +17542093375
|
||||
4 Captaining & Crewing → Federico +17542093375
|
||||
5 Yacht Care & Detailing → Federico +17542093375
|
||||
6 Crew Placement & Staffing → Federico +17542093375
|
||||
7 Other / Otro → BOTH
|
||||
══════════════════════════════════════════════════════
|
||||
`);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('Fatal:', e); process.exit(1); });
|
||||
Reference in New Issue
Block a user