41
services/debugLog.js
Normal file
41
services/debugLog.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Send error details to DEBUGGING_CHANNEL_ID when set.
|
||||
* Call setClient(client) from the main bot on ready so errors can be posted.
|
||||
*/
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
let client = null;
|
||||
|
||||
function setClient(c) {
|
||||
client = c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post an error to the debugging channel (if DEBUGGING_CHANNEL_ID and client are set).
|
||||
* @param {string} context - e.g. 'escalate', 'deescalate', 'email-routing', 'Gmail poll'
|
||||
* @param {Error} error
|
||||
* @param {import('discord.js').Interaction} [interaction]
|
||||
* @param {import('discord.js').Client} [overrideClient] - use this client instead of stored (e.g. from gmail-poll)
|
||||
*/
|
||||
async function logError(context, error, interaction = null, overrideClient = null) {
|
||||
const c = overrideClient || client;
|
||||
if (!c || !CONFIG.DEBUGGING_CHANNEL_ID) return;
|
||||
|
||||
try {
|
||||
const channel = await c.channels.fetch(CONFIG.DEBUGGING_CHANNEL_ID);
|
||||
const userLine = interaction?.user?.tag
|
||||
? `User: ${interaction.user.tag}\n`
|
||||
: '';
|
||||
const commandLine = (interaction?.commandName || interaction?.customId)
|
||||
? `Command/Button: ${interaction.commandName || interaction.customId}\n`
|
||||
: '';
|
||||
const stack = (error.stack || error.message || String(error)).slice(0, 1500);
|
||||
await channel.send({
|
||||
content: `\`[${context}]\` ${error.message || String(error)}\n${userLine}${commandLine}\n\`\`\`${stack}\`\`\``
|
||||
});
|
||||
} catch (_) {
|
||||
// ignore send failures
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setClient, logError };
|
||||
234
services/gmail.js
Normal file
234
services/gmail.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Gmail service – OAuth client, send reply, send ticket-closed email.
|
||||
*/
|
||||
const { google } = require('googleapis');
|
||||
const { CONFIG } = require('../config');
|
||||
const { extractRawEmail } = require('../utils');
|
||||
|
||||
function getGmailClient() {
|
||||
const auth = new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET
|
||||
);
|
||||
auth.setCredentials({ refresh_token: CONFIG.REFRESH_TOKEN });
|
||||
return google.gmail({ version: 'v1', auth });
|
||||
}
|
||||
|
||||
async function sendGmailReply(
|
||||
threadId,
|
||||
replyText,
|
||||
recipientEmail,
|
||||
subject,
|
||||
discordUser,
|
||||
messageId
|
||||
) {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
`Re: ${subject}`
|
||||
).toString('base64')}?=`;
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${discordUser} on Discord</p>
|
||||
<p>${replyText.replace(/\n/g, '<br>')}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${discordUser}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const headers = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
messageId ? `In-Reply-To: ${messageId}` : '',
|
||||
messageId ? `References: ${messageId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(headers.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId }
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
|
||||
// Send to the ticket sender (customer), not derived from thread (which can be support)
|
||||
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||
}
|
||||
} catch (_) {
|
||||
/* use ticket.subject and no In-Reply-To if thread fetch fails */
|
||||
}
|
||||
|
||||
const finalSubject = `${CONFIG.TICKET_CLOSE_SUBJECT_PREFIX} ${subjectHeader}`;
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(
|
||||
finalSubject
|
||||
).toString('base64')}?=`;
|
||||
|
||||
const serverDisplayName = discordDisplayName || 'Support';
|
||||
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>${CONFIG.TICKET_CLOSE_MESSAGE.replace(/\n/g, '<br>')}</p>
|
||||
<p style="margin-top: 16px;">${CONFIG.TICKET_CLOSE_SIGNATURE}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${serverDisplayName}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket closed email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification email in the ticket thread (e.g. escalation, high-priority).
|
||||
* @param {Object} ticket - Ticket with gmailThreadId, senderEmail, subject
|
||||
* @param {string} subjectLine - Subject line (e.g. "Ticket escalated" or "Priority updated")
|
||||
* @param {string} messageBody - Plain or HTML message body
|
||||
* @param {string} [fromLabel] - Label for "From" (e.g. "Support on Discord")
|
||||
*/
|
||||
async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fromLabel) {
|
||||
try {
|
||||
const gmail = getGmailClient();
|
||||
const recipientEmail = extractRawEmail(ticket.senderEmail || '').toLowerCase();
|
||||
if (!recipientEmail || recipientEmail === CONFIG.MY_EMAIL) return;
|
||||
|
||||
let subjectHeader = ticket.subject || 'Support';
|
||||
let msgId = null;
|
||||
try {
|
||||
const thread = await gmail.users.threads.get({
|
||||
userId: 'me',
|
||||
id: ticket.gmailThreadId
|
||||
});
|
||||
const messages = thread.data.messages || [];
|
||||
const lastMsg = [...messages].reverse()[0];
|
||||
if (lastMsg?.payload?.headers) {
|
||||
const subj = lastMsg.payload.headers.find(h => h.name === 'Subject')?.value;
|
||||
if (subj) subjectHeader = subj;
|
||||
msgId = lastMsg.payload.headers.find(h => h.name === 'Message-ID')?.value;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const finalSubject = subjectLine || subjectHeader;
|
||||
const utf8Subject = `=?utf-8?B?${Buffer.from(finalSubject).toString('base64')}?=`;
|
||||
const label = fromLabel || CONFIG.SUPPORT_NAME || 'Support';
|
||||
const htmlBody = `
|
||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||
<p><strong>From:</strong> ${label} on Discord</p>
|
||||
<p>${(messageBody || '').replace(/\n/g, '<br>')}</p>
|
||||
<hr style="border:none; border-top:1px solid #ddd; margin:20px 0;">
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding-right: 12px;">
|
||||
<img src="${CONFIG.LOGO_URL}" width="65">
|
||||
</td>
|
||||
<td style="border-left: 1px solid #ddd; padding-left: 12px;">
|
||||
<p style="margin: 0; font-weight: bold;">${label}</p>
|
||||
<div style="color: #666; font-size: 12px;">${CONFIG.SIGNATURE}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>`;
|
||||
|
||||
const rawHeaders = [
|
||||
`From: ${CONFIG.MY_EMAIL}`,
|
||||
`To: ${recipientEmail}`,
|
||||
`Subject: ${utf8Subject}`,
|
||||
msgId ? `In-Reply-To: ${msgId}` : '',
|
||||
msgId ? `References: ${msgId}` : '',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
htmlBody
|
||||
].filter(Boolean);
|
||||
|
||||
const raw = Buffer.from(rawHeaders.join('\r\n'))
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
await gmail.users.messages.send({
|
||||
userId: 'me',
|
||||
requestBody: { raw, threadId: ticket.gmailThreadId }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ticket notification email error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGmailClient,
|
||||
sendGmailReply,
|
||||
sendTicketClosedEmail,
|
||||
sendTicketNotificationEmail
|
||||
};
|
||||
34
services/guildSettings.js
Normal file
34
services/guildSettings.js
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Guild-specific settings (e.g. email ticket routing).
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
|
||||
const GuildSettings = mongoose.model('GuildSettings');
|
||||
|
||||
/**
|
||||
* Get email ticket routing for a guild. Returns 'thread' or 'category'.
|
||||
* If not set, defaults from CONFIG: thread if EMAIL_THREAD_CHANNEL_ID is set, else category.
|
||||
* @param {string} guildId
|
||||
* @returns {Promise<'thread'|'category'>}
|
||||
*/
|
||||
async function getEmailRouting(guildId) {
|
||||
const doc = await GuildSettings.findOne({ guildId }).select('emailRouting').lean();
|
||||
if (doc && doc.emailRouting) return doc.emailRouting;
|
||||
return CONFIG.EMAIL_THREAD_CHANNEL_ID ? 'thread' : 'category';
|
||||
}
|
||||
|
||||
/**
|
||||
* Set email ticket routing for a guild.
|
||||
* @param {string} guildId
|
||||
* @param {'thread'|'category'} value
|
||||
*/
|
||||
async function setEmailRouting(guildId, value) {
|
||||
await GuildSettings.findOneAndUpdate(
|
||||
{ guildId },
|
||||
{ $set: { emailRouting: value, updatedAt: new Date() } },
|
||||
{ upsert: true, new: true }
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getEmailRouting, setEmailRouting };
|
||||
427
services/tickets.js
Normal file
427
services/tickets.js
Normal file
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Ticket database helpers – counters, rename, limits, auto-close,
|
||||
* reminders, auto-unclaim, channel creation.
|
||||
*/
|
||||
const { ChannelType, PermissionFlagsBits } = require('discord.js');
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { CONFIG } = require('../config');
|
||||
const { getPriorityEmoji } = require('../utils');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
const TicketCounter = mongoose.model('TicketCounter');
|
||||
|
||||
// --- TICKET NUMBER ---
|
||||
|
||||
async function getNextTicketNumber(senderEmail) {
|
||||
const senderLocal = senderEmail.split('@')[0].toLowerCase();
|
||||
const counter = await TicketCounter.findOneAndUpdate(
|
||||
{ senderLocal },
|
||||
{ $inc: { counter: 1 } },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
||||
);
|
||||
return { local: senderLocal, number: counter.counter };
|
||||
}
|
||||
|
||||
async function saveZammadId(gmailThreadId, zammadId) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId },
|
||||
{ $set: { zammadTicketId: zammadId } }
|
||||
);
|
||||
}
|
||||
|
||||
// --- RENAME + NAMING ---
|
||||
// Discord rate limit: 2 channel renames per 10 minutes per channel (see https://discord.com/developers/docs/topics/rate-limits).
|
||||
// When limit is reached we skip the rename and post: "Channel renamed too quickly. Try again <t:unlock:R>."
|
||||
|
||||
const RENAME_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const RENAME_LIMIT = 2;
|
||||
|
||||
function getSenderLocal(senderEmail) {
|
||||
return (senderEmail || 'unknown').split('@')[0].toLowerCase();
|
||||
}
|
||||
|
||||
function makeTicketName({ escalated, claimed }, ticket, guild) {
|
||||
const senderLocal = getSenderLocal(ticket.senderEmail);
|
||||
const num = ticket.ticketNumber || 1;
|
||||
if (escalated) {
|
||||
return claimed
|
||||
? `e-ticket-${senderLocal}-${num}`
|
||||
: `escalated-ticket-${senderLocal}-${num}`;
|
||||
}
|
||||
return `ticket-${senderLocal}-${num}`;
|
||||
}
|
||||
|
||||
async function canRename(ticket) {
|
||||
const now = Date.now();
|
||||
const windowStart = (ticket.renameWindowStart && new Date(ticket.renameWindowStart).getTime()) || 0;
|
||||
let count = ticket.renameCount || 0;
|
||||
|
||||
if (now - windowStart >= RENAME_WINDOW_MS) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { renameWindowStart: new Date(now), renameCount: 0 } }
|
||||
);
|
||||
ticket.renameWindowStart = new Date(now);
|
||||
ticket.renameCount = 0;
|
||||
return { ok: true, remaining: RENAME_LIMIT, waitMs: 0 };
|
||||
}
|
||||
|
||||
const remaining = RENAME_LIMIT - count;
|
||||
if (remaining <= 0) {
|
||||
const waitMs = RENAME_WINDOW_MS - (now - windowStart);
|
||||
return { ok: false, remaining: 0, waitMs };
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $inc: { renameCount: 1 } }
|
||||
);
|
||||
ticket.renameCount = count + 1;
|
||||
return { ok: true, remaining: RENAME_LIMIT - (count + 1), waitMs: 0 };
|
||||
}
|
||||
|
||||
function minutesFromMs(ms) {
|
||||
return Math.max(1, Math.ceil(ms / 60000));
|
||||
}
|
||||
|
||||
// --- RATE LIMIT (per-user ticket creation) ---
|
||||
|
||||
const ticketCreationByUser = new Map(); // userId -> { count, resetAt }
|
||||
|
||||
/**
|
||||
* Check if the user can create a ticket (rate limit). If allowed, consumes one slot.
|
||||
* @param {string} userId - Discord user ID
|
||||
* @returns {{ allowed: boolean, retryAfterMs?: number }}
|
||||
*/
|
||||
function checkTicketCreationRateLimit(userId) {
|
||||
const limit = CONFIG.RATE_LIMIT_TICKETS_PER_USER;
|
||||
const windowMs = (CONFIG.RATE_LIMIT_WINDOW_MINUTES || 60) * 60 * 1000;
|
||||
if (!limit || limit <= 0) return { allowed: true };
|
||||
|
||||
const now = Date.now();
|
||||
let entry = ticketCreationByUser.get(userId);
|
||||
if (!entry || now >= entry.resetAt) {
|
||||
entry = { count: 1, resetAt: now + windowMs };
|
||||
ticketCreationByUser.set(userId, entry);
|
||||
return { allowed: true };
|
||||
}
|
||||
if (entry.count >= limit) {
|
||||
return { allowed: false, retryAfterMs: entry.resetAt - now };
|
||||
}
|
||||
entry.count++;
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// --- CHANNEL CREATION (overflow: Discord limit 50 channels per category) ---
|
||||
|
||||
const CHANNELS_PER_CATEGORY_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Pick the first category that has room (< 50 channels). Main + overflow IDs in order.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {string[]} categoryIds [mainId, ...overflowIds]
|
||||
* @returns {string|null} category id to use as parent, or null
|
||||
*/
|
||||
function pickTicketCategoryId(guild, categoryIds) {
|
||||
if (!guild || !Array.isArray(categoryIds)) return null;
|
||||
const list = categoryIds.filter(Boolean);
|
||||
for (const id of list) {
|
||||
const cat = guild.channels.cache.get(id);
|
||||
if (!cat || cat.type !== ChannelType.GuildCategory) continue;
|
||||
const count = guild.channels.cache.filter(c => c.parentId === id).size;
|
||||
if (count < CHANNELS_PER_CATEGORY_LIMIT) return id;
|
||||
}
|
||||
return list[0] || null;
|
||||
}
|
||||
|
||||
async function createTicketChannel(guild, ticketNumber, userId, subject) {
|
||||
if (CONFIG.USE_THREADS && CONFIG.THREAD_PARENT_CHANNEL) {
|
||||
const parentChannel = guild.channels.cache.get(CONFIG.THREAD_PARENT_CHANNEL);
|
||||
if (!parentChannel) {
|
||||
throw new Error('Thread parent channel not found');
|
||||
}
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(userId);
|
||||
// Add all members with the support role so they can see and reply in the thread
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === userId) continue; // already added
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
} else {
|
||||
const categoryIds = [CONFIG.TICKET_CATEGORY_ID, ...(CONFIG.EMAIL_TICKET_OVERFLOW_CATEGORY_IDS || [])];
|
||||
const parentId = pickTicketCategoryId(guild, categoryIds);
|
||||
if (!parentId) {
|
||||
throw new Error('Ticket category not found or all categories full (50 channels max per category)');
|
||||
}
|
||||
|
||||
const channel = await guild.channels.create({
|
||||
name: `ticket-${ticketNumber}`,
|
||||
type: ChannelType.GuildText,
|
||||
parent: parentId,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: guild.id,
|
||||
deny: [PermissionFlagsBits.ViewChannel]
|
||||
},
|
||||
{
|
||||
id: userId,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
},
|
||||
{
|
||||
id: CONFIG.ROLE_ID_TO_PING,
|
||||
allow: [
|
||||
PermissionFlagsBits.ViewChannel,
|
||||
PermissionFlagsBits.SendMessages,
|
||||
PermissionFlagsBits.ReadMessageHistory
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private Discord ticket thread under DISCORD_THREAD_CHANNEL_ID.
|
||||
* Adds creator and all members with ROLE_ID_TO_PING.
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} creatorUserId
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createDiscordTicketAsThread(guild, ticketNumber, creatorUserId) {
|
||||
const parentId = CONFIG.DISCORD_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('DISCORD_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Discord thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
await thread.members.add(creatorUserId);
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
if (memberId === creatorUserId) continue;
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a private email ticket thread under EMAIL_THREAD_CHANNEL_ID.
|
||||
* Adds all members with ROLE_ID_TO_PING (no creator; email tickets have no Discord user).
|
||||
* @param {import('discord.js').Guild} guild
|
||||
* @param {number} ticketNumber
|
||||
* @param {string} chanName
|
||||
* @returns {Promise<import('discord.js').ThreadChannel>}
|
||||
*/
|
||||
async function createEmailTicketAsThread(guild, ticketNumber, chanName) {
|
||||
const parentId = CONFIG.EMAIL_THREAD_CHANNEL_ID;
|
||||
if (!parentId) throw new Error('EMAIL_THREAD_CHANNEL_ID is not set');
|
||||
const parentChannel = guild.channels.cache.get(parentId);
|
||||
if (!parentChannel) throw new Error('Email thread parent channel not found');
|
||||
|
||||
const thread = await parentChannel.threads.create({
|
||||
name: chanName || `🎫・ticket-${ticketNumber}`,
|
||||
autoArchiveDuration: 1440,
|
||||
type: ChannelType.PrivateThread,
|
||||
invitable: false,
|
||||
reason: `Ticket #${ticketNumber}`
|
||||
});
|
||||
|
||||
if (CONFIG.ROLE_ID_TO_PING) {
|
||||
const role = guild.roles.cache.get(CONFIG.ROLE_ID_TO_PING);
|
||||
if (role?.members?.size) {
|
||||
for (const [memberId] of role.members) {
|
||||
await thread.members.add(memberId).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
return thread;
|
||||
}
|
||||
|
||||
// --- LIMITS & PERMISSIONS ---
|
||||
|
||||
async function checkTicketLimits(senderEmail) {
|
||||
if (!CONFIG.GLOBAL_TICKET_LIMIT) return { ok: true };
|
||||
|
||||
const currentCount = await Ticket.countDocuments({ senderEmail, status: 'open' });
|
||||
if (currentCount >= CONFIG.GLOBAL_TICKET_LIMIT) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `You have reached the maximum limit of ${CONFIG.GLOBAL_TICKET_LIMIT} open tickets.`
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function hasBlacklistedRole(member) {
|
||||
if (!CONFIG.BLACKLISTED_ROLES || CONFIG.BLACKLISTED_ROLES.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return member.roles.cache.some(role =>
|
||||
CONFIG.BLACKLISTED_ROLES.includes(role.id)
|
||||
);
|
||||
}
|
||||
|
||||
// --- ACTIVITY ---
|
||||
|
||||
async function updateTicketActivity(gmailThreadId) {
|
||||
const now = new Date();
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId },
|
||||
{ $set: { lastActivity: now, reminderSent: false } }
|
||||
);
|
||||
}
|
||||
|
||||
// --- SCHEDULED CHECKS ---
|
||||
// These accept `client` and optionally `sendTicketClosedEmail` to avoid circular deps.
|
||||
|
||||
async function checkAutoClose(client, sendTicketClosedEmail) {
|
||||
if (!CONFIG.AUTO_CLOSE_ENABLED) return;
|
||||
|
||||
const cutoffTime = new Date(Date.now() - (CONFIG.AUTO_CLOSE_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const staleTickets = await Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: cutoffTime, $ne: null }
|
||||
}).lean();
|
||||
|
||||
for (const ticket of staleTickets) {
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(CONFIG.AUTO_CLOSE_MESSAGE);
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { status: 'closed' } }
|
||||
);
|
||||
|
||||
await sendTicketClosedEmail(ticket, 'Auto-Close System');
|
||||
|
||||
setTimeout(() => channel.delete().catch(() => {}), 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-close error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkReminders(client) {
|
||||
if (!CONFIG.REMINDER_ENABLED) return;
|
||||
|
||||
const reminderTime = new Date(Date.now() - (CONFIG.REMINDER_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const ticketsNeedingReminder = await Ticket.find({
|
||||
status: 'open',
|
||||
lastActivity: { $lt: reminderTime, $ne: null },
|
||||
reminderSent: false
|
||||
}).lean();
|
||||
|
||||
for (const ticket of ticketsNeedingReminder) {
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
const message = CONFIG.REMINDER_MESSAGE.replace('{hours}', CONFIG.REMINDER_AFTER_HOURS);
|
||||
await channel.send(message);
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { reminderSent: true } }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Reminder error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAutoUnclaim(client) {
|
||||
if (!CONFIG.AUTO_UNCLAIM_ENABLED) return;
|
||||
|
||||
const unclaimTime = new Date(Date.now() - (CONFIG.AUTO_UNCLAIM_AFTER_HOURS * 60 * 60 * 1000));
|
||||
const staleClaimedTickets = await Ticket.find({
|
||||
status: 'open',
|
||||
claimedBy: { $ne: null },
|
||||
lastActivity: { $lt: unclaimTime, $ne: null }
|
||||
}).lean();
|
||||
|
||||
for (const ticket of staleClaimedTickets) {
|
||||
try {
|
||||
const guild = client.guilds.cache.first();
|
||||
if (!guild) continue;
|
||||
|
||||
const channel = await guild.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { claimedBy: null } }
|
||||
);
|
||||
|
||||
await channel.send(
|
||||
`This ticket has been auto-unclaimed due to inactivity (${CONFIG.AUTO_UNCLAIM_AFTER_HOURS} hours).`
|
||||
);
|
||||
|
||||
console.log(`Auto-unclaimed ticket ${ticket.gmailThreadId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-unclaim error for ticket ${ticket.gmailThreadId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextTicketNumber,
|
||||
saveZammadId,
|
||||
pickTicketCategoryId,
|
||||
createDiscordTicketAsThread,
|
||||
createEmailTicketAsThread,
|
||||
RENAME_WINDOW_MS,
|
||||
RENAME_LIMIT,
|
||||
getSenderLocal,
|
||||
makeTicketName,
|
||||
canRename,
|
||||
minutesFromMs,
|
||||
checkTicketCreationRateLimit,
|
||||
createTicketChannel,
|
||||
checkTicketLimits,
|
||||
hasBlacklistedRole,
|
||||
updateTicketActivity,
|
||||
checkAutoClose,
|
||||
checkReminders,
|
||||
checkAutoUnclaim
|
||||
};
|
||||
99
services/zammad-sync.js
Normal file
99
services/zammad-sync.js
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Syncs Zammad agent replies to Discord (for Discord tickets) and Gmail (for email tickets).
|
||||
* Polls Zammad ticket articles and pushes new customer-visible Agent replies to the right channel.
|
||||
*/
|
||||
const { mongoose } = require('../db-connection');
|
||||
const { getZammadTicketArticles } = require('./zammad');
|
||||
const { sendGmailReply } = require('./gmail');
|
||||
const { htmlToTextWithBlocks } = require('../utils');
|
||||
|
||||
const Ticket = mongoose.model('Ticket');
|
||||
|
||||
function bodyToText(body, contentType) {
|
||||
if (!body) return '';
|
||||
const isHtml = (contentType || '').toLowerCase().includes('html');
|
||||
return isHtml ? htmlToTextWithBlocks(body).trim() : String(body).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run once: find open tickets with Zammad ID, fetch new agent (customer-visible) articles,
|
||||
* post to Discord or send via Gmail, then update lastSyncedZammadArticleId.
|
||||
* @param {import('discord.js').Client} client - Discord client (for posting to ticket channels)
|
||||
*/
|
||||
async function syncZammadReplies(client) {
|
||||
if (!client?.channels) return;
|
||||
|
||||
const tickets = await Ticket.find({
|
||||
zammadTicketId: { $exists: true, $ne: null },
|
||||
status: 'open'
|
||||
})
|
||||
.select('gmailThreadId discordThreadId zammadTicketId lastSyncedZammadArticleId senderEmail subject')
|
||||
.lean();
|
||||
|
||||
for (const ticket of tickets) {
|
||||
try {
|
||||
const articles = await getZammadTicketArticles(ticket.zammadTicketId);
|
||||
// Only agent replies that are customer-visible (not internal notes)
|
||||
const agentReplies = articles.filter(
|
||||
(a) => a.sender === 'Agent' && a.internal === false && a.body
|
||||
);
|
||||
if (agentReplies.length === 0) continue;
|
||||
|
||||
const lastSynced = ticket.lastSyncedZammadArticleId || 0;
|
||||
const newReplies = agentReplies.filter((a) => a.id > lastSynced);
|
||||
const maxId = Math.max(lastSynced, ...agentReplies.map((a) => a.id));
|
||||
|
||||
// First run: just advance cursor so we don't resend existing articles
|
||||
if (newReplies.length === 0) {
|
||||
if (maxId > lastSynced) {
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { lastSyncedZammadArticleId: maxId } }
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const isDiscordTicket = ticket.gmailThreadId.startsWith('discord-');
|
||||
|
||||
for (const article of newReplies) {
|
||||
const text = bodyToText(article.body, article.content_type);
|
||||
if (!text) continue;
|
||||
|
||||
const fromLabel = article.created_by || 'Support';
|
||||
|
||||
if (isDiscordTicket && ticket.discordThreadId) {
|
||||
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
||||
if (channel) {
|
||||
await channel.send(`**${fromLabel}** (via Zammad):\n${text}`).catch((err) => {
|
||||
console.error('Zammad sync: Discord send failed:', err.message);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Email ticket: send reply via Gmail
|
||||
try {
|
||||
await sendGmailReply(
|
||||
ticket.gmailThreadId,
|
||||
text,
|
||||
ticket.senderEmail,
|
||||
ticket.subject || 'Support',
|
||||
fromLabel,
|
||||
null
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Zammad sync: Gmail send failed:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Ticket.updateOne(
|
||||
{ gmailThreadId: ticket.gmailThreadId },
|
||||
{ $set: { lastSyncedZammadArticleId: maxId } }
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Zammad sync error for ticket', ticket.gmailThreadId, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { syncZammadReplies };
|
||||
213
services/zammad.js
Normal file
213
services/zammad.js
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Zammad API service – create/close tickets, manage users, post articles.
|
||||
*/
|
||||
const axios = require('axios');
|
||||
const { ZAMMAD } = require('../config');
|
||||
|
||||
const zammadHeaders = () =>
|
||||
ZAMMAD.URL && ZAMMAD.TOKEN
|
||||
? { Authorization: `Token token=${ZAMMAD.TOKEN}`, 'Content-Type': 'application/json' }
|
||||
: null;
|
||||
|
||||
function baseUrl() {
|
||||
return ZAMMAD.URL ? ZAMMAD.URL.replace(/\/+$/, '') : '';
|
||||
}
|
||||
|
||||
async function createZammadTicket({ subject, body, email, name, gameName, gameKey, group, discordUsername }) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN) {
|
||||
console.warn('Zammad not configured; skipping ticket create.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = `${baseUrl()}/api/v1/tickets`;
|
||||
const isDiscordTicket = Boolean(discordUsername);
|
||||
const firstname = isDiscordTicket ? '' : (name || 'Email Customer').split(' ')[0];
|
||||
const lastname = isDiscordTicket ? '' : (name || 'Email Customer').split(' ').slice(1).join(' ') || 'Customer';
|
||||
|
||||
const payload = {
|
||||
title: subject || 'Support',
|
||||
group,
|
||||
customer: {
|
||||
email,
|
||||
firstname: firstname || '–',
|
||||
lastname: lastname || '–',
|
||||
role_ids: [3]
|
||||
},
|
||||
state: 'new',
|
||||
priority: '2 normal',
|
||||
article: {
|
||||
subject: subject || 'Support',
|
||||
body: `Email: ${email}\nGame: ${gameName}\n\nMessage:\n${body}`,
|
||||
type: 'note',
|
||||
internal: false,
|
||||
sender: 'Customer',
|
||||
from: isDiscordTicket ? `${discordUsername} <${email}>` : `${name} <${email}>`
|
||||
}
|
||||
};
|
||||
|
||||
if (gameKey) payload.gameid = gameKey;
|
||||
if (discordUsername) payload.discordusername = String(discordUsername).slice(0, 120);
|
||||
|
||||
const res = await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
async function closeZammadTicket(zammadTicketId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return;
|
||||
const url = `${baseUrl()}/api/v1/tickets/${zammadTicketId}`;
|
||||
await axios.patch(url, { state: 'closed' }, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateZammadUserDiscordId(zammadUserId, discordId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadUserId || !discordId) return;
|
||||
const url = `${baseUrl()}/api/v1/users/${zammadUserId}`;
|
||||
await axios.patch(url, { discord_id: String(discordId) }, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function updateZammadUser(zammadUserId, attrs) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadUserId || !attrs || Object.keys(attrs).length === 0) return;
|
||||
const url = `${baseUrl()}/api/v1/users/${zammadUserId}`;
|
||||
const body = {};
|
||||
if (attrs.discord_id != null) body.discord_id = String(attrs.discord_id);
|
||||
if (attrs.discord_username != null) body.discord_username = String(attrs.discord_username).slice(0, 120);
|
||||
if (Object.keys(body).length === 0) return;
|
||||
await axios.patch(url, body, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function searchZammadUsers(query) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !query) return [];
|
||||
try {
|
||||
const { data } = await axios.get(`${baseUrl()}/api/v1/users/search`, {
|
||||
params: { query: String(query).trim() },
|
||||
headers: zammadHeaders()
|
||||
});
|
||||
return Array.isArray(data) ? data : data?.users || [];
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function createZammadUser({ email, firstname, lastname, login, discordId, discordUsername }) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !email) return null;
|
||||
const isDiscord = Boolean(discordUsername);
|
||||
const payload = {
|
||||
login: login || email,
|
||||
email: email.trim(),
|
||||
firstname: (firstname || '').trim() || (isDiscord ? '–' : 'Customer'),
|
||||
lastname: (lastname || '').trim() || (isDiscord ? '–' : 'User'),
|
||||
role_ids: [3]
|
||||
};
|
||||
if (discordId) payload.discord_id = String(discordId);
|
||||
if (discordUsername) payload.discord_username = String(discordUsername).slice(0, 120);
|
||||
const { data } = await axios.post(`${baseUrl()}/api/v1/users`, payload, { headers: zammadHeaders() });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function ensureZammadUserForDiscordUser(websiteUser, opts = {}) {
|
||||
if (!websiteUser?.email || !ZAMMAD.URL || !ZAMMAD.TOKEN) return null;
|
||||
const email = String(websiteUser.email).trim().toLowerCase();
|
||||
const discordId = websiteUser.discordID ? String(websiteUser.discordID) : null;
|
||||
const discordUsername = opts.discordUsername ? String(opts.discordUsername).slice(0, 120) : null;
|
||||
const firstname = (websiteUser.firstname || '').trim();
|
||||
const lastname = (websiteUser.lastname || '').trim();
|
||||
let zammadUser = null;
|
||||
try {
|
||||
const list = await searchZammadUsers(email);
|
||||
zammadUser = list.find((u) => (u.email || '').toLowerCase() === email) || null;
|
||||
} catch (e) {
|
||||
console.error('Zammad user search failed:', e.response?.data || e.message);
|
||||
return null;
|
||||
}
|
||||
if (!zammadUser) {
|
||||
try {
|
||||
zammadUser = await createZammadUser({
|
||||
email,
|
||||
firstname: firstname || (discordUsername ? '–' : 'Customer'),
|
||||
lastname: lastname || (discordUsername ? '–' : 'User'),
|
||||
login: email,
|
||||
discordId,
|
||||
discordUsername
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Zammad user create failed:', e.response?.data || e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (zammadUser?.id && (discordId || discordUsername)) {
|
||||
try {
|
||||
await updateZammadUser(zammadUser.id, {
|
||||
...(discordId && { discord_id: discordId }),
|
||||
...(discordUsername && { discord_username: discordUsername })
|
||||
});
|
||||
} catch (_) {
|
||||
/* custom attributes may not exist in Zammad */
|
||||
}
|
||||
}
|
||||
return zammadUser?.id ?? null;
|
||||
}
|
||||
|
||||
async function getZammadTicketArticles(zammadTicketId) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return [];
|
||||
const url = `${baseUrl()}/api/v1/ticket_articles/by_ticket/${zammadTicketId}`;
|
||||
const res = await axios.get(url, {
|
||||
headers: { Authorization: `Token token=${ZAMMAD.TOKEN}` }
|
||||
});
|
||||
return Array.isArray(res.data) ? res.data : [];
|
||||
}
|
||||
|
||||
async function addZammadArticle(zammadTicketId, body, { from: fromDisplay } = {}) {
|
||||
if (!ZAMMAD.URL || !ZAMMAD.TOKEN || !zammadTicketId) return;
|
||||
const url = `${baseUrl()}/api/v1/ticket_articles`;
|
||||
const payload = {
|
||||
ticket_id: zammadTicketId,
|
||||
body: body || '',
|
||||
content_type: 'text/plain',
|
||||
type: 'note',
|
||||
internal: false,
|
||||
sender: 'Agent'
|
||||
};
|
||||
if (fromDisplay) {
|
||||
payload.subject = `Discord reply from ${fromDisplay}`;
|
||||
}
|
||||
await axios.post(url, payload, {
|
||||
headers: {
|
||||
Authorization: `Token token=${ZAMMAD.TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createZammadTicket,
|
||||
closeZammadTicket,
|
||||
updateZammadUserDiscordId,
|
||||
updateZammadUser,
|
||||
searchZammadUsers,
|
||||
createZammadUser,
|
||||
ensureZammadUserForDiscordUser,
|
||||
getZammadTicketArticles,
|
||||
addZammadArticle,
|
||||
zammadHeaders
|
||||
};
|
||||
Reference in New Issue
Block a user