395 lines
14 KiB
JavaScript
395 lines
14 KiB
JavaScript
const https = require('https');
|
|
const urlModule = require('url');
|
|
|
|
module.exports = function DiscordChatRelay(mod) {
|
|
const config = loadConfig();
|
|
const webhooks = config.webhooks || {};
|
|
const enabled = config.enabled !== false;
|
|
const botToken = config.botToken || '';
|
|
const discordChannels = config.discordChannels || {};
|
|
const pollInterval = config.pollInterval || 3000;
|
|
const logLevel = (config.logLevel || 'info').toLowerCase();
|
|
|
|
const log = {
|
|
info: (...args) => { console.log('[Discord Chat Relay]', ...args); },
|
|
warn: (...args) => { if (logLevel !== 'info') console.log('[Discord Chat Relay]', ...args); },
|
|
debug: (...args) => { if (logLevel === 'debug') console.log('[Discord Chat Relay]', ...args); }
|
|
};
|
|
|
|
// Channel mapping
|
|
const CHANNELS = {
|
|
4: 'Trade',
|
|
27: 'General'
|
|
};
|
|
|
|
// Which channels go to which webhook
|
|
const CHANNEL_TO_WEBHOOK = {
|
|
27: 'general',
|
|
4: 'trade'
|
|
};
|
|
|
|
// Track last seen message IDs per Discord channel
|
|
const lastMessageIds = {};
|
|
let pollTimer = null;
|
|
|
|
function loadConfig() {
|
|
try {
|
|
delete require.cache[require.resolve('./module.config.json')];
|
|
return require('./module.config.json');
|
|
} catch (e) {
|
|
return { enabled: true, webhooks: {} };
|
|
}
|
|
}
|
|
|
|
function getChannelName(channelId) {
|
|
return CHANNELS[channelId] || `Channel ${channelId}`;
|
|
}
|
|
|
|
function stripHtmlTags(text) {
|
|
if (!text) return '';
|
|
let result = text.replace(/<[^>]*>/g, '');
|
|
result = result.replace(/</g, '').replace(/>/g, '');
|
|
return result;
|
|
}
|
|
|
|
// ====== DISCORD -> GAME (WEBHOOK SENDING - EXISTING) ======
|
|
|
|
function sendToDiscord(playerName, message, webhookType) {
|
|
const webhookUrl = webhooks[webhookType];
|
|
if (!webhookUrl) {
|
|
log.warn(`Webhook for "${webhookType}" not configured`);
|
|
return;
|
|
}
|
|
|
|
const payload = JSON.stringify({
|
|
content: message,
|
|
username: playerName,
|
|
avatar_url: null
|
|
});
|
|
|
|
log.debug('Sending payload:', payload.substring(0, 100));
|
|
|
|
const options = {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Content-Length': Buffer.byteLength(payload)
|
|
}
|
|
};
|
|
|
|
try {
|
|
const req = https.request(webhookUrl, options, (res) => {
|
|
log.debug('Response status:', res.statusCode);
|
|
if (res.statusCode !== 204 && res.statusCode !== 200) {
|
|
log.warn(`Warning: ${res.statusCode}`);
|
|
}
|
|
});
|
|
|
|
req.on('error', (err) => {
|
|
log.warn(`Request error: ${err.message}`);
|
|
});
|
|
|
|
req.write(payload);
|
|
req.end();
|
|
} catch (e) {
|
|
log.warn(`Exception: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// ====== DISCORD -> GAME (BOT POLLING) ======
|
|
|
|
/**
|
|
* Makes a request to the Discord REST API using the bot token.
|
|
* @param {string} endpoint - API endpoint (e.g., '/channels/123/messages')
|
|
* @param {object} [options] - Additional options
|
|
* @returns {Promise<any>}
|
|
*/
|
|
function discordApiRequest(endpoint, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const parsedUrl = urlModule.parse(`https://discord.com/api/v10${endpoint}`);
|
|
|
|
const reqOptions = {
|
|
hostname: parsedUrl.hostname,
|
|
path: parsedUrl.path,
|
|
method: options.method || 'GET',
|
|
headers: {
|
|
'Authorization': `Bot ${botToken}`,
|
|
'User-Agent': 'DiscordBot (discord-chat-relay, 1.0.0)'
|
|
}
|
|
};
|
|
|
|
if (options.method === 'POST' && options.body) {
|
|
const body = JSON.stringify(options.body);
|
|
reqOptions.headers['Content-Type'] = 'application/json';
|
|
reqOptions.headers['Content-Length'] = Buffer.byteLength(body);
|
|
}
|
|
|
|
const req = https.request(reqOptions, (res) => {
|
|
let data = '';
|
|
res.on('data', chunk => data += chunk);
|
|
res.on('end', () => {
|
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
log.warn(`API error ${res.statusCode}: ${data.substring(0, 200)}`);
|
|
reject(new Error(`HTTP ${res.statusCode}`));
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(data));
|
|
} catch (e) {
|
|
resolve(data);
|
|
}
|
|
});
|
|
});
|
|
|
|
req.on('error', reject);
|
|
|
|
if (options.method === 'POST' && options.body) {
|
|
req.write(JSON.stringify(options.body));
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dynamically discovers all available versions of a protocol message.
|
|
* @param {string} messageName - Message name (e.g. 'S_CHAT')
|
|
* @returns {number[]} Available versions sorted descending (highest first)
|
|
*/
|
|
function getMessageVersions(messageName) {
|
|
try {
|
|
const messages = mod.dispatch?.protocol?.messages;
|
|
if (messages && messages instanceof Map) {
|
|
const defs = messages.get(messageName);
|
|
if (defs instanceof Map && defs.size > 0) {
|
|
return [...defs.keys()].map(Number).sort((a, b) => b - a);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Builds the correct fields object for a given S_CHAT version.
|
|
* @param {number} version - S_CHAT version
|
|
* @param {number} channel - Game chat channel ID
|
|
* @param {string} name - Author name
|
|
* @param {string} message - Message content
|
|
* @returns {object} Fields object for mod.toClient()
|
|
*/
|
|
function buildSChatFields(version, channel, name, message) {
|
|
if (version >= 3) {
|
|
return {
|
|
channel,
|
|
gameId: 0n,
|
|
isWorldEventTarget: false,
|
|
gm: false,
|
|
founder: false,
|
|
name,
|
|
message
|
|
};
|
|
}
|
|
// v1 and v2 share the same field structure
|
|
return {
|
|
channel,
|
|
authorName: name,
|
|
message,
|
|
blue: 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sends a message from Discord to the game client.
|
|
* @param {number} gameChannel - Game chat channel ID
|
|
* @param {string} username - Discord username
|
|
* @param {string} content - Message content
|
|
*/
|
|
const MAX_CHARS = 300;
|
|
|
|
function sendToGame(gameChannel, username, content) {
|
|
const prefix = `[Discord][${username}]: `;
|
|
// Truncate content so the full message fits within the game's 300-char limit
|
|
const maxContentLen = MAX_CHARS - prefix.length;
|
|
const truncatedContent = content.length > maxContentLen
|
|
? content.substring(0, maxContentLen - 3) + '...'
|
|
: content;
|
|
const formattedMessage = `${prefix}${truncatedContent}`;
|
|
|
|
log.warn(`Sending to game [${getChannelName(gameChannel)}]: ${formattedMessage}`);
|
|
|
|
// Dynamically discover available S_CHAT versions from the protocol definitions
|
|
const versions = getMessageVersions('S_CHAT');
|
|
|
|
for (const version of versions) {
|
|
try {
|
|
const fields = buildSChatFields(version, gameChannel, '[Discord]', `[${username}]: ${content}`);
|
|
mod.toClient('S_CHAT', version, fields);
|
|
log.warn(`Sent via S_CHAT v${version}`);
|
|
return;
|
|
} catch (e) {
|
|
log.debug(`S_CHAT v${version} failed: ${e.message}`);
|
|
}
|
|
}
|
|
|
|
// Last resort: use mod.command.message() (shows as system message, not in channel)
|
|
try {
|
|
if (mod.command && typeof mod.command.message === 'function') {
|
|
mod.command.message(formattedMessage);
|
|
log.warn('Sent via mod.command.message() (fallback)');
|
|
}
|
|
} catch (e2) {
|
|
log.warn(`All send methods failed: ${e2.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compare two Discord snowflake IDs safely (they can exceed Number.MAX_SAFE_INTEGER).
|
|
* Discord snowflakes with the same length can be compared as strings.
|
|
*/
|
|
function isNewerId(newId, oldId) {
|
|
if (!oldId) return true;
|
|
// String comparison works if both have the same length
|
|
if (newId.length !== oldId.length) return newId.length > oldId.length;
|
|
return newId > oldId;
|
|
}
|
|
|
|
/**
|
|
* Polls all configured Discord channels for new messages.
|
|
*/
|
|
async function pollDiscordChannels() {
|
|
for (const [discordChannelId, gameChannelId] of Object.entries(discordChannels)) {
|
|
try {
|
|
const messages = await discordApiRequest(
|
|
`/channels/${discordChannelId}/messages?limit=5`
|
|
);
|
|
|
|
if (!Array.isArray(messages) || messages.length === 0) continue;
|
|
|
|
// Process messages from oldest to newest (reverse order)
|
|
const reversed = [...messages].reverse();
|
|
|
|
for (const msg of reversed) {
|
|
// Skip bot's own messages
|
|
if (msg.author && msg.author.bot) continue;
|
|
|
|
// Skip messages we've already seen
|
|
if (lastMessageIds[discordChannelId] && !isNewerId(msg.id, lastMessageIds[discordChannelId])) continue;
|
|
|
|
// Use display name with priority: server nickname > global display name > username
|
|
let username = 'Unknown';
|
|
if (msg.author) {
|
|
username = (msg.member && msg.member.nick) || msg.author.global_name || msg.author.username;
|
|
}
|
|
const content = stripHtmlTags(msg.content || '');
|
|
|
|
if (content.trim()) {
|
|
log.warn(`New message from ${username}: "${content.substring(0, 100)}"`);
|
|
sendToGame(parseInt(gameChannelId), username, content);
|
|
}
|
|
}
|
|
|
|
// Update the latest message ID silently
|
|
const latestId = messages[0] ? messages[0].id : null;
|
|
if (latestId && (!lastMessageIds[discordChannelId] ||
|
|
isNewerId(latestId, lastMessageIds[discordChannelId]))) {
|
|
lastMessageIds[discordChannelId] = latestId;
|
|
}
|
|
} catch (e) {
|
|
log.warn(`Poll error: ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initializes the Discord bot: validates the token and fetches latest message IDs
|
|
* to avoid replaying old messages on startup.
|
|
*/
|
|
async function initBot() {
|
|
log.info('Initializing Discord bot...');
|
|
|
|
try {
|
|
// Validate bot token by fetching bot user info
|
|
const botInfo = await discordApiRequest('/users/@me');
|
|
log.info(`Bot connected as: ${botInfo.username}#${botInfo.discriminator || ''}`);
|
|
|
|
// Initialize last message IDs for each channel (so we don't replay old messages)
|
|
for (const discordChannelId of Object.keys(discordChannels)) {
|
|
try {
|
|
const messages = await discordApiRequest(
|
|
`/channels/${discordChannelId}/messages?limit=1`
|
|
);
|
|
if (Array.isArray(messages) && messages.length > 0) {
|
|
lastMessageIds[discordChannelId] = messages[0].id;
|
|
}
|
|
} catch (e) {
|
|
log.warn(`Could not fetch messages for channel ${discordChannelId}`);
|
|
}
|
|
}
|
|
|
|
log.info(`Bot initialized. Watching ${Object.keys(discordChannels).length} channel(s)`);
|
|
|
|
// Start polling
|
|
pollTimer = setInterval(pollDiscordChannels, pollInterval);
|
|
} catch (e) {
|
|
log.warn(`Bot init failed: ${e.message}`);
|
|
log.warn('Check botToken in module.config.json');
|
|
}
|
|
}
|
|
|
|
// ====== INIT ======
|
|
|
|
if (!enabled) {
|
|
log.info('Mod disabled');
|
|
return;
|
|
}
|
|
|
|
log.info('Setting up C_CHAT hook');
|
|
|
|
// Hook game chat (game -> Discord)
|
|
mod.hook('C_CHAT', 1, (event) => {
|
|
try {
|
|
if (!event) {
|
|
return true;
|
|
}
|
|
|
|
const webhookType = CHANNEL_TO_WEBHOOK[event.channel];
|
|
|
|
if (!webhookType) {
|
|
return true;
|
|
}
|
|
|
|
let playerName = 'Unknown';
|
|
try {
|
|
playerName = (mod.game && mod.game.me && mod.game.me.name) || 'Unknown';
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
|
|
const cleanMessage = stripHtmlTags(event.message);
|
|
const channelName = getChannelName(event.channel);
|
|
|
|
if (cleanMessage.trim()) {
|
|
log.warn(`Sending to Discord [${channelName}]: ${playerName}: ${cleanMessage.substring(0, 50)}`);
|
|
sendToDiscord(playerName, cleanMessage, webhookType);
|
|
}
|
|
} catch (e) {
|
|
log.warn(`Error: ${e.message}`);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Start Discord bot (Discord -> game)
|
|
if (botToken && Object.keys(discordChannels).length > 0) {
|
|
log.info('Starting Discord bot polling...');
|
|
initBot();
|
|
} else {
|
|
log.info('Bot not configured - one-way mode only (game -> Discord)');
|
|
if (!botToken) log.warn(' Set "botToken" in module.config.json');
|
|
if (Object.keys(discordChannels).length === 0) log.warn(' Set "discordChannels" in module.config.json');
|
|
}
|
|
|
|
log.info('Mod loaded successfully');
|
|
log.info('General webhook:', webhooks.general ? 'Configured' : 'Not configured');
|
|
log.info('Trade webhook:', webhooks.trade ? 'Configured' : 'Not configured');
|
|
}; |