Compare commits
4 Commits
1a46fb696a
...
ca737039f8
| Author | SHA1 | Date | |
|---|---|---|---|
| ca737039f8 | |||
| bf901039bc | |||
| 071fae2ea3 | |||
| 3300a7fc19 |
@@ -35,7 +35,6 @@ TRANSCRIPT_CHANNEL_ID= # Channel for ticket transcripts on close
|
|||||||
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
LOGGING_CHANNEL_ID= # Channel for lifecycle log messages
|
||||||
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
|
DEBUGGING_CHANNEL_ID= # Channel for error logs (escalate, poll, etc.); optional
|
||||||
BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional
|
BACKUP_EXPORT_CHANNEL_ID= # Channel where /backup and /export post .txt files; optional
|
||||||
ACCOUNT_INFO_CHANNEL_ID= # Channel for account info lookups; optional
|
|
||||||
DISCORD_CHANNEL_ID= # General Discord channel (if used)
|
DISCORD_CHANNEL_ID= # General Discord channel (if used)
|
||||||
|
|
||||||
# --- Discord: Ticket copy & buttons ---
|
# --- Discord: Ticket copy & buttons ---
|
||||||
@@ -59,11 +58,6 @@ NGROK_URL= # Public URL (optional); run ngrok outside thi
|
|||||||
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
DISCORD_ONLY_PORT=5000 # Port for healthcheck / Discord-only server
|
||||||
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
|
# HEALTHCHECK_HOST= # Optional; bind address for health server (default: all interfaces; use 127.0.0.1 for local-only)
|
||||||
|
|
||||||
# --- bOSScord (support cockpit) ---
|
|
||||||
# Set BOSSCORD_API_KEY to enable /api (ticket list, thread, send message). Use same key in bOSScord app login.
|
|
||||||
# BOSSCORD_API_KEY= # e.g. from: openssl rand -hex 32
|
|
||||||
# BOSSCORD_CORS_ORIGIN=* # Optional; default * (set to bOSScord origin in production)
|
|
||||||
|
|
||||||
# --- Database ---
|
# --- Database ---
|
||||||
MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db)
|
MONGODB_URI= # MongoDB connection string (e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db?authSource=broccoli_db)
|
||||||
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
# MONGODB_DATABASE= # Optional; DB name usually in MONGODB_URI path (not read by app currently)
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ TRANSCRIPT_CHANNEL_ID=
|
|||||||
LOGGING_CHANNEL_ID=
|
LOGGING_CHANNEL_ID=
|
||||||
DEBUGGING_CHANNEL_ID=
|
DEBUGGING_CHANNEL_ID=
|
||||||
BACKUP_EXPORT_CHANNEL_ID=
|
BACKUP_EXPORT_CHANNEL_ID=
|
||||||
ACCOUNT_INFO_CHANNEL_ID=
|
|
||||||
DISCORD_CHANNEL_ID=
|
DISCORD_CHANNEL_ID=
|
||||||
|
|
||||||
# --- Discord: Ticket copy & buttons ---
|
# --- Discord: Ticket copy & buttons ---
|
||||||
@@ -59,10 +58,6 @@ MY_EMAIL=
|
|||||||
DISCORD_ONLY_PORT=5000
|
DISCORD_ONLY_PORT=5000
|
||||||
# HEALTHCHECK_HOST=
|
# HEALTHCHECK_HOST=
|
||||||
|
|
||||||
# --- bOSScord (support cockpit) ---
|
|
||||||
# BOSSCORD_API_KEY=
|
|
||||||
# BOSSCORD_CORS_ORIGIN=*
|
|
||||||
|
|
||||||
# --- Database (test cluster or local) ---
|
# --- Database (test cluster or local) ---
|
||||||
MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test
|
MONGODB_URI= # e.g. mongodb://broccoli_bot:CHANGE_ME@localhost:27017/broccoli_db_test?authSource=broccoli_db_test
|
||||||
# MONGODB_DATABASE=
|
# MONGODB_DATABASE=
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -49,4 +49,8 @@ cursor.yml
|
|||||||
*.local.yml
|
*.local.yml
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
CLAUDE.md
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
|
||||||
|
*.bak
|
||||||
|
*.bak-*
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# You can override the included template(s) by including variable overrides
|
|
||||||
# SAST customization: https://docs.gitlab.com/user/application_security/sast/#available-cicd-variables
|
|
||||||
# Secret Detection customization: https://docs.gitlab.com/user/application_security/secret_detection/pipeline/configure/
|
|
||||||
# Dependency Scanning customization: https://docs.gitlab.com/user/application_security/dependency_scanning/#customizing-analyzer-behavior
|
|
||||||
# Container Scanning customization: https://docs.gitlab.com/user/application_security/container_scanning/#customizing-analyzer-behavior
|
|
||||||
# Note that environment variables can be set in several places
|
|
||||||
# See https://docs.gitlab.com/ci/variables/#cicd-variable-precedence
|
|
||||||
stages:
|
|
||||||
- test
|
|
||||||
- secret-detection
|
|
||||||
sast:
|
|
||||||
stage: test
|
|
||||||
include:
|
|
||||||
- template: Security/SAST.gitlab-ci.yml
|
|
||||||
- template: Security/Secret-Detection.gitlab-ci.yml
|
|
||||||
variables:
|
|
||||||
SECRET_DETECTION_ENABLED: 'true'
|
|
||||||
secret_detection:
|
|
||||||
stage: secret-detection
|
|
||||||
87
FEATURES.md
87
FEATURES.md
@@ -1,87 +0,0 @@
|
|||||||
## Broccolini Bot – Feature Overview
|
|
||||||
|
|
||||||
Broccolini Bot is a Discord support bot that turns Gmail emails and Discord messages into trackable support tickets stored in MongoDB.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Email & Discord Ticketing
|
|
||||||
|
|
||||||
**Summary:** Connects Gmail and Discord so each support conversation becomes a ticket channel or thread.
|
|
||||||
|
|
||||||
- Email → Discord ticket channels or threads (with overflow categories)
|
|
||||||
- Discord-only tickets created from panels or context menus
|
|
||||||
- Full Gmail reply threading for email-sourced tickets
|
|
||||||
- Ticket transcripts saved to a Discord channel and optionally emailed on close
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ticket Workflow & Management
|
|
||||||
|
|
||||||
**Summary:** Provides a structured workflow for creating, handling, and closing tickets.
|
|
||||||
|
|
||||||
- Claim / unclaim with claimer emojis in channel names
|
|
||||||
- Priority levels (low / normal / medium / high) with emojis
|
|
||||||
- Escalation and de-escalation between tiered support categories
|
|
||||||
- Close confirmation, force-close, and automatic transcript generation
|
|
||||||
- Auto-close, inactivity reminders, and auto-unclaim (configurable)
|
|
||||||
- Per-ticket limits and global ticket limits to prevent abuse
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Discord UI: Panels, Buttons & Modals
|
|
||||||
|
|
||||||
**Summary:** Uses rich Discord components so users and staff interact with tickets through buttons and forms.
|
|
||||||
|
|
||||||
- `/panel` command to post “Open ticket” panels
|
|
||||||
- Ticket creation via modal (email, game, description fields)
|
|
||||||
- Ticket action row with Close, Claim, Escalate, and De-escalate buttons
|
|
||||||
- Thread-style or category-channel tickets, or panels that offer both
|
|
||||||
- `/setup` wizard to guide initial panel and category configuration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Staff Tools & Notifications
|
|
||||||
|
|
||||||
**Summary:** Gives staff better visibility and control over tickets and workloads.
|
|
||||||
|
|
||||||
- `/add` and `/remove` to manage who can see a ticket
|
|
||||||
- `/transfer`, `/move`, `/topic`, `/stats`, `/search`, `/backup`, `/export`
|
|
||||||
- `/accountinfo` for account lookups by email or Discord user
|
|
||||||
- Per-staff notification channels with reply alerts and unclaimed digests
|
|
||||||
- Optional DM reply alerts via `/notifydm`
|
|
||||||
- Optional private staff-only threads attached to ticket channels
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tags, Saved Responses & Variables
|
|
||||||
|
|
||||||
**Summary:** Speeds up replies and keeps tickets categorized.
|
|
||||||
|
|
||||||
- `/tag` command with predefined ticket tags and emojis
|
|
||||||
- `/response` commands to create, edit, send, delete, and list saved replies
|
|
||||||
- Template variables (ticket, staff, server, date/time, hours, etc.) in responses
|
|
||||||
- Tag usage and response usage tracked in MongoDB
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Automation, Patterns & Surge Detection
|
|
||||||
|
|
||||||
**Summary:** Monitors ticket volume and chat activity to warn staff about problems early.
|
|
||||||
|
|
||||||
- Background jobs for auto-close, reminders, and auto-unclaim
|
|
||||||
- Pattern detection for repeat users, games, tags, escalations, and stale tickets
|
|
||||||
- Surge detection for high ticket volume, backlogs, and no-staff situations
|
|
||||||
- Chat alerts for busy channels or messages without staff replies
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings UI, Logging, API & Configuration
|
|
||||||
|
|
||||||
**Summary:** Provides a web UI plus environment-based configuration and optional integrations.
|
|
||||||
|
|
||||||
- Optional Broccolini settings web UI (`settings-site/`) to edit Discord channels, categories, Gmail credentials, ticket behavior, surge alerts, pattern thresholds, appearance, and advanced options without touching `.env`
|
|
||||||
- All behavior still backed by `.env` and `config.js` (messages, colors, timeouts, limits)
|
|
||||||
- Dedicated Discord channels for transcripts, logs, security, automation, and Gmail polling
|
|
||||||
- Optional HTTP API under `/api` with token-based auth
|
|
||||||
- Healthcheck endpoint (`GET /`) for Docker and load balancers
|
|
||||||
|
|
||||||
@@ -11,7 +11,6 @@ const { mongoose } = require('./db-connection');
|
|||||||
// Handlers
|
// Handlers
|
||||||
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
const { handleButton, handleTicketModal } = require('./handlers/buttons');
|
||||||
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
const { handleCommand, handleContextMenu, handleAutocomplete } = require('./handlers/commands');
|
||||||
const { handleSendAccountInfoToChannel, BUTTON_PREFIX } = require('./handlers/accountinfo');
|
|
||||||
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
|
const { handleSetupButton, handleSetupModal, handleSetupSelect, PREFIX_BUTTON: SETUP_BUTTON_PREFIX, PREFIX_MODAL: SETUP_MODAL_PREFIX, PREFIX_SELECT: SETUP_SELECT_PREFIX } = require('./handlers/setup');
|
||||||
const { handleDiscordReply } = require('./handlers/messages');
|
const { handleDiscordReply } = require('./handlers/messages');
|
||||||
|
|
||||||
@@ -19,8 +18,8 @@ const { handleDiscordReply } = require('./handlers/messages');
|
|||||||
const { sendTicketClosedEmail } = require('./services/gmail');
|
const { sendTicketClosedEmail } = require('./services/gmail');
|
||||||
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
const { checkAutoClose, checkAutoUnclaim, reconcileDeletedTicketChannels, resumePendingDeletes } = require('./services/tickets');
|
||||||
const { registerCommands } = require('./commands/register');
|
const { registerCommands } = require('./commands/register');
|
||||||
const bosscordRoutes = require('./routes/bosscord');
|
// Holds a reference to the Discord client for the settings-site /internal/discord/guild lookup.
|
||||||
const { setBot } = require('./api/bosscordClient');
|
const { setBot } = require('./api/botClient');
|
||||||
const { poll } = require('./gmail-poll');
|
const { poll } = require('./gmail-poll');
|
||||||
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
|
const { setClient: setDebugClient, logError, logSystem } = require('./services/debugLog');
|
||||||
|
|
||||||
@@ -114,11 +113,6 @@ async function runHandler(name, interaction, fn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
client.on('interactionCreate', async interaction => {
|
client.on('interactionCreate', async interaction => {
|
||||||
if (interaction.isButton() && interaction.customId.startsWith(BUTTON_PREFIX)) {
|
|
||||||
const handled = await runHandler('handleSendAccountInfoToChannel', interaction, () => handleSendAccountInfoToChannel(interaction));
|
|
||||||
if (handled) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
if (interaction.isButton() && interaction.customId.startsWith(SETUP_BUTTON_PREFIX)) {
|
||||||
try {
|
try {
|
||||||
const handled = await handleSetupButton(interaction);
|
const handled = await handleSetupButton(interaction);
|
||||||
@@ -212,13 +206,6 @@ client.once('ready', async () => {
|
|||||||
await connectMongoDB(process.env.MONGODB_URI);
|
await connectMongoDB(process.env.MONGODB_URI);
|
||||||
setDebugClient(client);
|
setDebugClient(client);
|
||||||
setBot(client);
|
setBot(client);
|
||||||
if (process.env.BOSSCORD_API_KEY) {
|
|
||||||
app.use('/api', bosscordRoutes);
|
|
||||||
app.use('/api', (err, req, res, next) => {
|
|
||||||
console.error('bOSScord API error:', err && err.stack ? err.stack : err);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
const healthcheckHost = CONFIG.HEALTHCHECK_HOST || undefined;
|
||||||
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
|
httpServer = app.listen(CONFIG.PORT, healthcheckHost, () => {
|
||||||
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
console.log(`Healthcheck server listening on ${healthcheckHost || '*'}:${CONFIG.PORT}`);
|
||||||
|
|||||||
@@ -463,31 +463,6 @@ async function registerCommands() {
|
|||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
.setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
|
||||||
|
|
||||||
new SlashCommandBuilder()
|
|
||||||
.setName('accountinfo')
|
|
||||||
.setDescription('Look up website account info by email or Discord user')
|
|
||||||
.setContexts([InteractionContextType.Guild])
|
|
||||||
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall])
|
|
||||||
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub
|
|
||||||
.setName('email')
|
|
||||||
.setDescription('Look up by email address')
|
|
||||||
.addStringOption(opt =>
|
|
||||||
opt.setName('email').setDescription('Account email').setRequired(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.addSubcommand(sub =>
|
|
||||||
sub
|
|
||||||
.setName('discord')
|
|
||||||
.setDescription('Look up by Discord user')
|
|
||||||
.addUserOption(opt =>
|
|
||||||
opt.setName('user').setDescription('Discord user').setRequired(true)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
new SlashCommandBuilder()
|
new SlashCommandBuilder()
|
||||||
.setName('signature')
|
.setName('signature')
|
||||||
.setDescription('Set your personal email signature (valediction, display name, tagline)')
|
.setDescription('Set your personal email signature (valediction, display name, tagline)')
|
||||||
|
|||||||
@@ -57,7 +57,6 @@ const CONFIG = {
|
|||||||
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
LOG_CHAN: process.env.LOGGING_CHANNEL_ID,
|
||||||
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
DEBUGGING_CHANNEL_ID: process.env.DEBUGGING_CHANNEL_ID || null,
|
||||||
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
|
BACKUP_EXPORT_CHANNEL_ID: process.env.BACKUP_EXPORT_CHANNEL_ID || null,
|
||||||
ACCOUNT_INFO_CHANNEL_ID: process.env.ACCOUNT_INFO_CHANNEL_ID || null,
|
|
||||||
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
|
DISCORD_CHANNEL_ID: process.env.DISCORD_CHANNEL_ID || null,
|
||||||
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
CLIENT_ID: process.env.DISCORD_APPLICATION_ID,
|
||||||
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
REFRESH_TOKEN: process.env.REFRESH_TOKEN,
|
||||||
@@ -66,7 +65,7 @@ const CONFIG = {
|
|||||||
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
SUPPORT_NAME: process.env.SUPPORT_NAME || 'Support',
|
||||||
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
PORT: toInt(process.env.DISCORD_ONLY_PORT, 5000),
|
||||||
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
HEALTHCHECK_HOST: process.env.HEALTHCHECK_HOST || null, // null = listen on all interfaces; set to 127.0.0.1 for local-only
|
||||||
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '<br>'),
|
SIGNATURE: (process.env.EMAIL_SIGNATURE || '').trim().replace(/\\n/g, '\n'),
|
||||||
GAME_LIST: process.env.GAME_LIST || '',
|
GAME_LIST: process.env.GAME_LIST || '',
|
||||||
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
DISCORD_THREAD_CHANNEL_ID: process.env.DISCORD_THREAD_CHANNEL_ID || null,
|
||||||
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
EMAIL_THREAD_CHANNEL_ID: process.env.EMAIL_THREAD_CHANNEL_ID || null,
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* Account info command: look up website User by email or Discord ID,
|
|
||||||
* show ephemeral embed with option to send transcript to account info channel.
|
|
||||||
*/
|
|
||||||
const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js');
|
|
||||||
const { CONFIG } = require('../config');
|
|
||||||
const { mongoose } = require('../db-connection');
|
|
||||||
const { logSecurity } = require('../services/debugLog');
|
|
||||||
const { enqueueSend } = require('../services/channelQueue');
|
|
||||||
const { isStaff } = require('../utils');
|
|
||||||
|
|
||||||
const User = mongoose.model('User');
|
|
||||||
|
|
||||||
const BUTTON_PREFIX = 'send_account_info_';
|
|
||||||
const MAX_CUSTOM_ID_LENGTH = 100;
|
|
||||||
|
|
||||||
function buildAccountInfoEmbed(user, requestedBy = null) {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setTitle('Account Info')
|
|
||||||
.setColor(CONFIG.EMBED_COLOR_INFO)
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
embed.addFields({
|
|
||||||
name: 'Email',
|
|
||||||
value: user.email || '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
embed.addFields({
|
|
||||||
name: 'Discord ID',
|
|
||||||
value: user.discordID ? `<@${user.discordID}>` : '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
embed.addFields({
|
|
||||||
name: 'Customer ID',
|
|
||||||
value: user.customerId || '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const servers = user.servers || [];
|
|
||||||
const serverOrder = user.serverOrder || [];
|
|
||||||
const ordered = serverOrder.length
|
|
||||||
? serverOrder.map(id => servers.find(s => s._id && s._id.toString() === id) || servers[serverOrder.indexOf(id)]).filter(Boolean)
|
|
||||||
: servers;
|
|
||||||
|
|
||||||
if (ordered.length === 0) {
|
|
||||||
embed.addFields({
|
|
||||||
name: 'Servers',
|
|
||||||
value: '*No servers*',
|
|
||||||
inline: false
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ordered.forEach((server, i) => {
|
|
||||||
const n = i + 1;
|
|
||||||
embed.addFields({
|
|
||||||
name: `Server ${n} – Game`,
|
|
||||||
value: server.game || '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
embed.addFields({
|
|
||||||
name: `Server ${n} – IP`,
|
|
||||||
value: server.ip || '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
embed.addFields({
|
|
||||||
name: `Server ${n} – Port`,
|
|
||||||
value: server.serverPort != null ? String(server.serverPort) : '*not set*',
|
|
||||||
inline: true
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestedBy) {
|
|
||||||
embed.setFooter({ text: `Requested by ${requestedBy}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
return embed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAccountInfoCommand(interaction) {
|
|
||||||
const subcommand = interaction.options.getSubcommand();
|
|
||||||
let user = null;
|
|
||||||
|
|
||||||
if (subcommand === 'email') {
|
|
||||||
const email = (interaction.options.getString('email') || '').trim().toLowerCase();
|
|
||||||
if (!email) {
|
|
||||||
return interaction.reply({ content: 'Please provide an email.', ephemeral: true });
|
|
||||||
}
|
|
||||||
user = await User.findOne({ email }).lean();
|
|
||||||
} else if (subcommand === 'discord') {
|
|
||||||
const target = interaction.options.getUser('user');
|
|
||||||
if (!target) {
|
|
||||||
return interaction.reply({ content: 'Please provide a Discord user.', ephemeral: true });
|
|
||||||
}
|
|
||||||
user = await User.findOne({ discordID: target.id }).lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return interaction.reply({
|
|
||||||
content: subcommand === 'email' ? 'No account found for that email.' : 'No account found for that Discord user/ID.',
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const identifier = subcommand === 'email'
|
|
||||||
? interaction.options.getString('email')
|
|
||||||
: interaction.options.getUser('user')?.tag || 'unknown';
|
|
||||||
logSecurity('Account lookup', interaction.user, `lookup: ${subcommand} → ${identifier}`, null, 0x0099ff).catch(() => {});
|
|
||||||
|
|
||||||
const embed = buildAccountInfoEmbed(user, interaction.user.tag);
|
|
||||||
const components = [];
|
|
||||||
|
|
||||||
if (CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
|
||||||
const safeEmail = (user.email || '').slice(0, 50);
|
|
||||||
const safeDiscordId = (user.discordID || '').slice(0, 50);
|
|
||||||
const customId = `${BUTTON_PREFIX}discord:${safeDiscordId}`;
|
|
||||||
if (customId.length <= MAX_CUSTOM_ID_LENGTH) {
|
|
||||||
components.push(
|
|
||||||
new ActionRowBuilder().addComponents(
|
|
||||||
new ButtonBuilder()
|
|
||||||
.setCustomId(customId)
|
|
||||||
.setLabel('Send to account info channel')
|
|
||||||
.setStyle(ButtonStyle.Secondary)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await interaction.reply({
|
|
||||||
embeds: [embed],
|
|
||||||
components,
|
|
||||||
ephemeral: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSendAccountInfoToChannel(interaction) {
|
|
||||||
if (!interaction.isButton() || !interaction.customId.startsWith(BUTTON_PREFIX)) return false;
|
|
||||||
|
|
||||||
// Dispatched directly from interactionCreate — no upstream command-level staff gate here, so enforce it.
|
|
||||||
if (!isStaff(interaction.member)) {
|
|
||||||
logSecurity('Unauthorized account-info button', interaction.user, `non-staff pressed ${interaction.customId}`, null, 0xff0000).catch(() => {});
|
|
||||||
await interaction.reply({ content: 'You do not have permission to do that.', ephemeral: true }).catch(() => {});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = interaction.customId.slice(BUTTON_PREFIX.length);
|
|
||||||
const [type, value] = payload.includes(':') ? payload.split(':') : [payload, ''];
|
|
||||||
|
|
||||||
let user = null;
|
|
||||||
if (type === 'email') {
|
|
||||||
const email = Buffer.from(value, 'base64').toString('utf8').toLowerCase();
|
|
||||||
user = await User.findOne({ email }).lean();
|
|
||||||
} else if (type === 'discord' && value) {
|
|
||||||
user = await User.findOne({ discordID: value }).lean();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
await interaction.update({ content: 'Account no longer found.', components: [] }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Account no longer found.', ephemeral: true })
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!CONFIG.ACCOUNT_INFO_CHANNEL_ID) {
|
|
||||||
await interaction.update({ content: 'Account info channel is not configured.', components: [] }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Account info channel is not configured.', ephemeral: true })
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = await interaction.client.channels.fetch(CONFIG.ACCOUNT_INFO_CHANNEL_ID).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
await interaction.update({ content: 'Could not find account info channel.', components: [] }).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Could not find account info channel.', ephemeral: true })
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const embed = buildAccountInfoEmbed(user, `${interaction.user.tag} (from ticket)`);
|
|
||||||
await enqueueSend(channel, { embeds: [embed] });
|
|
||||||
|
|
||||||
await interaction.update({
|
|
||||||
content: 'Account info sent to account transcript channel.',
|
|
||||||
components: []
|
|
||||||
}).catch(() =>
|
|
||||||
interaction.followUp({ content: 'Account info sent to account transcript channel.', ephemeral: true })
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
buildAccountInfoEmbed,
|
|
||||||
handleAccountInfoCommand,
|
|
||||||
handleSendAccountInfoToChannel,
|
|
||||||
BUTTON_PREFIX
|
|
||||||
};
|
|
||||||
@@ -30,7 +30,6 @@ const { logError, logSystem } = require('../services/debugLog');
|
|||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Transcript = mongoose.model('Transcript');
|
const Transcript = mongoose.model('Transcript');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
const User = mongoose.model('User');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main button/modal handler – called from interactionCreate.
|
* Main button/modal handler – called from interactionCreate.
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ const { enqueueRename, enqueueMove, enqueueSend } = require('../services/channel
|
|||||||
const { setNotifyDm } = require('../services/staffSettings');
|
const { setNotifyDm } = require('../services/staffSettings');
|
||||||
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
const { trackInteraction, trackError, getAnalyticsSummary } = require('./analytics');
|
||||||
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
const { logTicketEvent, logSecurity, logError } = require('../services/debugLog');
|
||||||
const { handleAccountInfoCommand } = require('./accountinfo');
|
|
||||||
const { handleSetupCommand } = require('./setup');
|
const { handleSetupCommand } = require('./setup');
|
||||||
const { pendingCloses } = require('./pendingCloses');
|
const { pendingCloses } = require('./pendingCloses');
|
||||||
|
|
||||||
const Ticket = mongoose.model('Ticket');
|
const Ticket = mongoose.model('Ticket');
|
||||||
const Tag = mongoose.model('Tag');
|
const Tag = mongoose.model('Tag');
|
||||||
const User = mongoose.model('User');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
* True if member has the support role (ROLE_ID_TO_PING) or any ADDITIONAL_STAFF_ROLES.
|
||||||
@@ -800,12 +798,6 @@ async function handleCommand(interaction) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// /accountinfo
|
|
||||||
if (interaction.commandName === 'accountinfo') {
|
|
||||||
await handleAccountInfoCommand(interaction);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// /help
|
// /help
|
||||||
if (interaction.commandName === 'help') {
|
if (interaction.commandName === 'help') {
|
||||||
const embed = new EmbedBuilder()
|
const embed = new EmbedBuilder()
|
||||||
@@ -818,7 +810,7 @@ async function handleCommand(interaction) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Ticket Management',
|
name: 'Ticket Management',
|
||||||
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description\n`/accountinfo email` - Look up website account by email\n`/accountinfo discord @user` - Look up website account by Discord user'
|
value: '`/transfer @staff` - Transfer ticket to another staff member\n`/move #category` - Move ticket to another category\n`/force-close` - Force close ticket without confirmation\n`/topic <text>` - Set ticket topic/description'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Saved Responses',
|
name: 'Saved Responses',
|
||||||
|
|||||||
790
models.js
790
models.js
@@ -1,795 +1,5 @@
|
|||||||
var mongoose = require('mongoose');
|
var mongoose = require('mongoose');
|
||||||
|
|
||||||
mongoose.model('Host', new mongoose.Schema({
|
|
||||||
hostname: String,
|
|
||||||
ip: String,
|
|
||||||
region: String,
|
|
||||||
provider: String,
|
|
||||||
memory: String,
|
|
||||||
status: String,
|
|
||||||
ipGateway: String,
|
|
||||||
memFree: Number,
|
|
||||||
cpuUsage: Number,
|
|
||||||
diskFree: Number,
|
|
||||||
lastSeen: { type: Number, default: Date.now },
|
|
||||||
lostInUse: { type: [Number], default: [] },
|
|
||||||
statsHistory: [{
|
|
||||||
timestamp: Number,
|
|
||||||
memFree: Number,
|
|
||||||
cpuUsage: Number,
|
|
||||||
diskFree: Number
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update for each new game
|
|
||||||
mongoose.model('User', new mongoose.Schema({
|
|
||||||
email: String,
|
|
||||||
discordID: {type: String, default: ""},
|
|
||||||
customerId: String,
|
|
||||||
usedPaypal: {type: Boolean, default: false},
|
|
||||||
passwordHash: String,
|
|
||||||
resetPasswordToken: String,
|
|
||||||
resetPasswordExpires: Date,
|
|
||||||
sessionToken: {type: String, default: null},
|
|
||||||
indifferentBroccoli: {type: Boolean, default: false},
|
|
||||||
paymentLink: {type: String, default: null},
|
|
||||||
palpocalypseEligible: {type: Boolean, default: false},
|
|
||||||
palpocalypseClaimed: {type: Boolean, default: false},
|
|
||||||
//Admin
|
|
||||||
machineStats: [{
|
|
||||||
name: String,
|
|
||||||
memoryFree: Number,
|
|
||||||
cpuUsagePercentage: Number,
|
|
||||||
diskFree: Number
|
|
||||||
}],
|
|
||||||
|
|
||||||
//Subusers
|
|
||||||
subUserServers: [{
|
|
||||||
linuxUsername: String,
|
|
||||||
permissions: { type: Object, default: {} }
|
|
||||||
}],
|
|
||||||
|
|
||||||
subusers: [{
|
|
||||||
email: String,
|
|
||||||
inviteToken: String,
|
|
||||||
inviteExpires: Date,
|
|
||||||
}],
|
|
||||||
|
|
||||||
// Activity log
|
|
||||||
activities: [{
|
|
||||||
serverId: mongoose.Schema.Types.ObjectId,
|
|
||||||
action: String,
|
|
||||||
timestamp: { type: Number, default: Date.now }
|
|
||||||
}],
|
|
||||||
|
|
||||||
serverOrder: [String],
|
|
||||||
|
|
||||||
servers: [{
|
|
||||||
// Public server page info
|
|
||||||
tags: String,
|
|
||||||
thumbnailImageLink: String,
|
|
||||||
links: [String],
|
|
||||||
|
|
||||||
// Server settings
|
|
||||||
status: {type: String, default: "Setting Up"},
|
|
||||||
isTrial: {type: Boolean, default: false},
|
|
||||||
trialExpiry: {type: Date, default: null},
|
|
||||||
sentExpiryNotification: {type: Boolean, default: false},
|
|
||||||
sentTrialEndedNotification: {type: Boolean, default: false},
|
|
||||||
sentWelcomeFeedbackRequest: {type: Boolean, default: false},
|
|
||||||
sentUpcomingCancellationNotice : {type: Boolean, default: false},
|
|
||||||
linuxUsername: String,
|
|
||||||
linuxPassword: String, //todo: store hash
|
|
||||||
telnetPassword: String,
|
|
||||||
controlPanelPassword: String,
|
|
||||||
subscriptionId: {type:String,default:null},
|
|
||||||
subscriptionId_PayPal: {type:String,default:null},
|
|
||||||
subscriptionId_PayPalFrozen: {type:String,default:null},
|
|
||||||
subscriptionActive: {type: Boolean, default: false},
|
|
||||||
subscriptionStatus: {type: String, default: null},
|
|
||||||
subscriptionScheduledFreeze: {type: String, default: null},
|
|
||||||
subscriptionScheduledFreezeJobId: {type: String, default: null},
|
|
||||||
subscriptionScheduledCancel: {type: String, default: null},
|
|
||||||
subscriptionScheduledCancelJobId: {type: String, default: null},
|
|
||||||
ip: String,
|
|
||||||
ipGateway: {type: String, default: null},
|
|
||||||
serverPort: {type: Number, default: null},
|
|
||||||
serverPortGateway: {type: Number, default: null},
|
|
||||||
region: {type: String, default: "na-east"},
|
|
||||||
game: String,
|
|
||||||
discordAdmins: {type: [String], default: []},
|
|
||||||
// Generic game settings
|
|
||||||
serverName: String,
|
|
||||||
serverPassword: String, //todo: store hash?
|
|
||||||
gameWorld: String, // pre-Alpha 17
|
|
||||||
serverDescription: String,
|
|
||||||
serverMaxPlayerCount: Number,
|
|
||||||
worldName: String,
|
|
||||||
|
|
||||||
// StarRupture settings
|
|
||||||
sessionName: {type: String, default: "IndifferentWorld"},
|
|
||||||
saveGameInterval: {type: Number, default: 300},
|
|
||||||
saveGameName: {type: String, default: "AutoSave0.sav"},
|
|
||||||
loadSavedGame: {type: Boolean, default: true},
|
|
||||||
|
|
||||||
scheduledRestarts: {
|
|
||||||
type: [{
|
|
||||||
command: {type: String, default: "restart"},
|
|
||||||
rconCommand: String,
|
|
||||||
minute: Number,
|
|
||||||
hour: Number,
|
|
||||||
day: Number,
|
|
||||||
intervalValue: Number,
|
|
||||||
intervalUnit: String,
|
|
||||||
intervalMinute: Number
|
|
||||||
}],
|
|
||||||
default: []
|
|
||||||
},
|
|
||||||
admins: [{
|
|
||||||
steamId: String,
|
|
||||||
permissionLevel: Number
|
|
||||||
}],
|
|
||||||
backups: [{
|
|
||||||
timestamp: Number
|
|
||||||
}],
|
|
||||||
ActiveMods: [{
|
|
||||||
modId: String
|
|
||||||
}],
|
|
||||||
playersOnline: [{
|
|
||||||
name: String,
|
|
||||||
raw: {
|
|
||||||
score: Number,
|
|
||||||
time: Number
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
|
|
||||||
//-----7 Day to Die-----//
|
|
||||||
serverIsPublic: String, // pre-Alpha 17
|
|
||||||
adminPassword: String, //todo: store hash?,
|
|
||||||
serverWebsiteUrl: String,
|
|
||||||
gameName: String,
|
|
||||||
gameDifficulty: Number,
|
|
||||||
gameMode: String, // don't make the GameModeSurvival or else anyone can place blocks anywhere
|
|
||||||
zombiesRun: Number,
|
|
||||||
buildCreate: String,
|
|
||||||
dayNightLength: Number,
|
|
||||||
dayLightLength: Number,
|
|
||||||
playerKillingMode: Number,
|
|
||||||
persistentPlayerProfiles: String,
|
|
||||||
playerSafeZoneLevel: Number,
|
|
||||||
playerSafeZoneHours: Number,
|
|
||||||
deathPenalty: Number,
|
|
||||||
dropOnDeath: Number,
|
|
||||||
dropOnQuit: Number,
|
|
||||||
bloodMoonEnemyCount: Number,
|
|
||||||
enemySpawnMode: String,
|
|
||||||
enemyDifficulty: Number,
|
|
||||||
blockDurabilityModifier: Number,
|
|
||||||
lootAbundance: Number,
|
|
||||||
lootRespawnDays: Number,
|
|
||||||
landClaimSize: Number,
|
|
||||||
landClaimDeadZone: Number,
|
|
||||||
landClaimExpiryTime: Number,
|
|
||||||
landClaimDecayMode: Number,
|
|
||||||
landClaimOnlineDurabilityModifier: Number,
|
|
||||||
landClaimOfflineDurabilityModifier: Number,
|
|
||||||
airDropFrequency: Number,
|
|
||||||
airDropMarker: String,
|
|
||||||
maxSpawnedZombies: Number,
|
|
||||||
maxSpawnedAnimals: Number,
|
|
||||||
eacEnabled: String,
|
|
||||||
maxUncoveredMapChunksPerPlayer: Number,
|
|
||||||
bedrollDeadZoneSize: Number,
|
|
||||||
questProgressionDailyLimit: Number,
|
|
||||||
maxChunkAge: Number,
|
|
||||||
serverAllowCrossplay: {type: String, default: "false"},
|
|
||||||
biomeProgression: {type: String, default: "true"},
|
|
||||||
stormFreq: {type: Number, default: 100},
|
|
||||||
allowSpawnNearFriend: {type: Number, default: 2},
|
|
||||||
ignoreEOSSanctions: {type: String, default: "false"},
|
|
||||||
playerCount: Number,
|
|
||||||
jarRefund: {type: Number, default: 0},
|
|
||||||
|
|
||||||
// Alpha >= 17 only
|
|
||||||
version: Number,
|
|
||||||
serverVisibility: Number,
|
|
||||||
serverReservedSlots: Number,
|
|
||||||
serverReservedSlotsPermission: Number,
|
|
||||||
serverDisabledNetworkProtocols: String,
|
|
||||||
worldGenSeed: String,
|
|
||||||
worldGenSize: Number, //todo: figure out max
|
|
||||||
telnetFailedLoginLimit: Number, // todo: figure out max or don't use
|
|
||||||
telnetFailedLoginsBlocktime: Number, // todo: figure out max or don't use
|
|
||||||
terminalWindowEnabled: Boolean, //pre Alpha 17.2 default was false
|
|
||||||
partySharedKillRange: Number,
|
|
||||||
hideCommandExecutionLog: Number,
|
|
||||||
serverLoginConfirmationText: String,
|
|
||||||
zombieFeralSense: Number,
|
|
||||||
zombieMove: Number,
|
|
||||||
zombieMoveNight: Number,
|
|
||||||
zombieFeralMove: Number,
|
|
||||||
zombieBMMove: Number,
|
|
||||||
// Alpha >= 17.2 only
|
|
||||||
bloodMoonFrequency: Number,
|
|
||||||
bloodMoonRange: Number,
|
|
||||||
bloodMoonWarning: Number,
|
|
||||||
xpMultiplier: Number,
|
|
||||||
blockDamagePlayer: Number,
|
|
||||||
blockDamageAI: Number,
|
|
||||||
blockDamageAIBM: Number,
|
|
||||||
landClaimCount: Number,
|
|
||||||
// Alpha >= 18 only
|
|
||||||
serverMaxAllowedViewDistance: Number,
|
|
||||||
serverMaxWorldTransferSpeedKiBs: Number,
|
|
||||||
bedrollExpiryTime: Number,
|
|
||||||
|
|
||||||
sevenDaysRegion: String,
|
|
||||||
language: String,
|
|
||||||
|
|
||||||
//-----Abiotic Factor-----//
|
|
||||||
|
|
||||||
//-----ARK-----//
|
|
||||||
BETA: {type: String, default: "public"},
|
|
||||||
AdminLogging: Boolean,
|
|
||||||
AllowCaveBuildingPvE: Boolean,
|
|
||||||
AllowFlyerCarryPvE: Boolean,
|
|
||||||
AllowHideDamageSourceFromLogs: Boolean,
|
|
||||||
AllowSharedConnections: Boolean,
|
|
||||||
AllowTekSuitPowersInGenesis: Boolean,
|
|
||||||
allowThirdPersonPlayer: Boolean,
|
|
||||||
alwaysNotifyPlayerJoined: Boolean,
|
|
||||||
alwaysNotifyPlayerLeft: Boolean,
|
|
||||||
AutoSavePeriodMinutes: Number,
|
|
||||||
bAllowPlatformSaddleMultiFloors: Boolean,
|
|
||||||
BanListURL: String,
|
|
||||||
bForceCanRideFliers: Boolean,
|
|
||||||
ClampResourceHarvestDamage: Boolean,
|
|
||||||
Cluster: [{
|
|
||||||
serverName: String,
|
|
||||||
gameWorld: String,
|
|
||||||
clusterId: String,
|
|
||||||
serverId: String,
|
|
||||||
serverPort: Number,
|
|
||||||
serverMaxPlayerCount: Number
|
|
||||||
}],
|
|
||||||
CrossARKAllowForeignDinoDownloads: Boolean,
|
|
||||||
Crossplay: {type: Boolean, default: false},
|
|
||||||
NoBattlEye: {type: Boolean, default: false},
|
|
||||||
ForceAllowCaveFlyers: {type: Boolean, default: false},
|
|
||||||
ShowFloatingDamageText: {type: Boolean, default: false},
|
|
||||||
CryopodNerfDamageMult: Number,
|
|
||||||
CryopodNerfDuration: Number,
|
|
||||||
CryopodNerfIncomingDamageMultPercent: Number,
|
|
||||||
CustomDynamicConfigUrl: String,
|
|
||||||
DayCycleSpeedScale: Number,
|
|
||||||
DayTimeSpeedScale: Number,
|
|
||||||
DifficultyOffset: Number,
|
|
||||||
DinoCharacterFoodDrainMultiplier: Number,
|
|
||||||
DinoCharacterHealthRecoveryMultiplier: Number,
|
|
||||||
DinoCharacterStaminaDrainMultiplier: Number,
|
|
||||||
DinoCountMultiplier: Number,
|
|
||||||
DinoDamageMultiplier: Number,
|
|
||||||
DinoResistanceMultiplier: Number,
|
|
||||||
DisableDinoDecayPvE: Boolean,
|
|
||||||
DisablePvEGamma: Boolean,
|
|
||||||
DisableStructureDecayPvE: Boolean,
|
|
||||||
DisableWeatherFog: Boolean,
|
|
||||||
EnableCryopodNerf: Boolean,
|
|
||||||
EnableCryoSicknessPVE: Boolean,
|
|
||||||
EnablePvPGamma: Boolean,
|
|
||||||
GameIniSettings: [{
|
|
||||||
text: String
|
|
||||||
}],
|
|
||||||
globalVoiceChat: Boolean,
|
|
||||||
HarvestAmountMultiplier: Number,
|
|
||||||
HarvestHealthMultiplier: Number,
|
|
||||||
ItemStackSizeMultiplier: Number,
|
|
||||||
MaxGateFrameOnSaddles: Number,
|
|
||||||
MaxPlatformSaddleStructureLimit: Number,
|
|
||||||
MaxPlayers: Number,
|
|
||||||
MaxStructuresInRange: Number,
|
|
||||||
MaxTamedDinos: Number,
|
|
||||||
MaxTributeDinos: Number,
|
|
||||||
MaxTributeItems: Number,
|
|
||||||
NightTimeSpeedScale: Number,
|
|
||||||
noTributeDownloads: Boolean,
|
|
||||||
PerPlatformMaxStructuresMultiplier: Number,
|
|
||||||
PlatformSaddleBuildAreaBoundsMultiplier: Number,
|
|
||||||
PlayerCharacterFoodDrainMultiplier: Number,
|
|
||||||
PlayerCharacterHealthRecoveryMultiplier: Number,
|
|
||||||
PlayerCharacterStaminaDrainMultiplier: Number,
|
|
||||||
PlayerCharacterWaterDrainMultiplier: Number,
|
|
||||||
PlayerDamageMultiplier: Number,
|
|
||||||
PlayerResistanceMultiplier: Number,
|
|
||||||
proximityChat: Boolean,
|
|
||||||
PvEDinoDecayPeriodMultiplier: Number,
|
|
||||||
PvEStructureDecayDestructionPeriod: Number,
|
|
||||||
PvEStructureDecayPeriodMultiplier: Number,
|
|
||||||
PvPStructureDecay: Boolean,
|
|
||||||
RandomSupplyCratePoints: Boolean,
|
|
||||||
ResourcesRespawnPeriodMultiplier: Number,
|
|
||||||
ServerAdminPassword: String,
|
|
||||||
serverForceNoHud: Boolean,
|
|
||||||
serverHardcore: Boolean,
|
|
||||||
serverPVE: Boolean,
|
|
||||||
ShowMapPlayerLocation: Boolean,
|
|
||||||
SpectatorPassword: String,
|
|
||||||
StructureDamageMultiplier: Number,
|
|
||||||
StructureResistanceMultiplier: Number,
|
|
||||||
TamingSpeedMultiplier: Number,
|
|
||||||
TheMaxStructuresInRange: Number,
|
|
||||||
TribeNameChangeCooldown: Number,
|
|
||||||
TributeCharacterExpirationSeconds: Number,
|
|
||||||
TributeDinoExpirationSeconds: Number,
|
|
||||||
TributeItemExpirationSeconds: Number,
|
|
||||||
XPMultiplier: Number,
|
|
||||||
|
|
||||||
gameIni: String,
|
|
||||||
|
|
||||||
//-----Conan Exiles-----//
|
|
||||||
modList: String,
|
|
||||||
|
|
||||||
//-----Core Keeper-----//
|
|
||||||
gameID: String,
|
|
||||||
worldSeed: {type: Number, default: 0},
|
|
||||||
worldIndex: {type: Number, default: 0},
|
|
||||||
worldMode: {type: Number, default: 0},
|
|
||||||
season: {type: Number, default: -1},
|
|
||||||
corekeeperMods: {type: String, default: ""},
|
|
||||||
|
|
||||||
//-----Counter Strike 2 (CS2)-----//
|
|
||||||
|
|
||||||
//-----DayZ-----//
|
|
||||||
enableWhitelist: { type: Boolean, default: false },
|
|
||||||
disable3rdPerson: { type: Boolean, default: false },
|
|
||||||
disableCrosshair: { type: Boolean, default: false },
|
|
||||||
disablePersonalLight: { type: Boolean, default: false },
|
|
||||||
disableVoicechat: { type: Boolean, default: false },
|
|
||||||
modList: {type: String, default: ""},
|
|
||||||
|
|
||||||
//-----ECO-----//
|
|
||||||
|
|
||||||
//-----Enshrouded-----//
|
|
||||||
|
|
||||||
//-----Factorio-----//
|
|
||||||
spaceAgeEnabled: {type: Boolean, default: true},
|
|
||||||
autoUpdateMods: {type: Boolean, default: false},
|
|
||||||
visibilityPublic: {type: Boolean, default: true},
|
|
||||||
factorioUsername: {type: String, default: ""},
|
|
||||||
factorioPassword: {type: String, default: ""},
|
|
||||||
factorioToken: {type: String, default: ""},
|
|
||||||
requireUserVerification: {type: Boolean, default: true},
|
|
||||||
allowCommands: {type: String, default: "admins-only"},
|
|
||||||
afkAutokickInterval: {type: Number, default: 0},
|
|
||||||
autoPause: {type: Boolean, default: true},
|
|
||||||
autoPauseWhenPlayersConnect: {type: Boolean, default: false},
|
|
||||||
onlyAdminsCanPause: {type: Boolean, default: true},
|
|
||||||
|
|
||||||
//-----FiveM-----//
|
|
||||||
licenseKey: {type: String, default: ""},
|
|
||||||
locale: String,
|
|
||||||
|
|
||||||
//-----The Front-----//
|
|
||||||
extraArgs: String,
|
|
||||||
|
|
||||||
//-----Garry's Mod-----//
|
|
||||||
workshopCollection: String,
|
|
||||||
serverCheats: Boolean,
|
|
||||||
customParameters: String,
|
|
||||||
GLST: String,
|
|
||||||
|
|
||||||
//-----Hytale-----//
|
|
||||||
viewDistance: {type: Number, default: 12},
|
|
||||||
MaxViewRadius: {type: Number, default: 32},
|
|
||||||
serverMOTD: {type: String, default: ""},
|
|
||||||
defaultWorld: {type: String, default: "default"},
|
|
||||||
selectedWorld: {type: String, default: "default"},
|
|
||||||
IsPvpEnabled: {type: Boolean, default: false},
|
|
||||||
IsFallDamageEnabled: {type: Boolean, default: true},
|
|
||||||
IsGameTimePaused: {type: Boolean, default: false},
|
|
||||||
IsSpawningNPC: {type: Boolean, default: true},
|
|
||||||
IsSpawnMarkersEnabled: {type: Boolean, default: true},
|
|
||||||
IsAllNPCFrozen: {type: Boolean, default: false},
|
|
||||||
IsCompassUpdating: {type: Boolean, default: true},
|
|
||||||
IsObjectiveMarkersEnabled: {type: Boolean, default: true},
|
|
||||||
itemsLossMode: {type: String, default: "Configured"},
|
|
||||||
itemsAmountLossPercentage: {type: Number, default: 10},
|
|
||||||
itemsDurabilityLossPercentage: {type: Number, default: 10},
|
|
||||||
gameMode: {type: String, default: "Adventure"},
|
|
||||||
hytaleOAuthUrl: String,
|
|
||||||
hytaleAuthLinkClicked: {type: Boolean, default: false},
|
|
||||||
|
|
||||||
//-----Palworld-----//
|
|
||||||
AutoResetGuildTimeNoOnlinePlayers: {type: Number, default: 72},
|
|
||||||
bActiveUNKO: {type: Boolean, default: false},
|
|
||||||
BanListURL: {type: String, default: "https://api.palworldgame.com/api/banlist.txt"},
|
|
||||||
BaseCampMaxNum: {type: Number, default: 128},
|
|
||||||
BaseCampWorkerMaxNum: {type: Number, default: 15},
|
|
||||||
bAutoResetGuildNoOnlinePlayers: {type: Boolean, default: false},
|
|
||||||
bCanPickupOtherGuildDeathPenaltyDrop: {type: Boolean, default: false},
|
|
||||||
bEnableAimAssistKeyboard: {type: Boolean, default: false},
|
|
||||||
bEnableAimAssistPad: {type: Boolean, default: true},
|
|
||||||
bEnableDefenseOtherGuildPlayer: {type: Boolean, default: false},
|
|
||||||
bEnableFastTravel: {type: Boolean, default: true},
|
|
||||||
bEnableFriendlyFire: {type: Boolean, default: false},
|
|
||||||
bEnableInvaderEnemy: {type: Boolean, default: true},
|
|
||||||
bEnableNonLoginPenalty: {type: Boolean, default: true},
|
|
||||||
bEnablePlayerToPlayerDamage: {type: Boolean, default: false},
|
|
||||||
bExistPlayerAfterLogout: {type: Boolean, default: false},
|
|
||||||
bIsMultiplay: {type: Boolean, default: false},
|
|
||||||
bIsPvP: {type: Boolean, default: false},
|
|
||||||
bIsStartLocationSelectByMap: {type: Boolean, default: true},
|
|
||||||
BuildObjectDamageRate: {type: Number, default: 1},
|
|
||||||
BuildObjectDeteriorationDamageRate: {type: Number, default: 1},
|
|
||||||
bUseAuth: {type: Boolean, default: true},
|
|
||||||
CollectionDropRate: {type: Number, default: 1},
|
|
||||||
CollectionObjectHpRate: {type: Number, default: 1},
|
|
||||||
CollectionObjectRespawnSpeedRate: {type: Number, default: 1},
|
|
||||||
CoopPlayerMaxNum: {type: Number, default: 4},
|
|
||||||
DayTimeSpeedRate: {type: Number, default: 1},
|
|
||||||
DeathPenalty: {type: String, default: "All"},
|
|
||||||
Difficulty: {type: String, default: "None"},
|
|
||||||
DropItemAliveMaxHours: {type: Number, default: 1},
|
|
||||||
DropItemMaxNum: {type: Number, default: 3000},
|
|
||||||
DropItemMaxNum_UNKO: {type: Number, default: 100},
|
|
||||||
EnemyDropItemRate: {type: Number, default: 1},
|
|
||||||
ExpRate: {type: Number, default: 1},
|
|
||||||
GuildPlayerMaxNum: {type: Number, default: 20},
|
|
||||||
NightTimeSpeedRate: {type: Number, default: 1},
|
|
||||||
PalAutoHPRegeneRate: {type: Number, default: 1},
|
|
||||||
PalAutoHpRegeneRateInSleep: {type: Number, default: 1},
|
|
||||||
PalCaptureRate: {type: Number, default: 1},
|
|
||||||
PalDamageRateAttack: {type: Number, default: 1},
|
|
||||||
PalDamageRateDefense: {type: Number, default: 1},
|
|
||||||
PalEggDefaultHatchingTime: {type: Number, default: 72},
|
|
||||||
PalSpawnNumRate: {type: Number, default: 1},
|
|
||||||
PalStaminaDecreaceRate: {type: Number, default: 1},
|
|
||||||
PalStomachDecreaceRate: {type: Number, default: 1},
|
|
||||||
PlayerAutoHPRegeneRate: {type: Number, default: 1},
|
|
||||||
PlayerAutoHpRegeneRateInSleep: {type: Number, default: 1},
|
|
||||||
PlayerDamageRateAttack: {type: Number, default: 1},
|
|
||||||
PlayerDamageRateDefense: {type: Number, default: 1},
|
|
||||||
PlayerStaminaDecreaceRate: {type: Number, default: 1},
|
|
||||||
PlayerStomachDecreaceRate: {type: Number, default: 1},
|
|
||||||
palRegion: {type: String, default: ""},
|
|
||||||
WorkSpeedRate: {type: Number, default: 1},
|
|
||||||
Community: {type: Boolean, default: false},
|
|
||||||
BaseCampMaxNumInGuild : {type: Number, default: 3},
|
|
||||||
ConnectPlatform: {type: String, default: "Steam"},
|
|
||||||
SupplyDropSpan : {type: Number, default: 180},
|
|
||||||
palworldVersion: {type: String, default: "Latest"},
|
|
||||||
RandomizerType: {type: String, default: "None"},
|
|
||||||
RandomizerSeed: {type: String, default: ""},
|
|
||||||
ChatPostLimitPerMinute: {type: Number, default: 10},
|
|
||||||
EnablePredatorBossPal: {type: Boolean, default: true},
|
|
||||||
BuildObjectHpRate: {type: Number, default: 1},
|
|
||||||
Hardcore: {type: Boolean, default: false},
|
|
||||||
CharacterRecreateInHardcore: {type: Boolean, default: false},
|
|
||||||
PalLost: {type: Boolean, default: false},
|
|
||||||
BuildAreaLimit: {type: Boolean, default: false},
|
|
||||||
ItemWeightRate: {type: Number, default: 1},
|
|
||||||
MaxBuildingLimitNum: {type: Number, default: 0},
|
|
||||||
CrossplayPlatforms: {type: String, default: "(Steam,Xbox,PS5,Mac)"},
|
|
||||||
AllowGlobalPalboxExport: {type: Boolean, default: true},
|
|
||||||
AllowGlobalPalboxImport: {type: Boolean, default: false},
|
|
||||||
randomPalLevels: {type: Boolean, default: false},
|
|
||||||
equipmentDurabilityDamageRate: {type: Number, default: 1},
|
|
||||||
itemContainerForceMarkDirtyInterval: {type: Number, default: 1},
|
|
||||||
itemCorruptionMultiplier: {type: Number, default: 1},
|
|
||||||
|
|
||||||
//-----Project Zomboid-----//
|
|
||||||
autoRestartEnabled: {type: Boolean, default: false},
|
|
||||||
build42Unstable: {type: Boolean, default: false},
|
|
||||||
PZVersion: {type: String, default: "41.78.16"},
|
|
||||||
|
|
||||||
//-----Rust-----//
|
|
||||||
mapSize: Number,
|
|
||||||
maxMapSize: Number,
|
|
||||||
mapSeed: Number,
|
|
||||||
oxideEnabled: Boolean,
|
|
||||||
|
|
||||||
//-----Valheim-----//
|
|
||||||
valheimPlusEnabled: Boolean,
|
|
||||||
valheimPlusFork: {type: String, default: "valheimPlus"},
|
|
||||||
|
|
||||||
//-----Satisfactory-----//
|
|
||||||
serverVersion: {type: String, default: "public"},
|
|
||||||
satisfactoryAdminPassword: {type: String, default: ''},
|
|
||||||
satisfactoryHealth: {type: String, default: ''},
|
|
||||||
satisfactoryActiveSession: {type: String, default: ''},
|
|
||||||
satisfactoryTechTier: {type: Number, default: 0},
|
|
||||||
satisfactoryTickRate: {type: Number, default: 0},
|
|
||||||
satisfactoryGameDuration: {type: Number, default: 0},
|
|
||||||
satisfactoryActiveSchematic: {type: String, default: ''},
|
|
||||||
satisfactoryIsGamePaused: {type: Boolean, default: false},
|
|
||||||
|
|
||||||
//-----Sons of the Forest-----//
|
|
||||||
|
|
||||||
//-----Soulmask-----//
|
|
||||||
pvMode: {type: String, default: "pvp"},
|
|
||||||
|
|
||||||
//-----Terraria-----//
|
|
||||||
//----Already made/Non-config.json----//
|
|
||||||
//gameDifficulty: Number - 7days
|
|
||||||
//WorldGenSize: Number - 7days
|
|
||||||
MaxSlots: Number, //uses serverMaxPlayerCount
|
|
||||||
MOTD: String,
|
|
||||||
secure: Number,
|
|
||||||
//----Booleans----//
|
|
||||||
UseServerName: Boolean,
|
|
||||||
DebugLogs: Boolean,
|
|
||||||
DisableLoginBeforeJoin: Boolean,
|
|
||||||
IgnoreChestStacksOnLoad: Boolean,
|
|
||||||
Autosave: Boolean,
|
|
||||||
AnnounceSave: Boolean,
|
|
||||||
SaveWorldOnCrash: Boolean,
|
|
||||||
SaveWorldOnLastPlayerExit: Boolean,
|
|
||||||
InfiniteInvasion: Boolean,
|
|
||||||
SpawnProtection: Boolean,
|
|
||||||
RangeChecks: Boolean,
|
|
||||||
HardcoreOnly: Boolean,
|
|
||||||
MediumCoreOnly: Boolean,
|
|
||||||
DisableBuild: Boolean,
|
|
||||||
DisableHardmode: Boolean,
|
|
||||||
DisableDungeonGuardian: Boolean,
|
|
||||||
DisableClownBombs: Boolean,
|
|
||||||
DisableSnowBalls: Boolean,
|
|
||||||
DisableTombstones: Boolean,
|
|
||||||
DisableInvisPvP: Boolean,
|
|
||||||
RegionProtectChests: Boolean,
|
|
||||||
RegionProtectGemLocks: Boolean,
|
|
||||||
IgnoreProjUpdate: Boolean,
|
|
||||||
IgnoreProjKill: Boolean,
|
|
||||||
AllowCutTilesAndBreakables: Boolean,
|
|
||||||
AllowIce: Boolean,
|
|
||||||
AllowCrimsonCreep: Boolean,
|
|
||||||
AllowCorruptionCreep: Boolean,
|
|
||||||
AllowHallowCreep: Boolean,
|
|
||||||
PreventBannedItemSpawn: Boolean,
|
|
||||||
PreventDeadModification: Boolean,
|
|
||||||
PreventInvalidPlaceStyle: Boolean,
|
|
||||||
ForceXmas: Boolean,
|
|
||||||
ForceHalloween: Boolean,
|
|
||||||
AllowAllowedGroupsToSpawnBannedItems: Boolean,
|
|
||||||
AnonymousBossInvasions: Boolean,
|
|
||||||
RememberLeavePos: Boolean,
|
|
||||||
KickOnMediumcoreDeath: Boolean,
|
|
||||||
BanOnMediumCoreDeath: Boolean,
|
|
||||||
KickOnHardcoreDeath: Boolean,
|
|
||||||
BanOnHardcoreDeath: Boolean,
|
|
||||||
EnableWhitelist: Boolean,
|
|
||||||
EnableIPBans: Boolean,
|
|
||||||
EnableUUIDBans: Boolean,
|
|
||||||
EnableBanOnUsernames: Boolean,
|
|
||||||
KickProxyUsers: Boolean,
|
|
||||||
RequireLogin: Boolean,
|
|
||||||
AllowLoginAnyUsername: Boolean,
|
|
||||||
AllowRegisterAnyUsername: Boolean,
|
|
||||||
DisableUUIDLogin: Boolean,
|
|
||||||
KickEmptyUUID: Boolean,
|
|
||||||
KickOnTilePaintThresholdBroken: Boolean,
|
|
||||||
KickOnTileLiquidThresholdBroken: Boolean,
|
|
||||||
KickOnTileKillThresholdBroken: Boolean,
|
|
||||||
KickOnTilePlaceThresholdBroken: Boolean,
|
|
||||||
KickOnDamageThresholdBroken: Boolean,
|
|
||||||
KickOnProjectileThresholdBroken: Boolean,
|
|
||||||
KickOnHealOtherThresholdBroken: Boolean,
|
|
||||||
ProjIgnoreShrapnel: Boolean,
|
|
||||||
DisableSpewLogs: Boolean,
|
|
||||||
DisableSecondUpdateLogs: Boolean,
|
|
||||||
EnableGeoIP: Boolean,
|
|
||||||
DisplayIPToAdmins: Boolean,
|
|
||||||
EnableChatAboveHeads: Boolean,
|
|
||||||
//----Numbers----//
|
|
||||||
ReservedSlots: Number,
|
|
||||||
InvasionMultiplier: Number,
|
|
||||||
DefaultSpawnRate: Number,
|
|
||||||
DefaultMaximumSpawns: Number,
|
|
||||||
SpawnProtectionRadius: Number,
|
|
||||||
MaxRangeForDisabled: Number,
|
|
||||||
StatueSpawn200: Number,
|
|
||||||
StatueSpawn600: Number,
|
|
||||||
StatueSpawnWorld: Number,
|
|
||||||
RespawnSeconds: Number,
|
|
||||||
RespawnBossSeconds: Number,
|
|
||||||
MaxHP: Number,
|
|
||||||
MaxMP: Number,
|
|
||||||
BombExplosionRadius: Number,
|
|
||||||
MaximumLoginAttempts: Number,
|
|
||||||
MinimumPasswordLength: Number,
|
|
||||||
BCryptWorkFactor: Number,
|
|
||||||
TilePaintThreshold: Number,
|
|
||||||
TileKillThreshold: Number,
|
|
||||||
TilePlaceThreshold: Number,
|
|
||||||
TileLiquidThreshold: Number,
|
|
||||||
MaxDamage: Number,
|
|
||||||
MaxProjDamage: Number,
|
|
||||||
ProjectileThreshold: Number,
|
|
||||||
HealOtherThreshold: Number,
|
|
||||||
//----Strings----//
|
|
||||||
PvPMode: String,
|
|
||||||
ForceTime: String,
|
|
||||||
DefaultRegistrationGroupName: String,
|
|
||||||
DefaultGuestGroupName: String,
|
|
||||||
MediumcoreKickReason: String,
|
|
||||||
MediumcoreBanReason: String,
|
|
||||||
HardcoreKickReason: String,
|
|
||||||
HardcoreBanReason: String,
|
|
||||||
WhitelistKickReason: String,
|
|
||||||
ServerFullReason: String,
|
|
||||||
ServerFullNoReservedReason: String,
|
|
||||||
HashAlgorithm: String,
|
|
||||||
CommandSpecifier: String,
|
|
||||||
CommandSilentSpecifier: String,
|
|
||||||
SuperAdminChatPrefix: String,
|
|
||||||
SuperAdminChatSuffix: String,
|
|
||||||
ChatFormat: String,
|
|
||||||
ChatAboveHeadsFormat: String,
|
|
||||||
|
|
||||||
//-----Minecraft-----//
|
|
||||||
saveName: String,
|
|
||||||
enableCommandBlock: Boolean,
|
|
||||||
allowFlight: Boolean,
|
|
||||||
iconLink: String,
|
|
||||||
resourcePackLink: String,
|
|
||||||
resourcePackLinkSHA1: String,
|
|
||||||
requireResourcePack: Boolean,
|
|
||||||
resourcePackPrompt: String,
|
|
||||||
enforceWhitelist: Boolean,
|
|
||||||
maxBuildHeight: Number,
|
|
||||||
allowNether: Boolean,
|
|
||||||
generateStructures: Boolean,
|
|
||||||
spawnAnimals: Boolean,
|
|
||||||
spawnNPCS: Boolean,
|
|
||||||
spawnMonsters: Boolean,
|
|
||||||
forceGamemode: Boolean,
|
|
||||||
enableHardcore: Boolean,
|
|
||||||
enablePvP: Boolean,
|
|
||||||
playerIdleTimeout: Number,
|
|
||||||
serverMaxAllowedViewDistance: Number,
|
|
||||||
levelType: String,
|
|
||||||
generatorSettings: String,
|
|
||||||
enableRcon: Boolean,
|
|
||||||
rconPassword: String,
|
|
||||||
broadcastRconToOps: Boolean,
|
|
||||||
broadcastConsoleToOps: Boolean,
|
|
||||||
opPermissionLevel: Number,
|
|
||||||
functionPermissionLevel: Number,
|
|
||||||
serverType: {type: String, default: "VANILLA"},
|
|
||||||
serverTypeVersion: {type: String, default: ""},
|
|
||||||
gameDifficultyString: String,
|
|
||||||
maxPlayers: Number,
|
|
||||||
modpackurl: String,
|
|
||||||
modpackName: String,
|
|
||||||
modpackVersion: String,
|
|
||||||
mcVersion: {type: String, default: "LATEST"},
|
|
||||||
javaVersion: {type: String, default: "latest"},
|
|
||||||
maxTickTime: Number,
|
|
||||||
spawnProtectionRadius: Number,
|
|
||||||
|
|
||||||
selectedMods: [{
|
|
||||||
// For Minecraft
|
|
||||||
name: String,
|
|
||||||
slug: String,
|
|
||||||
// For Project Zomboid
|
|
||||||
title: String,
|
|
||||||
workshopId: String,
|
|
||||||
modId: String,
|
|
||||||
mapFolder: String,
|
|
||||||
}],
|
|
||||||
|
|
||||||
//-----VRising-----//
|
|
||||||
vrisingBepInExEnabled: Boolean,
|
|
||||||
|
|
||||||
//-----Icarus-----//
|
|
||||||
shutdownIfNotJoinedFor: Number,
|
|
||||||
shutdownIfEmptyFor: Number,
|
|
||||||
|
|
||||||
//-----Vintage Story-----//
|
|
||||||
serverLanguage: String,
|
|
||||||
serverWelcomeMessage: String,
|
|
||||||
whitelistMode: Number,
|
|
||||||
allowPvp: Boolean,
|
|
||||||
verifyPlayerAuth: Boolean,
|
|
||||||
allowFireSpread: Boolean,
|
|
||||||
allowFallingBlocks: Boolean,
|
|
||||||
passTimeWhenEmpty: Boolean,
|
|
||||||
clientConnectionTimeout: Number,
|
|
||||||
maxChunkRadius: Number,
|
|
||||||
chatRateLimit: Number,
|
|
||||||
maxOwnedGroupChannelsPerUser: Number,
|
|
||||||
seed: String,
|
|
||||||
allowCreativeMode: Boolean,
|
|
||||||
playStyle: String,
|
|
||||||
worldType: String,
|
|
||||||
mapSizeX: Number,
|
|
||||||
mapSizeY: Number,
|
|
||||||
mapSizeZ: Number,
|
|
||||||
gameMode: String,
|
|
||||||
startingClimate: String,
|
|
||||||
spawnRadius: Number,
|
|
||||||
graceTimer: Number,
|
|
||||||
deathPunishment: String,
|
|
||||||
droppedItemsTimer: Number,
|
|
||||||
seasons: String,
|
|
||||||
playerlives: Number,
|
|
||||||
lungCapacity: Number,
|
|
||||||
daysPerMonth: Number,
|
|
||||||
harshWinters: Boolean,
|
|
||||||
blockGravity: String,
|
|
||||||
caveIns: String,
|
|
||||||
allowUndergroundFarming: Boolean,
|
|
||||||
noLiquidSourceTransport: Boolean,
|
|
||||||
bodyTemperatureResistance: Number,
|
|
||||||
creatureHostility: String,
|
|
||||||
creatureStrength: Number,
|
|
||||||
creatureSwimSpeed: Number,
|
|
||||||
playerHealthPoints: Number,
|
|
||||||
playerHungerSpeed: Number,
|
|
||||||
playerHealthRegenSpeed: Number,
|
|
||||||
playerMoveSpeed: Number,
|
|
||||||
foodSpoilSpeed: Number,
|
|
||||||
saplingGrowthRate: Number,
|
|
||||||
toolDurability: Number,
|
|
||||||
toolMiningSpeed: Number,
|
|
||||||
propickNodeSearchRadius: Number,
|
|
||||||
microblockChiseling: String,
|
|
||||||
allowCoordinateHud: Boolean,
|
|
||||||
allowMap: Boolean,
|
|
||||||
colorAccurateWorldmap: Boolean,
|
|
||||||
loreContent: Boolean,
|
|
||||||
clutterObtainable: String,
|
|
||||||
lightningFires: Boolean,
|
|
||||||
allowTimeswitch: Boolean,
|
|
||||||
temporalStability: Boolean,
|
|
||||||
temporalStorms: String,
|
|
||||||
tempstormDurationMul: Number,
|
|
||||||
temporalRifts: String,
|
|
||||||
temporalGearRespawnUses: Number,
|
|
||||||
temporalStormSleeping: Number,
|
|
||||||
worldClimate: String,
|
|
||||||
landcover: Number,
|
|
||||||
oceanscale: Number,
|
|
||||||
upheavelCommonness: Number,
|
|
||||||
geologicActivity: Number,
|
|
||||||
landformScale: Number,
|
|
||||||
worldWidth: Number,
|
|
||||||
worldLength: Number,
|
|
||||||
worldEdge: String,
|
|
||||||
polarEquatorDistance: Number,
|
|
||||||
globalTemperature: Number,
|
|
||||||
globalPrecipitation: Number,
|
|
||||||
globalForestation: Number,
|
|
||||||
globalDepositSpawnRate: Number,
|
|
||||||
surfaceCopperDeposits: Number,
|
|
||||||
surfaceTinDeposits: Number,
|
|
||||||
snowAccum: Boolean,
|
|
||||||
allowLandClaiming: Boolean,
|
|
||||||
classExclusiveRecipes: Boolean,
|
|
||||||
auctionHouse: Boolean,
|
|
||||||
vsVersion: String
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
|
|
||||||
mongoose.model('DashboardMetrics', new mongoose.Schema({
|
|
||||||
timestamp: { type: Date, default: Date.now, expires: 31536000 },
|
|
||||||
activeUsers: Number,
|
|
||||||
workerId: String
|
|
||||||
}));
|
|
||||||
|
|
||||||
mongoose.model('ErrorLog', new mongoose.Schema({
|
|
||||||
timestamp: { type: Date, default: Date.now, expires: 2592000 }, // 30 days
|
|
||||||
statusCode: Number,
|
|
||||||
message: String,
|
|
||||||
stack: String,
|
|
||||||
url: String,
|
|
||||||
method: String,
|
|
||||||
userId: String,
|
|
||||||
userEmail: String,
|
|
||||||
authenticated: Boolean,
|
|
||||||
sessionValid: Boolean
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ===== Broccolini Bot Models =====
|
// ===== Broccolini Bot Models =====
|
||||||
|
|
||||||
const ticketSchema = new mongoose.Schema({
|
const ticketSchema = new mongoose.Schema({
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@@ -69,7 +69,7 @@ router.post('/config', express.json(), async (req, res) => {
|
|||||||
// GET /discord/guild — return guild info for smart dropdowns
|
// GET /discord/guild — return guild info for smart dropdowns
|
||||||
router.get('/discord/guild', async (req, res) => {
|
router.get('/discord/guild', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const client = require('../api/bosscordClient').getBot();
|
const client = require('../api/botClient').getBot();
|
||||||
if (!client) return res.status(503).json({ error: 'Bot not ready' });
|
if (!client) return res.status(503).json({ error: 'Bot not ready' });
|
||||||
|
|
||||||
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
const guild = client.guilds.cache.get(CONFIG.DISCORD_GUILD_ID);
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Bulk lookup Discord user information - IMPROVED VERSION
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Saves progress incrementally (every 100 users)
|
|
||||||
* - Can resume from where it left off
|
|
||||||
* - Better error handling
|
|
||||||
* - Uses guild member cache when possible
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/bulk-lookup-users-v2.js <input_file> <output_file>
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
const envPath = path.join(__dirname, '../../.env');
|
|
||||||
const result = require('dotenv').config({ path: envPath });
|
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse command line args
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 2) {
|
|
||||||
console.error('Usage: node scripts/bulk-lookup-users-v2.js <input_file> <output_file>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputFile = args[0];
|
|
||||||
const outputFile = args[1];
|
|
||||||
|
|
||||||
// Read user IDs from input file
|
|
||||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
console.log(`✅ Loaded ${userIds.length} user IDs from ${inputFile}`);
|
|
||||||
|
|
||||||
// Load existing results if any (for resume capability)
|
|
||||||
let results = {};
|
|
||||||
let processed = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
if (fs.existsSync(outputFile)) {
|
|
||||||
try {
|
|
||||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
|
||||||
results = existing.users || {};
|
|
||||||
processed = Object.keys(results).length;
|
|
||||||
errors = existing.errors || 0;
|
|
||||||
console.log(`📂 Found existing results: ${processed} users already processed`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`⚠️ Could not load existing results, starting fresh`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMembers
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
async function lookupUser(userId) {
|
|
||||||
// Skip if already processed
|
|
||||||
if (results[userId]) {
|
|
||||||
return results[userId];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await client.users.fetch(userId);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
globalName: user.globalName || user.username,
|
|
||||||
tag: user.tag,
|
|
||||||
bot: user.bot,
|
|
||||||
avatar: user.displayAvatarURL()
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
id: userId,
|
|
||||||
error: error.message,
|
|
||||||
username: null,
|
|
||||||
globalName: null,
|
|
||||||
tag: null,
|
|
||||||
bot: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResults() {
|
|
||||||
const output = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total_users: userIds.length,
|
|
||||||
processed: processed,
|
|
||||||
successful: processed - errors,
|
|
||||||
errors: errors,
|
|
||||||
users: results
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processUsers() {
|
|
||||||
console.log('\n🚀 Starting bulk lookup...');
|
|
||||||
console.log(` Progress will be saved every 100 users\n`);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const startProcessed = processed;
|
|
||||||
|
|
||||||
// Filter out already processed users
|
|
||||||
const toProcess = userIds.filter(id => !results[id]);
|
|
||||||
console.log(` ${toProcess.length} users remaining to process\n`);
|
|
||||||
|
|
||||||
// Process one at a time (safer and can still be reasonably fast)
|
|
||||||
for (let i = 0; i < toProcess.length; i++) {
|
|
||||||
const userId = toProcess[i];
|
|
||||||
|
|
||||||
const result = await lookupUser(userId);
|
|
||||||
results[result.id] = result;
|
|
||||||
|
|
||||||
if (!result.success) {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
// Save every 100 users
|
|
||||||
if (processed % 100 === 0) {
|
|
||||||
saveResults();
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000);
|
|
||||||
const rate = (processed - startProcessed) / elapsed;
|
|
||||||
const remaining = (toProcess.length - i - 1) / rate;
|
|
||||||
console.log(`💾 Progress: ${processed}/${userIds.length} (${errors} errors) - saved checkpoint - ~${remaining.toFixed(0)}s remaining`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slower delay to avoid rate limits (500ms = 2 requests/second - more reliable)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final save
|
|
||||||
saveResults();
|
|
||||||
|
|
||||||
const totalTime = ((Date.now() - startTime) / 1000);
|
|
||||||
|
|
||||||
console.log(`\n${'='.repeat(70)}`);
|
|
||||||
console.log(`✅ Lookup Complete!`);
|
|
||||||
console.log(`${'='.repeat(70)}`);
|
|
||||||
console.log(` Total time: ${totalTime.toFixed(1)}s`);
|
|
||||||
console.log(` Total processed: ${processed}/${userIds.length}`);
|
|
||||||
console.log(` Successful: ${processed - errors} (${((processed - errors)/userIds.length*100).toFixed(1)}%)`);
|
|
||||||
console.log(` Errors: ${errors}`);
|
|
||||||
console.log(` Rate: ${((processed - startProcessed)/totalTime).toFixed(1)} users/second`);
|
|
||||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
|
||||||
|
|
||||||
// Sample successful results
|
|
||||||
const sample = Object.values(results).filter(r => r.success).slice(0, 5);
|
|
||||||
if (sample.length > 0) {
|
|
||||||
console.log('📋 Sample results:');
|
|
||||||
sample.forEach(u => console.log(` ${u.username} (${u.id})`));
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
console.log(`✅ Bot logged in as ${client.user.tag}\n`);
|
|
||||||
processUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('❌ Discord client error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle graceful shutdown
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n\n⚠️ Interrupted! Saving progress...');
|
|
||||||
saveResults();
|
|
||||||
console.log('✅ Progress saved. You can resume by running the same command again.\n');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN);
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Bulk lookup Discord user information
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/bulk-lookup-users.js <input_file> <output_file>
|
|
||||||
*
|
|
||||||
* Input file: Text file with one user ID per line
|
|
||||||
* Output file: JSON file with user lookup results
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
// Load environment variables from repo root
|
|
||||||
const envPath = path.join(__dirname, '../../.env');
|
|
||||||
console.log(`Loading .env from: ${envPath}`);
|
|
||||||
const result = require('dotenv').config({ path: envPath });
|
|
||||||
if (result.error) {
|
|
||||||
console.error(`Error loading .env: ${result.error.message}`);
|
|
||||||
// Try broccolini-bot/.env as fallback
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOKEN = process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN;
|
|
||||||
const GUILD_ID = process.env.GUILD_ID || process.env.SERVER_ID;
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('Error: DISCORD_BOT_TOKEN must be set in .env');
|
|
||||||
console.error('Available env vars:', Object.keys(process.env).filter(k => k.includes('DISCORD')));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse command line args
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 2) {
|
|
||||||
console.error('Usage: node scripts/bulk-lookup-users.js <input_file> <output_file>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputFile = args[0];
|
|
||||||
const outputFile = args[1];
|
|
||||||
|
|
||||||
// Read user IDs from input file
|
|
||||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
console.log(`Loaded ${userIds.length} user IDs from ${inputFile}`);
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMembers
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = {};
|
|
||||||
let processed = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
async function lookupUser(userId) {
|
|
||||||
try {
|
|
||||||
// Add timeout to prevent hanging
|
|
||||||
const timeoutPromise = new Promise((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error('Lookup timeout')), 10000)
|
|
||||||
);
|
|
||||||
|
|
||||||
const fetchPromise = client.users.fetch(userId);
|
|
||||||
const user = await Promise.race([fetchPromise, timeoutPromise]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
globalName: user.globalName || user.username,
|
|
||||||
tag: user.tag,
|
|
||||||
bot: user.bot,
|
|
||||||
avatar: user.displayAvatarURL()
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// Handle errors (not found, timeout, rate limit)
|
|
||||||
if (error.message.includes('429')) {
|
|
||||||
console.log(` ⚠️ Rate limit hit for user ${userId}, will retry`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
id: userId,
|
|
||||||
error: error.message,
|
|
||||||
username: null,
|
|
||||||
globalName: null,
|
|
||||||
tag: null,
|
|
||||||
bot: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processUsers() {
|
|
||||||
console.log('\nStarting bulk lookup...');
|
|
||||||
console.log('This will take a few minutes for 2,428 users\n');
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Process in batches to avoid rate limits
|
|
||||||
const BATCH_SIZE = 3; // Very small batches to avoid rate limits
|
|
||||||
const DELAY_MS = 2000; // 2 seconds between batches
|
|
||||||
|
|
||||||
for (let i = 0; i < userIds.length; i += BATCH_SIZE) {
|
|
||||||
const batch = userIds.slice(i, i + BATCH_SIZE);
|
|
||||||
|
|
||||||
// Lookup batch in parallel
|
|
||||||
const promises = batch.map(userId => lookupUser(userId));
|
|
||||||
const batchResults = await Promise.all(promises);
|
|
||||||
|
|
||||||
// Store results
|
|
||||||
batchResults.forEach(result => {
|
|
||||||
if (!result.success) {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
results[result.id] = result;
|
|
||||||
processed++;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log every batch for debugging
|
|
||||||
if (processed <= 50) {
|
|
||||||
console.log(` Batch complete: ${processed} users processed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress update every 100 users
|
|
||||||
if (processed % 100 === 0 || processed === userIds.length) {
|
|
||||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
const rate = (processed / elapsed).toFixed(1);
|
|
||||||
const remaining = ((userIds.length - processed) / rate).toFixed(0);
|
|
||||||
console.log(`Progress: ${processed}/${userIds.length} (${errors} errors) - ${elapsed}s elapsed, ~${remaining}s remaining`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next batch to avoid rate limits
|
|
||||||
if (i + BATCH_SIZE < userIds.length) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
||||||
console.log(`\n✅ Completed in ${totalTime}s`);
|
|
||||||
console.log(` Successful: ${processed - errors}`);
|
|
||||||
console.log(` Errors: ${errors}`);
|
|
||||||
|
|
||||||
// Save results
|
|
||||||
const output = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total_users: userIds.length,
|
|
||||||
successful: processed - errors,
|
|
||||||
errors: errors,
|
|
||||||
users: results
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
|
||||||
console.log(`\n💾 Saved results to ${outputFile}`);
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
console.log(`✅ Bot logged in as ${client.user.tag}`);
|
|
||||||
processUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('Discord client error:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN);
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Export transcript channel messages with embed "Users in transcript" to JSONL.
|
|
||||||
* Each line: { message_id, created, ticket_name, ticket_owner_id, users: [{ id, count }], total }
|
|
||||||
* Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages] [outputPath]
|
|
||||||
* If outputPath is omitted, writes to stdout (redirect: node ... > transcript_embeds.jsonl).
|
|
||||||
* If outputPath is given, writes JSONL to that file (avoids dotenv/logs mixing with JSON).
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const channelId = process.argv[2];
|
|
||||||
const maxMessages = parseInt(process.argv[3], 10) || 10000;
|
|
||||||
const outputPath = process.argv[4];
|
|
||||||
const PAGE = 100;
|
|
||||||
|
|
||||||
// Parse "Users in transcript" value: "5 - <@123> - name#0\n 4 - <@456> - ..."
|
|
||||||
function parseUsersInTranscript(value) {
|
|
||||||
const users = [];
|
|
||||||
let total = 0;
|
|
||||||
const lines = (value || '').split(/\n/).map((s) => s.trim()).filter(Boolean);
|
|
||||||
for (const line of lines) {
|
|
||||||
const match = line.match(/^(\d+)\s+-\s+<@!?(\d+)>/);
|
|
||||||
if (match) {
|
|
||||||
const count = parseInt(match[1], 10);
|
|
||||||
users.push({ id: match[2], count });
|
|
||||||
total += count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { users, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TOKEN || !channelId) {
|
|
||||||
console.error('Usage: node scripts/export-transcript-embeds.js <channelId> [maxMessages]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.error('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (outputPath) {
|
|
||||||
fs.writeFileSync(outputPath, '');
|
|
||||||
}
|
|
||||||
let totalScanned = 0;
|
|
||||||
let before = undefined;
|
|
||||||
while (totalScanned < maxMessages) {
|
|
||||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
|
||||||
const options = before ? { limit, before } : { limit };
|
|
||||||
const messages = await channel.messages.fetch(options);
|
|
||||||
if (messages.size === 0) break;
|
|
||||||
totalScanned += messages.size;
|
|
||||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
|
||||||
if (!m.embeds?.length) continue;
|
|
||||||
for (const emb of m.embeds) {
|
|
||||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
|
||||||
if (!usersField?.value) continue;
|
|
||||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
|
||||||
const ticketName = ticketNameField?.value?.trim() || '';
|
|
||||||
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
|
|
||||||
const ownerMatch = ownerField?.value?.match(/<@!?(\d+)>/);
|
|
||||||
const ticket_owner_id = ownerMatch ? ownerMatch[1] : null;
|
|
||||||
const { users, total } = parseUsersInTranscript(usersField.value);
|
|
||||||
if (users.length === 0 && !ticket_owner_id) continue;
|
|
||||||
const out = {
|
|
||||||
message_id: m.id,
|
|
||||||
created: m.createdAt.toISOString(),
|
|
||||||
ticket_name: ticketName,
|
|
||||||
ticket_owner_id: ticket_owner_id || undefined,
|
|
||||||
users,
|
|
||||||
total,
|
|
||||||
};
|
|
||||||
const line = JSON.stringify(out) + '\n';
|
|
||||||
if (outputPath) {
|
|
||||||
fs.appendFileSync(outputPath, line);
|
|
||||||
} else {
|
|
||||||
process.stdout.write(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
|
|
||||||
before = oldestMsg?.id;
|
|
||||||
if (messages.size < PAGE) break;
|
|
||||||
}
|
|
||||||
process.stderr.write('Scanned ' + totalScanned + ' messages\n');
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Fetch recent messages from a Discord channel.
|
|
||||||
* Usage: node scripts/fetch-channel-messages.js <channelId> [limit]
|
|
||||||
* Default limit: 10
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const channelId = process.argv[2];
|
|
||||||
const limit = Math.min(parseInt(process.argv[3], 10) || 10, 100);
|
|
||||||
|
|
||||||
if (!TOKEN || !channelId) {
|
|
||||||
console.error('Usage: node scripts/fetch-channel-messages.js <channelId> [limit]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.log('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const messages = await channel.messages.fetch({ limit });
|
|
||||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
|
||||||
console.log('Messages fetched:', messages.size, '(requested', limit + ')');
|
|
||||||
if (messages.size === 0) {
|
|
||||||
console.log('No messages visible (empty channel or no Read Message History permission).');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
|
||||||
const preview = (m.content || '(embed/attachment only)').slice(0, 80);
|
|
||||||
console.log('---');
|
|
||||||
console.log('ID:', m.id, '| Author:', m.author.tag, '|', m.createdAt.toISOString());
|
|
||||||
console.log(preview + (m.content && m.content.length > 80 ? '...' : ''));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Fetch a Discord channel by ID and print its name and type.
|
|
||||||
* Usage: node scripts/fetch-channel.js <channelId>
|
|
||||||
* Example: node scripts/fetch-channel.js 1335424071227281520
|
|
||||||
*
|
|
||||||
* Uses DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN from .env (broccolini-bot or parent).
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const channelId = process.argv[2];
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('❌ No bot token (DISCORD_BOT_TOKEN or MEMBER_BOT_TOKEN)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!channelId) {
|
|
||||||
console.error('Usage: node scripts/fetch-channel.js <channelId>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch((err) => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.log('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.log('Channel ID:', channel.id);
|
|
||||||
console.log('Name:', channel.name);
|
|
||||||
console.log('Type:', channel.type);
|
|
||||||
if (channel.guild) console.log('Guild:', channel.guild.name, `(${channel.guild.id})`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Fetch a Discord message by channel ID and message ID.
|
|
||||||
* Usage: node scripts/fetch-message.js <channelId> <messageId>
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const [channelId, messageId] = process.argv.slice(2);
|
|
||||||
|
|
||||||
if (!TOKEN || !channelId || !messageId) {
|
|
||||||
console.error('Usage: node scripts/fetch-message.js <channelId> <messageId>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.log('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
const message = await channel.messages.fetch(messageId).catch((err) => null);
|
|
||||||
if (!message) {
|
|
||||||
console.log('Message not found (wrong channel, deleted, or no access).');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
|
||||||
console.log('Message ID:', message.id);
|
|
||||||
console.log('Author:', message.author.tag, '(' + message.author.id + ')');
|
|
||||||
console.log('Created:', message.createdAt ? message.createdAt.toISOString() : message.createdTimestamp);
|
|
||||||
console.log('Content:', message.content || '(empty or embed only)');
|
|
||||||
if (message.embeds && message.embeds.length) {
|
|
||||||
message.embeds.forEach((emb, i) => {
|
|
||||||
console.log('\n--- Embed', i + 1, '---');
|
|
||||||
if (emb.title) console.log('Title:', emb.title);
|
|
||||||
if (emb.description) console.log('Description:', emb.description);
|
|
||||||
if (emb.url) console.log('URL:', emb.url);
|
|
||||||
if (emb.fields && emb.fields.length) {
|
|
||||||
emb.fields.forEach((f) => console.log('Field:', f.name, '\n', f.value));
|
|
||||||
}
|
|
||||||
if (emb.footer?.text) console.log('Footer:', emb.footer.text);
|
|
||||||
// Ticket name for display (e.g. "indifferentketchup🍅" from "indifferentketchup🍅-claimed-7235")
|
|
||||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
|
||||||
if (ticketNameField?.value) {
|
|
||||||
const full = ticketNameField.value.trim();
|
|
||||||
const short = full.replace(/-claimed-\d+$/, '').trim();
|
|
||||||
console.log('Ticket (short):', short || full);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Find transcript channel messages whose embed "Users in transcript" lists a given member ID.
|
|
||||||
* Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]
|
|
||||||
* Example: node scripts/find-transcript-by-member.js 1335424071227281520 219276746153787392 500
|
|
||||||
* Fetches in pages of 100; maxMessages limits total (e.g. 500 = 5 pages). Default 100.
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const channelId = process.argv[2];
|
|
||||||
const memberId = process.argv[3];
|
|
||||||
const maxMessages = parseInt(process.argv[4], 10) || 100;
|
|
||||||
const PAGE = 100;
|
|
||||||
|
|
||||||
if (!TOKEN || !channelId || !memberId) {
|
|
||||||
console.error('Usage: node scripts/find-transcript-by-member.js <channelId> <memberId> [maxMessages]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.log('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
console.log('Channel:', channel.name, '(' + channel.id + ')');
|
|
||||||
console.log('Looking for member ID', memberId, 'in embed "Users in transcript"');
|
|
||||||
const memberRef = `<@${memberId}>`;
|
|
||||||
let totalScanned = 0;
|
|
||||||
let found = 0;
|
|
||||||
let before = undefined;
|
|
||||||
while (totalScanned < maxMessages) {
|
|
||||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
|
||||||
const options = before ? { limit, before } : { limit };
|
|
||||||
const messages = await channel.messages.fetch(options);
|
|
||||||
if (messages.size === 0) break;
|
|
||||||
totalScanned += messages.size;
|
|
||||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
|
||||||
if (!m.embeds?.length) continue;
|
|
||||||
for (const emb of m.embeds) {
|
|
||||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
|
||||||
if (!usersField?.value || !usersField.value.includes(memberRef)) continue;
|
|
||||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
|
||||||
const ticketName = ticketNameField?.value?.trim() || '(no Ticket Name field)';
|
|
||||||
console.log('\n--- Match ---');
|
|
||||||
console.log('Message ID:', m.id);
|
|
||||||
console.log('Created:', m.createdAt.toISOString());
|
|
||||||
console.log('Ticket Name:', ticketName);
|
|
||||||
console.log('Users in transcript:\n' + usersField.value);
|
|
||||||
found++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const oldestMsg = messages.reduce((a, m) => (m.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? m : a), null);
|
|
||||||
before = oldestMsg?.id;
|
|
||||||
if (messages.size < PAGE) break;
|
|
||||||
}
|
|
||||||
console.log('\nTotal messages scanned:', totalScanned);
|
|
||||||
console.log('Total messages matching member', memberId, ':', found);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Find transcript messages whose embed "Ticket Owner" is a given user ID.
|
|
||||||
* Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]
|
|
||||||
* If totalMessages is given, only show messages where "Users in transcript" sum equals that.
|
|
||||||
* Example: node scripts/find-transcript-by-owner.js 1335424071227281520 241129484483297280 5 10000
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const channelId = process.argv[2];
|
|
||||||
const ownerId = process.argv[3];
|
|
||||||
const totalMessages = parseInt(process.argv[4], 10) || null;
|
|
||||||
const maxMessages = parseInt(process.argv[5], 10) || 10000;
|
|
||||||
const PAGE = 100;
|
|
||||||
|
|
||||||
function parseUsersTotal(value) {
|
|
||||||
let total = 0;
|
|
||||||
(value || '').split(/\n/).forEach((line) => {
|
|
||||||
const m = line.trim().match(/^(\d+)\s+-\s+<@!?\d+>/);
|
|
||||||
if (m) total += parseInt(m[1], 10);
|
|
||||||
});
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!TOKEN || !channelId || !ownerId) {
|
|
||||||
console.error('Usage: node scripts/find-transcript-by-owner.js <channelId> <ownerId> [totalMessages] [maxMessages]');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerRef = `<@${ownerId}>`;
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null);
|
|
||||||
if (!channel) {
|
|
||||||
console.error('Channel not found or bot cannot access it.');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.error('Channel:', channel.name, '(' + channel.id + ')');
|
|
||||||
console.error('Looking for Ticket Owner', ownerId, totalMessages != null ? 'and total=' + totalMessages : '');
|
|
||||||
let totalScanned = 0;
|
|
||||||
let before = undefined;
|
|
||||||
let found = 0;
|
|
||||||
while (totalScanned < maxMessages) {
|
|
||||||
const limit = Math.min(PAGE, maxMessages - totalScanned);
|
|
||||||
const options = before ? { limit, before } : { limit };
|
|
||||||
const messages = await channel.messages.fetch(options);
|
|
||||||
if (messages.size === 0) break;
|
|
||||||
totalScanned += messages.size;
|
|
||||||
for (const [, m] of messages.sort((a, b) => b.createdTimestamp - a.createdTimestamp)) {
|
|
||||||
if (!m.embeds?.length) continue;
|
|
||||||
for (const emb of m.embeds) {
|
|
||||||
const ownerField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket owner'));
|
|
||||||
if (!ownerField?.value || !ownerField.value.includes(ownerRef)) continue;
|
|
||||||
const usersField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('users in transcript'));
|
|
||||||
const total = usersField?.value ? parseUsersTotal(usersField.value) : 0;
|
|
||||||
if (totalMessages != null && total !== totalMessages) continue;
|
|
||||||
const ticketNameField = emb.fields?.find((f) => f.name && f.name.toLowerCase().includes('ticket name'));
|
|
||||||
const ticketName = ticketNameField?.value?.trim() || '';
|
|
||||||
console.log('Message ID:', m.id);
|
|
||||||
console.log('Created:', m.createdAt.toISOString());
|
|
||||||
console.log('Ticket Name:', ticketName);
|
|
||||||
console.log('Total messages:', total);
|
|
||||||
console.log('---');
|
|
||||||
found++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const oldestMsg = messages.reduce((a, msg) => (msg.createdTimestamp < (a?.createdTimestamp ?? Infinity) ? msg : a), null);
|
|
||||||
before = oldestMsg?.id;
|
|
||||||
if (messages.size < PAGE) break;
|
|
||||||
}
|
|
||||||
console.error('Scanned', totalScanned, 'messages, matches:', found);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e.message || e);
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(TOKEN).catch((e) => {
|
|
||||||
console.error('Login failed:', e.message);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Look up a Discord user by ID. Uses repo root .env for token so it works without broccolini-bot config.
|
|
||||||
* Usage: node scripts/lookup-user.js [user_id]
|
|
||||||
* Run from broccolini-bot/ (or use full path to script).
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
require('dotenv').config({ path: path.resolve(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const token = (process.env.DISCORD_BOT_TOKEN || process.env.DISCORD_TOKEN || '').trim();
|
|
||||||
if (!token) {
|
|
||||||
console.error('Set DISCORD_BOT_TOKEN or DISCORD_TOKEN in repo root .env (/IB-Discord-Bot/.env)');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
const userId = process.argv[2] || '140081819986034688';
|
|
||||||
|
|
||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
|
||||||
|
|
||||||
client.once('ready', async () => {
|
|
||||||
try {
|
|
||||||
const user = await client.users.fetch(userId);
|
|
||||||
console.log('User:', {
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
globalName: user.globalName ?? user.username,
|
|
||||||
tag: user.tag,
|
|
||||||
bot: user.bot
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Lookup failed:', err.message);
|
|
||||||
if (err.code === 10013) console.error('Unknown user, or bot does not share a server with this user.');
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
client.login(token);
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* User lookup using a dedicated minimal-permissions bot
|
|
||||||
*
|
|
||||||
* This bot:
|
|
||||||
* - Has NO server permissions
|
|
||||||
* - Only needs to be in the server
|
|
||||||
* - Uses separate token from main bot
|
|
||||||
* - Won't affect your main bot's rate limits
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* LOOKUP_BOT_TOKEN=your_token node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
// Load environment
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
// Use dedicated bot token OR fall back to main bot
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('❌ Error: No bot token found');
|
|
||||||
console.error(' Set MEMBER_BOT_TOKEN in .env or use DISCORD_BOT_TOKEN');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 2) {
|
|
||||||
console.error('Usage: node scripts/lookup-with-dedicated-bot.js <input_file> <output_file>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputFile = args[0];
|
|
||||||
const outputFile = args[1];
|
|
||||||
|
|
||||||
// Read user IDs
|
|
||||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
console.log(`✅ Loaded ${userIds.length} user IDs`);
|
|
||||||
|
|
||||||
// Load existing results
|
|
||||||
let results = {};
|
|
||||||
let processed = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
if (fs.existsSync(outputFile)) {
|
|
||||||
try {
|
|
||||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
|
||||||
results = existing.users || {};
|
|
||||||
processed = Object.keys(results).length;
|
|
||||||
errors = existing.errors || 0;
|
|
||||||
console.log(`📂 Found existing: ${processed} users`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`⚠️ Starting fresh`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create bot with MINIMAL intents
|
|
||||||
const client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds // Only need this to stay in server
|
|
||||||
// NO other intents needed!
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
async function lookupUser(userId) {
|
|
||||||
if (results[userId]) return results[userId];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const user = await client.users.fetch(userId);
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
globalName: user.globalName || user.username,
|
|
||||||
tag: user.tag,
|
|
||||||
bot: user.bot,
|
|
||||||
avatar: user.displayAvatarURL()
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
id: userId,
|
|
||||||
error: error.message,
|
|
||||||
username: null,
|
|
||||||
globalName: null,
|
|
||||||
tag: null,
|
|
||||||
bot: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResults() {
|
|
||||||
const output = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total_users: userIds.length,
|
|
||||||
processed: processed,
|
|
||||||
successful: processed - errors,
|
|
||||||
errors: errors,
|
|
||||||
bot_type: (process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN) ? 'dedicated' : 'main',
|
|
||||||
users: results
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processUsers() {
|
|
||||||
console.log('\n🚀 Starting lookups...');
|
|
||||||
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
|
|
||||||
console.log(` Bot type: ${isDedicated ? '✅ Dedicated lookup bot' : '⚠️ Main bot'}`);
|
|
||||||
console.log(` Rate: SLOW (1 user/second for safety)`);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const toProcess = userIds.filter(id => !results[id]);
|
|
||||||
console.log(` ${toProcess.length} users remaining\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < toProcess.length; i++) {
|
|
||||||
const userId = toProcess[i];
|
|
||||||
|
|
||||||
const result = await lookupUser(userId);
|
|
||||||
results[result.id] = result;
|
|
||||||
|
|
||||||
if (!result.success) errors++;
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
// Save every 10 users for frequent updates
|
|
||||||
if (processed % 10 === 0) {
|
|
||||||
saveResults();
|
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
|
||||||
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
|
|
||||||
const remaining = (toProcess.length - i - 1) / rate;
|
|
||||||
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - saved - ~${remaining.toFixed(0)}s left`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Very slow to avoid rate limits (1/second)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
saveResults();
|
|
||||||
|
|
||||||
const totalTime = (Date.now() - startTime) / 1000;
|
|
||||||
console.log(`\n${'='.repeat(60)}`);
|
|
||||||
console.log(`✅ Complete!`);
|
|
||||||
console.log(`${'='.repeat(60)}`);
|
|
||||||
console.log(` Time: ${totalTime.toFixed(1)}s`);
|
|
||||||
console.log(` Processed: ${processed}/${userIds.length}`);
|
|
||||||
console.log(` Successful: ${processed - errors}`);
|
|
||||||
console.log(` Errors: ${errors}`);
|
|
||||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
const isDedicated = !!(process.env.MEMBER_BOT_TOKEN || process.env.LOOKUP_BOT_TOKEN);
|
|
||||||
const botType = isDedicated ? 'DEDICATED LOOKUP BOT' : 'Main Bot';
|
|
||||||
console.log(`✅ Logged in as ${client.user.tag}`);
|
|
||||||
console.log(` Type: ${botType}`);
|
|
||||||
console.log();
|
|
||||||
processUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n\n⚠️ Interrupted! Saving...');
|
|
||||||
saveResults();
|
|
||||||
console.log('✅ Saved. Resume by running same command.\n');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔌 Connecting to Discord...');
|
|
||||||
client.login(TOKEN);
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Discord user lookup WITH ROLES
|
|
||||||
*
|
|
||||||
* Fetches:
|
|
||||||
* - User info (username, display name, avatar)
|
|
||||||
* - Guild member info (roles, join date, server nickname)
|
|
||||||
* - All Palpocalypse server roles
|
|
||||||
*
|
|
||||||
* Requires: Server Members Intent enabled in Discord Developer Portal
|
|
||||||
*/
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const GUILD_ID = '798321161082896395'; // Indifferent Broccoli server
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('❌ Error: No bot token found');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
|
||||||
if (args.length < 2) {
|
|
||||||
console.error('Usage: node scripts/lookup-with-roles.js <input_file> <output_file>');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputFile = args[0];
|
|
||||||
const outputFile = args[1];
|
|
||||||
|
|
||||||
const userIds = fs.readFileSync(inputFile, 'utf-8')
|
|
||||||
.split('\n')
|
|
||||||
.map(line => line.trim())
|
|
||||||
.filter(line => line.length > 0);
|
|
||||||
|
|
||||||
console.log(`✅ Loaded ${userIds.length} user IDs`);
|
|
||||||
|
|
||||||
let results = {};
|
|
||||||
let processed = 0;
|
|
||||||
let errors = 0;
|
|
||||||
|
|
||||||
if (fs.existsSync(outputFile)) {
|
|
||||||
try {
|
|
||||||
const existing = JSON.parse(fs.readFileSync(outputFile, 'utf-8'));
|
|
||||||
results = existing.users || {};
|
|
||||||
processed = Object.keys(results).length;
|
|
||||||
errors = existing.errors || 0;
|
|
||||||
console.log(`📂 Found existing: ${processed} users`);
|
|
||||||
} catch (e) {
|
|
||||||
console.log(`⚠️ Starting fresh`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildMembers // Required for roles!
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
let guild = null;
|
|
||||||
|
|
||||||
async function lookupUserWithRoles(userId) {
|
|
||||||
if (results[userId]) return results[userId];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Fetch basic user info
|
|
||||||
const user = await client.users.fetch(userId);
|
|
||||||
|
|
||||||
// Try to fetch guild member (for roles)
|
|
||||||
let roles = [];
|
|
||||||
let serverNickname = null;
|
|
||||||
let joinedAt = null;
|
|
||||||
let isInServer = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const member = await guild.members.fetch(userId);
|
|
||||||
isInServer = true;
|
|
||||||
serverNickname = member.nickname;
|
|
||||||
joinedAt = member.joinedAt ? member.joinedAt.toISOString() : null;
|
|
||||||
|
|
||||||
// Get all roles except @everyone
|
|
||||||
roles = member.roles.cache
|
|
||||||
.filter(role => role.name !== '@everyone')
|
|
||||||
.map(role => ({
|
|
||||||
id: role.id,
|
|
||||||
name: role.name,
|
|
||||||
color: role.hexColor,
|
|
||||||
position: role.position
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.position - a.position); // Highest role first
|
|
||||||
|
|
||||||
} catch (memberError) {
|
|
||||||
// User exists but not in this server
|
|
||||||
isInServer = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
id: user.id,
|
|
||||||
username: user.username,
|
|
||||||
globalName: user.globalName || user.username,
|
|
||||||
tag: user.tag,
|
|
||||||
bot: user.bot,
|
|
||||||
avatar: user.displayAvatarURL(),
|
|
||||||
// Server-specific data
|
|
||||||
server_nickname: serverNickname,
|
|
||||||
joined_at: joinedAt,
|
|
||||||
in_server: isInServer,
|
|
||||||
roles: roles,
|
|
||||||
role_names: roles.map(r => r.name),
|
|
||||||
highest_role: roles[0]?.name || null
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
id: userId,
|
|
||||||
error: error.message,
|
|
||||||
username: null,
|
|
||||||
globalName: null,
|
|
||||||
roles: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResults() {
|
|
||||||
const output = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
total_users: userIds.length,
|
|
||||||
processed: processed,
|
|
||||||
successful: processed - errors,
|
|
||||||
errors: errors,
|
|
||||||
guild_id: GUILD_ID,
|
|
||||||
includes_roles: true,
|
|
||||||
users: results
|
|
||||||
};
|
|
||||||
|
|
||||||
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processUsers() {
|
|
||||||
console.log('\n🎭 Starting lookups WITH ROLES...');
|
|
||||||
console.log(` Guild ID: ${GUILD_ID}`);
|
|
||||||
console.log(` Rate: 1 user/second\n`);
|
|
||||||
|
|
||||||
// Fetch guild
|
|
||||||
guild = await client.guilds.fetch(GUILD_ID);
|
|
||||||
console.log(`✅ Connected to: ${guild.name}\n`);
|
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
const toProcess = userIds.filter(id => !results[id]);
|
|
||||||
console.log(` ${toProcess.length} users remaining\n`);
|
|
||||||
|
|
||||||
for (let i = 0; i < toProcess.length; i++) {
|
|
||||||
const userId = toProcess[i];
|
|
||||||
|
|
||||||
const result = await lookupUserWithRoles(userId);
|
|
||||||
results[result.id] = result;
|
|
||||||
|
|
||||||
if (!result.success) errors++;
|
|
||||||
processed++;
|
|
||||||
|
|
||||||
// Save every 10 users
|
|
||||||
if (processed % 10 === 0) {
|
|
||||||
saveResults();
|
|
||||||
const elapsed = (Date.now() - startTime) / 1000;
|
|
||||||
const rate = (processed - (userIds.length - toProcess.length)) / elapsed;
|
|
||||||
const remaining = (toProcess.length - i - 1) / rate;
|
|
||||||
|
|
||||||
// Show sample with roles
|
|
||||||
if (result.success && result.roles.length > 0) {
|
|
||||||
const rolePreview = result.role_names.slice(0, 2).join(', ');
|
|
||||||
console.log(`💾 ${processed}/${userIds.length} - ${result.globalName} [${rolePreview}] - ~${remaining.toFixed(0)}s left`);
|
|
||||||
} else {
|
|
||||||
console.log(`💾 ${processed}/${userIds.length} (${errors} errors) - ~${remaining.toFixed(0)}s left`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
saveResults();
|
|
||||||
|
|
||||||
const totalTime = (Date.now() - startTime) / 1000;
|
|
||||||
|
|
||||||
// Stats
|
|
||||||
const usersWithRoles = Object.values(results).filter(u => u.success && u.roles.length > 0).length;
|
|
||||||
const allRoleNames = new Set();
|
|
||||||
Object.values(results).forEach(u => {
|
|
||||||
if (u.success) {
|
|
||||||
u.role_names?.forEach(r => allRoleNames.add(r));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`\n${'='.repeat(70)}`);
|
|
||||||
console.log(`✅ Complete with Roles!`);
|
|
||||||
console.log(`${'='.repeat(70)}`);
|
|
||||||
console.log(` Time: ${totalTime.toFixed(1)}s`);
|
|
||||||
console.log(` Processed: ${processed}/${userIds.length}`);
|
|
||||||
console.log(` Successful: ${processed - errors}`);
|
|
||||||
console.log(` Users with roles: ${usersWithRoles}`);
|
|
||||||
console.log(` Unique roles found: ${allRoleNames.size}`);
|
|
||||||
console.log(`\n💾 Saved to: ${outputFile}\n`);
|
|
||||||
|
|
||||||
// Show some roles
|
|
||||||
if (allRoleNames.size > 0) {
|
|
||||||
console.log('📋 Sample roles found:');
|
|
||||||
Array.from(allRoleNames).slice(0, 10).forEach(r => console.log(` • ${r}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
client.once('ready', () => {
|
|
||||||
console.log(`✅ Logged in as ${client.user.tag}\n`);
|
|
||||||
processUsers();
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('error', (error) => {
|
|
||||||
console.error('❌ Error:', error.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
console.log('\n\n⚠️ Interrupted! Saving...');
|
|
||||||
saveResults();
|
|
||||||
console.log('✅ Saved. Resume by running same command.\n');
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('🔌 Connecting to Discord...');
|
|
||||||
client.login(TOKEN);
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
/**
|
|
||||||
* Map batch tickets (TICKET: guild_channelId_suffix) to transcript channel messages.
|
|
||||||
*
|
|
||||||
* Connection:
|
|
||||||
* - Batch line: TICKET: 798321161082896395_1423340928588054621_indiffe → channelId = 1423340928588054621.
|
|
||||||
* - Transcript channel (🖥️│transcripts): each message is an embed with "Ticket Name: indifferentketchup🍅-claimed-7235".
|
|
||||||
* - Embed does NOT include channel ID, so we match by (1) ticket name (when known) or (2) time: transcript posted when ticket closes.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* node scripts/map-batch-to-transcript.js list [limit] -- fetch transcript messages, output CSV (messageId, created, ticket_name)
|
|
||||||
* node scripts/map-batch-to-transcript.js find <channelId> -- find transcript message(s) likely for this ticket (by time window)
|
|
||||||
*
|
|
||||||
* Known mapping (from embed): 1423340928588054621 ↔ message 1423400708769579120 (Ticket: indifferentketchup🍅-claimed-7235).
|
|
||||||
*/
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
const { Client, GatewayIntentBits } = require('discord.js');
|
|
||||||
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../.env') });
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
||||||
|
|
||||||
const TOKEN = process.env.MEMBER_BOT_TOKEN || process.env.DISCORD_BOT_TOKEN;
|
|
||||||
const TRANSCRIPT_CHANNEL_ID = '1335424071227281520';
|
|
||||||
const METRICS_CSV = path.join(__dirname, '../../Discord Ticket Transcripts/transcript_metrics_per_ticket.csv');
|
|
||||||
|
|
||||||
function getTicketNameFromEmbed(emb) {
|
|
||||||
const f = emb.fields?.find((x) => x.name && x.name.toLowerCase().includes('ticket name'));
|
|
||||||
return f ? f.value.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTranscriptMessages(client, limit = 100) {
|
|
||||||
const channel = await client.channels.fetch(TRANSCRIPT_CHANNEL_ID).catch(() => null);
|
|
||||||
if (!channel) return [];
|
|
||||||
const cap = Math.min(limit, 100); // Discord API max 100 per request
|
|
||||||
const messages = await channel.messages.fetch({ limit: cap });
|
|
||||||
const out = [];
|
|
||||||
for (const [, m] of messages) {
|
|
||||||
const emb = m.embeds?.[0];
|
|
||||||
const ticketName = emb ? getTicketNameFromEmbed(emb) : null;
|
|
||||||
out.push({
|
|
||||||
messageId: m.id,
|
|
||||||
created: m.createdAt ? m.createdAt.toISOString() : m.createdTimestamp,
|
|
||||||
createdTs: m.createdTimestamp,
|
|
||||||
ticketName: ticketName || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
out.sort((a, b) => b.createdTs - a.createdTs);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadMetricsCsv() {
|
|
||||||
if (!fs.existsSync(METRICS_CSV)) return [];
|
|
||||||
const text = fs.readFileSync(METRICS_CSV, 'utf8');
|
|
||||||
const lines = text.split(/\r?\n/).filter((l) => l.trim());
|
|
||||||
const header = lines[0].split(',');
|
|
||||||
const ticketIdIdx = header.indexOf('ticket_id');
|
|
||||||
const lastTsIdx = header.indexOf('last_message_ts');
|
|
||||||
if (ticketIdIdx === -1 || lastTsIdx === -1) return [];
|
|
||||||
const rows = [];
|
|
||||||
for (let i = 1; i < lines.length; i++) {
|
|
||||||
const parts = lines[i].split(',');
|
|
||||||
const ticketId = parts[ticketIdIdx];
|
|
||||||
const lastTs = parseInt(parts[lastTsIdx], 10);
|
|
||||||
if (!ticketId || !ticketId.includes('_')) continue;
|
|
||||||
const channelId = ticketId.split('_')[1];
|
|
||||||
if (channelId && !isNaN(lastTs)) rows.push({ ticketId, channelId, last_message_ts: lastTs });
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const cmd = process.argv[2];
|
|
||||||
const arg = process.argv[3];
|
|
||||||
|
|
||||||
if (!TOKEN) {
|
|
||||||
console.error('No bot token');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client({
|
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
client.once('ready', resolve);
|
|
||||||
client.login(TOKEN).catch(reject);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (cmd === 'list') {
|
|
||||||
const limit = Math.min(parseInt(arg, 10) || 100, 100);
|
|
||||||
const list = await fetchTranscriptMessages(client, limit);
|
|
||||||
console.log('transcript_message_id,created_iso,ticket_name');
|
|
||||||
list.forEach((r) => console.log([r.messageId, r.created, r.ticketName].map((c) => `"${String(c).replace(/"/g, '""')}"`).join(',')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cmd === 'find' && arg) {
|
|
||||||
const channelId = arg.trim();
|
|
||||||
const metrics = loadMetricsCsv();
|
|
||||||
const row = metrics.find((r) => r.channelId === channelId);
|
|
||||||
const closeTs = row ? row.last_message_ts : null;
|
|
||||||
const list = await fetchTranscriptMessages(client, 100);
|
|
||||||
const windowMs = 2 * 60 * 60 * 1000; // ±2 hours
|
|
||||||
const candidates = closeTs
|
|
||||||
? list.filter((r) => Math.abs(r.createdTs - closeTs) <= windowMs)
|
|
||||||
: list.slice(0, 20);
|
|
||||||
console.log('Batch ticket channelId:', channelId);
|
|
||||||
if (row) console.log('Ticket close time (last_message_ts):', closeTs, new Date(closeTs).toISOString());
|
|
||||||
console.log('Transcript channel messages (candidates by time or recent):');
|
|
||||||
candidates.forEach((r) => {
|
|
||||||
const delta = closeTs != null ? (r.createdTs - closeTs) / 60000 : null;
|
|
||||||
console.log(' ', r.messageId, r.created, r.ticketName || '(no name)', delta != null ? `delta ${delta.toFixed(0)} min` : '');
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Usage: node scripts/map-batch-to-transcript.js list [limit]');
|
|
||||||
console.log(' node scripts/map-batch-to-transcript.js find <channelId>');
|
|
||||||
} finally {
|
|
||||||
client.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -30,7 +30,7 @@ const ALLOWED_CONFIG_KEYS = new Set([
|
|||||||
'ADMIN_ID',
|
'ADMIN_ID',
|
||||||
// Channel IDs
|
// Channel IDs
|
||||||
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
'TRANSCRIPT_CHANNEL_ID', 'LOGGING_CHANNEL_ID', 'DEBUGGING_CHANNEL_ID',
|
||||||
'BACKUP_EXPORT_CHANNEL_ID', 'ACCOUNT_INFO_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
'BACKUP_EXPORT_CHANNEL_ID', 'DISCORD_CHANNEL_ID',
|
||||||
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
'GMAIL_LOG_CHANNEL_ID', 'AUTOMATION_LOG_CHANNEL_ID', 'RENAME_LOG_CHANNEL_ID',
|
||||||
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
'SECURITY_LOG_CHANNEL_ID', 'SYSTEM_LOG_CHANNEL_ID',
|
||||||
// Messages and labels
|
// Messages and labels
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ async function sendTicketClosedEmail(ticket, discordDisplayName) {
|
|||||||
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
const safeLogoUrl = escapeHtml(CONFIG.LOGO_URL || '');
|
||||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||||
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
const safeCloseMessage = escapeHtml(CONFIG.TICKET_CLOSE_MESSAGE || '').replace(/\n/g, '<br>');
|
||||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||||
const htmlBody = `
|
const htmlBody = `
|
||||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||||
@@ -185,7 +185,7 @@ async function sendTicketNotificationEmail(ticket, subjectLine, messageBody, fro
|
|||||||
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
const safeSignature = escapeHtml(CONFIG.SIGNATURE || '').replace(/\n/g, '<br>');
|
||||||
const serverDisplayName = label;
|
const serverDisplayName = label;
|
||||||
const safeCloseMessage = safeBody;
|
const safeCloseMessage = safeBody;
|
||||||
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || CONFIG.EMAIL_SIGNATURE || '').replace(/\n/g, '<br>');
|
const safeCloseSignature = escapeHtml(CONFIG.TICKET_CLOSE_SIGNATURE || '').replace(/\n/g, '<br>');
|
||||||
const htmlBody = `
|
const htmlBody = `
|
||||||
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
<div style="font-family: sans-serif; font-size: 14px; color: #333;">
|
||||||
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
<p><strong>From:</strong> ${serverDisplayName} on Discord</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user