// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable camelcase, no-console */ const axios = require('axios'); const ClientOAuth2 = require('client-oauth2'); const express = require('express'); const postMessageAs = require('./utils/post_message_as'); const webhookUtils = require('./utils/webhook_utils'); const port = 3000; const { SITE_URL, WEBHOOK_BASE_URL, ADMIN_USERNAME, ADMIN_PASSWORD, } = process.env; // eslint-disable-line no-process-env const server = express(); server.use(express.json()); server.use(express.urlencoded({extended: true})); // Log all incoming requests (minimal for test performance) server.use((req, res, next) => { console.log(`[${req.method}] ${req.url}`); next(); }); // Add error handling for failed requests server.use((err, req, res, next) => { console.error('=== SERVER ERROR ==='); console.error('Error:', err); console.error('Request URL:', req.url); console.error('Request Method:', req.method); console.error('=== END SERVER ERROR ==='); next(err); }); process.title = process.argv[2]; server.get('/', ping); server.post('/message_menus', postMessageMenus); server.post('/dialog_request', onDialogRequest); server.post('/simple_dialog_request', onSimpleDialogRequest); server.post('/user_and_channel_dialog_request', onUserAndChannelDialogRequest); server.post('/dialog_submit', onDialogSubmit); server.post('/boolean_dialog_request', onBooleanDialogRequest); server.post('/simple_dialog_error_request', onSimpleDialogErrorRequest); server.post('/select_fields_dialog_request', onSelectFieldsDialogRequest); server.post('/multiselect_dynamic_dialog_request', onMultiselectDynamicDialogRequest); server.post('/dynamic_options', getDynamicOptions); server.post('/dynamic_multiselect_options', getDynamicMultiselectOptions); server.post('/multiform_dialog_request', onMultiformDialogRequest); server.post('/multiform_dialog_submit', onMultiformDialogSubmit); server.post('/field_refresh_dialog_request', onFieldRefreshDialogRequest); server.post('/field_refresh_dialog_submit', onFieldRefreshDialogSubmit); server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse); server.post('/send_message_to_channel', postSendMessageToChannel); server.post('/post_outgoing_webhook', postOutgoingWebhook); server.post('/send_oauth_credentials', postSendOauthCredentials); server.get('/start_oauth', getStartOAuth); server.get('/complete_oauth', getCompleteOauth); server.post('/postOAuthMessage', postOAuthMessage); function ping(req, res) { const baseUrl = SITE_URL || 'http://localhost:8065'; const webhookBaseUrl = WEBHOOK_BASE_URL || 'http://localhost:3000'; return res.json({ message: 'I\'m alive!', baseUrl, webhookBaseUrl, }); } server.listen(port, () => console.log(`Webhook test server listening on port ${port}!`)); let appID; let appSecret; let client; let authedUser; function postSendOauthCredentials(req, res) { appID = req.body.appID.trim(); appSecret = req.body.appSecret.trim(); client = new ClientOAuth2({ clientId: appID, clientSecret: appSecret, authorizationUri: getBaseUrl() + '/oauth/authorize', accessTokenUri: getBaseUrl() + '/oauth/access_token', redirectUri: getWebhookBaseUrl() + '/complete_oauth', }); return res.status(200).send('OK'); } function getStartOAuth(req, res) { return res.redirect(client.code.getUri()); } function getCompleteOauth(req, res) { client.code.getToken(req.originalUrl).then((user) => { authedUser = user; return res.status(200).send('OK'); }).catch((reason) => { return res.status(reason.status).send(reason); }); } async function postOAuthMessage(req, res) { const {channelId, message, rootId, createAt} = req.body; const apiUrl = getBaseUrl() + '/api/v4/posts'; authedUser.sign({ method: 'post', url: apiUrl, }); try { await axios({ url: apiUrl, headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', Authorization: 'Bearer ' + authedUser.accessToken, }, method: 'post', data: { channel_id: channelId, message, type: '', create_at: createAt, root_id: rootId, }, }); } catch (err) { // Do nothing } return res.status(200).send('OK'); } function postSlackCompatibleMessageResponse(req, res) { const {spoiler, skipSlackParsing} = req.body.context; res.setHeader('Content-Type', 'application/json'); return res.json({ ephemeral_text: spoiler, skip_slack_parsing: skipSlackParsing, }); } function postMessageMenus(req, res) { let responseData = {}; const {body} = req; if (body && body.context.action === 'do_something') { responseData = { ephemeral_text: `Ephemeral | ${body.type} ${body.data_source} option: ${body.context.selected_option}`, }; } res.setHeader('Content-Type', 'application/json'); return res.json(responseData); } async function openDialog(dialog) { const baseUrl = getBaseUrl(); await axios({ method: 'post', url: `${baseUrl}/api/v4/actions/dialogs/open`, data: dialog, }); } function onDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getFullDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Full dialog triggered via slash command!'}); } function onSimpleDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getSimpleDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Simple dialog triggered via slash command!'}); } function onUserAndChannelDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getUserAndChannelDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Simple dialog triggered via slash command!'}); } function onBooleanDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getBooleanDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Simple dialog triggered via slash command!'}); } function onSimpleDialogErrorRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); // Create a simple dialog that will submit to the error endpoint const dialog = { trigger_id: body.trigger_id, url: `${webhookBaseUrl}/dialog_submit_error`, dialog: { callback_id: 'simpleerrorcallbackid', title: 'Simple Dialog Error Test', icon_url: 'http://www.mattermost.org/wp-content/uploads/2016/04/icon.png', submit_label: 'Submit Test', notify_on_cancel: true, state: 'somestate', elements: [ { display_name: 'Optional Text Field', name: 'optional_text', type: 'text', default: '', placeholder: 'Enter some text (will trigger error)...', help_text: 'This field will trigger a server error for testing', optional: true, min_length: 0, max_length: 100, }, ], }, }; openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Error dialog triggered via slash command!'}); } function onSelectFieldsDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); // Create a focused dialog with select field types for testing const dialog = { trigger_id: body.trigger_id, url: `${webhookBaseUrl}/dialog_submit`, dialog: { callback_id: 'selectfieldscallbackid', title: 'Select Fields Dialog Test', icon_url: 'http://www.mattermost.org/wp-content/uploads/2016/04/icon.png', submit_label: 'Submit Test', notify_on_cancel: true, state: 'somestate', elements: [ { display_name: 'Radio Option Selector', name: 'someradiooptions', type: 'radio', help_text: 'Choose your department', optional: false, options: [ { text: 'Engineering', value: 'engineering', }, { text: 'Sales', value: 'sales', }, { text: 'Marketing', value: 'marketing', }, ], }, { display_name: 'Option Selector', name: 'someoptionselector', type: 'select', subtype: '', default: '', placeholder: 'Select an option...', help_text: 'Choose from static options', optional: false, min_length: 0, max_length: 0, data_source: '', options: [ { text: 'Option1', value: 'opt1', }, { text: 'Option2', value: 'opt2', }, { text: 'Option3', value: 'opt3', }, ], }, { display_name: 'User Selector', name: 'someuserselector', type: 'select', subtype: '', default: '', placeholder: 'Select a user...', help_text: 'Choose a user from the team', optional: false, min_length: 0, max_length: 0, data_source: 'users', options: null, }, { display_name: 'Channel Selector', name: 'somechannelselector', type: 'select', subtype: '', default: '', placeholder: 'Select a channel...', help_text: 'Choose a channel from the list', optional: true, min_length: 0, max_length: 0, data_source: 'channels', options: null, }, ], }, }; openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Select fields dialog triggered via slash command!'}); } function onDialogSubmit(req, res) { const {body} = req; res.setHeader('Content-Type', 'application/json'); let message; if (body.cancelled) { message = 'Dialog cancelled'; sendSysadminResponse(message, body.channel_id); } else { const submission = body.submission || {}; // Debug logging for dialog submissions (can be removed in production) console.log('[POST] /dialog_submit - callback_id:', body.callback_id); // For boolean dialog, post detailed submission values to channel if (body.callback_id === 'booleancallbackid') { const booleanValues = { required_boolean: submission.required_boolean, optional_boolean: submission.optional_boolean, boolean_default_true: submission.boolean_default_true, boolean_default_false: submission.boolean_default_false, }; console.log('Boolean dialog submitted'); // Post structured submission results to channel for e2e verification message = `Boolean Dialog Submitted: - required_boolean: ${booleanValues.required_boolean} - optional_boolean: ${booleanValues.optional_boolean} - boolean_default_true: ${booleanValues.boolean_default_true} - boolean_default_false: ${booleanValues.boolean_default_false}`; } else if (body.callback_id === 'textfieldscallbackid') { const textValues = { text_field: submission.text_field, required_text: submission.required_text, email_field: submission.email_field, number_field: submission.number_field, password_field: submission.password_field, textarea_field: submission.textarea_field, }; console.log('Text fields dialog submitted'); // Post structured submission results to channel for e2e verification message = `Text Fields Dialog Submitted: - text_field: ${textValues.text_field} - required_text: ${textValues.required_text} - email_field: ${textValues.email_field} - number_field: ${textValues.number_field} - password_field: ${textValues.password_field} - textarea_field: ${textValues.textarea_field}`; } else if (body.callback_id === 'selectfieldscallbackid') { const selectValues = { someradiooptions: submission.someradiooptions, someoptionselector: submission.someoptionselector, someuserselector: submission.someuserselector, somechannelselector: submission.somechannelselector, }; console.log('Select fields dialog submitted'); // Post structured submission results to channel for e2e verification message = `Select Fields Dialog Submitted: - someradiooptions: ${selectValues.someradiooptions} - someoptionselector: ${selectValues.someoptionselector} - someuserselector: ${selectValues.someuserselector} - somechannelselector: ${selectValues.somechannelselector}`; } else { message = `Dialog submitted with values: ${JSON.stringify(submission)}`; } sendSysadminResponse(message, body.channel_id); } return res.json({text: message}); } /** * @route "POST /send_message_to_channel?type={messageType}&channel_id={channelId}" * @query type - message type of empty string for regular message if not provided (default), "system_message", etc * @query channel_id - channel where to send the message */ function postSendMessageToChannel(req, res) { const channelId = req.query.channel_id; const response = { response_type: 'in_channel', text: 'Extra response 2', channel_id: channelId, extra_responses: [{ response_type: 'in_channel', text: 'Hello World', channel_id: channelId, }], }; if (req.query.type) { response.type = req.query.type; } res.json(response); } function getWebhookBaseUrl() { return WEBHOOK_BASE_URL || 'http://localhost:3000'; } function getBaseUrl() { return SITE_URL || 'http://localhost:8065'; } // Convenient way to send response in a channel by using sysadmin account function sendSysadminResponse(message, channelId) { const username = ADMIN_USERNAME || 'sysadmin'; const password = ADMIN_PASSWORD || 'Sys@dmin-sample1'; const baseUrl = getBaseUrl(); postMessageAs(baseUrl, {sender: {username, password}, message, channelId}); } const responseTypes = ['in_channel', 'comment']; function getWebhookResponse(body, {responseType, username, iconUrl}) { const payload = Object.entries(body).map(([key, value]) => `- ${key}: "${value}"`).join('\n'); return ` \`\`\` #### Outgoing Webhook Payload ${payload} #### Webhook override to Mattermost instance - response_type: "${responseType}" - type: "" - username: "${username}" - icon_url: "${iconUrl}" \`\`\` `; } /** * @route "POST /post_outgoing_webhook?override_username={username}&override_icon_url={iconUrl}&response_type={comment}" * @query override_username - the user name that overrides the user name defined by the outgoing webhook * @query override_icon_url - the user icon url that overrides the user icon url defined by the outgoing webhook * @query response_type - "in_channel" (default) or "comment" */ function postOutgoingWebhook(req, res) { const {body, query} = req; if (!body) { res.status(404).send({error: 'Invalid data'}); } const responseType = query.response_type || responseTypes[0]; const username = query.override_username || ''; const iconUrl = query.override_icon_url || ''; const response = { text: getWebhookResponse(body, {responseType, username, iconUrl}), username, icon_url: iconUrl, type: '', response_type: responseType, }; res.status(200).send(response); } function onMultiselectDynamicDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getMultiselectDynamicDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Multiselect dynamic dialog triggered via slash command!'}); } function getDynamicOptions(req, res) { const {body} = req; const searchTerm = body.user_input || ''; // Respond immediately for test performance const response = webhookUtils.getDynamicOptionsResponse(searchTerm); res.status(200).json(response); } function getDynamicMultiselectOptions(req, res) { const {body} = req; const searchTerm = body.user_input || ''; // Respond immediately for test performance const response = webhookUtils.getDynamicMultiselectOptionsResponse(searchTerm); res.status(200).json(response); } // Multiform dialog endpoints function onMultiformDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); const dialog = webhookUtils.getMultiformDialog(body.trigger_id, webhookBaseUrl, 1); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Multiform dialog triggered via slash command!'}); } function onMultiformDialogSubmit(req, res) { const {body} = req; const callbackId = body.callback_id; let response; if (callbackId === 'multiform_step_1') { // Return step 2 dialog const webhookBaseUrl = getWebhookBaseUrl(); const step2Dialog = webhookUtils.getMultiformDialog(body.trigger_id, webhookBaseUrl, 2); response = { type: 'form', form: step2Dialog.dialog, }; } else if (callbackId === 'multiform_step_2') { // Return step 3 dialog const webhookBaseUrl = getWebhookBaseUrl(); const step3Dialog = webhookUtils.getMultiformDialog(body.trigger_id, webhookBaseUrl, 3); response = { type: 'form', form: step3Dialog.dialog, }; } else if (callbackId === 'multiform_step_3') { // Final submission - send success message to channel const submissionText = Object.entries(body.submission). map(([key, value]) => `${key}: ${value}`). join('\n'); const message = `Multiform submission completed!\n\`\`\`\n${submissionText}\n\`\`\``; sendSysadminResponse(message, body.channel_id); response = {}; } else if (body.cancelled) { // Handle cancellation let stepNumber; if (callbackId === 'multiform_step_1') { stepNumber = 1; } else if (callbackId === 'multiform_step_2') { stepNumber = 2; } else { stepNumber = 3; } const message = `Multiform dialog was cancelled at step ${stepNumber}`; sendSysadminResponse(message, body.channel_id); response = {}; } res.status(200).json(response); } // Field refresh dialog endpoints function onFieldRefreshDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const webhookBaseUrl = getWebhookBaseUrl(); // Start with basic field refresh dialog (no project type selected) const dialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Field refresh dialog triggered via slash command!'}); } function onFieldRefreshDialogSubmit(req, res) { const {body} = req; let response; // Check if this is a refresh request (indicated by 'type': 'refresh' or selected_field) if (body.type === 'refresh' || body.submission.selected_field) { // Get the current project type from submission const projectType = body.submission.project_type; if (projectType && projectType !== 'unknown_type') { // Return refreshed dialog with project-specific fields const webhookBaseUrl = getWebhookBaseUrl(); const refreshedDialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl, projectType); response = { type: 'form', form: refreshedDialog.dialog, }; } else { // Invalid project type - return error or basic dialog const webhookBaseUrl = getWebhookBaseUrl(); const basicDialog = webhookUtils.getFieldRefreshDialog(body.trigger_id, webhookBaseUrl); response = { type: 'form', form: basicDialog.dialog, }; } } else if (body.cancelled) { // Handle cancellation const message = 'Field refresh dialog was cancelled'; sendSysadminResponse(message, body.channel_id); response = {}; } else { // Final submission - send success message to channel const submissionText = Object.entries(body.submission). map(([key, value]) => `${key}: ${value}`). join('\n'); const message = `Field refresh dialog submitted successfully!\n\`\`\`\n${submissionText}\n\`\`\``; sendSysadminResponse(message, body.channel_id); response = {}; } res.status(200).json(response); } // Catch-all route for unmatched requests server.use((req, res) => { res.status(404).json({ error: 'Not Found', method: req.method, url: req.originalUrl, message: 'This endpoint does not exist on the webhook server', }); }); server.listen(port, () => { console.log(`Webhook test server listening on port ${port}!`); });