Skip to content

🚀 release: v0.1.8 alpha #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Mar 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ GUILD_ID=your_guild_id_here
UNTHREAD_API_KEY=your_unthread_api_key_here
UNTHREAD_TRIAGE_CHANNEL_ID=your_unthread_triage_channel_id_here
UNTHREAD_EMAIL_INBOX_ID=your_unthread_email_inbox_id_here
UNTHREAD_WEBHOOK_SECRET=your_unthread_webhook_secret_here
UNTHREAD_WEBHOOK_SECRET=your_unthread_webhook_secret_here
REDIS_URL=your_redis_url_here
14 changes: 9 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
{
"name": "unthread-discord-bot",
"description": "A simple discord bot integration for unthread.io",
"version": "0.1.0",
"version": "0.1.8",
"private": true,
"main": "index.js",
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
"start": "node src/index.js && node src/deploy_commands.js",
"dev": "nodemon src/index.js",
"deploycommand": "node src/deploy_commands.js"
},
"keywords": [],
"author": "Waren Gonzaga",
Expand All @@ -18,13 +19,16 @@
"bugs": {
"url": "https://github.com/wgtechlabs/unthread-discord-bot/issues"
},
"engines": {
"node": ">=18.16.0"
},
"dependencies": {
"@keyv/redis": "^4.3.1",
"crypto": "^1.0.1",
"discord.js": "^14.18.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"sequelize": "^6.37.5",
"sqlite3": "^5.1.7"
"keyv": "^5.3.1"
},
"devDependencies": {
"nodemon": "^3.1.9"
Expand Down
20 changes: 15 additions & 5 deletions src/commands/support/support.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,32 @@ module.exports = {
.setTitle('Support Ticket');

// Add input fields
const titleInput = new TextInputBuilder()
.setCustomId('titleInput')
.setLabel('Ticket Title')
.setPlaceholder('Title of your issue...')
.setStyle(TextInputStyle.Short)
.setRequired(true);

const issueInput = new TextInputBuilder()
.setCustomId('issueInput')
.setLabel('Describe your issue')
.setLabel('Summary')
.setPlaceholder('Please describe your issue...')
.setStyle(TextInputStyle.Paragraph)
.setRequired(true);

const emailInput = new TextInputBuilder()
.setCustomId('emailInput')
.setLabel('Your Email Address')
.setLabel('Contact Email')
.setPlaceholder('Your email valid address...')
.setStyle(TextInputStyle.Short)
.setRequired(true);

// Add inputs to the modal
const firstActionRow = new ActionRowBuilder().addComponents(issueInput);
const secondActionRow = new ActionRowBuilder().addComponents(emailInput);
modal.addComponents(firstActionRow, secondActionRow);
const firstActionRow = new ActionRowBuilder().addComponents(titleInput);
const secondActionRow = new ActionRowBuilder().addComponents(issueInput);
const thirdActionRow = new ActionRowBuilder().addComponents(emailInput);
modal.addComponents(firstActionRow, secondActionRow, thirdActionRow);

// Show the modal
await interaction.showModal(modal);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,18 @@ module.exports = {
async execute(interaction) {
// for support ticket
if (interaction.isModalSubmit() && interaction.customId === 'supportModal') {
const title = interaction.fields.getTextInputValue('titleInput'); // Get the title input value
const issue = interaction.fields.getTextInputValue('issueInput');
const email = interaction.fields.getTextInputValue('emailInput');
console.log(`Support ticket submitted: ${issue}, email: ${email}`);
console.log(`Support ticket submitted: ${title}, ${issue}, email: ${email}`);

// Acknowledge the interaction immediately
await interaction.deferReply({ ephemeral: true });

let ticket;
// Create ticket via unthread.io API (ensuring customer exists)
try {
ticket = await createTicket(interaction.user, issue, email);
ticket = await createTicket(interaction.user, title, issue, email); // Pass the title input value
console.log('Ticket created:', ticket);
} catch (error) {
console.error('Ticket creation failed:', error);
Expand All @@ -33,6 +34,13 @@ module.exports = {

// Add the user to the private thread
await thread.members.add(interaction.user.id);

// Send the initial message to the thread
await thread.send({
content: `
> **Ticket #:** ${ticket.friendlyId}\n> **Title:** ${title}\n> **Issue:** ${issue}\n> **Contact:** ${email}
`,
});

// Bind the Unthread ticket with the Discord thread
// Assuming the ticket object has a property (e.g., id or ticketId) to be used
Expand Down
15 changes: 5 additions & 10 deletions src/events/messageCreate.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const { Events } = require("discord.js");
const { version } = require("../../package.json");
const { sendMessageToUnthread, Ticket, Customer } = require("../services/unthread");
const { sendMessageToUnthread, getTicketByDiscordThreadId, getCustomerById } = require("../services/unthread");

module.exports = {
name: Events.MessageCreate,
Expand All @@ -12,10 +12,9 @@ module.exports = {
// If the message is in a thread, check for its mapping to forward to Unthread.
if (message.channel.isThread()) {
try {
// Find the ticket mapping by Discord thread ID.
const ticketMapping = await Ticket.findOne({ where: { discordThreadId: message.channel.id } });
// Retrieve the ticket mapping by Discord thread ID using Keyv
const ticketMapping = await getTicketByDiscordThreadId(message.channel.id);
if (ticketMapping) {
// Prepare the message to send by incorporating a quote if available.
let messageToSend = message.content;
if (message.reference && message.reference.messageId) {
let quotedMessage;
Expand All @@ -28,8 +27,8 @@ module.exports = {
}
}

// Get the customer to retrieve the email
const customer = await Customer.findByPk(message.author.id);
// Get the customer using Redis
const customer = await getCustomerById(message.author.id);
if (!customer) {
console.error(`Customer record not found for ${message.author.id}`);
} else {
Expand All @@ -53,10 +52,6 @@ module.exports = {
};

async function handleLegacyCommands(message) {
// get the details from user who send command
const member = message.member;
const mention = message.mentions;

// check ping
if (message.content === "!!ping") {
message.reply(`Latency is ${Date.now() - message.createdTimestamp}ms.`);
Expand Down
4 changes: 2 additions & 2 deletions src/events/ready.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ module.exports = {
execute(bot) {
bot.user?.setPresence({
activities: [{
name: `porn`,
type: ActivityType.Watching
name: `support tickets`,
type: ActivityType.Listening
}]

});
Expand Down
158 changes: 85 additions & 73 deletions src/services/unthread.js
Original file line number Diff line number Diff line change
@@ -1,61 +1,10 @@
const { Sequelize, DataTypes } = require('sequelize');
const { decodeHtmlEntities } = require('../utils/decodeHtmlEntities');
require('dotenv').config();

// Initialize Sequelize using SQLite (adjust storage as needed)
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: './database.sqlite'
});

// Define the Customer model with Discord ID as the primary key
const Customer = sequelize.define('Customer', {
discordId: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
},
discordUsername: {
type: DataTypes.STRING,
allowNull: false,
},
discordName: {
type: DataTypes.STRING,
allowNull: false,
},
customerId: {
type: DataTypes.STRING,
allowNull: false,
},
email: {
type: DataTypes.STRING,
allowNull: false,
},
}, {
tableName: 'customers',
timestamps: true,
});
const { setKey, getKey } = require('../utils/memory');

// Define a new Ticket model to bind Unthread ticket with Discord thread
const Ticket = sequelize.define('Ticket', {
unthreadTicketId: {
type: DataTypes.STRING,
primaryKey: true,
allowNull: false,
},
discordThreadId: {
type: DataTypes.STRING,
allowNull: false,
},
}, {
tableName: 'tickets',
timestamps: true,
});
require('dotenv').config();

// Sync the models with the database
sequelize.sync();
// --- Customer functions ---

// Function to call the unthread.io API to create a customer
async function createCustomerInUnthread(user) {
const response = await fetch('https://api.unthread.io/api/customers', {
method: 'POST',
Expand All @@ -78,24 +27,30 @@ async function createCustomerInUnthread(user) {
return customerId;
}

// Save the customer details locally using Sequelize.
// Modified to accept the email parameter.
async function saveCustomer(user, email) {
const existing = await Customer.findByPk(user.id);
const key = `customer:${user.id}`;
let existing = await getKey(key);
if (existing) return existing;

const customerId = await createCustomerInUnthread(user);
return await Customer.create({
const customer = {
discordId: user.id,
discordUsername: user.username,
discordName: user.tag,
customerId,
email,
});
};
await setKey(key, customer);
return customer;
}

// Function to create a ticket via unthread.io API using the customerId
async function createTicket(user, issue, email) {
async function getCustomerById(discordId) {
return await getKey(`customer:${discordId}`);
}

// --- Ticket functions ---

async function createTicket(user, title, issue, email) {
// Ensure the user has a customer record (creates one if needed)
const customer = await saveCustomer(user, email);

Expand All @@ -107,7 +62,7 @@ async function createTicket(user, issue, email) {
},
body: JSON.stringify({
type: 'email',
title: 'Discord Ticket',
title: title,
markdown: `${issue}`,
status: 'open',
triageChannelId: process.env.UNTHREAD_TRIAGE_CHANNEL_ID,
Expand All @@ -129,23 +84,66 @@ async function createTicket(user, issue, email) {
return data;
}

// Helper function to bind an Unthread ticket with a Discord thread
async function bindTicketWithThread(unthreadTicketId, discordThreadId) {
return await Ticket.create({
unthreadTicketId,
discordThreadId,
});
const ticket = { unthreadTicketId, discordThreadId };
await setKey(`ticket:discord:${discordThreadId}`, ticket);
await setKey(`ticket:unthread:${unthreadTicketId}`, ticket);
return ticket;
}

// New function to process incoming webhook events from unthread.io
async function getTicketByDiscordThreadId(discordThreadId) {
return await getKey(`ticket:discord:${discordThreadId}`);
}

async function getTicketByUnthreadTicketId(unthreadTicketId) {
return await getKey(`ticket:unthread:${unthreadTicketId}`);
}

// --- Webhook handler ---

async function handleWebhookEvent(payload) {
console.log('Received webhook event from Unthread:', payload);

// Handle ticket update events
if (payload.event === 'conversation_updated') {
const { id, status } = payload.data;
if (status === 'closed') {
const ticketMapping = await getTicketByUnthreadTicketId(id);
if (!ticketMapping) {
console.error(`No Discord thread found for Unthread ticket ${id}`);
return;
}
const discordThread = await global.discordClient.channels.fetch(ticketMapping.discordThreadId);
if (!discordThread) {
console.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
return;
}
await discordThread.send('> This ticket has been closed.');
console.log(`Sent closure notification to Discord thread ${discordThread.id}`);
} else if (status === 'open') {
const ticketMapping = await getTicketByUnthreadTicketId(id);
if (!ticketMapping) {
console.error(`No Discord thread found for Unthread ticket ${id}`);
return;
}
const discordThread = await global.discordClient.channels.fetch(ticketMapping.discordThreadId);
if (!discordThread) {
console.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
return;
}
await discordThread.send('> This ticket has been re-opened. Our team will get back to you shortly.');
console.log(`Sent re-open notification to Discord thread ${discordThread.id}`);
}
return;
}

// Handle new message events
if (payload.event === 'message_created') {
const conversationId = payload.data.conversationId;
// Decode HTML entities here
const decodedMessage = decodeHtmlEntities(payload.data.text);
try {
const ticketMapping = await Ticket.findOne({ where: { unthreadTicketId: conversationId } });
const ticketMapping = await getTicketByUnthreadTicketId(conversationId);
if (!ticketMapping) {
console.error(`No Discord thread found for Unthread ticket ${conversationId}`);
return;
Expand All @@ -157,9 +155,23 @@ async function handleWebhookEvent(payload) {
}
console.log(`Found Discord thread: ${discordThread.id}`);

// Fetch recent messages in the thread
// Fetch the latest 10 messages in the newly created thread
const messages = await discordThread.messages.fetch({ limit: 10 });

/**
* Skip sending the webhook message if the bot sent the first message in the thread.
* This is to prevent send duplicate messages when the bot already sent a summary of the ticket.
*/
if (messages.size >= 2) {
const messagesArray = Array.from(messages.values()).sort((a, b) => a.createdTimestamp - b.createdTimestamp);
const secondMessage = messagesArray[1];
const latestMessage = messages.first();
if (secondMessage && latestMessage && secondMessage.id === latestMessage.id) {
console.log('Second message and latest message match. Skipping sending webhook message.');
return;
}
}

// Log the decoded message
console.log(`Decoded message: ${decodedMessage}`);

Expand Down Expand Up @@ -247,12 +259,12 @@ async function sendMessageToUnthread(conversationId, user, message, email) {
}

module.exports = {
Customer,
Ticket,
sequelize,
saveCustomer,
getCustomerById,
createTicket,
bindTicketWithThread,
getTicketByDiscordThreadId,
getTicketByUnthreadTicketId,
handleWebhookEvent,
sendMessageToUnthread,
};
Loading