Files
broccolini-bot/services/zammad-sync.js
root 519788c633 Initial commit
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 08:22:19 -06:00

100 lines
3.5 KiB
JavaScript

/**
* 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 };