Init
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# Discord Chat Relay
|
||||
|
||||
A bidirectional chat relay mod for TERA Proxy.
|
||||
|
||||
- **Game → Discord:** Forwards in-game chat to Discord via webhooks.
|
||||
- **Discord → Game:** Relays Discord messages into the game using a bot.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [TERA Proxy](https://github.com/justkeepquiet/tera-proxy-server) or compatible fork
|
||||
- A Discord **Webhook URL** for each chat channel (game → Discord)
|
||||
- A Discord **Bot Token** with `Send Messages` and `Read Message History` permissions (Discord → game)
|
||||
|
||||
## Installation
|
||||
|
||||
Place the `discord-chat-relay` folder inside your Proxy's `mods/` directory:
|
||||
|
||||
```
|
||||
mods/
|
||||
└── discord-chat-relay/
|
||||
├── index.js
|
||||
├── manifest.json
|
||||
├── module.config.json
|
||||
├── module.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
Restart your Proxy or reload the mod.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `module.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"webhooks": {
|
||||
"general": "https://discord.com/api/webhooks/...",
|
||||
"trade": "https://discord.com/api/webhooks/..."
|
||||
},
|
||||
"botToken": "your-discord-bot-token",
|
||||
"discordChannels": {
|
||||
"discord-channel-id": 27
|
||||
},
|
||||
"pollInterval": 3000,
|
||||
"logLevel": "info"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `enabled` | Set to `false` to disable the mod. |
|
||||
| `webhooks` | Webhook URLs for forwarding in-game chat to Discord. |
|
||||
| `botToken` | Discord bot token (required for Discord → game). |
|
||||
| `discordChannels` | Maps Discord channel IDs to in-game channels (`27` = General, `4` = Trade). |
|
||||
| `pollInterval` | Polling interval in ms (default: `3000`). |
|
||||
| `logLevel` | Log verbosity: `"info"` (minimal), `"warn"` (default, shows routing), `"debug"` (everything). |
|
||||
|
||||
## Logs
|
||||
|
||||
Check the Proxy console for lines prefixed with `[Discord Chat Relay]` to monitor activity and troubleshoot.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,395 @@
|
||||
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');
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": {
|
||||
"index.js": {
|
||||
"hash": "741D6808180ACBB489672E8BD7E07E251651E7BADDF6F3751CB3DC0E9C466AD0"
|
||||
},
|
||||
"module.json": {
|
||||
"hash": "711F2562EA3B7B427523511BA313A8FC84848B46C16AF90AD28F0E48B81BDF04"
|
||||
},
|
||||
"module.config.json": {
|
||||
"hash": "auto"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"webhooks": {
|
||||
"general": "https://discord.com/api/webhooks/...",
|
||||
"trade": "https://discord.com/api/webhooks/..."
|
||||
},
|
||||
"botToken": "",
|
||||
"discordChannels": {"00000000000000000000":27},
|
||||
"pollInterval": 3000,
|
||||
"logLevel": "info"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"disableAutoUpdate": true,
|
||||
"name": "discord-chat-relay",
|
||||
"author": "Archgeus",
|
||||
"description": "Bidirectional chat relay between TERA and Discord. Forwards in-game chat to Discord via webhooks and relays Discord messages to the game via a bot.",
|
||||
"options": {
|
||||
"guiName": "Discord Chat Relay"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user