import dotenv from 'dotenv';
dotenv.config();
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { Client, GatewayIntentBits, Partials } from 'discord.js';
import OpenAI from 'openai';
import axios from 'axios';
import https from 'https';
import { fileTypeFromBuffer } from 'file-type';
import { PDFExtract } from 'pdf.js-extract';
import sharp from 'sharp';
// --- Global Axios Settings ---
axios.defaults.timeout = 60000; // 60 seconds
axios.defaults.httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 10 });
// --- Validate Required Environment Variables ---
const requiredEnv = [
'OPENROUTER_API_KEY',
'DISCORD_BOT_TOKEN',
'INVOICE_CHANNEL_ID',
'PAID_INVOICE_CHANNEL_ID',
'BARSY_USER',
'BARSY_PASSWORD',
];
for (const key of requiredEnv) {
if (!process.env[key]) {
console.error(`Missing environment variable: ${key}`);
process.exit(1);
}
}
// --- Product List ---
const products = `
Measured in count/br
ID: 41 - Вода 0.33l
ID: 221 - Вода 0.75l
ID: 42 - Газирана вода 0.33l
ID: 43 - Тоник
ID: 44 - Кола
ID: 287 - Fritz Cola Zero
ID: 205 - Fritz Orange
ID: 45 - Three cents tonic water
ID: 46 - Редбул
ID: 49 - Джинджифилова бира
ID: 121 - Просеко 0.2l
ID: 135 - Корона
ID: 136 - Трима и Двама - Чисто и Просто
ID: 206 - Bloody muddy
ID: 207 - Black head
ID: 208 - Chipa
ID: 47 - Клуб Мате 0.3l Orignal
ID: 166 - Клуб Мате 0.5l Berries
ID: 198 - Пуканки
ID: 203 - Кроасан
ID: 218 - Запалка
ID: 220 - Мъфин
Measured in liters/l
ID: 214 - Коантро
ID: 216 - Вермут
ID: 120 - ПЛОДОВО ВИНО TRASTENA
ID: 281 - Просеко 0.7l
ID: 124 - ZACCAGNINI - БЯЛО
ID: 128 - SOL NEGRU - ЧЕРВЕНО
ID: 115 - ZACCAGNINI / БЯЛО
ID: 116 - ZACCAGNINI / РОЗЕ
ID: 117 - ZACCAGNINI / ЧЕРВЕНО
ID: 118 - SOL NEGRU 0.75l - БЯЛО
ID: 119 - SOL NEGRU 0.75l - ЧЕРВЕНО
ID: 227 - AFTER SHOCK 0.70л
ID: 228 - ALTOS BLANCO 0.7l
ID: 229 - ALTOS REPOSADO 0.7l
ID: 230 - KOSKENKORVA 1l
ID: 231 - REYKA 0.7l
ID: 232 - FINLANDIA 1l
ID: 233 - BELVEDERE 0.7l
ID: 235 - Узо 1l
ID: 236 - SHACKLETON 0.7l
ID: 237 - NAKED MALT 0.7l
ID: 238 - JOHNNIE BLACK LABEL 1l
ID: 239 - J&B 1l
ID: 240 - THE IRISHMAN SINGLE MALT 0.7l
ID: 241 - THE DEAD RABBITH 5 YO 0.7l
ID: 242 - BLACKBUSH 1l
ID: 243 - JAMESON BLACK BARREL 0.7l
ID: 244 - WOODFORD RESERVE 0.7l
ID: 245 - JACK DANIELS 1l
ID: 246 - MAKER\`S MARK 0.7l
ID: 247 - FOUR ROSES SINGLE BAREL 1l
ID: 248 - Wild Turkey 81 0.7l
ID: 249 - HIGHLAND PARK 12 YO 0.7l
ID: 250 - MACALLAN 12 YO 0.7l
ID: 251 - THE BALVENNIE 12 YO 0.7l
ID: 252 - LAFROAIG 10 YO 0.7l
ID: 253 - ARDBEG 10 YO 1l
ID: 254 - OBAN 14 YO 0.7l
ID: 255 - GLENFIDICH 12 YO 1l
ID: 256 - MONKEY SHOULDER 0.7l
ID: 257 - BIG PEAT 0.7l
ID: 258 - SCALLYWAG 0.7l
ID: 259 - TIMOROUS BEASTIE 0.7l
ID: 260 - MATUSALEM PLATINO 0.7l
ID: 261 - MATUSALEM EXTRA ANEJO 0.7l
ID: 262 - HAVANA 7 YO 0.7l
ID: 263 - SAILOR JERRY 0.7l
ID: 264 - BROKER\`S 1l
ID: 265 - THE BOTANIST 0.7l
ID: 266 - BOMBAY SAPPHIRE 1l
ID: 267 - HENDRICK\`S 0.7l
ID: 268 - GIN 1689 0.7l
ID: 269 - Безалкохолен джин 0.7l
ID: 270 - HENESSY V.S. 0.7l
ID: 271 - COURVOISIER V.S. 0.7l
ID: 272 - METAXA 7* 0.7l
ID: 273 - METAXA 12 * 0.7l
ID: 274 - KULTURNA RAKIA RESERVA 0.7l
ID: 275 - ХАН КРУМСКА КАЙСИЕВА РАКИЯ 0.5l
ID: 276 - Бейлис 1l
ID: 277 - Скинос 0.7l
ID: 278 - JAGERMEISTER 1l
ID: 279 - SHSANKY\`S WHIP 0.7l
ID: 280 - Аперол 1l
ID: 282 - Коантро 0.7l
ID: 283 - Вермут 1l
ID: 284 - Кампари 1l
ID: 285 - Giffard ликьор 0.7l
`;
// --- OpenAI Client ---
const openai = new OpenAI({
baseURL: 'https://openrouter.ai/api/v1',
apiKey: process.env.OPENROUTER_API_KEY,
defaultHeaders: {
'HTTP-Referer': 'vkashti.bar',
'X-Title': 'Vkashti',
},
});
// --- Helpers ---
// Get file buffer and its MIME type from URL.
const getFileType = async (url) => {
console.log(`Fetching file from URL: ${url}`);
const response = await axios.get(url, { responseType: 'arraybuffer' });
const buffer = Buffer.from(response.data);
const fileType = await fileTypeFromBuffer(buffer);
console.log(`Determined file type: ${fileType?.mime || 'unknown'}`);
return { buffer, fileType };
};
// Format PDF page content.
function formatPageContent(content) {
const sorted = [...content].sort((a, b) => {
const yDiff = Math.abs(a.y - b.y);
if (yDiff < 1) return a.x - b.x;
return b.y - a.y;
});
let currentY = null;
const lines = [];
let line = [];
for (const item of sorted) {
if (currentY === null) currentY = item.y;
if (Math.abs(currentY - item.y) < 1) {
line.push(item.str);
} else {
if (line.length) lines.push(line.join(' '));
line = [item.str];
currentY = item.y;
}
}
if (line.length) lines.push(line.join(' '));
return lines.join('\n');
}
// Extract text from a PDF buffer using a unique temporary file.
const extractPdfText = async (pdfBuffer) => {
const tmpFile = path.join(
os.tmpdir(),
`invoice-${Date.now()}-${Math.random().toString(36).substring(2)}.pdf`
);
console.log(`Writing temporary file: ${tmpFile}`);
try {
await fs.writeFile(tmpFile, pdfBuffer);
const pdfExtract = new PDFExtract();
console.log('Starting PDF extraction');
const data = await pdfExtract.extract(tmpFile);
console.log('PDF extraction completed');
return data.pages.map(page => formatPageContent(page.content)).join('\n\n');
} finally {
try {
await fs.unlink(tmpFile);
console.log(`Temporary file deleted: ${tmpFile}`);
} catch (err) {
console.warn(`Failed to remove temporary file: ${tmpFile}`);
}
}
};
// Extract invoice info from an image by passing its data URL to OpenAI.
const extractInvoiceItems = async (imageDataUrl) => {
console.log('Sending image data to Qwen/QVQ-72B-preview for invoice extraction');
const response = await openai.chat.completions.create({
model: 'Qwen/QVQ-72B-preview',
temperature: 0,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: 'Extract the store load info from the following invoice - date, purchased item(name, amount, price), does the price include VAT, total price',
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
},
},
],
},
],
});
if (!response || !response.choices || response.choices.length === 0) {
throw new Error("No choices returned from OpenAI API in extractInvoiceItems.");
}
let content = response.choices[0].message.content;
if (content.includes('```')) {
const match = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (match) content = match[1];
}
return content;
};
// Process invoice text (from PDF or image extraction) to produce structured JSON.
const extractStoreLoadFromText = async (text) => {
const prompt = `Extract the store load info from the following invoice:
${text}
Here are the products in our db. Please select the correct product for each item, write the product ID and the quantity.
${products}
current_price is important!
For items measured in liters, the price is per liter, provide the amount and in total liters.
For items measured in count, the price is per item, provide the amount and in total items.
Total price = number of items * price per item (no VAT), current_price = Total price (no VAT) / amount.
has_tax = 1 if the price includes VAT, 0 if it doesn't.
Often, in invoices the Единична цена means price per bottle.
If the bottle is 750ml, the current_price should be calculated by price per bottle / 0.75.
EXAMPLE JSON OUTPUT:
{
"store_load": {
"doc_date": "2025-01-20",
"doc_type_id": "1",
"depot_id": "1",
"has_tax": "1",
"description": "Some note about the load"
},
"articles": [
{
"article_id": "285",
"amount": "1.4",
"current_price": "3.23"
}
// ...
]
}`;
let response;
try {
response = await openai.chat.completions.create({
model: 'deepseek/deepseek-chat',
temperature: 0,
messages: [{ role: 'user', content: prompt }],
});
} catch (error) {
console.error("OpenAI API call failed in extractStoreLoadFromText:", error);
throw new Error("OpenAI API call failed during invoice text extraction.");
}
let content = response.choices?.[0]?.message?.content;
if (!content) {
throw new Error("No content returned from OpenAI API in extractStoreLoadFromText.");
}
if (content.includes('```')) {
const match = content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (match) content = match[1];
}
// Sanitize JSON: wrap unquoted property names with double quotes.
content = content.replace(/([{,]\s*)([A-Za-z0-9_]+)\s*:/g, '$1"$2":');
try {
return JSON.parse(content);
} catch (err) {
console.error("JSON parsing error in extractStoreLoadFromText:", err);
throw new Error(`Failed to parse JSON from invoice extraction: ${err.message}`);
}
};
// Create a delivery in Barsy.
const barsyApiUrl = 'https://vkashtibar.barsyonline.com/endpoints/json/';
const createDelivery = async (requestData) => {
try {
const response = await axios.post(
`${barsyApiUrl}Storeloads_create`,
requestData,
{
auth: {
username: process.env.BARSY_USER,
password: process.env.BARSY_PASSWORD,
},
headers: { 'Content-Type': 'application/json' },
}
);
return response.data;
} catch (error) {
console.error('Error creating delivery:', error);
throw error;
}
};
// --- New Helper Functions ---
const processAttachment = async (attachment) => {
console.log(`Processing attachment: ${attachment.url}`);
const { buffer, fileType } = await getFileType(attachment.url);
if (fileType?.mime === 'application/pdf') {
console.log('Attachment is a PDF');
return await extractPdfText(buffer);
} else if (fileType?.mime.startsWith('image/')) {
console.log('Attachment is an image');
const maxSize = 4 * 1024 * 1024; // 2MB
let imageBuffer = buffer;
let imageMime = fileType.mime;
if (buffer.length > maxSize) {
console.log('Image exceeds max size, compressing...');
let quality = 80;
let compressedBuffer = await sharp(buffer).jpeg({ quality }).toBuffer();
while (compressedBuffer.length > maxSize && quality > 10) {
quality -= 10;
console.log(`Reducing quality to ${quality}`);
compressedBuffer = await sharp(buffer).jpeg({ quality }).toBuffer();
}
imageBuffer = compressedBuffer;
imageMime = 'image/jpeg';
console.log('Compression completed');
}
const base64 = imageBuffer.toString('base64');
const dataUrl = `data:${imageMime};base64,${base64}`;
return await extractInvoiceItems(dataUrl);
} else {
console.warn(`Unsupported file type (${fileType?.mime}). Skipping attachment.`);
return null;
}
};
const processInvoiceAttachments = async (attachments) => {
const texts = [];
for (const attachment of attachments.values()) {
console.log(`Starting processing of attachment: ${attachment.url}`);
const text = await processAttachment(attachment);
if (text) texts.push(text);
}
return texts;
};
// --- Discord Client Setup ---
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
partials: [Partials.Channel],
});
const botToken = process.env.DISCORD_BOT_TOKEN;
const invoiceChannelIds = [process.env.INVOICE_CHANNEL_ID, process.env.PAID_INVOICE_CHANNEL_ID];
client.on('ready', () => {
console.log(`Logged in as ${client.user.tag}!`);
});
// --- Handler to Process Multiple Attachments ---
client.on('messageCreate', async (message) => {
try {
if (invoiceChannelIds.includes(message.channel.id) && !message.author.bot) {
if (message.attachments.size === 0) return;
// Use helper to process attachments
const invoiceTexts = await processInvoiceAttachments(message.attachments);
if (invoiceTexts.length === 0) {
await message.reply('No valid invoice attachments found.');
return;
}
const combinedInvoiceText = invoiceTexts.join('\n\n');
const storeLoad = await extractStoreLoadFromText(combinedInvoiceText);
const storeLoadResponse = await createDelivery(storeLoad);
const barsyLink = `https://vkashtibar.barsyonline.com/adminx/#storeloads_edit?id=${storeLoadResponse}&bid=1`;
await message.reply(`Delivery added in [Barsy](${barsyLink})! Press "Save and load into stock" to confirm.`);
}
} catch (error) {
console.error('Error processing message:', error);
await message.reply('Error processing file(s). Please ensure the file(s) are valid PDFs or images.');
}
});
client.login(botToken);