// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. /* eslint-disable no-console */ const express = require('express'); const axios = require('axios'); const ClientOAuth2 = require('client-oauth2'); const webhookUtils = require('./utils/webhook_utils'); const postMessageAs = require('./tests/plugins/post_message_as'); const port = 3000; const server = express(); server.use(express.json()); server.use(express.urlencoded({extended: true})); process.title = process.argv[2]; server.get('/', ping); server.post('/setup', doSetup); server.post('/message_menus', postMessageMenus); server.post('/dialog_request', onDialogRequest); server.post('/simple_dialog_request', onSimpleDialogRequest); server.post('/basic_dialog_request', onBasicDialogRequest); server.post('/user_and_channel_dialog_request', onUserAndChannelDialogRequest); server.post('/dialog_submit', onDialogSubmit); server.post('/boolean_dialog_request', onBooleanDialogRequest); server.post('/checkbox_group_dialog_request', onCheckboxGroupDialogRequest); server.post('/multiselect_dialog_request', onMultiSelectDialogRequest); server.post('/dynamic_select_dialog_request', onDynamicSelectDialogRequest); server.post('/file_upload_dialog_request', onFileUploadDialogRequest); server.post('/dynamic_select_source', onDynamicSelectSource); server.post('/dialog/field-refresh', onFieldRefreshDialogRequest); server.post('/dialog/multistep', onMultistepDialogRequest); server.post('/dialog/action_button_request', onActionButtonDialogRequest); server.post('/dialog/open_child', onOpenChildDialog); server.post('/field_refresh_source', onFieldRefreshSource); server.post('/datetime_dialog_request', onDateTimeDialogRequest); server.post('/datetime_dialog_submit', onDateTimeDialogSubmit); server.post('/slack_compatible_message_response', postSlackCompatibleMessageResponse); server.post('/mm_blocks_integration', postMmBlocksIntegration); server.post('/mm_blocks_integration_update', postMmBlocksIntegrationUpdate); server.post('/mm_blocks_integration_static_select', postMmBlocksIntegrationStaticSelect); server.post('/mm_blocks_integration_echo_query', postMmBlocksIntegrationEchoQuery); server.post('/mm_blocks_integration_echo_context', postMmBlocksIntegrationEchoContext); 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('/post_oauth_message', postOAuthMessage); server.listen(port, (err) => { if (err) { console.error(err); throw err; } console.log(`Webhook test server listening on port ${port}!`); }); function ping(req, res) { return res.json({ message: 'I\'m alive!', endpoints: [ 'GET /', 'POST /setup', 'POST /message_menus', 'POST /dialog_request', 'POST /simple_dialog_request', 'POST /basic_dialog_request', 'POST /user_and_channel_dialog_request', 'POST /dialog_submit', 'POST /boolean_dialog_request', 'POST /checkbox_group_dialog_request', 'POST /multiselect_dialog_request', 'POST /dynamic_select_dialog_request', 'POST /file_upload_dialog_request', 'POST /dynamic_select_source', 'POST /dialog/field-refresh', 'POST /dialog/multistep', 'POST /dialog/action_button_request', 'POST /dialog/open_child', 'POST /field_refresh_source', 'POST /datetime_dialog_request', 'POST /datetime_dialog_submit', 'POST /slack_compatible_message_response', 'POST /mm_blocks_integration', 'POST /mm_blocks_integration_update', 'POST /mm_blocks_integration_static_select', 'POST /mm_blocks_integration_echo_query', 'POST /mm_blocks_integration_echo_context', 'POST /send_message_to_channel', 'POST /post_outgoing_webhook', 'POST /send_oauth_credentials', 'GET /start_oauth', 'GET /complete_oauth', 'POST /post_oauth_message', ], }); } // Set base URLs and credential to be accessible by any endpoint let baseUrl; let webhookBaseUrl; let adminUsername; let adminPassword; function doSetup(req, res) { baseUrl = req.body.baseUrl; webhookBaseUrl = req.body.webhookBaseUrl; adminUsername = req.body.adminUsername; adminPassword = req.body.adminPassword; console.log(`Webhook server configured: baseUrl=${baseUrl}, webhookBaseUrl=${webhookBaseUrl}`); return res.status(201).send('Successfully setup the new base URLs and credential.'); } async function handleDialogOpen(req, res, getDialog, successText) { const {body} = req; if (!body.trigger_id) { res.setHeader('Content-Type', 'application/json'); return res.status(400).json({text: 'Missing trigger_id'}); } try { const dialog = getDialog(body.trigger_id, webhookBaseUrl); console.log(JSON.stringify(dialog, null, 2)); await openDialog(dialog); res.setHeader('Content-Type', 'application/json'); return res.json({text: successText}); } catch (err) { console.error('Failed to open dialog:', err.message); res.setHeader('Content-Type', 'application/json'); return res.status(500).json({text: `Failed to open dialog: ${err.message}`}); } } let client; let authedUser; function postSendOauthCredentials(req, res) { const { appID, appSecret, } = req.body; client = new ClientOAuth2({ clientId: appID, clientSecret: appSecret, authorizationUri: `${baseUrl}/oauth/authorize`, accessTokenUri: `${baseUrl}/oauth/access_token`, redirectUri: `${webhookBaseUrl}/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 = `${baseUrl}/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 { // 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, }); } /** * Mattermost mm_blocks external actions POST the same integration envelope as legacy message buttons. * @see model.PostActionIntegrationResponse */ function postMmBlocksIntegration(req, res) { const userName = req.body && req.body.user_name ? req.body.user_name : 'unknown'; res.setHeader('Content-Type', 'application/json'); return res.status(200).json({ ephemeral_text: `Playwright mm_blocks integration OK (user: ${userName}).`, skip_slack_parsing: true, }); } /** * Returns a PostActionIntegrationResponse update so the interactive post is edited in-place * (persisted webhook post or ephemeral mm_blocks post). */ function postMmBlocksIntegrationUpdate(req, res) { res.setHeader('Content-Type', 'application/json'); return res.status(200).json({ update: { message: 'E2E mm_blocks post updated (message field).', props: { mm_blocks: [ { type: 'text', text: 'PLAYWRIGHT_MM_BLOCKS_UPDATED', }, ], }, }, skip_slack_parsing: true, }); } /** Echoes URL query parameters Mattermost merged onto the integration request (action query + block query). */ function postMmBlocksIntegrationEchoQuery(req, res) { const entries = Object.keys(req.query || {}). sort(). map((k) => `${k}=${String(req.query[k])}`); const summary = entries.join('&'); res.setHeader('Content-Type', 'application/json'); return res.status(200).json({ ephemeral_text: `Playwright mm_blocks query OK (${summary})`, skip_slack_parsing: true, }); } /** Echoes `context.test_marker` from the Mattermost integration POST body for mm_blocks external actions. */ function postMmBlocksIntegrationEchoContext(req, res) { const ctx = (req.body && req.body.context) || {}; const marker = typeof ctx.test_marker === 'string' ? ctx.test_marker : JSON.stringify(ctx.test_marker ?? null); res.setHeader('Content-Type', 'application/json'); return res.status(200).json({ ephemeral_text: `Playwright mm_blocks context OK (test_marker: ${marker}).`, skip_slack_parsing: true, }); } /** Echoes `context.selected_option` from the Mattermost integration POST for mm_blocks static_select. */ function postMmBlocksIntegrationStaticSelect(req, res) { const selected = req.body && req.body.context && req.body.context.selected_option; const label = typeof selected === 'string' ? selected : JSON.stringify(selected ?? null); res.setHeader('Content-Type', 'application/json'); return res.status(200).json({ ephemeral_text: `Playwright mm_blocks static_select OK (selected_option: ${label}).`, skip_slack_parsing: true, }); } 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) { // Callers invoke this fire-and-forget (no await/catch), so any rejection here // would become an unhandled rejection and crash the whole webhook process. // Guard against a missing baseUrl (set by /setup) and swallow request errors. if (!baseUrl) { console.error('openDialog called before /setup ran — baseUrl is not set; skipping dialog open'); return; } try { await axios({ method: 'post', url: `${baseUrl}/api/v4/actions/dialogs/open`, data: dialog, }); } catch (err) { const status = err.response && err.response.status; const body = err.response && err.response.data; console.error('openDialog request failed:', status || err.code || err.message, body ? JSON.stringify(body) : ''); } } function onDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getFullDialog, 'Full dialog triggered via slash command!'); } function onSimpleDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getSimpleDialog, 'Simple dialog triggered via slash command!'); } function onBasicDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getBasicDialog, 'Basic dialog triggered via slash command!'); } function onUserAndChannelDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getUserAndChannelDialog, 'Simple dialog triggered via slash command!'); } function onBooleanDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getBooleanDialog, 'Simple dialog triggered via slash command!'); } function onCheckboxGroupDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getCheckboxGroupDialog, 'Checkbox dialog feature demo triggered via slash command!'); } function onMultiSelectDialogRequest(req, res) { const {body} = req; if (!body.trigger_id) { res.setHeader('Content-Type', 'application/json'); return res.status(400).json({text: 'Missing trigger_id'}); } const includeDefaults = req.query.includeDefaults === 'true' || req.query.includeDefaults === true; return handleDialogOpen( req, res, (triggerId, webhookUrl) => webhookUtils.getMultiSelectDialog(triggerId, webhookUrl, includeDefaults), 'Multiselect dialog triggered via slash command!', ); } function onDynamicSelectDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getDynamicSelectDialog, 'Dynamic select dialog triggered via slash command!'); } function onFileUploadDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getFileUploadDialog, 'File upload dialog triggered via slash command!'); } function onDynamicSelectSource(req, res) { const {body} = req; // Simulate dynamic options based on search text const searchText = (body.submission.query || '').toLowerCase(); const allOptions = [ {text: 'Backend Engineer', value: 'backend_eng'}, {text: 'Frontend Engineer', value: 'frontend_eng'}, {text: 'Full Stack Engineer', value: 'fullstack_eng'}, {text: 'DevOps Engineer', value: 'devops_eng'}, {text: 'QA Engineer', value: 'qa_eng'}, {text: 'Product Manager', value: 'product_mgr'}, {text: 'Engineering Manager', value: 'eng_mgr'}, {text: 'Senior Backend Engineer', value: 'sr_backend_eng'}, {text: 'Senior Frontend Engineer', value: 'sr_frontend_eng'}, {text: 'Principal Engineer', value: 'principal_eng'}, {text: 'Staff Engineer', value: 'staff_eng'}, {text: 'Technical Lead', value: 'tech_lead'}, ]; // Filter options based on search text const filteredOptions = searchText ? allOptions.filter((option) => option.text.toLowerCase().includes(searchText) || option.value.toLowerCase().includes(searchText)) : allOptions.slice(0, 6); // Limit to first 6 if no search res.setHeader('Content-Type', 'application/json'); return res.json({ items: filteredOptions, }); } async function onDateTimeDialogRequest(req, res) { const {body} = req; if (!body.trigger_id) { res.setHeader('Content-Type', 'application/json'); return res.status(400).json({text: 'Missing trigger_id'}); } try { let dialog; const command = body.text ? body.text.trim() : ''; switch (command) { case 'basic': dialog = webhookUtils.getBasicDateDialog(body.trigger_id, webhookBaseUrl); break; case 'mindate': dialog = webhookUtils.getMinDateConstraintDialog(body.trigger_id, webhookBaseUrl); break; case 'interval': dialog = webhookUtils.getCustomIntervalDialog(body.trigger_id, webhookBaseUrl); break; case 'relative': dialog = webhookUtils.getRelativeDateDialog(body.trigger_id, webhookBaseUrl); break; case 'timezone-manual': dialog = webhookUtils.getTimezoneManualDialog(body.trigger_id, webhookBaseUrl); break; default: dialog = webhookUtils.getBasicDateTimeDialog(body.trigger_id, webhookBaseUrl); break; } console.log('Opening DateTime dialog', dialog.dialog.title); await openDialog(dialog); res.setHeader('Content-Type', 'application/json'); return res.json({text: 'DateTime dialog triggered via slash command!'}); } catch (err) { console.error('Failed to open dialog:', err.message); res.setHeader('Content-Type', 'application/json'); return res.status(500).json({text: `Failed to open dialog: ${err.message}`}); } } function onDateTimeDialogSubmit(req, res) { console.log('DateTime dialog submit handler called!'); const {body} = req; res.setHeader('Content-Type', 'application/json'); // Log the submitted datetime values for debugging console.log('DateTime dialog submission:', JSON.stringify(body, null, 2)); // Extract datetime values from submission const submission = body.submission || {}; const eventDate = submission.event_date; const meetingTime = submission.meeting_time; const relativeDate = submission.relative_date; const relativeDateTime = submission.relative_datetime; // Create a success message with the submitted values let message = 'Form submitted successfully! '; if (eventDate || meetingTime || relativeDate || relativeDateTime) { message += 'Submitted values: '; if (eventDate) { message += `Event Date: ${eventDate}, `; } if (meetingTime) { message += `Meeting Time: ${meetingTime}, `; } if (relativeDate) { message += `Relative Date: ${relativeDate}, `; } if (relativeDateTime) { message += `Relative DateTime: ${relativeDateTime}, `; } message = message.slice(0, -2); // Remove trailing comma and space } // Send success response that will appear as a post in the channel sendSysadminResponse(message, body.channel_id); return res.json({text: message}); } function onDialogSubmit(req, res) { const {body} = req; res.setHeader('Content-Type', 'application/json'); let message; if (body.cancelled) { message = 'Dialog cancelled'; console.log('[WEBHOOK] Dialog cancelled'); sendSysadminResponse(message, body.channel_id); return res.json({text: message}); } // Check if this is a multistep submission if (body.callback_id === 'multistep_callback') { const currentState = body.state || ''; // Determine next step based on current state if (currentState === 'step1') { // Move to step 2 const nextForm = webhookUtils.getMultistepStep2Dialog(null, webhookBaseUrl); return res.json({ type: 'form', form: nextForm, }); } else if (currentState === 'step2') { // Move to step 3 const nextForm = webhookUtils.getMultistepStep3Dialog(null, webhookBaseUrl); return res.json({ type: 'form', form: nextForm, }); } // Final step - complete the multistep const submission = body.submission || {}; message = `Multistep completed successfully! Final step values: ${JSON.stringify(submission, null, 2)}`; sendSysadminResponse(message, body.channel_id); return res.json({text: message}); } // Check if this is a field refresh dialog submission if (body.callback_id === 'field_refresh_callback') { const submission = body.submission || {}; message = `Field refresh dialog submitted successfully! Values: ${JSON.stringify(submission, null, 2)}`; sendSysadminResponse(message, body.channel_id); return res.json({text: message}); } // Regular dialog submission // Format submission data for the channel message const formatSubmissionValue = (value) => { if (Array.isArray(value)) { return JSON.stringify(value); } if (value && typeof value === 'object') { return JSON.stringify(value); } return String(value); }; const sanitize = (str) => String(str).replace(/[<>&"']/g, (ch) => `&#${ch.charCodeAt(0)};`); const submissionData = Object.entries(body.submission || {}). map(([key, value]) => `**${sanitize(key)}**: ${sanitize(formatSubmissionValue(value))}`). join('\n'); message = `Dialog submitted successfully!\n\n**Submission Data:**\n${submissionData}`; 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); } // Convenient way to send response in a channel by using sysadmin account function sendSysadminResponse(message, channelId) { postMessageAs({ sender: { username: adminUsername, password: adminPassword, }, message, channelId, baseUrl, }); } 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 onFieldRefreshDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getFieldRefreshDialog, 'Field refresh dialog triggered via slash command!'); } function onMultistepDialogRequest(req, res) { return handleDialogOpen(req, res, webhookUtils.getMultistepStep1Dialog, 'Multistep dialog triggered via slash command!'); } function onActionButtonDialogRequest(req, res) { const {body} = req; if (body.trigger_id) { const dialog = webhookUtils.getActionButtonParentDialog(body.trigger_id, webhookBaseUrl); openDialog(dialog); } res.setHeader('Content-Type', 'application/json'); return res.json({text: 'Action button dialog triggered!'}); } async function onOpenChildDialog(req, res) { const {body} = req; // context.source identifies which action button on the parent dialog was // pressed; it is forwarded by the server in the PostActionIntegrationRequest. const source = (body.context && body.context.source) || 'Unknown'; console.log('onOpenChildDialog called with trigger_id:', body.trigger_id, 'source:', source); if (body.trigger_id) { const childDialog = webhookUtils.getActionButtonChildDialog(body.trigger_id, webhookBaseUrl, source); // Await the dialog open before responding. The server's /execute call // (DoActionRequest) waits for this response, so awaiting here ensures the // child's WS open_dialog event is published before the browser's // executeDialogAction promise resolves — removing the render race in tests. await openDialog(childDialog); } res.setHeader('Content-Type', 'application/json'); return res.json({}); } function onFieldRefreshSource(req, res) { const {body} = req; const submission = body.submission || {}; const projectType = submission.project_type; const projectName = submission.project_name || ''; res.setHeader('Content-Type', 'application/json'); // Return updated form based on project type selection const elements = [ { display_name: 'Project Name', name: 'project_name', type: 'text', placeholder: 'Enter project name', default: projectName, optional: false, }, { display_name: 'Project Type', name: 'project_type', type: 'select', refresh: true, placeholder: 'Select project type...', default: projectType, options: [ {text: 'Web Application', value: 'web'}, {text: 'Mobile App', value: 'mobile'}, {text: 'API Service', value: 'api'}, ], }, ]; // Add different fields based on project type if (projectType === 'web') { elements.push({ display_name: 'Framework', name: 'framework', type: 'select', placeholder: 'Select framework...', options: [ {text: 'React', value: 'react'}, {text: 'Vue', value: 'vue'}, {text: 'Angular', value: 'angular'}, ], }); } else if (projectType === 'mobile') { elements.push({ display_name: 'Platform', name: 'platform', type: 'select', placeholder: 'Select platform...', options: [ {text: 'iOS', value: 'ios'}, {text: 'Android', value: 'android'}, {text: 'React Native', value: 'react-native'}, ], }); } else if (projectType === 'api') { elements.push({ display_name: 'Language', name: 'language', type: 'select', placeholder: 'Select language...', options: [ {text: 'Go', value: 'go'}, {text: 'Node.js', value: 'nodejs'}, {text: 'Python', value: 'python'}, ], }); } return res.json({ type: 'form', form: { title: 'Field Refresh Demo', introduction_text: 'Enter project name then select type to see different fields', submit_label: 'Submit', elements, }, }); }