tududi/backend/modules/tasks/taskSummaryService.js
Chris 782bed235b
Fix Telegram task display bug by escaping backslashes (#860)
* Fix Telegram task display bug by escaping backslashes

* fixup! Fix Telegram task display bug by escaping backslashes
2026-02-24 14:19:45 +02:00

350 lines
11 KiB
JavaScript

const { User, Task, Project, Tag } = require('../../models');
const { Op } = require('sequelize');
const TelegramPoller = require('../telegram/telegramPoller');
// escape markdown special characters
const escapeMarkdown = (text) => {
if (!text) return '';
// Characters that need to be escaped in MarkdownV2: \ _*[]()~`>#+-=|{}.!
// Backslash must be escaped first to avoid double-escaping
return text
.toString()
.replace(/\\/g, '\\\\')
.replace(/([_*\[\]()~`>#+\-=|{}.!])/g, '\\$1');
};
// get priority emoji
const getPriorityEmoji = (priority) => {
const emojiMap = {
2: '🔴', // high
1: '🟠', // medium
0: '🟢', // low
};
return emojiMap[priority] || '⚪';
};
// 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 };
};
// 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`;
};
// 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;
};
// 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;
};
// calculate next run time
const calculateNextRunTime = (user, fromTime = new Date()) => {
const frequency = user.task_summary_frequency;
const from = new Date(fromTime);
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
}
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;
},
};
const calculator = calculations[frequency];
return calculator ? calculator() : calculations.daily();
};
// 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
parent_task_id: null,
updated_at: {
[Op.gte]: today,
[Op.lt]: tomorrow,
},
},
include: [{ model: Project, attributes: ['name'] }],
order: [['name', 'ASC']],
});
// Side effect function to fetch suggested tasks
const fetchSuggestedTasks = async (userId, excludedIds) => {
// Create date limit for suggested tasks (30 days from now)
const maxSuggestionDate = new Date();
maxSuggestionDate.setDate(maxSuggestionDate.getDate() + 30);
return await Task.findAll({
where: {
user_id: userId,
status: { [Op.ne]: 2 }, // not done
id: { [Op.notIn]: excludedIds },
[Op.or]: [
{ due_date: null }, // tasks without due dates
{ due_date: { [Op.lte]: maxSuggestionDate } }, // tasks due within 30 days
],
},
include: [{ model: Project, attributes: ['name'] }],
order: [
['priority', 'DESC'],
['name', 'ASC'],
],
limit: 5,
});
};
// Side effect function to send telegram message
const sendTelegramMessage = async (
token,
chatId,
message,
replyToMessageId = null,
options = {}
) => {
const poller = TelegramPoller;
return await poller.sendTelegramMessage(
token,
chatId,
message,
replyToMessageId,
options
);
};
// 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,
});
// Function to generate summary for user (contains side effects)
const generateSummaryForUser = async (userId) => {
try {
const user = await fetchUser(userId);
if (!user) return null;
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 with MarkdownV2 formatting
// If MarkdownV2 parsing fails (e.g. unescaped characters), retry as plain text
try {
await sendTelegramMessage(
user.telegram_bot_token,
user.telegram_chat_id,
summary,
null,
{ parseMode: 'MarkdownV2' }
);
} catch (markdownError) {
console.warn(
`MarkdownV2 send failed for user ${userId}, retrying as plain text:`,
markdownError.message
);
const plainSummary = summary.replace(
/\\([_*\[\]()~`>#+\-=|{}.!\\])/g,
'$1'
);
await sendTelegramMessage(
user.telegram_bot_token,
user.telegram_chat_id,
plainSummary
);
}
// 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;
}
};
// Export functional interface
module.exports = {
generateSummaryForUser,
sendSummaryToUser,
calculateNextRunTime,
// For testing
_escapeMarkdown: escapeMarkdown,
_getPriorityEmoji: getPriorityEmoji,
_createTodayDateRange: createTodayDateRange,
_formatTaskForDisplay: formatTaskForDisplay,
_buildTaskSection: buildTaskSection,
_buildSummaryMessage: buildSummaryMessage,
};