* Fix Telegram task display bug by escaping backslashes * fixup! Fix Telegram task display bug by escaping backslashes
350 lines
11 KiB
JavaScript
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,
|
|
};
|