240 lines
8.8 KiB
JavaScript
240 lines
8.8 KiB
JavaScript
/**
|
|
* bOSScord API routes for broccolini-bot: ticket list/detail, thread from Discord, send message.
|
|
* Auth via BOSSCORD_API_KEY. Mount on Express in broccolini-discord.js.
|
|
*/
|
|
require('../models'); // ensure Ticket model is registered
|
|
const express = require('express');
|
|
const mongoose = require('mongoose');
|
|
const rateLimit = require('express-rate-limit');
|
|
const { getBot } = require('../api/bosscordClient');
|
|
const { getGmailClient, sendGmailReply } = require('../services/gmail');
|
|
const { updateTicketActivity } = require('../services/tickets');
|
|
const { enqueueSend } = require('../services/channelQueue');
|
|
const { extractRawEmail, safeEqual } = require('../utils');
|
|
const { CONFIG } = require('../config');
|
|
|
|
const router = express.Router();
|
|
const Ticket = mongoose.model('Ticket');
|
|
|
|
const CORS_ORIGIN = process.env.BOSSCORD_CLIENT_ORIGIN || 'http://100.114.205.53:3081';
|
|
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 60 * 1000,
|
|
max: 60,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Too many requests, please try again later.' }
|
|
});
|
|
|
|
function corsMiddleware(req, res, next) {
|
|
res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Staff-Discord-Id');
|
|
if (req.method === 'OPTIONS') {
|
|
return res.sendStatus(204);
|
|
}
|
|
next();
|
|
}
|
|
|
|
function authMiddleware(req, res, next) {
|
|
const key = process.env.BOSSCORD_API_KEY;
|
|
if (!key) {
|
|
return res.status(503).json({ error: 'bOSScord API not configured (BOSSCORD_API_KEY)' });
|
|
}
|
|
const auth = req.headers.authorization;
|
|
const token = auth && auth.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
// Identical response body for missing vs invalid token — don't tell a probe which state it's in.
|
|
if (!safeEqual(token, key)) {
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
router.use(apiLimiter);
|
|
router.use(corsMiddleware);
|
|
router.use(authMiddleware);
|
|
|
|
function requireDb(req, res, next) {
|
|
if (mongoose.connection.readyState !== 1) {
|
|
return res.status(503).json({ error: 'Database not ready yet. Wait for the bot to finish starting.' });
|
|
}
|
|
next();
|
|
}
|
|
|
|
router.use(requireDb);
|
|
|
|
function resolveTicketId(id) {
|
|
if (mongoose.Types.ObjectId.isValid(id) && String(new mongoose.Types.ObjectId(id)) === id) {
|
|
return Ticket.findOne({ _id: id });
|
|
}
|
|
const num = parseInt(id, 10);
|
|
if (!Number.isNaN(num)) {
|
|
return Ticket.findOne({ ticketNumber: num });
|
|
}
|
|
return Ticket.findOne({ gmailThreadId: id });
|
|
}
|
|
|
|
/** GET /api/tickets — list tickets. Query: status, priority, claimedBy, limit */
|
|
router.get('/tickets', async (req, res, next) => {
|
|
try {
|
|
if (!Ticket) return res.status(503).json({ error: 'Ticket model not loaded' });
|
|
const { status, priority, claimedBy, limit = 50 } = req.query;
|
|
const filter = {};
|
|
if (status) filter.status = status;
|
|
if (priority) filter.priority = priority;
|
|
if (claimedBy !== undefined && claimedBy !== '') filter.claimedBy = claimedBy === 'null' ? null : claimedBy;
|
|
const limitNum = Math.min(parseInt(limit, 10) || 50, 100);
|
|
const tickets = await Ticket.find(filter)
|
|
.sort({ lastActivity: -1, createdAt: -1 })
|
|
.limit(limitNum)
|
|
.lean();
|
|
return res.json({ tickets });
|
|
} catch (err) {
|
|
console.error('GET /api/tickets error:', err.message);
|
|
console.error(err.stack);
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
/** GET /api/me/tickets — "my tickets" (claimed by staff). Query: X-Staff-Discord-Id or claimedBy */
|
|
router.get('/me/tickets', async (req, res) => {
|
|
try {
|
|
const claimedBy = req.headers['x-staff-discord-id'] || req.query.claimedBy;
|
|
if (!claimedBy) {
|
|
return res.status(400).json({ error: 'Provide X-Staff-Discord-Id header or claimedBy query' });
|
|
}
|
|
const tickets = await Ticket.find({ claimedBy, status: 'open' })
|
|
.sort({ lastActivity: -1, createdAt: -1 })
|
|
.limit(100)
|
|
.lean();
|
|
res.json({ tickets });
|
|
} catch (err) {
|
|
console.error('GET /api/me/tickets:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/** GET /api/tickets/:id — single ticket metadata */
|
|
router.get('/tickets/:id', async (req, res) => {
|
|
try {
|
|
const ticket = await resolveTicketId(req.params.id);
|
|
if (!ticket) {
|
|
return res.status(404).json({ error: 'Ticket not found' });
|
|
}
|
|
const out = ticket.toObject ? ticket.toObject() : { ...ticket };
|
|
if (CONFIG.DISCORD_GUILD_ID) out.guildId = CONFIG.DISCORD_GUILD_ID;
|
|
res.json(out);
|
|
} catch (err) {
|
|
console.error('GET /api/tickets/:id:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/** GET /api/tickets/:id/messages — thread from Discord */
|
|
router.get('/tickets/:id/messages', async (req, res) => {
|
|
try {
|
|
const ticket = await resolveTicketId(req.params.id);
|
|
if (!ticket) {
|
|
return res.status(404).json({ error: 'Ticket not found' });
|
|
}
|
|
if (!ticket.discordThreadId) {
|
|
return res.json({ messages: [] });
|
|
}
|
|
const client = getBot();
|
|
if (!client) {
|
|
return res.status(503).json({ error: 'Discord client not ready' });
|
|
}
|
|
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 100);
|
|
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
if (!channel) {
|
|
return res.status(404).json({ error: 'Discord channel not found' });
|
|
}
|
|
const messages = await channel.messages.fetch({ limit });
|
|
const list = messages
|
|
.sort((a, b) => a.createdTimestamp - b.createdTimestamp)
|
|
.map((m) => ({
|
|
id: m.id,
|
|
author: m.author?.username || 'unknown',
|
|
authorId: m.author?.id,
|
|
content: m.content,
|
|
timestamp: m.createdAt?.toISOString?.(),
|
|
isBot: m.author?.bot ?? false
|
|
}));
|
|
res.json({ messages: list });
|
|
} catch (err) {
|
|
console.error('GET /api/tickets/:id/messages:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
/** POST /api/tickets/:id/messages — send message to Discord; for email tickets, also send via Gmail */
|
|
router.post('/tickets/:id/messages', express.json(), async (req, res) => {
|
|
try {
|
|
const ticket = await resolveTicketId(req.params.id);
|
|
if (!ticket) {
|
|
return res.status(404).json({ error: 'Ticket not found' });
|
|
}
|
|
if (!ticket.discordThreadId) {
|
|
return res.status(400).json({ error: 'Ticket has no Discord channel' });
|
|
}
|
|
const content = req.body?.content;
|
|
if (!content || typeof content !== 'string') {
|
|
return res.status(400).json({ error: 'Body must include content (string)' });
|
|
}
|
|
const client = getBot();
|
|
if (!client) {
|
|
return res.status(503).json({ error: 'Discord client not ready' });
|
|
}
|
|
const channel = await client.channels.fetch(ticket.discordThreadId).catch(() => null);
|
|
if (!channel) {
|
|
return res.status(404).json({ error: 'Discord channel not found' });
|
|
}
|
|
const discordUser = req.body.displayName || 'bOSScord';
|
|
// Content originates from the bOSScord web UI (staff-gated) but still crosses an HTTP boundary —
|
|
// allow explicit user/role mentions a staff member typed, block @everyone/@here.
|
|
await enqueueSend(channel, { content, allowedMentions: { parse: ['users', 'roles'] } });
|
|
|
|
if (!ticket.gmailThreadId.startsWith('discord-')) {
|
|
try {
|
|
const gmail = getGmailClient();
|
|
const thread = await gmail.users.threads.get({
|
|
userId: 'me',
|
|
id: ticket.gmailThreadId
|
|
});
|
|
const last = [...(thread.data.messages || [])].reverse().find((msg) => {
|
|
const from = msg.payload?.headers?.find((h) => h.name === 'From')?.value || '';
|
|
return !from.toLowerCase().includes(CONFIG.MY_EMAIL);
|
|
});
|
|
if (last?.payload?.headers) {
|
|
let recipient = last.payload.headers.find((h) => h.name === 'From')?.value || '';
|
|
const replyTo = last.payload.headers.find((h) => h.name === 'Reply-To')?.value;
|
|
if (replyTo) recipient = replyTo;
|
|
const subject = last.payload.headers.find((h) => h.name === 'Subject')?.value || 'Support';
|
|
const msgId = last.payload.headers.find((h) => h.name === 'Message-ID')?.value;
|
|
const recipientEmail = extractRawEmail(recipient).toLowerCase();
|
|
if (recipientEmail && recipientEmail !== CONFIG.MY_EMAIL) {
|
|
await sendGmailReply(
|
|
ticket.gmailThreadId,
|
|
content,
|
|
recipientEmail,
|
|
subject,
|
|
discordUser,
|
|
msgId
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('bOSScord Gmail reply error:', e);
|
|
}
|
|
}
|
|
|
|
await updateTicketActivity(ticket.gmailThreadId);
|
|
res.status(201).json({ ok: true });
|
|
} catch (err) {
|
|
console.error('POST /api/tickets/:id/messages:', err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|