diff --git a/package.json b/package.json index f84f4f1..aaa4f91 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "license": "ISC", "description": "", "dependencies": { - "@google/genai": "^1.41.0", + "@google/generative-ai": "^0.24.1", "@prisma/adapter-pg": "^7.3.0", "dotenv": "^17.2.4", "ethers": "^6.16.0", diff --git a/src/bot/handlers/callbacks.ts b/src/bot/handlers/callbacks.ts index 7176aba..8987974 100644 --- a/src/bot/handlers/callbacks.ts +++ b/src/bot/handlers/callbacks.ts @@ -11,13 +11,13 @@ export class CallbackHandlers { switch (data) { case 'currency_cUSD': - return this.handleCurrencySelection(ctx, 'cUSD'); + return CallbackHandlers.handleCurrencySelection(ctx, 'cUSD'); case 'currency_cEUR': - return this.handleCurrencySelection(ctx, 'cEUR'); + return CallbackHandlers.handleCurrencySelection(ctx, 'cEUR'); case 'confirm_pay': - return this.handleConfirmPay(ctx); + return CallbackHandlers.handleConfirmPay(ctx); case 'cancel_pay': - return this.handleCancelPay(ctx); + return CallbackHandlers.handleCancelPay(ctx); default: await ctx.reply('Unknown action'); } @@ -118,4 +118,4 @@ export class CallbackHandlers { private static async handleCancelPay(ctx: BotContext) { await ctx.editMessageText('āŒ Payment cancelled.'); } -} \ No newline at end of file +} diff --git a/src/bot/handlers/commands.ts b/src/bot/handlers/commands.ts index 74ccdc4..6326cec 100644 --- a/src/bot/handlers/commands.ts +++ b/src/bot/handlers/commands.ts @@ -4,6 +4,12 @@ import { Formatters } from '../utils/formatters.js'; import { APP_NAME } from '../../config/constants.js'; import { celoService } from '../../services/blockchain/celo.js'; +interface EmployeeSummary { + name: string; + salaryAmount: number | string; + preferredCurrency: string; +} + export class CommandHandlers { // /start command static async start(ctx: BotContext) { @@ -215,7 +221,7 @@ export class CommandHandlers { let summary = 'šŸ“Š Payroll Summary\n\n'; - const byCurrency: Record = {}; + const byCurrency: Record = {}; company.employees.forEach((emp) => { const currency = emp.preferredCurrency; diff --git a/src/bot/handlers/messages.ts b/src/bot/handlers/messages.ts index 3bf6689..28d3f59 100644 --- a/src/bot/handlers/messages.ts +++ b/src/bot/handlers/messages.ts @@ -1,10 +1,12 @@ import type { BotContext } from '../../types/bot.js'; +import { wageFlowAIAgent } from '../../services/ai/index.js'; import { ConversationHandlers } from './conversations.js'; export class MessageHandlers { // Route text messages to appropriate handler static async handleText(ctx: BotContext) { - const text = ctx.message?.text; + const message = ctx.message; + const text = message && 'text' in message ? message.text : undefined; if (!text) return; console.log(`šŸ“Ø Received message: "${text}" | Session state: ${ctx.session.state}`); @@ -17,14 +19,31 @@ export class MessageHandlers { return ConversationHandlers.handleText(ctx); } - // Default response for random text + const telegramId = ctx.from?.id; + + if (telegramId && wageFlowAIAgent.isEnabled()) { + try { + await ctx.sendChatAction('typing'); + const aiReply = await wageFlowAIAgent.reply(telegramId, text); + + if (aiReply) { + await ctx.reply(aiReply); + return; + } + } catch (error) { + console.error('AI response error:', error); + } + } + + // Default response fallback await ctx.reply( "I'm not sure what you mean. Try these commands:\n\n" + '/start - Get started\n' + '/add_employee - Add team member\n' + '/employees - View team\n' + '/pay - Pay everyone\n' + - '/help - Show help' + '/help - Show help\n\n' + + 'Tip: Add GEMINI_API_KEY in your .env to enable smart AI chat replies.' ); } -} \ No newline at end of file +} diff --git a/src/services/ai/agent.ts b/src/services/ai/agent.ts index e69de29..bffd8ca 100644 --- a/src/services/ai/agent.ts +++ b/src/services/ai/agent.ts @@ -0,0 +1,75 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import { env } from '../../config/env.js'; +import { Company, Employee } from '../database/index.js'; +import { buildSystemPrompt, buildUserPrompt, type PayrollContext } from './prompts.js'; + +interface EmployeeSnapshot { + name: string; + salaryAmount: number; + preferredCurrency: string; +} + +const DEFAULT_MODEL = 'gemini-1.5-flash'; + +export class WageFlowAIAgent { + private readonly client: GoogleGenerativeAI | null; + + constructor() { + this.client = env.GEMINI_API_KEY ? new GoogleGenerativeAI(env.GEMINI_API_KEY) : null; + } + + isEnabled(): boolean { + return Boolean(this.client); + } + + async reply(telegramId: number, userMessage: string): Promise { + if (!this.client) return null; + + const context = await this.fetchPayrollContext(telegramId); + const model = this.client.getGenerativeModel({ model: DEFAULT_MODEL }); + + const result = await model.generateContent([ + buildSystemPrompt(), + '\n\n', + buildUserPrompt(userMessage, context), + ]); + + const text = result.response.text(); + return text?.trim() || null; + } + + private async fetchPayrollContext(telegramId: number): Promise { + const company = await Company.findOne({ + where: { telegramId: BigInt(telegramId) }, + include: [ + { + model: Employee, + as: 'employees', + where: { status: 'active' }, + required: false, + }, + ], + }); + + const employees = (company?.employees || []) as EmployeeSnapshot[]; + + const monthlyPayrollByCurrency = employees.reduce>((acc, employee) => { + const currency = employee.preferredCurrency; + acc[currency] = (acc[currency] || 0) + Number(employee.salaryAmount); + return acc; + }, {}); + + return { + companyName: company?.name || 'Unknown company', + employeeCount: employees.length, + monthlyPayrollByCurrency, + employees: employees.map((employee) => ({ + name: employee.name, + salaryAmount: Number(employee.salaryAmount), + preferredCurrency: employee.preferredCurrency, + })), + }; + } +} + +export const wageFlowAIAgent = new WageFlowAIAgent(); diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts new file mode 100644 index 0000000..78ab42d --- /dev/null +++ b/src/services/ai/index.ts @@ -0,0 +1 @@ +export { WageFlowAIAgent, wageFlowAIAgent } from './agent.js'; diff --git a/src/services/ai/prompts.ts b/src/services/ai/prompts.ts index e69de29..955bb2c 100644 --- a/src/services/ai/prompts.ts +++ b/src/services/ai/prompts.ts @@ -0,0 +1,49 @@ +import type { Employee } from '../database/models/Employee.js'; + +export interface PayrollContext { + companyName: string; + employeeCount: number; + monthlyPayrollByCurrency: Record; + employees: Pick[]; +} + +function formatPayrollByCurrency(monthlyPayrollByCurrency: Record): string { + const entries = Object.entries(monthlyPayrollByCurrency); + if (entries.length === 0) return 'No payroll data available yet.'; + + return entries.map(([currency, amount]) => `${currency}: ${amount.toFixed(2)}`).join(', '); +} + +export function buildSystemPrompt(): string { + return [ + 'You are WageFlow AI, a smart payroll copilot inside a Telegram bot.', + 'Be concise, practical, and friendly.', + 'Focus on payroll operations, team management, Celo cUSD/cEUR context, and risk checks.', + 'If user asks unrelated topics, politely redirect to payroll, HR ops, and crypto payroll concerns.', + 'Never claim to execute blockchain transfers yourself; instruct the user to use bot commands like /pay.', + 'When giving steps, format them as short numbered lists.', + ].join(' '); +} + +export function buildUserPrompt(userMessage: string, context: PayrollContext): string { + return [ + `User message: "${userMessage}"`, + '', + 'Current WageFlow context:', + `- Company: ${context.companyName}`, + `- Active employees: ${context.employeeCount}`, + `- Monthly payroll by currency: ${formatPayrollByCurrency(context.monthlyPayrollByCurrency)}`, + `- Employees: ${ + context.employees.length + ? context.employees + .map( + (employee) => + `${employee.name} (${Number(employee.salaryAmount).toFixed(2)} ${employee.preferredCurrency})` + ) + .join(', ') + : 'none' + }`, + '', + 'Give a helpful response aligned to this context. Keep it under 120 words.', + ].join('\n'); +} diff --git a/src/services/blockchain/celo.ts b/src/services/blockchain/celo.ts index ca788e5..cca750f 100644 --- a/src/services/blockchain/celo.ts +++ b/src/services/blockchain/celo.ts @@ -12,16 +12,18 @@ import type { class CeloService { private provider: ethers.JsonRpcProvider; private wallet: ethers.Wallet; - private tokens: Record; + private tokens: Record; constructor() { - // ── Provider ──────────────────────────────────────────────────────────── - this.provider = new ethers.JsonRpcProvider(env.CELO_RPC_URL); + const rpcUrl = env.CELO_RPC_URL || NETWORK.RPC_URL; + this.provider = new ethers.JsonRpcProvider(rpcUrl); - // ── Wallet ────────────────────────────────────────────────────────────── - this.wallet = new ethers.Wallet(env.PRIVATE_KEY!, this.provider); + if (!env.PRIVATE_KEY) { + throw new Error('PRIVATE_KEY is required for Celo payments'); + } + + this.wallet = new ethers.Wallet(env.PRIVATE_KEY, this.provider); - // ── Token Contracts ───────────────────────────────────────────────────── this.tokens = { cUSD: new ethers.Contract(TOKENS.cUSD.address, ERC20_ABI, this.wallet), cEUR: new ethers.Contract(TOKENS.cEUR.address, ERC20_ABI, this.wallet), @@ -29,28 +31,17 @@ class CeloService { console.log('āœ… Celo Service ready'); console.log(' Network :', NETWORK.NAME); - console.log(' Wallet :', this.wallet.address); + console.log(' Wallet :', this.shortenAddress(this.wallet.address)); } - // ──────────────────────────────────────────────────────────────────────────── - // WALLET INFO - // ──────────────────────────────────────────────────────────────────────────── - - /** - * Get the bot's wallet address - */ getAddress(): string { return this.wallet.address; } - /** - * Get single token balance - */ async getBalance(currency: Currency, address?: string): Promise { try { - const addr = address ?? this.wallet.address; - const token = this.tokens[currency]; - const raw = await token.balanceOf(addr); + const addr = this.normalizeAddress(address ?? this.wallet.address); + const raw = await this.tokens[currency].balanceOf(addr); return ethers.formatUnits(raw, TOKENS[currency].decimals); } catch (error: any) { console.error(`Failed to get ${currency} balance:`, error.message); @@ -58,12 +49,9 @@ class CeloService { } } - /** - * Get native CELO balance - */ async getCeloBalance(address?: string): Promise { try { - const addr = address ?? this.wallet.address; + const addr = this.normalizeAddress(address ?? this.wallet.address); const raw = await this.provider.getBalance(addr); return ethers.formatEther(raw); } catch (error: any) { @@ -72,9 +60,6 @@ class CeloService { } } - /** - * Get all balances at once - */ async getAllBalances(address?: string): Promise { const [cUSD, cEUR, CELO] = await Promise.all([ this.getBalance('cUSD', address).catch(() => '0'), @@ -85,67 +70,66 @@ class CeloService { return { cUSD, cEUR, CELO }; } - /** - * Check if wallet has enough balance - */ async hasSufficientBalance(amount: string, currency: Currency): Promise { - const balance = await this.getBalance(currency); - return parseFloat(balance) >= parseFloat(amount); - } + const amountUnits = this.parseAmountToUnits(amount, currency); + if (!amountUnits) return false; - // ──────────────────────────────────────────────────────────────────────────── - // PAYMENTS - // ──────────────────────────────────────────────────────────────────────────── + const balance = await this.tokens[currency].balanceOf(this.wallet.address); + return balance >= amountUnits; + } - /** - * Pay a single employee - */ async payEmployee( recipientAddress: string, amount: string, currency: Currency = 'cUSD' ): Promise { try { - console.log(`\nšŸ’ø Paying ${amount} ${currency} → ${recipientAddress}`); + if (!(await this.isExpectedNetwork())) { + return { + success: false, + error: `Wrong network configured. Expected chainId ${NETWORK.CHAIN_ID}`, + }; + } - // ── Validate address ─────────────────────────────────────────────────── if (!this.isValidAddress(recipientAddress)) { return { success: false, error: 'Invalid recipient address' }; } - // ── Check balance ────────────────────────────────────────────────────── - const balance = await this.getBalance(currency); - if (parseFloat(balance) < parseFloat(amount)) { + const recipient = this.normalizeAddress(recipientAddress); + if (recipient === this.wallet.address) { + return { success: false, error: 'Recipient address cannot be bot wallet address' }; + } + + const amountUnits = this.parseAmountToUnits(amount, currency); + if (!amountUnits) { + return { success: false, error: `Invalid ${currency} amount` }; + } + + const balanceUnits = await this.tokens[currency].balanceOf(this.wallet.address); + if (balanceUnits < amountUnits) { + const have = ethers.formatUnits(balanceUnits, TOKENS[currency].decimals); + const need = ethers.formatUnits(amountUnits, TOKENS[currency].decimals); return { success: false, - error: `Insufficient ${currency}. Have ${parseFloat(balance).toFixed(2)}, need ${amount}`, + error: `Insufficient ${currency}. Have ${have}, need ${need}`, }; } - // ── Check CELO for gas ───────────────────────────────────────────────── - const celoBalance = await this.getCeloBalance(); - if (parseFloat(celoBalance) < 0.001) { + const celoBalance = await this.provider.getBalance(this.wallet.address); + if (celoBalance < ethers.parseEther('0.001')) { return { success: false, error: 'Insufficient CELO for gas fees. Get CELO from faucet.celo.org', }; } - // ── Execute transfer ─────────────────────────────────────────────────── - const token = this.tokens[currency]; - const amountWei = ethers.parseUnits(amount, TOKENS[currency].decimals); - - console.log(' Sending transaction...'); - const tx = await token.transfer(recipientAddress, amountWei); + console.log(`\nšŸ’ø Paying ${ethers.formatUnits(amountUnits, TOKENS[currency].decimals)} ${currency} → ${this.shortenAddress(recipient)}`); + const tx = await this.tokens[currency].transfer(recipient, amountUnits); console.log(' TX Hash:', tx.hash); - // ── Wait for confirmation ────────────────────────────────────────────── - console.log(' Waiting for confirmation...'); const receipt = await tx.wait(1); - - if (receipt && receipt.status === 1) { - console.log(' āœ… Payment confirmed!'); - console.log(` Explorer: ${NETWORK.EXPLORER_URL}/tx/${receipt.hash}`); + if (receipt?.status === 1) { + console.log(` āœ… Payment confirmed: ${this.getTxLink(receipt.hash)}`); return { success: true, txHash: receipt.hash }; } @@ -157,10 +141,11 @@ class CeloService { } } - /** - * Pay multiple employees in batch - */ async payBatch(payments: BatchPaymentItem[]): Promise { + if (payments.length > 100) { + throw new Error('Batch too large. Maximum 100 payments per batch'); + } + console.log(`\nšŸ“¦ Starting batch payroll: ${payments.length} employees`); console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n'); @@ -170,11 +155,7 @@ class CeloService { const payment = payments[i]; console.log(`[${i + 1}/${payments.length}] ${payment.name}`); - const result = await this.payEmployee( - payment.address, - payment.amount, - payment.currency - ); + const result = await this.payEmployee(payment.address, payment.amount, payment.currency); results.push({ ...payment, @@ -183,15 +164,13 @@ class CeloService { error: result.error, }); - // ── Small delay between transactions ─────────────────────────────────── if (i < payments.length - 1) { await this.sleep(2000); } } - // ── Print summary ────────────────────────────────────────────────────── const succeeded = results.filter((r) => r.success).length; - const failed = results.filter((r) => !r.success).length; + const failed = results.length - succeeded; console.log('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); console.log(`āœ… Succeeded: ${succeeded}`); @@ -201,13 +180,6 @@ class CeloService { return results; } - // ──────────────────────────────────────────────────────────────────────────── - // TRANSACTION INFO - // ──────────────────────────────────────────────────────────────────────────── - - /** - * Get transaction details by hash - */ async getTransaction(txHash: string) { try { return await this.provider.getTransaction(txHash); @@ -217,9 +189,6 @@ class CeloService { } } - /** - * Get transaction receipt by hash - */ async getTransactionReceipt(txHash: string) { try { return await this.provider.getTransactionReceipt(txHash); @@ -229,9 +198,6 @@ class CeloService { } } - /** - * Get current gas price - */ async getGasPrice(): Promise { try { const feeData = await this.provider.getFeeData(); @@ -244,34 +210,18 @@ class CeloService { } } - /** - * Get current block number - */ async getBlockNumber(): Promise { return await this.provider.getBlockNumber(); } - /** - * Get network info - */ async getNetwork() { return await this.provider.getNetwork(); } - // ──────────────────────────────────────────────────────────────────────────── - // HELPERS - // ──────────────────────────────────────────────────────────────────────────── - - /** - * Check if an address is valid - */ isValidAddress(address: string): boolean { return ethers.isAddress(address); } - /** - * Format address to checksum format - */ formatAddress(address: string): string { try { return ethers.getAddress(address); @@ -280,58 +230,53 @@ class CeloService { } } - /** - * Shorten address for display - */ shortenAddress(address: string): string { + if (address.length < 10) return address; return `${address.slice(0, 6)}...${address.slice(-4)}`; } - /** - * Build explorer link for transaction - */ getTxLink(txHash: string): string { return `${NETWORK.EXPLORER_URL}/tx/${txHash}`; } - /** - * Build explorer link for address - */ getAddressLink(address: string): string { return `${NETWORK.EXPLORER_URL}/address/${address}`; } - /** - * Parse common ethers errors into readable messages - */ private parseError(error: any): string { - if (error.code === 'INSUFFICIENT_FUNDS') { - return 'Not enough CELO for gas fees'; - } - if (error.code === 'NONCE_EXPIRED') { - return 'Transaction nonce error. Please try again'; - } - if (error.code === 'NETWORK_ERROR') { - return 'Network connection issue. Please try again'; - } - if (error.code === 'TIMEOUT') { - return 'Transaction timed out. Check the blockchain explorer'; - } - if (error.reason) { - return error.reason; - } - if (error.shortMessage) { - return error.shortMessage; - } + if (error.code === 'INSUFFICIENT_FUNDS') return 'Not enough CELO for gas fees'; + if (error.code === 'NONCE_EXPIRED') return 'Transaction nonce error. Please try again'; + if (error.code === 'NETWORK_ERROR') return 'Network connection issue. Please try again'; + if (error.code === 'TIMEOUT') return 'Transaction timed out. Check the blockchain explorer'; + if (error.reason) return error.reason; + if (error.shortMessage) return error.shortMessage; return error.message || 'Unknown error'; } - /** - * Sleep helper - */ + private parseAmountToUnits(amount: string, currency: Currency): bigint | null { + if (!/^\d+(\.\d+)?$/.test(amount)) return null; + + try { + const amountUnits = ethers.parseUnits(amount, TOKENS[currency].decimals); + if (amountUnits <= 0n) return null; + return amountUnits; + } catch { + return null; + } + } + + private normalizeAddress(address: string): string { + return ethers.getAddress(address); + } + + private async isExpectedNetwork(): Promise { + const network = await this.provider.getNetwork(); + return Number(network.chainId) === NETWORK.CHAIN_ID; + } + private sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } } -export const celoService = new CeloService(); \ No newline at end of file +export const celoService = new CeloService(); diff --git a/src/types/google-generative-ai.d.ts b/src/types/google-generative-ai.d.ts new file mode 100644 index 0000000..c28dff5 --- /dev/null +++ b/src/types/google-generative-ai.d.ts @@ -0,0 +1,10 @@ +declare module '@google/generative-ai' { + export class GoogleGenerativeAI { + constructor(apiKey: string); + getGenerativeModel(params: { model: string }): { + generateContent( + input: string | string[] + ): Promise<{ response: { text(): string } }>; + }; + } +}