Files
broccolini-bot/routes/bosscord.js
2026-04-18 11:10:41 +00:00

237 lines
8.4 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 } = 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;
if (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';
await enqueueSend(channel, content);
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;