Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/bot/handlers/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down Expand Up @@ -118,4 +118,4 @@ export class CallbackHandlers {
private static async handleCancelPay(ctx: BotContext) {
await ctx.editMessageText('❌ Payment cancelled.');
}
}
}
8 changes: 7 additions & 1 deletion src/bot/handlers/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -215,7 +221,7 @@ export class CommandHandlers {

let summary = '📊 Payroll Summary\n\n';

const byCurrency: Record<string, { employees: Employee[]; total: number }> = {};
const byCurrency: Record<string, { employees: EmployeeSummary[]; total: number }> = {};

company.employees.forEach((emp) => {
const currency = emp.preferredCurrency;
Expand Down
27 changes: 23 additions & 4 deletions src/bot/handlers/messages.ts
Original file line number Diff line number Diff line change
@@ -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}`);
Expand All @@ -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.'
);
}
}
}
75 changes: 75 additions & 0 deletions src/services/ai/agent.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<PayrollContext> {
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<Record<string, number>>((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();
1 change: 1 addition & 0 deletions src/services/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { WageFlowAIAgent, wageFlowAIAgent } from './agent.js';
49 changes: 49 additions & 0 deletions src/services/ai/prompts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { Employee } from '../database/models/Employee.js';

export interface PayrollContext {
companyName: string;
employeeCount: number;
monthlyPayrollByCurrency: Record<string, number>;
employees: Pick<Employee, 'name' | 'salaryAmount' | 'preferredCurrency'>[];
}

function formatPayrollByCurrency(monthlyPayrollByCurrency: Record<string, number>): 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');
}
Loading