diff --git a/.env.example b/.env.example
index 20b39f0..88a59c4 100644
--- a/.env.example
+++ b/.env.example
@@ -5,4 +5,6 @@ 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
-REDIS_URL=your_redis_url_here
\ No newline at end of file
+REDIS_URL=your_redis_url_here
+FORUM_CHANNEL_IDS=channel_id_1,channel_id_2,channel_id_3
+DEBUG_MODE=false
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 50075e8..f738087 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,4 +133,7 @@ dist
*.http
# Database
-*.sqlite
\ No newline at end of file
+*.sqlite
+
+# VS Code settings
+.vscode/settings.json
diff --git a/LICENSE b/LICENSE
index f288702..22d2132 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,5 +1,5 @@
- GNU GENERAL PUBLIC LICENSE
- Version 3, 29 June 2007
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
- The GNU General Public License is a free, copyleft license for
-software and other kinds of works.
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
-the GNU General Public License is intended to guarantee your freedom to
+our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
-software for all its users. We, the Free Software Foundation, use the
-GNU General Public License for most of our software; it applies also to
-any other work released this way by its authors. You can apply it to
-your programs, too.
+software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
- To protect your rights, we need to prevent others from denying you
-these rights or asking you to surrender the rights. Therefore, you have
-certain responsibilities if you distribute copies of the software, or if
-you modify it: responsibilities to respect the freedom of others.
-
- For example, if you distribute copies of such a program, whether
-gratis or for a fee, you must pass on to the recipients the same
-freedoms that you received. You must make sure that they, too, receive
-or can get the source code. And you must show them these terms so they
-know their rights.
-
- Developers that use the GNU GPL protect your rights with two steps:
-(1) assert copyright on the software, and (2) offer you this License
-giving you legal permission to copy, distribute and/or modify it.
-
- For the developers' and authors' protection, the GPL clearly explains
-that there is no warranty for this free software. For both users' and
-authors' sake, the GPL requires that modified versions be marked as
-changed, so that their problems will not be attributed erroneously to
-authors of previous versions.
-
- Some devices are designed to deny users access to install or run
-modified versions of the software inside them, although the manufacturer
-can do so. This is fundamentally incompatible with the aim of
-protecting users' freedom to change the software. The systematic
-pattern of such abuse occurs in the area of products for individuals to
-use, which is precisely where it is most unacceptable. Therefore, we
-have designed this version of the GPL to prohibit the practice for those
-products. If such problems arise substantially in other domains, we
-stand ready to extend this provision to those domains in future versions
-of the GPL, as needed to protect the freedom of users.
-
- Finally, every program is threatened constantly by software patents.
-States should not allow patents to restrict development and use of
-software on general-purpose computers, but in those that do, we wish to
-avoid the special danger that patents applied to a free program could
-make it effectively proprietary. To prevent this, the GPL assures that
-patents cannot be used to render the program non-free.
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
- "This License" refers to version 3 of the GNU General Public License.
+ "This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
- 13. Use with the GNU Affero General Public License.
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
-under version 3 of the GNU Affero General Public License into a single
+under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
-but the special requirements of the GNU Affero General Public License,
-section 13, concerning interaction through a network will apply to the
-combination as such.
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
-the GNU General Public License from time to time. Such new versions will
-be similar in spirit to the present version, but may differ in detail to
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
-Program specifies that a certain numbered version of the GNU General
+Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
-GNU General Public License, you may choose any version ever published
+GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
-versions of the GNU General Public License can be used, that proxy's
+versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -632,43 +630,32 @@ state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
- Copyright (C)
+ Copyright (C) 2025
This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
+ GNU Affero General Public License for more details.
- You should have received a copy of the GNU General Public License
+ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
- If the program does terminal interaction, make it output a short
-notice like this when it starts in an interactive mode:
-
- Copyright (C)
- This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
- This is free software, and you are welcome to redistribute it
- under certain conditions; type `show c' for details.
-
-The hypothetical commands `show w' and `show c' should show the appropriate
-parts of the General Public License. Of course, your program's commands
-might be different; for a GUI interface, you would use an "about box".
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
-For more information on this, and how to apply and follow the GNU GPL, see
+For more information on this, and how to apply and follow the GNU AGPL, see
.
-
- The GNU General Public License does not permit incorporating your program
-into proprietary programs. If your program is a subroutine library, you
-may consider it more useful to permit linking proprietary applications with
-the library. If this is what you want to do, use the GNU Lesser General
-Public License instead of this License. But first, please read
-.
diff --git a/README.md b/README.md
index d07c7ff..ecd7269 100644
--- a/README.md
+++ b/README.md
@@ -1,29 +1,67 @@
# Unthread Discord Bot đ¤
-[](https://github.com/wgtechlabs) [](https://github.com/sponsors/wgtechlabs)
-
-
+[](https://github.com/wgtechlabs) [](https://github.com/sponsors/wgtechlabs) [](https://github.com/wgtechlabs/unthread-discord-bot/releases) [](https://github.com/wgtechlabs/unthread-discord-bot/stargazers) [](https://github.com/wgtechlabs/unthread-discord-bot/blob/main/license)
-The Unthread Discord Bot is a powerful tool designed to streamline support ticket creation and management within Discord servers. By using simple commands, users can easily create support tickets, which are then managed through the Unthread platform. This bot integrates seamlessly with Discord and Unthread, providing a smooth and efficient support experience for both users and administrators.
+The Unthread Discord Bot is an official community project for Unthread, designed to streamline support ticket creation and management within Discord servers. By using simple commands, users can easily create support tickets, which are then managed through the Unthread platform. This bot integrates seamlessly with Discord and Unthread, providing a smooth and efficient support experience for both users and administrators.
## ⨠Key Features
- Create support tickets using the `/support` command.
+- Automatically create support tickets from posts in specific forum channels.
- Easy setup and configuration through the Discord Developer Portal.
- Integration with Unthread for advanced ticket management.
- Customizable environment settings for personalized bot behavior.
+## đĨ Easy Deployment
+
+You can use Railway to deploy this bot with just one click. Railway offers a seamless deployment experience without any configuration hassles.
+
+[](https://railway.com/template/nVHIjj?referralCode=dTwT-i)
+> [!TIP]
+> When you deploy using the Railway button above, you're directly supporting the ongoing development and maintenance of this project. Your support helps keep this bot free and continuously improving with new features. Thank you for your contribution! đâ¨
+
## đšī¸ Usage
-Use `/support` command to create a support ticket.
+### Creating a Support Ticket
+
+1. **Using the `/support` Command:**
+ - Type `/support` in any text channel where the bot has access.
+ - A modal will appear with fields for:
+ - Ticket Title: A brief description of your issue
+ - Summary: Detailed explanation of your problem
+ - Contact Email (Optional): Your email address for notifications
+
+2. **Using Forum Channels:**
+ - Create a new post in any forum channel that has been configured for ticket creation.
+ - Your post will automatically be converted to a support ticket.
+ - A confirmation message will appear in the thread.
+
+### Managing Tickets
+
+- **Replying to Tickets:**
+ - Simply reply in the private thread or forum post created by the bot.
+ - Your messages will be synced with the Unthread system.
-## đĻ Installation
+- **Viewing Ticket Status:**
+ - Status updates (open/closed) will be posted in the thread automatically.
+
+### Utility Commands
+
+- `/ping` - Shows bot latency and API ping metrics.
+- `/server` - Provides information about the Discord server.
+- `/user` - Shows details about your user account.
+- `/version` - Displays the current bot version.
+
+## đĻ Manual Installation
+
+> [!WARNING]
+> This is an advanced installation method and is not recommended for beginners. If you're new to Discord bot development, consider using the [Railway deployment method](#-easy-deployment) instead.
### 1. Create a Discord Application
@@ -93,6 +131,18 @@ Use `/support` command to create a support ticket.
Your bot should now be able to receive events from Unthread.
+### 8. Configure Forum Channels
+
+To enable automatic ticket creation from forum posts:
+
+1. Add forum channel IDs to your `.env` file:
+ ```
+ FORUM_CHANNEL_IDS=123456789012345678,234567890123456789
+ ```
+2. Each comma-separated ID represents a forum channel that will be monitored.
+3. Any new forum posts in these channels will automatically create a corresponding ticket in Unthread.
+4. Replies in the forum post will be synchronized with the Unthread ticket.
+
### How to Get Your Discord Server ID
1. Open Discord and go to your server.
@@ -120,23 +170,25 @@ Read the project's [contributing guide](./contributing.md) for more info.
Please report any issues and bugs by [creating a new issue here](https://github.com/wgtechlabs/unthread-discord-bot/issues/new/choose), also make sure you're reporting an issue that doesn't exist. Any help to improve the project would be appreciated. Thanks! đâ¨
-## đ Sponsor
+## đ Support
Like this project? **Leave a star**! âââââ
-Want to support my work and get some perks? [Become a sponsor](https://github.com/sponsors/warengonzaga)! đ
+There are several ways you can support this project:
-Or, you just love what I do? [Buy me a coffee](https://buymeacoffee.com/warengonzaga)! â
+- [Become a sponsor](https://github.com/sponsors/warengonzaga) and get some perks! đ
+- [Buy me a coffee](https://buymeacoffee.com/warengonzaga) if you just love what I do! â
+- Deploy using the [Railway Template](https://railway.com/template/nVHIjj?referralCode=dTwT-i) which directly supports the ongoing development! â¨
Recognized my open-source contributions? [Nominate me](https://stars.github.com/nominate) as GitHub Star! đĢ
## đ Code of Conduct
-Read the project's [code of conduct](./code_of_conduct.md).
+We're committed to providing a welcoming and inclusive environment for all contributors and users. Please review our project's [Code of Conduct](./code_of_conduct.md) to understand our community standards and expectations for participation.
## đ License
-This project is licensed under [GNU General Public License v3.0](https://opensource.org/licenses/GPL-3.0).
+This project is licensed under the [GNU Affero General Public License v3.0](https://opensource.org/licenses/AGPL-3.0). This license requires that all modifications to the code must be shared under the same license, especially when the software is used over a network. See the [LICENSE](LICENSE) file for the full license text.
## đ Author
@@ -146,4 +198,4 @@ This project is created by **[Waren Gonzaga](https://github.com/warengonzaga)**
---
-đģ with â¤ī¸ by [Waren Gonzaga](https://warengonzaga.com) and [Him](https://www.youtube.com/watch?v=HHrxS4diLew&t=44s) đ
+đģ with â¤ī¸ by [Waren Gonzaga](https://warengonzaga.com), [WG Technology Labs](https://wgtechlabs.com), and [Him](https://www.youtube.com/watch?v=HHrxS4diLew&t=44s) đ
diff --git a/package.json b/package.json
index 8b24210..ee4103b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "unthread-discord-bot",
"description": "A simple discord bot integration for unthread.io",
- "version": "0.1.20-alpha",
+ "version": "0.2.0-beta",
"private": true,
"main": "index.js",
"scripts": {
@@ -10,7 +10,7 @@
"deploycommand": "node src/deploy_commands.js"
},
"keywords": [],
- "author": "Waren Gonzaga",
+ "author": "Waren Gonzaga (https://warengonzaga.com)",
"license": "GPL-3.0",
"repository": {
"type": "git",
diff --git a/src/commands/support/support.js b/src/commands/support/support.js
index f5aeea8..4110be5 100644
--- a/src/commands/support/support.js
+++ b/src/commands/support/support.js
@@ -34,10 +34,10 @@ module.exports = {
const emailInput = new TextInputBuilder()
.setCustomId('emailInput')
- .setLabel('Contact Email')
- .setPlaceholder('Your email valid address...')
+ .setLabel('Contact Email (Optional)')
+ .setPlaceholder('Your email address or leave blank...')
.setStyle(TextInputStyle.Short)
- .setRequired(true);
+ .setRequired(false);
// Add inputs to the modal
const firstActionRow = new ActionRowBuilder().addComponents(titleInput);
diff --git a/src/deploy_commands.js b/src/deploy_commands.js
index 7c33dab..8784a1f 100644
--- a/src/deploy_commands.js
+++ b/src/deploy_commands.js
@@ -1,6 +1,7 @@
const { REST, Routes } = require('discord.js');
const fs = require('node:fs');
const path = require('node:path');
+const logger = require('./utils/logger');
require("dotenv").config();
@@ -23,7 +24,7 @@ for (const folder of commandFolders) {
if ('data' in command && 'execute' in command) {
commands.push(command.data.toJSON());
} else {
- console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
+ logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
}
@@ -34,7 +35,7 @@ const rest = new REST().setToken(DISCORD_BOT_TOKEN);
// and deploy your commands!
(async () => {
try {
- console.log(`Started refreshing ${commands.length} application (/) commands.`);
+ logger.info(`Started refreshing ${commands.length} application (/) commands.`);
// The put method is used to fully refresh all commands in the guild with the current set
const data = await rest.put(
@@ -42,9 +43,9 @@ const rest = new REST().setToken(DISCORD_BOT_TOKEN);
{ body: commands },
);
- console.log(`Successfully reloaded ${data.length} application (/) commands.`);
+ logger.info(`Successfully reloaded ${data.length} application (/) commands.`);
} catch (error) {
// And of course, make sure you catch and log any errors!
- console.error(error);
+ logger.error(error);
}
})();
\ No newline at end of file
diff --git a/src/events/error.js b/src/events/error.js
index 825f224..f0ab524 100644
--- a/src/events/error.js
+++ b/src/events/error.js
@@ -1,9 +1,10 @@
const { Events } = require('discord.js');
+const logger = require('../utils/logger');
module.exports = {
name: Events.Error,
once: false,
execute(error) {
- console.log(`[error]: ${error}`);
+ logger.error(`${error}`);
},
};
\ No newline at end of file
diff --git a/src/events/interactionCreate.js b/src/events/interactionCreate.js
index 1b2c7e2..756b88f 100644
--- a/src/events/interactionCreate.js
+++ b/src/events/interactionCreate.js
@@ -1,85 +1,133 @@
const { Events, ChannelType, MessageFlags } = require('discord.js');
const { createTicket, bindTicketWithThread } = require('../services/unthread');
+const { getKey, setKey } = require('../utils/memory');
+const logger = require('../utils/logger');
+/**
+ * InteractionCreate event handler
+ * Handles all Discord interactions including:
+ * - Modal submissions for support tickets
+ * - Chat input commands
+ */
module.exports = {
- name: Events.InteractionCreate,
- 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: ${title}, ${issue}, email: ${email}`);
+ name: Events.InteractionCreate,
+ async execute(interaction) {
+ // ===== SUPPORT TICKET MODAL HANDLING =====
+ if (interaction.isModalSubmit() && interaction.customId === 'supportModal') {
+ // Extract form data from modal submission
+ const title = interaction.fields.getTextInputValue('titleInput');
+ const issue = interaction.fields.getTextInputValue('issueInput');
+ let email = interaction.fields.getTextInputValue('emailInput');
+
+ if (!email || email.trim() === '') {
+ const customerKey = `customer:${interaction.user.id}`;
+ const existingCustomer = await getKey(customerKey);
+ email = existingCustomer?.email || `${interaction.user.username}@discord.user`;
- // Acknowledge the interaction immediately
- await interaction.deferReply({ ephemeral: true });
+ if (!existingCustomer) {
+ await setKey(customerKey, { email });
+ }
- let ticket;
- // Create ticket via unthread.io API (ensuring customer exists)
- try {
- ticket = await createTicket(interaction.user, title, issue, email); // Pass the title input value
- console.log('Ticket created:', ticket);
-
- if (!ticket.friendlyId) {
- throw new Error('Ticket was created but no friendlyId was provided');
- }
-
- // Create a private thread in the current channel
- const thread = await interaction.channel.threads.create({
- name: `ticket-#${ticket.friendlyId}`,
- type: ChannelType.PrivateThread,
- reason: 'Unthread Ticket',
- });
-
- // Add the user to the private thread
- await thread.members.add(interaction.user.id);
+ logger.debug(`Using fallback email for user ${interaction.user.id}: ${email}`);
+ } else {
+ const customerKey = `customer:${interaction.user.id}`;
+ await setKey(customerKey, { email });
+ logger.debug(`Stored email for user ${interaction.user.id}: ${email}`);
+ }
- // 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
- await bindTicketWithThread(ticket.id, thread.id);
-
- // Edit the deferred reply with confirmation
- await interaction.editReply('Your support ticket has been submitted! A private thread has been created for further communication.');
- } catch (error) {
- console.error('Ticket creation failed:', error);
- await interaction.editReply('Sorry, there was an error creating your support ticket. Please try again later.');
- return;
- }
+ logger.debug(`Support ticket submitted: ${title}, ${issue}, email: ${email}`);
- return;
- }
+ // Acknowledge interaction immediately to prevent Discord timeout
+ // Using ephemeral reply so only the submitter can see it
+ await interaction.deferReply({ ephemeral: true });
- if (!interaction.isChatInputCommand()) return;
+ let ticket;
+ // ===== TICKET CREATION WORKFLOW =====
+ try {
+ // Step 1: Create ticket in unthread.io using external API
+ ticket = await createTicket(interaction.user, title, issue, email);
+ logger.debug('Ticket created:', ticket);
+
+ // Validate ticket creation was successful
+ if (!ticket.friendlyId) {
+ throw new Error('Ticket was created but no friendlyId was provided');
+ }
+
+ // Step 2: Create a private Discord thread for this ticket
+ // This creates a separate conversation space for this support ticket
+ const thread = await interaction.channel.threads.create({
+ name: `ticket-#${ticket.friendlyId}`,
+ type: ChannelType.PrivateThread,
+ reason: 'Unthread Ticket',
+ });
+
+ // Step 3: Add the user who submitted the ticket to the private thread
+ await thread.members.add(interaction.user.id);
- const command = interaction.client.commands.get(interaction.commandName);
+ // Step 4: Send initial context information to the thread
+ await thread.send({
+ content: `
+ > **Ticket #:** ${ticket.friendlyId}\n> **Title:** ${title}\n> **Issue:** ${issue}
+ `,
+ });
- if (!command) {
- console.error(`No command matching ${interaction.commandName} was found.`);
- return;
- }
+ // Step 4.1: Send confirmation message
+ await thread.send({
+ content: `Hello <@${interaction.user.id}>, we have received your ticket and will respond shortly. Please check this thread for updates.`,
+ });
+
+ // Step 5: Associate the Discord thread with the ticket in the backend
+ // This allows messages in the thread to be synced with the ticket system
+ await bindTicketWithThread(ticket.id, thread.id);
+
+ // Step 6: Complete the interaction with confirmation message
+ await interaction.editReply('Your support ticket has been submitted! A private thread has been created for further assistance.');
+ } catch (error) {
+ // Handle any failures in the ticket creation workflow
+ // This could be API errors, permission issues, or Discord rate limits
+ logger.error('Ticket creation failed:', error);
+ await interaction.editReply('Sorry, there was an error creating your support ticket. Please try again later.');
+ return;
+ }
- try {
- await command.execute(interaction);
- } catch (error) {
- console.error(error);
- if (interaction.replied || interaction.deferred) {
- await interaction.followUp({
- content: 'There was an error while executing this command!',
- flags: MessageFlags.Ephemeral
- });
- } else {
- await interaction.reply({
- content: 'There was an error while executing this command!',
- flags: MessageFlags.Ephemeral
- });
- }
- }
- },
+ return;
+ }
+
+ // ===== COMMAND HANDLING =====
+ // Only proceed if this is a slash command interaction
+ if (!interaction.isChatInputCommand()) return;
+
+ // Look up the command handler based on the command name
+ const command = interaction.client.commands.get(interaction.commandName);
+
+ // Check if command exists in our registered commands
+ if (!command) {
+ logger.error(`No command matching ${interaction.commandName} was found.`);
+ return;
+ }
+
+ // ===== COMMAND EXECUTION WITH ERROR HANDLING =====
+ try {
+ // Execute the command with the interaction context
+ await command.execute(interaction);
+ } catch (error) {
+ // Log the full error for debugging
+ logger.error(error);
+
+ // Handle response based on interaction state
+ // If we already replied or deferred, use followUp
+ if (interaction.replied || interaction.deferred) {
+ await interaction.followUp({
+ content: 'There was an error while executing this command!',
+ flags: MessageFlags.Ephemeral
+ });
+ } else {
+ // For fresh interactions, use reply
+ await interaction.reply({
+ content: 'There was an error while executing this command!',
+ flags: MessageFlags.Ephemeral
+ });
+ }
+ }
+ },
};
\ No newline at end of file
diff --git a/src/events/messageCreate.js b/src/events/messageCreate.js
index de9139b..e230ec1 100644
--- a/src/events/messageCreate.js
+++ b/src/events/messageCreate.js
@@ -1,6 +1,8 @@
const { Events } = require("discord.js");
const { version } = require("../../package.json");
const { sendMessageToUnthread, getTicketByDiscordThreadId, getCustomerById } = require("../services/unthread");
+const { FORUM_CHANNEL_IDS } = process.env;
+const logger = require("../utils/logger");
module.exports = {
name: Events.MessageCreate,
@@ -12,10 +14,18 @@ module.exports = {
// If the message is in a thread, check for its mapping to forward to Unthread.
if (message.channel.isThread()) {
try {
+ const isForumPost = FORUM_CHANNEL_IDS &&
+ FORUM_CHANNEL_IDS.split(',').includes(message.channel.parentId) &&
+ message.id === message.channel.id;
+
+ if (isForumPost) return;
+
// Retrieve the ticket mapping by Discord thread ID using Keyv
const ticketMapping = await getTicketByDiscordThreadId(message.channel.id);
if (ticketMapping) {
let messageToSend = message.content;
+
+ // Handle quoted/referenced message
if (message.reference && message.reference.messageId) {
let quotedMessage;
try {
@@ -23,26 +33,43 @@ module.exports = {
quotedMessage = `> ${referenced.content}`;
messageToSend = `${quotedMessage}\n\n${message.content}`;
} catch (err) {
- console.error('Error fetching the referenced message:', err);
+ logger.error('Error fetching the referenced message:', err);
+ }
+ }
+
+ // Handle attachments
+ if (message.attachments.size > 0) {
+ const attachments = Array.from(message.attachments.values());
+ if (attachments.length > 0) {
+ const attachmentLinks = attachments.map((attachment, index) => {
+ const type = attachment.contentType?.startsWith('image/')
+ ? 'image'
+ : attachment.contentType?.startsWith('video/')
+ ? 'video'
+ : 'file';
+ return `[${type}_${index + 1}](${attachment.url})`;
+ });
+
+ // Add attachments list to the message with separator characters
+ messageToSend = messageToSend || '';
+ messageToSend += `\n\nAttachments: ${attachmentLinks.join(' | ')}`;
}
}
// 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 {
- const response = await sendMessageToUnthread(
- ticketMapping.unthreadTicketId,
- message.author,
- messageToSend,
- customer.email
- );
- console.log(`Forwarded message to Unthread for ticket ${ticketMapping.unthreadTicketId}`, response);
- }
+ const email = customer?.email || `${message.author.username}@discord.user`;
+
+ const response = await sendMessageToUnthread(
+ ticketMapping.unthreadTicketId,
+ message.author,
+ messageToSend,
+ email
+ );
+ logger.info(`Forwarded message to Unthread for ticket ${ticketMapping.unthreadTicketId}`, response);
}
} catch (error) {
- console.error("Error sending message to Unthread:", error);
+ logger.error("Error sending message to Unthread:", error);
}
}
@@ -55,12 +82,12 @@ async function handleLegacyCommands(message) {
// check ping
if (message.content === "!!ping") {
message.reply(`Latency is ${Date.now() - message.createdTimestamp}ms.`);
- console.log(`[log]: responded to ping command`);
+ logger.info(`responded to ping command`);
}
// check version
if (message.content === "!!version") {
message.reply(`Version: ${version}`);
- console.log(`[log]: responded to version command in version ${version}`);
+ logger.info(`responded to version command in version ${version}`);
}
}
\ No newline at end of file
diff --git a/src/events/ready.js b/src/events/ready.js
index 9b42efd..6cb7e85 100644
--- a/src/events/ready.js
+++ b/src/events/ready.js
@@ -1,5 +1,6 @@
const { Events, ActivityType } = require('discord.js');
const packageJSON = require('../../package.json');
+const logger = require('../utils/logger');
module.exports = {
name: Events.ClientReady,
@@ -16,6 +17,6 @@ module.exports = {
});
- console.log(`[online]: logged in as ${bot.user.tag} @ v${packageJSON.version}`);
+ logger.info(`Logged in as ${bot.user.tag} @ v${packageJSON.version}`);
},
};
\ No newline at end of file
diff --git a/src/events/threadCreate.js b/src/events/threadCreate.js
new file mode 100644
index 0000000..bbe8f8f
--- /dev/null
+++ b/src/events/threadCreate.js
@@ -0,0 +1,98 @@
+/**
+ * Thread Creation Event Handler
+ * Converts new forum posts in monitored channels to Unthread support tickets.
+ */
+const { Events, EmbedBuilder } = require('discord.js');
+const { createTicket, bindTicketWithThread } = require('../services/unthread');
+const { getKey, setKey } = require('../utils/memory');
+const logger = require('../utils/logger');
+require('dotenv').config();
+
+// Retrieve forum channel IDs from environment variables.
+// These channels are monitored for new threads to convert into tickets.
+const FORUM_CHANNEL_IDS = process.env.FORUM_CHANNEL_IDS ?
+ process.env.FORUM_CHANNEL_IDS.split(',') : [];
+
+module.exports = {
+ name: Events.ThreadCreate,
+ async execute(thread) {
+ // Ignore threads created in channels not listed in FORUM_CHANNEL_IDS.
+ if (!FORUM_CHANNEL_IDS.includes(thread.parentId)) return;
+
+ logger.info(`New forum post detected in monitored channel: ${thread.name}`);
+
+ try {
+ // Fetch the first message in the thread (the original forum post).
+ const messages = await thread.messages.fetch({ limit: 1 });
+ const firstMessage = messages.first();
+
+ if (!firstMessage) {
+ logger.error(`Could not find the initial message for forum post: ${thread.id}`);
+ return;
+ }
+
+ // Extract details from the forum post.
+ const author = firstMessage.author;
+ const title = thread.name;
+ const content = firstMessage.content;
+
+ // Check if the customer exists in memory; if not, create a default entry.
+ const customerKey = `customer:${author.id}`;
+ const existingCustomer = await getKey(customerKey);
+ let email = existingCustomer?.email || `${author.username}@discord.user`;
+
+ if (!existingCustomer) {
+ await setKey(customerKey, { email });
+ }
+
+ // Create a support ticket in Unthread using the forum post details.
+ const ticket = await createTicket(author, title, content, email);
+ if (!ticket.friendlyId) throw new Error('Ticket was created but no friendlyId was provided');
+
+ // Link the Discord thread with the Unthread ticket for communication.
+ await bindTicketWithThread(ticket.id, thread.id);
+
+ // Notify users in the thread that a ticket has been created.
+ const ticketEmbed = new EmbedBuilder()
+ .setColor(0xEB1A1A)
+ .setTitle(`Ticket #${ticket.friendlyId}`)
+ .setDescription('This forum post has been converted to a support ticket. The support team will respond here.')
+ .addFields(
+ { name: 'Ticket ID', value: `#${ticket.friendlyId}`, inline: true },
+ { name: 'Status', value: 'Open', inline: true },
+ { name: 'Title', value: title, inline: false },
+ { name: 'Created By', value: author.tag, inline: true }
+ )
+ .setFooter({ text: 'Unthread Support System' })
+ .setTimestamp();
+
+ await thread.send({ embeds: [ticketEmbed] });
+
+ // Add the confirmation message similar to private threads
+ await thread.send({
+ content: `Hello <@${author.id}>, we have received your ticket and will respond shortly. Please check this thread for updates.`
+ });
+
+ logger.info(`Forum post converted to ticket: #${ticket.friendlyId}`);
+ } catch (error) {
+ if (error.message.includes('timeout')) {
+ logger.error('Ticket creation is taking longer than expected. Please wait and try again.');
+ } else {
+ logger.error('An error occurred while creating the ticket:', error.message);
+ }
+ try {
+ // Notify users in the thread about the error.
+ const errorEmbed = new EmbedBuilder()
+ .setColor(0xFF0000)
+ .setTitle('Error Creating Support Ticket')
+ .setDescription('There was an error creating a support ticket from this forum post. A staff member will assist you shortly.')
+ .setFooter({ text: 'Unthread Support System' })
+ .setTimestamp();
+
+ await thread.send({ embeds: [errorEmbed] });
+ } catch (sendError) {
+ logger.error('Could not send error message to thread:', sendError);
+ }
+ }
+ },
+};
diff --git a/src/index.js b/src/index.js
index cd4eea8..b1d8a14 100644
--- a/src/index.js
+++ b/src/index.js
@@ -5,6 +5,7 @@ const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
const { webhookHandler } = require('./services/webhook');
+const logger = require('./utils/logger');
require("dotenv").config();
@@ -17,12 +18,14 @@ const client = new Client({
GatewayIntentBits.Guilds,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessages,
- GatewayIntentBits.GuildMessageReactions
+ GatewayIntentBits.GuildMessageReactions,
],
partials: [
Partials.Channel,
Partials.Message,
- Partials.Reaction
+ Partials.Reaction,
+ Partials.ThreadMember,
+ Partials.Thread
]
});
@@ -39,7 +42,7 @@ app.use(
app.post('/webhook/unthread', webhookHandler);
app.listen(port, () => {
- console.log(`Server listening on port ${port}`);
+ logger.info(`Server listening on port ${port}`);
});
/**
@@ -61,7 +64,7 @@ for (const folder of commandFolders) {
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
} else {
- console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
+ logger.warn(`The command at ${filePath} is missing a required "data" or "execute" property.`);
}
}
}
@@ -85,6 +88,6 @@ for (const file of eventFiles) {
client.login(DISCORD_BOT_TOKEN)
.then(() => {
global.discordClient = client;
- console.log('Discord client is ready and set globally.');
+ logger.info('Discord client is ready and set globally.');
})
- .catch(console.error);
+ .catch(logger.error);
diff --git a/src/services/unthread.js b/src/services/unthread.js
index 69e71ea..b72f76f 100644
--- a/src/services/unthread.js
+++ b/src/services/unthread.js
@@ -9,6 +9,7 @@
const { decodeHtmlEntities } = require('../utils/decodeHtmlEntities');
const { setKey, getKey } = require('../utils/memory');
const { EmbedBuilder } = require('discord.js');
+const logger = require('../utils/logger');
require('dotenv').config();
@@ -125,52 +126,61 @@ async function createTicket(user, title, issue, email) {
}
let data = await response.json();
- console.log('Initial ticket creation response:', data);
+ logger.debug('Initial ticket creation response:', JSON.stringify(data, null, 2));
if (!data.friendlyId && data.id) {
- console.log(`friendlyId not found in initial response. Starting polling for ticket ${data.id}`);
+ logger.debug(`friendlyId not found in initial response. Starting polling for ticket ${data.id}`);
- // Configuration for the polling mechanism
- const maxRetries = 5; // Maximum number of attempts to fetch the ticket
- const retryDelay = 2000; // Delay between attempts (2 seconds)
+ // Implementation of exponential backoff for retry logic
+ const maxRetries = 18; // Increased from 12 to 18 attempts
+ const baseDelayMs = 1000; // Start with 1 second
+ const maxDelayMs = 60000; // Increased from 40000 to 60000 ms (60 seconds)
+ const jitterFactor = 0.1; // Add up to 10% random jitter
- // Implement polling to wait for friendlyId to be generated by Unthread
for (let attempt = 0; attempt < maxRetries; attempt++) {
- console.log(`Waiting for friendlyId, attempt ${attempt + 1}/${maxRetries}`);
- // Pause execution to allow Unthread time to process the ticket
- await new Promise(resolve => setTimeout(resolve, retryDelay));
+ // Calculate exponential delay with jitter
+ let delayMs = Math.min(
+ maxDelayMs,
+ baseDelayMs * Math.pow(2, attempt)
+ );
- // Make a GET request to fetch the latest ticket data
- const ticketResponse = await fetch(`https://api.unthread.io/api/conversations/${data.id}`, {
- method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- 'X-API-KEY': process.env.UNTHREAD_API_KEY,
- },
- });
+ // Add random jitter to prevent synchronized retries
+ delayMs = delayMs * (1 + jitterFactor * Math.random());
- // If the request fails, log the error and continue to next attempt
- if (!ticketResponse.ok) {
- console.error(`Failed to fetch ticket: ${ticketResponse.status}`);
- continue;
- }
-
- // Parse the response and check for friendlyId
- const updatedData = await ticketResponse.json();
- console.log(`Polling result (attempt ${attempt + 1}):`, updatedData);
+ logger.debug(`Waiting for friendlyId, attempt ${attempt + 1}/${maxRetries} with delay of ${Math.round(delayMs)}ms`);
+ await new Promise(resolve => setTimeout(resolve, delayMs));
- // If friendlyId exists, update the data object and exit the polling loop
- if (updatedData.friendlyId) {
- data = updatedData;
- console.log(`Found friendlyId: ${data.friendlyId} after ${attempt + 1} attempts`);
- break;
+ try {
+ const ticketResponse = await fetch(`https://api.unthread.io/api/conversations/${data.id}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-API-KEY': process.env.UNTHREAD_API_KEY,
+ },
+ });
+
+ if (!ticketResponse.ok) {
+ logger.error(`Failed to fetch ticket: ${ticketResponse.status}, response: ${await ticketResponse.text()}`);
+ continue;
+ }
+
+ const updatedData = await ticketResponse.json();
+ logger.debug(`Polling result (attempt ${attempt + 1}):`, JSON.stringify(updatedData, null, 2));
+
+ if (updatedData.friendlyId) {
+ data = updatedData;
+ logger.info(`Found friendlyId: ${data.friendlyId} after ${attempt + 1} attempts`);
+ break;
+ }
+ } catch (error) {
+ logger.error(`Error during polling attempt ${attempt + 1}:`, error);
+ // Continue the retry loop rather than failing immediately on network errors
}
}
-
- // If we've exhausted all retries and still don't have a friendlyId, throw an error
- if (!data.friendlyId) {
- throw new Error(`Failed to get friendlyId for ticket ${data.id} after ${maxRetries} attempts`);
- }
+ }
+
+ if (!data.friendlyId) {
+ throw new Error(`Ticket was created but no friendlyId was provided after multiple polling attempts. Ticket ID: ${data.id}`);
}
return data;
@@ -223,19 +233,19 @@ async function getTicketByUnthreadTicketId(unthreadTicketId) {
* @returns {Object} - The processed payload
*/
async function handleWebhookEvent(payload) {
- console.log('Received webhook event from Unthread:', payload);
+ logger.debug('Received webhook event from Unthread:', payload);
if (payload.event === 'conversation_updated') {
const { id, status, friendlyId, title } = payload.data;
if (status === 'closed') {
const ticketMapping = await getTicketByUnthreadTicketId(id);
if (!ticketMapping) {
- console.error(`No Discord thread found for Unthread ticket ${id}`);
+ logger.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.`);
+ logger.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
return;
}
@@ -255,16 +265,16 @@ async function handleWebhookEvent(payload) {
}
await discordThread.send({ embeds: [closedEmbed] });
- console.log(`Sent closure notification embed to Discord thread ${discordThread.id}`);
+ logger.info(`Sent closure notification embed 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}`);
+ logger.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.`);
+ logger.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
return;
}
@@ -284,14 +294,14 @@ async function handleWebhookEvent(payload) {
}
await discordThread.send({ embeds: [reopenedEmbed] });
- console.log(`Sent reopen notification embed to Discord thread ${discordThread.id}`);
+ logger.info(`Sent reopen notification embed to Discord thread ${discordThread.id}`);
}
return;
}
if (payload.event === 'message_created') {
if (payload.data.metadata && payload.data.metadata.source === "discord") {
- console.log("Message originated from Discord, skipping to avoid duplication");
+ logger.debug("Message originated from Discord, skipping to avoid duplication");
return;
}
@@ -300,15 +310,15 @@ async function handleWebhookEvent(payload) {
try {
const ticketMapping = await getTicketByUnthreadTicketId(conversationId);
if (!ticketMapping) {
- console.error(`No Discord thread found for Unthread ticket ${conversationId}`);
+ logger.error(`No Discord thread found for Unthread ticket ${conversationId}`);
return;
}
const discordThread = await global.discordClient.channels.fetch(ticketMapping.discordThreadId);
if (!discordThread) {
- console.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
+ logger.error(`Discord thread with ID ${ticketMapping.discordThreadId} not found.`);
return;
}
- console.log(`Found Discord thread: ${discordThread.id}`);
+ logger.debug(`Found Discord thread: ${discordThread.id}`);
const messages = await discordThread.messages.fetch({ limit: 10 });
@@ -317,17 +327,40 @@ async function handleWebhookEvent(payload) {
const ticketSummaryMessage = messagesArray[1];
if (ticketSummaryMessage && ticketSummaryMessage.content.includes(decodedMessage.trim())) {
- console.log('Message content already exists in ticket summary. Skipping webhook message.');
+ logger.debug('Message content already exists in ticket summary. Skipping webhook message.');
return;
}
const duplicate = messages.some(msg => msg.content === decodedMessage);
if (duplicate) {
- console.log('Duplicate message detected. Skipping send.');
+ logger.debug('Duplicate message detected. Skipping send.');
return;
}
}
+ // Check for Discord CDN attachment patterns and skip if found
+ const discordCdnPattern = /Attachments: (?:]+\|(?:image|video|file)_\d+>|\[(?:image|video|file)_\d+\]https:\/\/cdn\.discordapp\.com\/attachments\/\d+\/\d+\/[^\]]+\))/i;
+
+ // More comprehensive check that handles multiple attachments with pipe separators
+ const hasDiscordAttachments =
+ discordCdnPattern.test(decodedMessage) ||
+ (decodedMessage.includes('Attachments:') &&
+ decodedMessage.includes('cdn.discordapp.com/attachments/') &&
+ (decodedMessage.includes('|image_') || decodedMessage.includes('|file_') || decodedMessage.includes('|video_')));
+
+ if (hasDiscordAttachments) {
+ logger.debug('Discord attachment links detected in webhook message. Skipping to avoid duplication.');
+ return;
+ }
+
+ // Skip attachments section when checking for duplicates
+ let messageContent = decodedMessage;
+ const attachmentSection = messageContent.match(/\n\nAttachments: (?:\[.+\]|\<.+\>)/);
+ if (attachmentSection) {
+ messageContent = messageContent.replace(attachmentSection[0], '').trim();
+ }
+
+ // Look for quoted content, but ignore attachment links
let quotedMessageMatch = decodedMessage.match(/^(>\s?.+(?:\n|$))+/);
let replyReference = null;
let contentToSend = decodedMessage;
@@ -336,19 +369,26 @@ async function handleWebhookEvent(payload) {
let quotedMessage = quotedMessageMatch[0].trim();
quotedMessage = quotedMessage.replace(/^>\s?/gm, '').trim();
const remainingText = decodedMessage.replace(quotedMessageMatch[0], '').trim();
- console.log(`Message being used to search: ${quotedMessage}`);
- console.log(`Message being used to search: ${remainingText}`);
- const matchingMsg = messages.find(msg => msg.content.trim() === quotedMessage);
- if (matchingMsg) {
- replyReference = matchingMsg.id;
- contentToSend = remainingText || " ";
- console.log(`Quoted text matched message ${matchingMsg.id}`);
- }
- const remainingTextDuplicate = messages.some(msg => msg.content.trim() === remainingText);
- if (remainingTextDuplicate) {
- console.log('Remaining text matches an existing message. Skipping send.');
- return;
+ if (!quotedMessage.startsWith("Attachments: [")) {
+ const matchingMsg = messages.find(msg => msg.content.trim() === quotedMessage);
+ if (matchingMsg) {
+ replyReference = matchingMsg.id;
+ contentToSend = remainingText || " ";
+ }
+
+ const remainingTextDuplicate = messages.some(msg => {
+ let msgContent = msg.content.trim();
+ const msgAttachmentSection = msgContent.match(/\n\nAttachments: \[.+\]/);
+ if (msgAttachmentSection) {
+ msgContent = msgContent.replace(msgAttachmentSection[0], '').trim();
+ }
+ return msgContent === remainingText;
+ });
+
+ if (remainingTextDuplicate) {
+ return;
+ }
}
}
@@ -357,13 +397,13 @@ async function handleWebhookEvent(payload) {
content: contentToSend,
reply: { messageReference: replyReference },
});
- console.log(`Sent reply message to Discord message ${replyReference} in thread ${discordThread.id}`);
+ logger.info(`Sent reply message to Discord message ${replyReference} in thread ${discordThread.id}`);
} else {
await discordThread.send(decodedMessage);
- console.log(`Sent message to Discord thread ${discordThread.id}`);
+ logger.info(`Sent message to Discord thread ${discordThread.id}`);
}
} catch (error) {
- console.error('Error processing new message webhook event:', error);
+ logger.error('Error processing new message webhook event:', error);
}
}
return payload;
diff --git a/src/services/webhook.js b/src/services/webhook.js
index 3f064fa..3d7e6df 100644
--- a/src/services/webhook.js
+++ b/src/services/webhook.js
@@ -1,6 +1,6 @@
-// language: JavaScript
const { createHmac } = require('crypto');
const { handleWebhookEvent: unthreadWebhookHandler } = require('./unthread');
+const logger = require('../utils/logger');
const SIGNING_SECRET = process.env.UNTHREAD_WEBHOOK_SECRET;
@@ -14,9 +14,9 @@ function verifySignature(req) {
}
function webhookHandler(req, res) {
- console.log('Webhook received:', req.rawBody);
+ logger.debug('Webhook received:', req.rawBody);
if (!verifySignature(req)) {
- console.error('Signature verification failed.');
+ logger.error('Signature verification failed.');
res.sendStatus(403);
return;
}
diff --git a/src/utils/logger.js b/src/utils/logger.js
new file mode 100644
index 0000000..44e18d9
--- /dev/null
+++ b/src/utils/logger.js
@@ -0,0 +1,96 @@
+/**
+ * Logger Module
+ *
+ * A simple logging utility that provides different logging levels with prefix labels.
+ * Controls output verbosity based on environment configuration.
+ *
+ * Usage:
+ * const logger = require('./utils/logger');
+ * logger.debug('Detailed information for debugging');
+ * logger.info('Important application events');
+ * logger.warn('Warning conditions');
+ * logger.error('Error conditions');
+ *
+ * Configuration:
+ * Set DEBUG_MODE=true in your .env file to enable debug logs.
+ * Debug logs are hidden by default in production environments.
+ */
+
+// Determine if debug mode is enabled from environment variables
+// This controls whether debug-level logs are displayed
+const debugMode = process.env.DEBUG_MODE === 'true';
+
+/**
+ * Log debug information (only visible when DEBUG_MODE=true)
+ * Used for detailed troubleshooting information that is too verbose for regular operation
+ *
+ * @param {...any} args - Arguments to log (strings, objects, etc.)
+ */
+function debug(...args) {
+ if (debugMode) {
+ if (args.length > 0) {
+ if (typeof args[0] === 'string') {
+ args[0] = `[DEBUG] ${args[0]}`; // Prepend label to string message
+ } else {
+ args.unshift('[DEBUG]'); // Add label as separate argument
+ }
+ }
+ console.log(...args);
+ }
+}
+
+/**
+ * Log informational messages (always visible)
+ * Used for general operational information about system activities
+ *
+ * @param {...any} args - Arguments to log (strings, objects, etc.)
+ */
+function info(...args) {
+ // Always show info-level logs regardless of debug mode
+ if (args.length > 0) {
+ if (typeof args[0] === 'string') {
+ args[0] = `[INFO] ${args[0]}`; // Prepend label to string message
+ } else {
+ args.unshift('[INFO]'); // Add label as separate argument
+ }
+ }
+ console.log(...args);
+}
+
+/**
+ * Log warning messages (always visible)
+ * Used for warning conditions that don't prevent the application from working
+ * but indicate potential problems or unexpected behavior
+ *
+ * @param {...any} args - Arguments to log (strings, objects, etc.)
+ */
+function warn(...args) {
+ if (args.length > 0) {
+ if (typeof args[0] === 'string') {
+ args[0] = `[WARN] ${args[0]}`; // Prepend label to string message
+ } else {
+ args.unshift('[WARN]'); // Add label as separate argument
+ }
+ }
+ console.warn(...args); // Uses console.warn for proper error stream routing
+}
+
+/**
+ * Log error messages (always visible)
+ * Used for error conditions that prevent normal operation but don't crash the application
+ *
+ * @param {...any} args - Arguments to log (strings, objects, etc.)
+ */
+function error(...args) {
+ if (args.length > 0) {
+ if (typeof args[0] === 'string') {
+ args[0] = `[ERROR] ${args[0]}`; // Prepend label to string message
+ } else {
+ args.unshift('[ERROR]'); // Add label as separate argument
+ }
+ }
+ console.error(...args); // Uses console.error for proper error stream routing
+}
+
+// Export the logging functions for use in other modules
+module.exports = { debug, info, error, warn };