Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Add notifications for mattermost plugin #10456

Merged
merged 3 commits into from
Nov 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,11 @@ REDIS_URL='redis://localhost:6379'
# STRIPE_SECRET_KEY=''
# STRIPE_PUBLISHABLE_KEY=''
# STRIPE_WEBHOOK_SECRET=''
# Disable the built in Mattermost webhook integration
# MATTERMOST_DISABLED='false'
# For private instances with SSO and the Mattermost plugin, set the secret and URL
# MATTERMOST_SECRET=''
# MATTERMOST_URL=''
# MSTEAMS_DISABLED='false'

# MAIL
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,17 @@ const notifyMattermost = async (
textOrAttachmentsArray: string | unknown[],
notificationText?: string
) => {
const {webhookUrl} = channel
if (!webhookUrl) {
const {webhookUrl, serverBaseUrl, sharedSecret} = channel
const notifyUrl = serverBaseUrl
? `${serverBaseUrl}/plugins/co.parabol.action/notify/${teamId}`
: webhookUrl
if (!notifyUrl) {
return 'success'
}
const manager = new MattermostServerManager(webhookUrl)
const manager = new MattermostServerManager(notifyUrl, sharedSecret ?? undefined)
const result = await manager.postMessage(textOrAttachmentsArray, notificationText)
if (result instanceof Error) {
sendToSentry(result, {userId: user.id, tags: {teamId, event, webhookUrl}})
sendToSentry(result, {userId: user.id, tags: {teamId, event, notifyUrl}})
return {
error: result
}
Expand Down Expand Up @@ -91,10 +94,13 @@ const makeEndMeetingButtons = (meeting: AnyMeeting) => {
}
}

type MattermostNotificationAuth = IntegrationProviderMattermost & {
type MattermostNotificationAuth = {
userId: string
teamId: string
channel: string | null
webhookUrl: string | null
serverBaseUrl: string | null
sharedSecret: string | null
}

const makeTeamPromptStartMeetingNotification = (
Expand Down Expand Up @@ -347,6 +353,19 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
})

async function getMattermost(dataLoader: DataLoaderWorker, teamId: string, userId: string) {
if (process.env.MATTERMOST_SECRET && process.env.MATTERMOST_URL) {
return [
MattermostNotificationHelper({
userId,
teamId,
serverBaseUrl: process.env.MATTERMOST_URL,
sharedSecret: process.env.MATTERMOST_SECRET,
channel: null,
webhookUrl: null
})
]
}

const auths = await dataLoader
.get('teamMemberIntegrationAuthsByTeamId')
.load({service: 'mattermost', teamId})
Expand Down
47 changes: 21 additions & 26 deletions packages/server/integrations/mattermost/mattermostWebhookHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ const markdownToDraftJS = (markdown: string) => {
return JSON.stringify(rawObject)
}

const gql = (strings: TemplateStringsArray) => strings.join('')

const eventLookup: Record<
string,
{
Expand All @@ -29,14 +31,15 @@ const eventLookup: Record<
}
> = {
meetingTemplates: {
query: `
query: gql`
query MeetingTemplates {
viewer {
availableTemplates(first: 2000) {
edges {
node {
id
name
bad
type
illustrationUrl
orgId
Expand All @@ -52,7 +55,7 @@ const eventLookup: Record<
retroSettings: meetingSettings(meetingType: retrospective) {
id
phaseTypes
...on RetrospectiveMeetingSettings {
... on RetrospectiveMeetingSettings {
disableAnonymity
}
}
Expand All @@ -77,11 +80,8 @@ const eventLookup: Record<
}
},
startRetrospective: {
query: `
mutation StartRetrospective(
$teamId: ID!
$templateId: ID!
) {
query: gql`
mutation StartRetrospective($teamId: ID!, $templateId: ID!) {
selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) {
meetingSettings {
id
Expand All @@ -98,7 +98,7 @@ const eventLookup: Record<
`
},
startCheckIn: {
query: `
query: gql`
mutation StartCheckIn($teamId: ID!) {
startCheckIn(teamId: $teamId) {
... on ErrorPayload {
Expand All @@ -116,11 +116,8 @@ const eventLookup: Record<
`
},
startSprintPoker: {
query: `
mutation StartSprintPokerMutation(
$teamId: ID!
$templateId: ID!
) {
query: gql`
mutation StartSprintPokerMutation($teamId: ID!, $templateId: ID!) {
selectTemplate(selectedTemplateId: $templateId, teamId: $teamId) {
meetingSettings {
id
Expand All @@ -142,17 +139,15 @@ const eventLookup: Record<
`
},
startTeamPrompt: {
query: `
mutation StartTeamPromptMutation(
$teamId: ID!
) {
query: gql`
mutation StartTeamPromptMutation($teamId: ID!) {
startTeamPrompt(teamId: $teamId) {
... on ErrorPayload {
error {
message
}
}
...on StartTeamPromptSuccess {
... on StartTeamPromptSuccess {
meeting {
id
}
Expand All @@ -162,14 +157,14 @@ const eventLookup: Record<
`
},
getMeetingSettings: {
query: `
query: gql`
query GetMeetingSettings($teamId: ID!, $meetingType: MeetingTypeEnum!) {
viewer {
team(teamId: $teamId) {
meetingSettings(meetingType: $meetingType) {
id
phaseTypes
...on RetrospectiveMeetingSettings {
... on RetrospectiveMeetingSettings {
disableAnonymity
}
}
Expand All @@ -188,7 +183,7 @@ const eventLookup: Record<
}
},
setMeetingSettings: {
query: `
query: gql`
mutation SetMeetingSettings(
$id: ID!
$checkinEnabled: Boolean
Expand All @@ -210,7 +205,7 @@ const eventLookup: Record<
}
}
}
`,
`,
convertResult: (data: any) => {
const {settings: meetingSettings} = data.setMeetingSettings
return {
Expand All @@ -222,7 +217,7 @@ const eventLookup: Record<
}
},
getActiveMeetings: {
query: `
query: gql`
query Meetings {
viewer {
teams {
Expand All @@ -231,9 +226,9 @@ const eventLookup: Record<
teamId
name
meetingType
...on RetrospectiveMeeting {
... on RetrospectiveMeeting {
phases {
...on ReflectPhase {
... on ReflectPhase {
reflectPrompts {
id
question
Expand Down Expand Up @@ -276,7 +271,7 @@ const eventLookup: Record<
}
}
},
query: `
query: gql`
mutation CreateReflectionMutation($input: CreateReflectionInput!) {
createReflection(input: $input) {
reflectionId
Expand Down
51 changes: 41 additions & 10 deletions packages/server/utils/MattermostServerManager.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {createSigner, httpbis} from 'http-message-signatures'
// Mattermost is a server-only integration for now, unlike the Slack integration

// We're following a similar manager pattern here should we wish to refactor the
Expand All @@ -15,11 +16,13 @@ export interface MattermostApiResponse {

abstract class MattermostManager {
webhookUrl: string
secret?: string
abstract fetch: typeof fetch
headers: any

constructor(webhookUrl: string) {
constructor(webhookUrl: string, secret?: string) {
this.webhookUrl = webhookUrl
this.secret = secret
}

private fetchWithTimeout = async (url: string, options: RequestInit) => {
Expand All @@ -41,13 +44,41 @@ abstract class MattermostManager {
// See: https://developers.mattermost.com/integrate/incoming-webhooks/

private async post(payload: any) {
const res = await this.fetchWithTimeout(this.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(payload)
const url = this.webhookUrl
const method = 'POST'
const body = JSON.stringify(payload)
const digestArray = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(body))
const digest = Array.from(new Uint8Array(digestArray))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
'Content-Digest': 'SHA-256=' + digest
} as Record<string, string>

if (this.secret) {
const key = createSigner(this.secret, 'hmac-sha256', 'foo')
const signedRequest = await httpbis.signMessage(
{
key,
name: 'parabol',
fields: ['@request-target', 'content-digest']
},
{
method,
url,
headers,
body
}
)
headers['Signature'] = signedRequest.headers['Signature']!
headers['Signature-Input'] = signedRequest.headers['Signature-Input']!
}
const res = await this.fetchWithTimeout(url, {
method,
headers,
body
})
if (res instanceof Error) return res
if (res.status !== 200) {
Expand Down Expand Up @@ -86,8 +117,8 @@ abstract class MattermostManager {

class MattermostServerManager extends MattermostManager {
fetch = fetch
constructor(webhookUrl: string) {
super(webhookUrl)
constructor(webhookUrl: string, secret?: string) {
super(webhookUrl, secret)
}
}

Expand Down
Loading