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

214 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
};