Skip to content

Commit

Permalink
chore: Add Mattermost webhook handler (#10237)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dschoordsch authored Nov 4, 2024
1 parent 2870593 commit f50e32f
Show file tree
Hide file tree
Showing 8 changed files with 443 additions and 4 deletions.
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ REDIS_URL='redis://localhost:6379'
# STRIPE_PUBLISHABLE_KEY=''
# STRIPE_WEBHOOK_SECRET=''
# MATTERMOST_DISABLED='false'
# MATTERMOST_SECRET=''
# MSTEAMS_DISABLED='false'

# MAIL
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
The retro-specific meeting settings
The poker-specific meeting settings
"""
type PokerMeetingSettings implements TeamMeetingSettings {
id: ID!
Expand Down
384 changes: 384 additions & 0 deletions packages/server/integrations/mattermost/mattermostWebhookHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,384 @@
import {createVerifier, httpbis} from 'http-message-signatures'
import {markdownToDraft} from 'markdown-draft-js'
import {Variables} from 'relay-runtime'
import {HttpRequest, HttpResponse} from 'uWebSockets.js'
import AuthToken from '../../database/types/AuthToken'
import uWSAsyncHandler from '../../graphql/uWSAsyncHandler'
import parseBody from '../../parseBody'
import getKysely from '../../postgres/getKysely'
import getGraphQLExecutor from '../../utils/getGraphQLExecutor'
import sendToSentry from '../../utils/sendToSentry'

const MATTERMOST_SECRET = process.env.MATTERMOST_SECRET
const PORT = __PRODUCTION__ ? process.env.PORT : process.env.SOCKET_PORT
const HOST = process.env.HOST
const PORT_SUFFIX = HOST !== 'localhost' ? '' : `:${PORT}`
const ORIGIN = `${process.env.PROTO}://${HOST}${PORT_SUFFIX}`

const markdownToDraftJS = (markdown: string) => {
const rawObject = markdownToDraft(markdown)
return JSON.stringify(rawObject)
}

const eventLookup: Record<
string,
{
query: string
convertResult?: (data: any) => any
convertInput?: (input: any) => any
}
> = {
meetingTemplates: {
query: `
query MeetingTemplates {
viewer {
availableTemplates(first: 2000) {
edges {
node {
id
name
type
illustrationUrl
orgId
teamId
scope
}
}
}
teams {
id
name
orgId
retroSettings: meetingSettings(meetingType: retrospective) {
id
phaseTypes
...on RetrospectiveMeetingSettings {
disableAnonymity
}
}
pokerSettings: meetingSettings(meetingType: poker) {
id
phaseTypes
}
actionSettings: meetingSettings(meetingType: action) {
id
phaseTypes
}
}
}
}
`,
convertResult: (data: any) => {
const restructured = {
availableTemplates: data.viewer.availableTemplates.edges.map((edge: any) => edge.node),
teams: data.viewer.teams
}
return restructured
}
},
startRetrospective: {
query: `
mutation StartRetrospective(
$teamId: ID!
$templateId: ID!
) {
selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) {
meetingSettings {
id
}
}
startRetrospective(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
}
}
`
},
startCheckIn: {
query: `
mutation StartCheckIn($teamId: ID!) {
startCheckIn(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
... on StartCheckInSuccess {
meeting {
id
}
}
}
}
`
},
startSprintPoker: {
query: `
mutation StartSprintPokerMutation(
$teamId: ID!
$templateId: ID!
) {
selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) {
meetingSettings {
id
}
}
startSprintPoker(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
... on StartSprintPokerSuccess {
meeting {
id
}
}
}
}
`
},
startTeamPrompt: {
query: `
mutation StartTeamPromptMutation(
$teamId: ID!
) {
startTeamPrompt(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
...on StartTeamPromptSuccess {
meeting {
id
}
}
}
}
`
},
getMeetingSettings: {
query: `
query GetMeetingSettings($teamId: ID!, $meetingType: MeetingTypeEnum!) {
viewer {
team(teamId: $teamId) {
meetingSettings(meetingType: $meetingType) {
id
phaseTypes
...on RetrospectiveMeetingSettings {
disableAnonymity
}
}
}
}
}
`,
convertResult: (data: any) => {
const {meetingSettings} = data.viewer.team
return {
id: meetingSettings.id,
checkinEnabled: meetingSettings.phaseTypes.includes('checkin'),
teamHealthEnabled: meetingSettings.phaseTypes.includes('TEAM_HEALTH'),
disableAnonymity: meetingSettings.disableAnonymity
}
}
},
setMeetingSettings: {
query: `
mutation SetMeetingSettings(
$id: ID!
$checkinEnabled: Boolean
$teamHealthEnabled: Boolean
$disableAnonymity: Boolean
) {
setMeetingSettings(
settingsId: $id
checkinEnabled: $checkinEnabled
teamHealthEnabled: $teamHealthEnabled
disableAnonymity: $disableAnonymity
) {
settings {
id
phaseTypes
... on RetrospectiveMeetingSettings {
disableAnonymity
}
}
}
}
`,
convertResult: (data: any) => {
const {settings: meetingSettings} = data.setMeetingSettings
return {
id: meetingSettings.id,
checkinEnabled: meetingSettings.phaseTypes.includes('checkin'),
teamHealthEnabled: meetingSettings.phaseTypes.includes('TEAM_HEALTH'),
disableAnonymity: meetingSettings.disableAnonymity
}
}
},
getActiveMeetings: {
query: `
query Meetings {
viewer {
teams {
activeMeetings {
id
teamId
name
meetingType
...on RetrospectiveMeeting {
phases {
...on ReflectPhase {
reflectPrompts {
id
question
description
}
stages {
isComplete
}
}
}
templateId
}
}
}
}
}
`,
convertResult: (data: any) => {
const activeMeetings = data.viewer.teams.flatMap((team: any) => team.activeMeetings)
return activeMeetings.map((meeting: any) => {
const {phases, ...rest} = meeting
const reflectPhase = phases?.find((phase: any) => 'reflectPrompts' in phase)
if (!reflectPhase) return rest
const isComplete = !reflectPhase.stages.some((stage: any) => !stage.isComplete)
return {
...rest,
reflectPrompts: reflectPhase.reflectPrompts,
isComplete
}
})
}
},
createReflection: {
convertInput: (variables: any) => {
const {content, ...rest} = variables
return {
input: {
content: markdownToDraftJS(content),
...rest
}
}
},
query: `
mutation CreateReflectionMutation($input: CreateReflectionInput!) {
createReflection(input: $input) {
reflectionId
}
}
`
}
}

const publishWebhookGQL = async <NarrowResponse>(
query: string,
variables: Variables,
email: string
) => {
const pg = getKysely()
const user = await pg
.selectFrom('User')
.selectAll()
.where('email', '=', email)
.executeTakeFirstOrThrow()
try {
const authToken = new AuthToken({sub: user.id, tms: user.tms})
return await getGraphQLExecutor().publish<NarrowResponse>({
authToken,
query,
variables,
isPrivate: false
})
} catch (e) {
const error = e instanceof Error ? e : new Error('GQL executor failed to publish')
sendToSentry(error, {tags: {query: query.slice(0, 50)}})
return undefined
}
}

const mattermostWebhookHandler = uWSAsyncHandler(async (res: HttpResponse, req: HttpRequest) => {
if (!MATTERMOST_SECRET) {
res.writeStatus('404').end()
return
}
const headers = {
'content-type': req.getHeader('content-type'),
'content-digest': req.getHeader('content-digest'),
'content-length': req.getHeader('content-length'),
signature: req.getHeader('signature'),
'signature-input': req.getHeader('signature-input')
}

const verified = await httpbis.verifyMessage(
{
async keyLookup(_: any) {
// TODO When we support multiple Parabol - Mattermost connections, we should look up the key from IntegrationProvider
// const keyId = params.keyid;
return {
id: '',
algs: ['hmac-sha256'],
verify: createVerifier(MATTERMOST_SECRET, 'hmac-sha256')
}
}
},
{
method: req.getMethod(),
url: ORIGIN + req.getUrl(),
headers
}
)
if (!verified) {
res.writeStatus('401').end()
return
}

const body = await parseBody<{query: string; variables: Record<string, any>; email: string}>({
res
})

const {query, variables, email} = body ?? {}
if (!email) {
res.writeStatus('401').end()
return
}
if (!query) {
res.writeStatus('400').end()
return
}

const event = eventLookup[query as keyof typeof eventLookup]
if (!event) {
sendToSentry(new Error('Received unknown mattermost webhook event'), {tags: {query: query!}})
res.writeStatus('400').end()
return
}
const convertedInput = event.convertInput?.(variables) ?? variables
const result = await publishWebhookGQL<{data: any}>(event.query, convertedInput, email)
if (result?.data) {
const convertedResult = event.convertResult?.(result.data) ?? result.data
res
.writeStatus('200')
.writeHeader('Content-Type', 'application/json')
.end(JSON.stringify(convertedResult))
} else {
res.writeStatus('500').end()
}
})

export default mattermostWebhookHandler
Loading

0 comments on commit f50e32f

Please sign in to comment.