Replace classes with functions
This commit is contained in:
parent
3c1209a5a9
commit
fd1da2de43
8 changed files with 938 additions and 666 deletions
|
|
@ -42,13 +42,13 @@
|
|||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Test files
|
||||
test/
|
||||
spec/
|
||||
**/*.test.js
|
||||
**/*.spec.js
|
||||
**/jest.config.*
|
||||
**/vitest.config.*
|
||||
# Test files (removed to allow tests in Docker build)
|
||||
# test/
|
||||
# spec/
|
||||
# **/*.test.js
|
||||
# **/*.spec.js
|
||||
# **/jest.config.*
|
||||
# **/vitest.config.*
|
||||
|
||||
# Documentation and assets
|
||||
README.md
|
||||
|
|
|
|||
15
Dockerfile
15
Dockerfile
|
|
@ -73,17 +73,28 @@ RUN apk add --no-cache --virtual .test-deps \
|
|||
|
||||
# Copy backend package files and install all dependencies (including dev)
|
||||
COPY backend/package*.json ./backend/
|
||||
RUN cd backend && npm install --no-audit --no-fund
|
||||
RUN cd backend && \
|
||||
# Retry npm install with exponential backoff for network issues
|
||||
for i in 1 2 3; do \
|
||||
npm install --no-audit --no-fund && break || \
|
||||
(echo "npm install failed, attempt $i/3, retrying in $((i*5)) seconds..." && sleep $((i*5))); \
|
||||
done
|
||||
|
||||
# Copy backend source code
|
||||
# Copy backend source code including tests
|
||||
COPY backend/ ./backend/
|
||||
|
||||
# Run tests
|
||||
RUN cd backend && npm test
|
||||
|
||||
# Create test completion marker to ensure tests passed
|
||||
RUN echo "Tests passed successfully" > /app/test-success.marker
|
||||
|
||||
# Stage 4: Final Production Image (minimal base)
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
# Copy test success marker to ensure tests passed before production build
|
||||
COPY --from=test /app/test-success.marker /tmp/test-success.marker
|
||||
|
||||
# Create non-root user first (before installing packages)
|
||||
RUN addgroup -g 1001 -S app && \
|
||||
adduser -S app -u 1001 -G app
|
||||
|
|
|
|||
|
|
@ -112,16 +112,38 @@ module.exports = (sequelize) => {
|
|||
WAITING: 4
|
||||
};
|
||||
|
||||
// Instance methods for priority and status
|
||||
Task.prototype.getPriorityName = function() {
|
||||
// priority and status
|
||||
const getPriorityName = (priorityValue) => {
|
||||
const priorities = ['low', 'medium', 'high'];
|
||||
return priorities[this.priority] || 'low';
|
||||
return priorities[priorityValue] || 'low';
|
||||
};
|
||||
|
||||
Task.prototype.getStatusName = function() {
|
||||
const getStatusName = (statusValue) => {
|
||||
const statuses = ['not_started', 'in_progress', 'done', 'archived', 'waiting'];
|
||||
return statuses[this.status] || 'not_started';
|
||||
return statuses[statusValue] || 'not_started';
|
||||
};
|
||||
|
||||
const getPriorityValue = (priorityName) => {
|
||||
const priorities = { 'low': 0, 'medium': 1, 'high': 2 };
|
||||
return priorities[priorityName] !== undefined ? priorities[priorityName] : 0;
|
||||
};
|
||||
|
||||
const getStatusValue = (statusName) => {
|
||||
const statuses = {
|
||||
'not_started': 0,
|
||||
'in_progress': 1,
|
||||
'done': 2,
|
||||
'archived': 3,
|
||||
'waiting': 4
|
||||
};
|
||||
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
|
||||
};
|
||||
|
||||
// Attach utility functions to model
|
||||
Task.getPriorityName = getPriorityName;
|
||||
Task.getStatusName = getStatusName;
|
||||
Task.getPriorityValue = getPriorityValue;
|
||||
Task.getStatusValue = getStatusValue;
|
||||
|
||||
return Task;
|
||||
};
|
||||
|
|
@ -87,14 +87,18 @@ module.exports = (sequelize) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Virtual field for password
|
||||
User.prototype.setPassword = async function(password) {
|
||||
this.password_digest = await bcrypt.hash(password, 10);
|
||||
// password operations
|
||||
const hashPassword = async (password) => {
|
||||
return await bcrypt.hash(password, 10);
|
||||
};
|
||||
|
||||
User.prototype.checkPassword = async function(password) {
|
||||
return await bcrypt.compare(password, this.password_digest);
|
||||
const checkPassword = async (password, hashedPassword) => {
|
||||
return await bcrypt.compare(password, hashedPassword);
|
||||
};
|
||||
|
||||
// Attach utility functions to model
|
||||
User.hashPassword = hashPassword;
|
||||
User.checkPassword = checkPassword;
|
||||
|
||||
return User;
|
||||
};
|
||||
|
|
@ -2,64 +2,130 @@ const fs = require('fs');
|
|||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
class QuotesService {
|
||||
constructor() {
|
||||
this.quotes = [];
|
||||
this.loadQuotes();
|
||||
}
|
||||
// create default quotes
|
||||
const createDefaultQuotes = () => [
|
||||
"Believe you can and you're halfway there.",
|
||||
"The only way to do great work is to love what you do.",
|
||||
"It always seems impossible until it's done.",
|
||||
"Focus on progress, not perfection.",
|
||||
"One task at a time leads to great accomplishments."
|
||||
];
|
||||
|
||||
loadQuotes() {
|
||||
try {
|
||||
const quotesPath = path.join(__dirname, '../config/quotes.yml');
|
||||
|
||||
if (fs.existsSync(quotesPath)) {
|
||||
const fileContents = fs.readFileSync(quotesPath, 'utf8');
|
||||
const data = yaml.load(fileContents);
|
||||
|
||||
if (data && data.quotes && Array.isArray(data.quotes)) {
|
||||
this.quotes = data.quotes;
|
||||
console.log(`Loaded ${this.quotes.length} quotes from configuration`);
|
||||
} else {
|
||||
console.warn('No quotes found in configuration file');
|
||||
this.setDefaultQuotes();
|
||||
}
|
||||
} else {
|
||||
console.warn('Quotes configuration file not found, using defaults');
|
||||
this.setDefaultQuotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error.message);
|
||||
this.setDefaultQuotes();
|
||||
}
|
||||
}
|
||||
// get quotes file path
|
||||
const getQuotesFilePath = () =>
|
||||
path.join(__dirname, '../config/quotes.yml');
|
||||
|
||||
setDefaultQuotes() {
|
||||
this.quotes = [
|
||||
"Believe you can and you're halfway there.",
|
||||
"The only way to do great work is to love what you do.",
|
||||
"It always seems impossible until it's done.",
|
||||
"Focus on progress, not perfection.",
|
||||
"One task at a time leads to great accomplishments."
|
||||
];
|
||||
}
|
||||
// Side effect function to check if file exists
|
||||
const fileExists = (filePath) =>
|
||||
fs.existsSync(filePath);
|
||||
|
||||
getRandomQuote() {
|
||||
if (this.quotes.length === 0) {
|
||||
return "Stay focused and keep going!";
|
||||
}
|
||||
// Side effect function to read file contents
|
||||
const readFileContents = (filePath) =>
|
||||
fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
// parse YAML content
|
||||
const parseYamlContent = (content) => {
|
||||
try {
|
||||
return yaml.load(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse YAML: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// validate quotes data structure
|
||||
const validateQuotesData = (data) =>
|
||||
data && data.quotes && Array.isArray(data.quotes);
|
||||
|
||||
// extract quotes from data
|
||||
const extractQuotes = (data) => {
|
||||
if (validateQuotesData(data)) {
|
||||
return data.quotes;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Side effect function to load quotes from file
|
||||
const loadQuotesFromFile = () => {
|
||||
try {
|
||||
const quotesPath = getQuotesFilePath();
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * this.quotes.length);
|
||||
return this.quotes[randomIndex];
|
||||
}
|
||||
if (!fileExists(quotesPath)) {
|
||||
console.warn('Quotes configuration file not found, using defaults');
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
|
||||
getAllQuotes() {
|
||||
return this.quotes;
|
||||
const fileContents = readFileContents(quotesPath);
|
||||
const data = parseYamlContent(fileContents);
|
||||
const quotes = extractQuotes(data);
|
||||
|
||||
if (quotes) {
|
||||
console.log(`Loaded ${quotes.length} quotes from configuration`);
|
||||
return quotes;
|
||||
} else {
|
||||
console.warn('No quotes found in configuration file');
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error.message);
|
||||
return createDefaultQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
getQuotesCount() {
|
||||
return this.quotes.length;
|
||||
// get random index
|
||||
const getRandomIndex = (arrayLength) =>
|
||||
Math.floor(Math.random() * arrayLength);
|
||||
|
||||
// get random quote from array
|
||||
const getRandomQuoteFromArray = (quotes) => {
|
||||
if (quotes.length === 0) {
|
||||
return "Stay focused and keep going!";
|
||||
}
|
||||
}
|
||||
|
||||
const randomIndex = getRandomIndex(quotes.length);
|
||||
return quotes[randomIndex];
|
||||
};
|
||||
|
||||
// Export singleton instance
|
||||
module.exports = new QuotesService();
|
||||
// get all quotes
|
||||
const getAllQuotesFromArray = (quotes) =>
|
||||
[...quotes]; // Return copy to maintain immutability
|
||||
|
||||
// get quotes count
|
||||
const getQuotesCount = (quotes) =>
|
||||
quotes.length;
|
||||
|
||||
// Initialize quotes on module load
|
||||
let quotes = loadQuotesFromFile();
|
||||
|
||||
// Function to reload quotes (contains side effects)
|
||||
const reloadQuotes = () => {
|
||||
quotes = loadQuotesFromFile();
|
||||
return quotes;
|
||||
};
|
||||
|
||||
// get random quote
|
||||
const getRandomQuote = () =>
|
||||
getRandomQuoteFromArray(quotes);
|
||||
|
||||
// get all quotes
|
||||
const getAllQuotes = () =>
|
||||
getAllQuotesFromArray(quotes);
|
||||
|
||||
// get count
|
||||
const getCount = () =>
|
||||
getQuotesCount(quotes);
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
getRandomQuote,
|
||||
getAllQuotes,
|
||||
getQuotesCount: getCount,
|
||||
reloadQuotes,
|
||||
// For testing
|
||||
_createDefaultQuotes: createDefaultQuotes,
|
||||
_getQuotesFilePath: getQuotesFilePath,
|
||||
_parseYamlContent: parseYamlContent,
|
||||
_validateQuotesData: validateQuotesData,
|
||||
_extractQuotes: extractQuotes,
|
||||
_getRandomIndex: getRandomIndex,
|
||||
_getRandomQuoteFromArray: getRandomQuoteFromArray
|
||||
};
|
||||
|
|
@ -2,180 +2,193 @@ const cron = require('node-cron');
|
|||
const { User } = require('../models');
|
||||
const TaskSummaryService = require('./taskSummaryService');
|
||||
|
||||
class TaskScheduler {
|
||||
constructor() {
|
||||
this.jobs = new Map();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
// Create scheduler state
|
||||
const createSchedulerState = () => ({
|
||||
jobs: new Map(),
|
||||
isInitialized: false
|
||||
});
|
||||
|
||||
static getInstance() {
|
||||
if (!TaskScheduler.instance) {
|
||||
TaskScheduler.instance = new TaskScheduler();
|
||||
// Global mutable state (will be managed functionally)
|
||||
let schedulerState = createSchedulerState();
|
||||
|
||||
// Check if scheduler should be disabled
|
||||
const shouldDisableScheduler = () =>
|
||||
process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true';
|
||||
|
||||
// Create job configuration
|
||||
const createJobConfig = () => ({
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
// Create cron expressions
|
||||
const getCronExpression = (frequency) => {
|
||||
const expressions = {
|
||||
daily: '0 7 * * *',
|
||||
weekdays: '0 7 * * 1-5',
|
||||
weekly: '0 7 * * 1',
|
||||
'1h': '0 * * * *',
|
||||
'2h': '0 */2 * * *',
|
||||
'4h': '0 */4 * * *',
|
||||
'8h': '0 */8 * * *',
|
||||
'12h': '0 */12 * * *'
|
||||
};
|
||||
return expressions[frequency];
|
||||
};
|
||||
|
||||
// Create job handler
|
||||
const createJobHandler = (frequency) => async () => {
|
||||
console.log(`Running scheduled task: ${frequency} task summary`);
|
||||
await processSummariesForFrequency(frequency);
|
||||
};
|
||||
|
||||
// Create job entries
|
||||
const createJobEntries = () => {
|
||||
const frequencies = ['daily', 'weekdays', 'weekly', '1h', '2h', '4h', '8h', '12h'];
|
||||
|
||||
return frequencies.map(frequency => {
|
||||
const cronExpression = getCronExpression(frequency);
|
||||
const jobHandler = createJobHandler(frequency);
|
||||
const jobConfig = createJobConfig();
|
||||
const job = cron.schedule(cronExpression, jobHandler, jobConfig);
|
||||
|
||||
return [frequency, job];
|
||||
});
|
||||
};
|
||||
|
||||
// Start all jobs
|
||||
const startJobs = (jobs) => {
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.start();
|
||||
console.log(`Started scheduler for frequency: ${frequency}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Stop all jobs
|
||||
const stopJobs = (jobs) => {
|
||||
jobs.forEach((job, frequency) => {
|
||||
job.stop();
|
||||
console.log(`Stopped scheduler for frequency: ${frequency}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to fetch users for frequency
|
||||
const fetchUsersForFrequency = async (frequency) => {
|
||||
return await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: { [require('sequelize').Op.ne]: null },
|
||||
telegram_chat_id: { [require('sequelize').Op.ne]: null },
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: frequency
|
||||
}
|
||||
return TaskScheduler.instance;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) {
|
||||
console.log('Task scheduler already initialized');
|
||||
return;
|
||||
// Side effect function to send summary to user
|
||||
const sendSummaryToUser = async (userId, frequency) => {
|
||||
try {
|
||||
const success = await TaskSummaryService.sendSummaryToUser(userId);
|
||||
if (success) {
|
||||
console.log(`Sent ${frequency} summary to user ${userId}`);
|
||||
} else {
|
||||
console.log(`Failed to send ${frequency} summary to user ${userId}`);
|
||||
}
|
||||
return success;
|
||||
} catch (error) {
|
||||
console.error(`Error sending ${frequency} summary to user ${userId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Don't schedule in test environment
|
||||
if (process.env.NODE_ENV === 'test' || process.env.DISABLE_SCHEDULER === 'true') {
|
||||
console.log('Task scheduler disabled for test environment');
|
||||
return;
|
||||
}
|
||||
// Function to process summaries for frequency (contains side effects)
|
||||
const processSummariesForFrequency = async (frequency) => {
|
||||
try {
|
||||
const users = await fetchUsersForFrequency(frequency);
|
||||
console.log(`Processing ${users.length} users for frequency: ${frequency}`);
|
||||
|
||||
console.log('Initializing task scheduler...');
|
||||
const results = await Promise.allSettled(
|
||||
users.map(user => sendSummaryToUser(user.id, frequency))
|
||||
);
|
||||
|
||||
// Daily schedule at 7 AM (for users with daily frequency)
|
||||
const dailyJob = cron.schedule('0 7 * * *', async () => {
|
||||
console.log('Running scheduled task: Daily task summary');
|
||||
await this.processSummariesForFrequency('daily');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error processing summaries for frequency ${frequency}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Weekdays schedule at 7 AM (Monday through Friday)
|
||||
const weekdaysJob = cron.schedule('0 7 * * 1-5', async () => {
|
||||
console.log('Running scheduled task: Weekday task summary');
|
||||
await this.processSummariesForFrequency('weekdays');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
// Weekly schedule at 7 AM on Monday
|
||||
const weeklyJob = cron.schedule('0 7 * * 1', async () => {
|
||||
console.log('Running scheduled task: Weekly task summary');
|
||||
await this.processSummariesForFrequency('weekly');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
// Hourly schedules
|
||||
const hourlyJob = cron.schedule('0 * * * *', async () => {
|
||||
console.log('Running scheduled task: Hourly (1h) task summary');
|
||||
await this.processSummariesForFrequency('1h');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
const twoHourlyJob = cron.schedule('0 */2 * * *', async () => {
|
||||
console.log('Running scheduled task: 2-hour task summary');
|
||||
await this.processSummariesForFrequency('2h');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
const fourHourlyJob = cron.schedule('0 */4 * * *', async () => {
|
||||
console.log('Running scheduled task: 4-hour task summary');
|
||||
await this.processSummariesForFrequency('4h');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
const eightHourlyJob = cron.schedule('0 */8 * * *', async () => {
|
||||
console.log('Running scheduled task: 8-hour task summary');
|
||||
await this.processSummariesForFrequency('8h');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
const twelveHourlyJob = cron.schedule('0 */12 * * *', async () => {
|
||||
console.log('Running scheduled task: 12-hour task summary');
|
||||
await this.processSummariesForFrequency('12h');
|
||||
}, {
|
||||
scheduled: false,
|
||||
timezone: 'UTC'
|
||||
});
|
||||
|
||||
// Store jobs for later management
|
||||
this.jobs.set('daily', dailyJob);
|
||||
this.jobs.set('weekdays', weekdaysJob);
|
||||
this.jobs.set('weekly', weeklyJob);
|
||||
this.jobs.set('1h', hourlyJob);
|
||||
this.jobs.set('2h', twoHourlyJob);
|
||||
this.jobs.set('4h', fourHourlyJob);
|
||||
this.jobs.set('8h', eightHourlyJob);
|
||||
this.jobs.set('12h', twelveHourlyJob);
|
||||
|
||||
// Start all jobs
|
||||
this.jobs.forEach((job, frequency) => {
|
||||
job.start();
|
||||
console.log(`Started scheduler for frequency: ${frequency}`);
|
||||
});
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('Task scheduler initialized successfully');
|
||||
// Function to initialize scheduler (contains side effects)
|
||||
const initialize = async () => {
|
||||
if (schedulerState.isInitialized) {
|
||||
console.log('Task scheduler already initialized');
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
async processSummariesForFrequency(frequency) {
|
||||
try {
|
||||
const users = await User.findAll({
|
||||
where: {
|
||||
telegram_bot_token: { [require('sequelize').Op.ne]: null },
|
||||
telegram_chat_id: { [require('sequelize').Op.ne]: null },
|
||||
task_summary_enabled: true,
|
||||
task_summary_frequency: frequency
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Processing ${users.length} users for frequency: ${frequency}`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const success = await TaskSummaryService.sendSummaryToUser(user.id);
|
||||
if (success) {
|
||||
console.log(`Sent ${frequency} summary to user ${user.id}`);
|
||||
} else {
|
||||
console.log(`Failed to send ${frequency} summary to user ${user.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error sending ${frequency} summary to user ${user.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing summaries for frequency ${frequency}:`, error);
|
||||
}
|
||||
if (shouldDisableScheduler()) {
|
||||
console.log('Task scheduler disabled for test environment');
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.isInitialized) {
|
||||
console.log('Task scheduler not initialized, nothing to stop');
|
||||
return;
|
||||
}
|
||||
console.log('Initializing task scheduler...');
|
||||
|
||||
console.log('Stopping task scheduler...');
|
||||
this.jobs.forEach((job, frequency) => {
|
||||
job.stop();
|
||||
console.log(`Stopped scheduler for frequency: ${frequency}`);
|
||||
});
|
||||
// Create job entries
|
||||
const jobEntries = createJobEntries();
|
||||
const jobs = new Map(jobEntries);
|
||||
|
||||
this.jobs.clear();
|
||||
this.isInitialized = false;
|
||||
console.log('Task scheduler stopped');
|
||||
// Start all jobs
|
||||
startJobs(jobs);
|
||||
|
||||
// Update state immutably
|
||||
schedulerState = {
|
||||
jobs,
|
||||
isInitialized: true
|
||||
};
|
||||
|
||||
console.log('Task scheduler initialized successfully');
|
||||
return schedulerState;
|
||||
};
|
||||
|
||||
// Function to stop scheduler (contains side effects)
|
||||
const stop = async () => {
|
||||
if (!schedulerState.isInitialized) {
|
||||
console.log('Task scheduler not initialized, nothing to stop');
|
||||
return schedulerState;
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.stop();
|
||||
await this.initialize();
|
||||
}
|
||||
console.log('Stopping task scheduler...');
|
||||
|
||||
// Stop all jobs
|
||||
stopJobs(schedulerState.jobs);
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
initialized: this.isInitialized,
|
||||
jobCount: this.jobs.size,
|
||||
jobs: Array.from(this.jobs.keys())
|
||||
};
|
||||
}
|
||||
}
|
||||
// Reset state immutably
|
||||
schedulerState = createSchedulerState();
|
||||
|
||||
console.log('Task scheduler stopped');
|
||||
return schedulerState;
|
||||
};
|
||||
|
||||
module.exports = TaskScheduler;
|
||||
// Function to restart scheduler
|
||||
const restart = async () => {
|
||||
await stop();
|
||||
return await initialize();
|
||||
};
|
||||
|
||||
// Get scheduler status
|
||||
const getStatus = () => ({
|
||||
initialized: schedulerState.isInitialized,
|
||||
jobCount: schedulerState.jobs.size,
|
||||
jobs: Array.from(schedulerState.jobs.keys())
|
||||
});
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
initialize,
|
||||
stop,
|
||||
restart,
|
||||
getStatus,
|
||||
processSummariesForFrequency,
|
||||
// For testing
|
||||
_createSchedulerState: createSchedulerState,
|
||||
_shouldDisableScheduler: shouldDisableScheduler,
|
||||
_getCronExpression: getCronExpression
|
||||
};
|
||||
|
|
@ -2,237 +2,285 @@ const { User, Task, Project, Tag } = require('../models');
|
|||
const { Op } = require('sequelize');
|
||||
const TelegramPoller = require('./telegramPoller');
|
||||
|
||||
class TaskSummaryService {
|
||||
// Helper method to escape special characters for MarkdownV2
|
||||
static escapeMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
|
||||
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
|
||||
}
|
||||
// escape markdown special characters
|
||||
const escapeMarkdown = (text) => {
|
||||
if (!text) return '';
|
||||
// Characters that need to be escaped in MarkdownV2: _*[]()~`>#+-=|{}.!
|
||||
return text.toString().replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
|
||||
};
|
||||
|
||||
static async generateSummaryForUser(userId) {
|
||||
try {
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) return null;
|
||||
// get priority emoji
|
||||
const getPriorityEmoji = (priority) => {
|
||||
const emojiMap = {
|
||||
2: '🔴', // high
|
||||
1: '🟠', // medium
|
||||
0: '🟢' // low
|
||||
};
|
||||
return emojiMap[priority] || '⚪';
|
||||
};
|
||||
|
||||
// Get today's date
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
// create date range for today
|
||||
const createTodayDateRange = () => {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
return { today, tomorrow };
|
||||
};
|
||||
|
||||
// Get today's tasks, in progress tasks, etc.
|
||||
const dueToday = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
due_date: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
},
|
||||
status: { [Op.ne]: 2 } // not done
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
// format task for display
|
||||
const formatTaskForDisplay = (task, index, includeStatus = false) => {
|
||||
const priorityEmoji = getPriorityEmoji(task.priority);
|
||||
const statusEmoji = includeStatus ? '✅ ' : '';
|
||||
const taskName = escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
return `${index + 1}\\. ${statusEmoji}${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
};
|
||||
|
||||
const inProgress = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 1 // in_progress
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
// build task section
|
||||
const buildTaskSection = (tasks, title, includeStatus = false) => {
|
||||
if (tasks.length === 0) return '';
|
||||
|
||||
let section = `${title}\n`;
|
||||
section += tasks.map((task, index) =>
|
||||
formatTaskForDisplay(task, index, includeStatus)
|
||||
).join('');
|
||||
section += '\n';
|
||||
|
||||
return section;
|
||||
};
|
||||
|
||||
const completedToday = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 2, // done
|
||||
updated_at: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
}
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
// build summary message
|
||||
const buildSummaryMessage = (taskSections) => {
|
||||
let message = "📋 *Today's Task Summary*\n\n";
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
|
||||
message += "✏️ *Today's Plan*\n\n";
|
||||
|
||||
message += taskSections.dueToday;
|
||||
message += taskSections.inProgress;
|
||||
message += taskSections.suggested;
|
||||
message += taskSections.completed;
|
||||
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||||
message += "🎯 *Stay focused and make it happen\\!*";
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
// Generate summary message
|
||||
let message = "📋 *Today's Task Summary*\n\n";
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
|
||||
message += "✏️ *Today's Plan*\n\n";
|
||||
// calculate next run time
|
||||
const calculateNextRunTime = (user, fromTime = new Date()) => {
|
||||
const frequency = user.task_summary_frequency;
|
||||
const from = new Date(fromTime);
|
||||
|
||||
// Add due today tasks
|
||||
if (dueToday.length > 0) {
|
||||
message += "🚀 *Tasks Due Today:*\n";
|
||||
dueToday.forEach((task, index) => {
|
||||
const priorityEmoji = this.getPriorityEmoji(task.priority);
|
||||
const taskName = this.escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
});
|
||||
message += "\n";
|
||||
const calculations = {
|
||||
daily: () => {
|
||||
const nextDay = new Date(from);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
nextDay.setHours(7, 0, 0, 0);
|
||||
return nextDay;
|
||||
},
|
||||
|
||||
weekdays: () => {
|
||||
const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
let daysToAdd = 1;
|
||||
if (currentDay === 5) { // Friday
|
||||
daysToAdd = 3; // Skip to Monday
|
||||
} else if (currentDay === 6) { // Saturday
|
||||
daysToAdd = 2; // Skip to Monday
|
||||
}
|
||||
|
||||
// Add in progress tasks
|
||||
if (inProgress.length > 0) {
|
||||
message += "⚙️ *In Progress Tasks:*\n";
|
||||
inProgress.forEach((task, index) => {
|
||||
const priorityEmoji = this.getPriorityEmoji(task.priority);
|
||||
const taskName = this.escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
});
|
||||
message += "\n";
|
||||
}
|
||||
|
||||
// Get suggested tasks (not done, not in due today or in progress)
|
||||
const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)];
|
||||
|
||||
const suggestedTasks = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.ne]: 2 }, // not done
|
||||
id: { [Op.notIn]: excludedIds }
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['priority', 'DESC'], ['name', 'ASC']],
|
||||
limit: 5
|
||||
});
|
||||
|
||||
if (suggestedTasks.length > 0) {
|
||||
message += "💡 *Suggested Tasks:*\n";
|
||||
suggestedTasks.forEach((task, index) => {
|
||||
const priorityEmoji = this.getPriorityEmoji(task.priority);
|
||||
const taskName = this.escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
message += `${index + 1}\\. ${priorityEmoji} ${taskName}${projectInfo}\n`;
|
||||
});
|
||||
message += "\n";
|
||||
}
|
||||
|
||||
// Add completed tasks
|
||||
if (completedToday.length > 0) {
|
||||
message += "✅ *Completed Today:*\n";
|
||||
completedToday.forEach((task, index) => {
|
||||
const taskName = this.escapeMarkdown(task.name);
|
||||
const projectInfo = task.Project ? ` \\[${this.escapeMarkdown(task.Project.name)}\\]` : '';
|
||||
message += `${index + 1}\\. ✅ ${taskName}${projectInfo}\n`;
|
||||
});
|
||||
message += "\n";
|
||||
}
|
||||
|
||||
// Add footer
|
||||
message += "━━━━━━━━━━━━━━━━━━━━━━━━\n";
|
||||
message += "🎯 *Stay focused and make it happen\\!*";
|
||||
|
||||
return message;
|
||||
} catch (error) {
|
||||
console.error('Error generating task summary:', error);
|
||||
return null;
|
||||
const nextWeekday = new Date(from);
|
||||
nextWeekday.setDate(nextWeekday.getDate() + daysToAdd);
|
||||
nextWeekday.setHours(7, 0, 0, 0);
|
||||
return nextWeekday;
|
||||
},
|
||||
|
||||
weekly: () => {
|
||||
const nextWeek = new Date(from);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
nextWeek.setHours(7, 0, 0, 0);
|
||||
return nextWeek;
|
||||
},
|
||||
|
||||
'1h': () => {
|
||||
const nextHour = new Date(from);
|
||||
nextHour.setHours(nextHour.getHours() + 1);
|
||||
return nextHour;
|
||||
},
|
||||
|
||||
'2h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 2);
|
||||
return next;
|
||||
},
|
||||
|
||||
'4h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 4);
|
||||
return next;
|
||||
},
|
||||
|
||||
'8h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 8);
|
||||
return next;
|
||||
},
|
||||
|
||||
'12h': () => {
|
||||
const next = new Date(from);
|
||||
next.setHours(next.getHours() + 12);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
static getPriorityEmoji(priority) {
|
||||
switch (priority) {
|
||||
case 2: return '🔴'; // high
|
||||
case 1: return '🟠'; // medium
|
||||
case 0: return '🟢'; // low
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
const calculator = calculations[frequency];
|
||||
return calculator ? calculator() : calculations.daily();
|
||||
};
|
||||
|
||||
static async sendSummaryToUser(userId) {
|
||||
try {
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user || !user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return false;
|
||||
// Side effect function to fetch user by ID
|
||||
const fetchUser = async (userId) =>
|
||||
await User.findByPk(userId);
|
||||
|
||||
// Side effect function to fetch due today tasks
|
||||
const fetchDueTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
due_date: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
},
|
||||
status: { [Op.ne]: 2 } // not done
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
// Side effect function to fetch in progress tasks
|
||||
const fetchInProgressTasks = async (userId) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 1 // in_progress
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
// Side effect function to fetch completed today tasks
|
||||
const fetchCompletedTodayTasks = async (userId, today, tomorrow) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: 2, // done
|
||||
updated_at: {
|
||||
[Op.gte]: today,
|
||||
[Op.lt]: tomorrow
|
||||
}
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
||||
const summary = await this.generateSummaryForUser(userId);
|
||||
if (!summary) return false;
|
||||
// Side effect function to fetch suggested tasks
|
||||
const fetchSuggestedTasks = async (userId, excludedIds) =>
|
||||
await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.ne]: 2 }, // not done
|
||||
id: { [Op.notIn]: excludedIds }
|
||||
},
|
||||
include: [{ model: Project, attributes: ['name'] }],
|
||||
order: [['priority', 'DESC'], ['name', 'ASC']],
|
||||
limit: 5
|
||||
});
|
||||
|
||||
// Send the message via Telegram
|
||||
const poller = TelegramPoller.getInstance();
|
||||
await poller.sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
summary
|
||||
);
|
||||
// Side effect function to send telegram message
|
||||
const sendTelegramMessage = async (token, chatId, message) => {
|
||||
const poller = TelegramPoller;
|
||||
return await poller.sendTelegramMessage(token, chatId, message);
|
||||
};
|
||||
|
||||
// Update the last run time and calculate the next run time
|
||||
const now = new Date();
|
||||
const nextRun = this.calculateNextRunTime(user, now);
|
||||
// Side effect function to update user tracking fields
|
||||
const updateUserTracking = async (user, lastRun, nextRun) =>
|
||||
await user.update({
|
||||
task_summary_last_run: lastRun,
|
||||
task_summary_next_run: nextRun
|
||||
});
|
||||
|
||||
// Update the user's tracking fields
|
||||
await user.update({
|
||||
task_summary_last_run: now,
|
||||
task_summary_next_run: nextRun
|
||||
});
|
||||
// Function to generate summary for user (contains side effects)
|
||||
const generateSummaryForUser = async (userId) => {
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user) return null;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending task summary to user ${userId}:`, error.message);
|
||||
const { today, tomorrow } = createTodayDateRange();
|
||||
|
||||
// Fetch all task data in parallel
|
||||
const [dueToday, inProgress, completedToday] = await Promise.all([
|
||||
fetchDueTodayTasks(userId, today, tomorrow),
|
||||
fetchInProgressTasks(userId),
|
||||
fetchCompletedTodayTasks(userId, today, tomorrow)
|
||||
]);
|
||||
|
||||
// Get suggested tasks (excluding already fetched ones)
|
||||
const excludedIds = [...dueToday.map(t => t.id), ...inProgress.map(t => t.id)];
|
||||
const suggestedTasks = await fetchSuggestedTasks(userId, excludedIds);
|
||||
|
||||
// Build task sections
|
||||
const taskSections = {
|
||||
dueToday: buildTaskSection(dueToday, "🚀 *Tasks Due Today:*"),
|
||||
inProgress: buildTaskSection(inProgress, "⚙️ *In Progress Tasks:*"),
|
||||
suggested: buildTaskSection(suggestedTasks, "💡 *Suggested Tasks:*"),
|
||||
completed: buildTaskSection(completedToday, "✅ *Completed Today:*", true)
|
||||
};
|
||||
|
||||
return buildSummaryMessage(taskSections);
|
||||
} catch (error) {
|
||||
console.error('Error generating task summary:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to send summary to user (contains side effects)
|
||||
const sendSummaryToUser = async (userId) => {
|
||||
try {
|
||||
const user = await fetchUser(userId);
|
||||
if (!user || !user.telegram_bot_token || !user.telegram_chat_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const summary = await generateSummaryForUser(userId);
|
||||
if (!summary) return false;
|
||||
|
||||
// Send the message via Telegram
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
user.telegram_chat_id,
|
||||
summary
|
||||
);
|
||||
|
||||
// Update tracking fields
|
||||
const now = new Date();
|
||||
const nextRun = calculateNextRunTime(user, now);
|
||||
await updateUserTracking(user, now, nextRun);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error sending task summary to user ${userId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
static calculateNextRunTime(user, fromTime = new Date()) {
|
||||
const frequency = user.task_summary_frequency;
|
||||
const from = new Date(fromTime);
|
||||
|
||||
switch (frequency) {
|
||||
case 'daily':
|
||||
// Next day at 7 AM
|
||||
const nextDay = new Date(from);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
nextDay.setHours(7, 0, 0, 0);
|
||||
return nextDay;
|
||||
|
||||
case 'weekdays':
|
||||
// Next weekday at 7 AM
|
||||
const currentDay = from.getDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
let daysToAdd = 1;
|
||||
if (currentDay === 5) { // Friday
|
||||
daysToAdd = 3; // Skip to Monday
|
||||
} else if (currentDay === 6) { // Saturday
|
||||
daysToAdd = 2; // Skip to Monday
|
||||
}
|
||||
const nextWeekday = new Date(from);
|
||||
nextWeekday.setDate(nextWeekday.getDate() + daysToAdd);
|
||||
nextWeekday.setHours(7, 0, 0, 0);
|
||||
return nextWeekday;
|
||||
|
||||
case 'weekly':
|
||||
// Next Monday at 7 AM
|
||||
const nextWeek = new Date(from);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
nextWeek.setHours(7, 0, 0, 0);
|
||||
return nextWeek;
|
||||
|
||||
case '1h':
|
||||
return new Date(from.getTime() + 60 * 60 * 1000);
|
||||
|
||||
case '2h':
|
||||
return new Date(from.getTime() + 2 * 60 * 60 * 1000);
|
||||
|
||||
case '4h':
|
||||
return new Date(from.getTime() + 4 * 60 * 60 * 1000);
|
||||
|
||||
case '8h':
|
||||
return new Date(from.getTime() + 8 * 60 * 60 * 1000);
|
||||
|
||||
case '12h':
|
||||
return new Date(from.getTime() + 12 * 60 * 60 * 1000);
|
||||
|
||||
default:
|
||||
// Default to daily
|
||||
const defaultNext = new Date(from);
|
||||
defaultNext.setDate(defaultNext.getDate() + 1);
|
||||
defaultNext.setHours(7, 0, 0, 0);
|
||||
return defaultNext;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskSummaryService;
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
generateSummaryForUser,
|
||||
sendSummaryToUser,
|
||||
calculateNextRunTime,
|
||||
// For testing
|
||||
_escapeMarkdown: escapeMarkdown,
|
||||
_getPriorityEmoji: getPriorityEmoji,
|
||||
_createTodayDateRange: createTodayDateRange,
|
||||
_formatTaskForDisplay: formatTaskForDisplay,
|
||||
_buildTaskSection: buildTaskSection,
|
||||
_buildSummaryMessage: buildSummaryMessage
|
||||
};
|
||||
|
|
@ -1,261 +1,369 @@
|
|||
const https = require('https');
|
||||
const { User, InboxItem } = require('../models');
|
||||
|
||||
class TelegramPoller {
|
||||
constructor() {
|
||||
this.running = false;
|
||||
this.interval = null;
|
||||
this.pollInterval = 5000; // 5 seconds
|
||||
this.usersToPool = [];
|
||||
this.userStatus = {};
|
||||
// Create poller state
|
||||
const createPollerState = () => ({
|
||||
running: false,
|
||||
interval: null,
|
||||
pollInterval: 5000, // 5 seconds
|
||||
usersToPool: [],
|
||||
userStatus: {}
|
||||
});
|
||||
|
||||
// Global mutable state (managed functionally)
|
||||
let pollerState = createPollerState();
|
||||
|
||||
// Check if user exists in list
|
||||
const userExistsInList = (users, userId) =>
|
||||
users.some(u => u.id === userId);
|
||||
|
||||
// Add user to list
|
||||
const addUserToList = (users, user) => {
|
||||
if (userExistsInList(users, user.id)) {
|
||||
return users;
|
||||
}
|
||||
return [...users, user];
|
||||
};
|
||||
|
||||
// Singleton pattern
|
||||
static getInstance() {
|
||||
if (!TelegramPoller.instance) {
|
||||
TelegramPoller.instance = new TelegramPoller();
|
||||
}
|
||||
return TelegramPoller.instance;
|
||||
// Remove user from list
|
||||
const removeUserFromList = (users, userId) =>
|
||||
users.filter(u => u.id !== userId);
|
||||
|
||||
// Remove user status
|
||||
const removeUserStatus = (userStatus, userId) => {
|
||||
const { [userId]: removed, ...rest } = userStatus;
|
||||
return rest;
|
||||
};
|
||||
|
||||
// Update user status
|
||||
const updateUserStatus = (userStatus, userId, updates) => ({
|
||||
...userStatus,
|
||||
[userId]: {
|
||||
...userStatus[userId],
|
||||
...updates
|
||||
}
|
||||
});
|
||||
|
||||
// Add user to polling list
|
||||
async addUser(user) {
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return false;
|
||||
}
|
||||
// Get highest update ID from updates
|
||||
const getHighestUpdateId = (updates) => {
|
||||
if (!updates.length) return 0;
|
||||
return Math.max(...updates.map(u => u.update_id));
|
||||
};
|
||||
|
||||
// Check if user already in list
|
||||
const exists = this.usersToPool.find(u => u.id === user.id);
|
||||
if (!exists) {
|
||||
this.usersToPool.push(user);
|
||||
}
|
||||
|
||||
// Start polling if not already running
|
||||
if (this.usersToPool.length > 0 && !this.running) {
|
||||
this.startPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
// Create message parameters
|
||||
const createMessageParams = (chatId, text, replyToMessageId = null) => {
|
||||
const params = { chat_id: chatId, text: text };
|
||||
if (replyToMessageId) {
|
||||
params.reply_to_message_id = replyToMessageId;
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
// Remove user from polling list
|
||||
removeUser(userId) {
|
||||
this.usersToPool = this.usersToPool.filter(u => u.id !== userId);
|
||||
delete this.userStatus[userId];
|
||||
// Create Telegram API URL
|
||||
const createTelegramUrl = (token, endpoint, params = {}) => {
|
||||
const baseUrl = `https://api.telegram.org/bot${token}/${endpoint}`;
|
||||
if (Object.keys(params).length === 0) return baseUrl;
|
||||
|
||||
const searchParams = new URLSearchParams(params);
|
||||
return `${baseUrl}?${searchParams}`;
|
||||
};
|
||||
|
||||
// Stop polling if no users left
|
||||
if (this.usersToPool.length === 0 && this.running) {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Start the polling process
|
||||
startPolling() {
|
||||
if (this.running) return;
|
||||
|
||||
console.log('Starting Telegram polling...');
|
||||
this.running = true;
|
||||
|
||||
this.interval = setInterval(async () => {
|
||||
try {
|
||||
await this.pollUpdates();
|
||||
} catch (error) {
|
||||
console.error('Error polling Telegram:', error.message);
|
||||
}
|
||||
}, this.pollInterval);
|
||||
}
|
||||
|
||||
// Stop the polling process
|
||||
stopPolling() {
|
||||
if (!this.running) return;
|
||||
|
||||
console.log('Stopping Telegram polling...');
|
||||
this.running = false;
|
||||
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll for updates from Telegram
|
||||
async pollUpdates() {
|
||||
for (const user of this.usersToPool) {
|
||||
const token = user.telegram_bot_token;
|
||||
if (!token) continue;
|
||||
|
||||
try {
|
||||
const lastUpdateId = this.userStatus[user.id]?.lastUpdateId || 0;
|
||||
const updates = await this.getTelegramUpdates(token, lastUpdateId + 1);
|
||||
|
||||
if (updates && updates.length > 0) {
|
||||
await this.processUpdates(user, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error getting updates for user ${user.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get updates from Telegram API
|
||||
getTelegramUpdates(token, offset) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=1`;
|
||||
// Side effect function to make HTTP GET request
|
||||
const makeHttpGetRequest = (url, timeout = 5000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
https.get(url, { timeout }, (res) => {
|
||||
let data = '';
|
||||
|
||||
https.get(url, { timeout: 5000 }, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response.ok && Array.isArray(response.result)) {
|
||||
resolve(response.result);
|
||||
} else {
|
||||
console.error('Telegram API error:', response);
|
||||
resolve([]);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
reject(error);
|
||||
}).on('timeout', () => {
|
||||
reject(new Error('Request timeout'));
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
reject(error);
|
||||
}).on('timeout', () => {
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Side effect function to make HTTP POST request
|
||||
const makeHttpPostRequest = (url, postData, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Process updates received from Telegram
|
||||
async processUpdates(user, updates) {
|
||||
if (!updates.length) return;
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
};
|
||||
|
||||
// Track the highest update_id
|
||||
const highestUpdateId = Math.max(...updates.map(u => u.update_id));
|
||||
// Side effect function to get Telegram updates
|
||||
const getTelegramUpdates = async (token, offset) => {
|
||||
try {
|
||||
const url = createTelegramUrl(token, 'getUpdates', {
|
||||
offset: offset.toString(),
|
||||
timeout: '1'
|
||||
});
|
||||
|
||||
// Save the last update ID for this user
|
||||
if (!this.userStatus[user.id]) {
|
||||
this.userStatus[user.id] = {};
|
||||
const response = await makeHttpGetRequest(url, 5000);
|
||||
|
||||
if (response.ok && Array.isArray(response.result)) {
|
||||
return response.result;
|
||||
} else {
|
||||
console.error('Telegram API error:', response);
|
||||
return [];
|
||||
}
|
||||
this.userStatus[user.id].lastUpdateId = highestUpdateId;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
for (const update of updates) {
|
||||
try {
|
||||
if (update.message && update.message.text) {
|
||||
await this.processMessage(user, update);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing update ${update.update_id}:`, error.message);
|
||||
// Side effect function to send Telegram message
|
||||
const sendTelegramMessage = async (token, chatId, text, replyToMessageId = null) => {
|
||||
try {
|
||||
const messageParams = createMessageParams(chatId, text, replyToMessageId);
|
||||
const postData = JSON.stringify(messageParams);
|
||||
const url = createTelegramUrl(token, 'sendMessage');
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await makeHttpPostRequest(url, postData, options);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Side effect function to update user chat ID
|
||||
const updateUserChatId = async (userId, chatId) => {
|
||||
await User.update(
|
||||
{ telegram_chat_id: chatId },
|
||||
{ where: { id: userId } }
|
||||
);
|
||||
};
|
||||
|
||||
// Side effect function to create inbox item
|
||||
const createInboxItem = async (content, userId) => {
|
||||
return await InboxItem.create({
|
||||
content: content,
|
||||
source: 'telegram',
|
||||
user_id: userId
|
||||
});
|
||||
};
|
||||
|
||||
// Function to process a single message (contains side effects)
|
||||
const processMessage = async (user, update) => {
|
||||
const message = update.message;
|
||||
const text = message.text;
|
||||
const chatId = message.chat.id.toString();
|
||||
const messageId = message.message_id;
|
||||
|
||||
console.log(`Processing message from user ${user.id}: ${text}`);
|
||||
|
||||
// Update chat ID if needed
|
||||
if (!user.telegram_chat_id) {
|
||||
console.log(`Updating user's telegram_chat_id to ${chatId}`);
|
||||
await updateUserChatId(user.id, chatId);
|
||||
user.telegram_chat_id = chatId; // Update local object
|
||||
}
|
||||
|
||||
// Process a single message
|
||||
async processMessage(user, update) {
|
||||
const message = update.message;
|
||||
const text = message.text;
|
||||
const chatId = message.chat.id.toString();
|
||||
const messageId = message.message_id;
|
||||
try {
|
||||
// Create inbox item
|
||||
const inboxItem = await createInboxItem(text, user.id);
|
||||
console.log(`Created inbox item ${inboxItem.id} from Telegram message`);
|
||||
|
||||
console.log(`Processing message from user ${user.id}: ${text}`);
|
||||
// Send confirmation
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`✅ Added to Tududi inbox: "${text}"`,
|
||||
messageId
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create inbox item:', error.message);
|
||||
|
||||
// Save the chat_id if not already saved
|
||||
if (!user.telegram_chat_id) {
|
||||
console.log(`Updating user's telegram_chat_id to ${chatId}`);
|
||||
await User.update(
|
||||
{ telegram_chat_id: chatId },
|
||||
{ where: { id: user.id } }
|
||||
);
|
||||
user.telegram_chat_id = chatId; // Update local object
|
||||
// Send error message
|
||||
await sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`❌ Failed to add to inbox: ${error.message}`,
|
||||
messageId
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to process updates (contains side effects)
|
||||
const processUpdates = async (user, updates) => {
|
||||
if (!updates.length) return;
|
||||
|
||||
// Get highest update ID
|
||||
const highestUpdateId = getHighestUpdateId(updates);
|
||||
|
||||
// Update user status
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
userStatus: updateUserStatus(pollerState.userStatus, user.id, {
|
||||
lastUpdateId: highestUpdateId
|
||||
})
|
||||
};
|
||||
|
||||
// Process each update
|
||||
for (const update of updates) {
|
||||
try {
|
||||
if (update.message && update.message.text) {
|
||||
await processMessage(user, update);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing update ${update.update_id}:`, error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to poll updates for all users (contains side effects)
|
||||
const pollUpdates = async () => {
|
||||
for (const user of pollerState.usersToPool) {
|
||||
const token = user.telegram_bot_token;
|
||||
if (!token) continue;
|
||||
|
||||
try {
|
||||
// Create an inbox item
|
||||
const inboxItem = await InboxItem.create({
|
||||
content: text,
|
||||
source: 'telegram',
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
console.log(`Created inbox item ${inboxItem.id} from Telegram message`);
|
||||
|
||||
// Send confirmation
|
||||
await this.sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`✅ Added to Tududi inbox: "${text}"`,
|
||||
messageId
|
||||
);
|
||||
const lastUpdateId = pollerState.userStatus[user.id]?.lastUpdateId || 0;
|
||||
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
|
||||
|
||||
if (updates && updates.length > 0) {
|
||||
await processUpdates(user, updates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create inbox item:', error.message);
|
||||
|
||||
// Send error message
|
||||
await this.sendTelegramMessage(
|
||||
user.telegram_bot_token,
|
||||
chatId,
|
||||
`❌ Failed to add to inbox: ${error.message}`,
|
||||
messageId
|
||||
);
|
||||
console.error(`Error getting updates for user ${user.id}:`, error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Send a message to Telegram
|
||||
sendTelegramMessage(token, chatId, text, replyToMessageId = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageParams = {
|
||||
chat_id: chatId,
|
||||
text: text
|
||||
};
|
||||
// Function to start polling (contains side effects)
|
||||
const startPolling = () => {
|
||||
if (pollerState.running) return;
|
||||
|
||||
if (replyToMessageId) {
|
||||
messageParams.reply_to_message_id = replyToMessageId;
|
||||
}
|
||||
console.log('Starting Telegram polling...');
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await pollUpdates();
|
||||
} catch (error) {
|
||||
console.error('Error polling Telegram:', error.message);
|
||||
}
|
||||
}, pollerState.pollInterval);
|
||||
|
||||
const postData = JSON.stringify(messageParams);
|
||||
const url = `https://api.telegram.org/bot${token}/sendMessage`;
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData)
|
||||
}
|
||||
};
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: true,
|
||||
interval
|
||||
};
|
||||
};
|
||||
|
||||
const req = https.request(url, options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => data += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Function to stop polling (contains side effects)
|
||||
const stopPolling = () => {
|
||||
if (!pollerState.running) return;
|
||||
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
console.log('Stopping Telegram polling...');
|
||||
|
||||
if (pollerState.interval) {
|
||||
clearInterval(pollerState.interval);
|
||||
}
|
||||
|
||||
// Get status of the poller
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.running,
|
||||
usersCount: this.usersToPool.length,
|
||||
pollInterval: this.pollInterval,
|
||||
userStatus: this.userStatus
|
||||
};
|
||||
}
|
||||
}
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
running: false,
|
||||
interval: null
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = TelegramPoller;
|
||||
// Function to add user (contains side effects)
|
||||
const addUser = async (user) => {
|
||||
if (!user || !user.telegram_bot_token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add user to list
|
||||
const newUsersList = addUserToList(pollerState.usersToPool, user);
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList
|
||||
};
|
||||
|
||||
// Start polling if not already running and we have users
|
||||
if (pollerState.usersToPool.length > 0 && !pollerState.running) {
|
||||
startPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Function to remove user (contains side effects)
|
||||
const removeUser = (userId) => {
|
||||
// Remove user from list and status
|
||||
const newUsersList = removeUserFromList(pollerState.usersToPool, userId);
|
||||
const newUserStatus = removeUserStatus(pollerState.userStatus, userId);
|
||||
|
||||
pollerState = {
|
||||
...pollerState,
|
||||
usersToPool: newUsersList,
|
||||
userStatus: newUserStatus
|
||||
};
|
||||
|
||||
// Stop polling if no users left
|
||||
if (pollerState.usersToPool.length === 0 && pollerState.running) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Get poller status
|
||||
const getStatus = () => ({
|
||||
running: pollerState.running,
|
||||
usersCount: pollerState.usersToPool.length,
|
||||
pollInterval: pollerState.pollInterval,
|
||||
userStatus: pollerState.userStatus
|
||||
});
|
||||
|
||||
// Export functional interface
|
||||
module.exports = {
|
||||
addUser,
|
||||
removeUser,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
getStatus,
|
||||
sendTelegramMessage,
|
||||
// For testing
|
||||
_createPollerState: createPollerState,
|
||||
_userExistsInList: userExistsInList,
|
||||
_addUserToList: addUserToList,
|
||||
_removeUserFromList: removeUserFromList,
|
||||
_getHighestUpdateId: getHighestUpdateId,
|
||||
_createMessageParams: createMessageParams,
|
||||
_createTelegramUrl: createTelegramUrl
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue