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

Move discord webhook logic to seperate file #3636

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
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 functions/.secret.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1249350284187275274/zmFazwD4y2vun042qcDx4a_UhejAU-EPxkWVtJZwpiGj_f9JmUO-u_kJW2mCmmO1HGar
DISCORD_CHANNEL_ID=1249349026625880115
DISCORD_BOT_TOKEN=TVRJME9UTTBOams0TmpnMk9EWXdORGt6T1EuR25PZ1IzLnlOMTBLRFZBMmRLbjJlX1A4MXc4TXdrT2hQTHNRTmRBLTVGY29R
2 changes: 1 addition & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"shell": "yarn build && firebase functions:shell",
"test": "firebase emulators:exec --only functions,firestore,hosting,auth,database,pubsub,storage --project demo-community-platform-emulated 'jest . --forceExit --detectOpenHandles --coverage --reporters=default --reporters=jest-junit'",
"test-ci": "./node_modules/.bin/firebase emulators:exec --only functions,firestore,hosting,auth,database,pubsub,storage --project demo-community-platform-emulated 'yarn jest . --forceExit --detectOpenHandles --coverage --reporters=default --reporters=jest-junit'",
"test:watch": "firebase emulators:exec --only functions,firestore,hosting,auth,database,pubsub,storage --ui --project demo-community-platform-emulated 'jest . --watch --forceExit --detectOpenHandles'",
"test:watch": "firebase emulators:exec --only functions,firestore,hosting,auth,database,storage --ui --project demo-community-platform-emulated 'jest . --watch --forceExit --detectOpenHandles'",
"serve:email-templates": "node ./src/emailNotifications/development/server.mjs"
},
"main": "index.js",
Expand Down
2 changes: 1 addition & 1 deletion functions/scripts/runtimeConfig/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const runtimeConfigTest: configVars = {
},
service: null as any,
deployment: {
site_url: 'http://localhost:4000',
site_url: 'http://localhost:3000',
},
prerender: {
api_key: 'fake_prerender_key',
Expand Down
6 changes: 6 additions & 0 deletions functions/scripts/set-up-environment-variables.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import * as path from 'path'
import { FUNCTIONS_DIR } from './paths'
// load .secret.local variables to tested functions - replace Google Secret Manger
require('dotenv').config({
path: path.resolve(FUNCTIONS_DIR, '.secret.local'),
})
process.env.FUNCTIONS_EMULATOR = 'true'
process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:4005'
// https://github.com/firebase/firebase-admin-node/issues/116
Expand Down
123 changes: 46 additions & 77 deletions functions/src/Integrations/firebase-discord.ts
Original file line number Diff line number Diff line change
@@ -1,86 +1,55 @@
import { CONFIG } from '../config/config'
import * as functions from 'firebase-functions'
import axios, { AxiosResponse, AxiosError } from 'axios'
import { IMapPin } from '../models'
import { IModerationStatus } from 'oa-shared'

const SITE_URL = CONFIG.deployment.site_url
// e.g. https://dev.onearmy.world or https://community.preciousplastic.com

const DISCORD_WEBHOOK_URL = CONFIG.integrations.discord_webhook

export const notifyPinAccepted = functions
.runWith({ memory: '512MB' })
.firestore.document('v3_mappins/{pinId}')
.onUpdate(async (change, context) => {
const info = (change.after.data() as IMapPin) || null
const prevInfo = (change.before.data() as IMapPin) || null
const previouslyAccepted =
prevInfo?.moderation === IModerationStatus.ACCEPTED
const shouldNotify =
info.moderation === IModerationStatus.ACCEPTED && !previouslyAccepted
if (!shouldNotify) {
return null
}
const { _id, type } = info
await axios
.post(DISCORD_WEBHOOK_URL, {
content: `📍 *New ${type}* pin from ${_id}. \n Location here <${SITE_URL}/map/#${_id}>`,
})
.then(handleResponse, handleErr)
.catch(handleErr)
})

export const notifyHowToAccepted = functions
.runWith({ memory: '512MB' })
.firestore.document('v3_howtos/{id}')
.onUpdate(async (change, context) => {
const info = change.after.exists ? change.after.data() : null
const prevInfo = change.before.exists ? change.before.data() : null
const previouslyAccepted =
prevInfo?.moderation === IModerationStatus.ACCEPTED
const shouldNotify =
info.moderation === IModerationStatus.ACCEPTED && !previouslyAccepted
if (!shouldNotify) {
return null
}
const { _createdBy, title, slug } = info
await axios
.post(DISCORD_WEBHOOK_URL, {
content: `📓 Yeah! New How To **${title}** by *${_createdBy}*
check it out: <${SITE_URL}/how-to/${slug}>`,
})
.then(handleResponse, handleErr)
.catch(handleErr)
})

export const notifyAcceptedQuestion = functions
.runWith({ memory: '512MB' })
.firestore.document('questions_rev20230926/{id}')
// currently, questions are immediately posted with no review.
// if that changes, this code will need to be updated.
.onCreate(async (snapshot) => {
const info = snapshot.data()
console.log(info)

const username = info._createdBy
const title = info.title
const slug = info.slug
import { IDiscordWebhookPayload } from '../models'

const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL
const DISCORD_CHANNEL_ID = process.env.DISCORD_CHANNEL_ID
const DISCORD_BOT_TOKEN = Buffer.from(
process.env.DISCORD_BOT_TOKEN,
'base64',
).toString('ascii')

/**
* Sends a Discord notification using the provided payload to the configured webhook URL.
*
* @param payload The payload containing the message content, username, avatar, and other optional properties.
* @returns A promise that resolves when the notification is successfully sent or rejects with an error.
*/
export const sendDiscordNotification = async (
payload: IDiscordWebhookPayload,
) => {
await axios
.post(DISCORD_WEBHOOK_URL, payload)
.then(handleResponse, handleErr)
.catch(handleErr)
}

try {
const response = await axios.post(DISCORD_WEBHOOK_URL, {
content: `❓ ${username} has a new question: ${title}\nHelp them out and answer here: <${SITE_URL}/questions/${slug}>`,
})
handleResponse(response)
} catch (error) {
handleErr(error)
}
})
/**
* Retrieves latest Discord messages from a configured channel.
*
* @param limit The maximum number of messages to retrieve (default is 50)
* @returns An array of Discord messages from the specified channel
* @throws Error if there is an issue with the API request or response
*/
export const getDiscordMessages = async (limit = 50) => {
const res = await axios
.get(
`https://discord.com/api/channels/${DISCORD_CHANNEL_ID}/messages?limit=${limit}`,
{
headers: {
Authorization: `Bot ${DISCORD_BOT_TOKEN}`,
},
},
)
.then(handleResponse, handleErr)
.catch(handleErr)
const messages = res.data ?? []
return messages
}

const handleResponse = (res: AxiosResponse) => {
console.log('post success')
return res
}

const handleErr = (err: AxiosError) => {
console.error('error')
console.log(err)
Expand Down
7 changes: 0 additions & 7 deletions functions/src/Integrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
import * as IntegrationsSlack from './firebase-slack'
import * as IntegrationsDiscord from './firebase-discord'
import * as IntegrationsPatreon from './patreon'

exports.notifyNewPin = IntegrationsSlack.notifyNewPin
exports.notifyPinAccepted = IntegrationsDiscord.notifyPinAccepted

exports.notifyNewHowTo = IntegrationsSlack.notifyNewHowTo
exports.notifyHowToAccepted = IntegrationsDiscord.notifyHowToAccepted

exports.notifyAcceptedQuestion = IntegrationsDiscord.notifyAcceptedQuestion

exports.patreonAuth = IntegrationsPatreon.patreonAuth
101 changes: 101 additions & 0 deletions functions/src/howtoUpdates/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const admin = require('firebase-admin')

const test = require('firebase-functions-test')()

import { v4 as uuid } from 'uuid'
import { getDiscordMessages } from '../Integrations/firebase-discord'
import { DB_ENDPOINTS } from '../models'
import { howtoUpdate } from './index'
import { IModerationStatus } from 'oa-shared'

describe('howtoUpdates', () => {
afterAll(test.cleanup)

function stubbedHowtoSnapshot(howtoId, props) {
return test.firestore.makeDocumentSnapshot(
{
_id: howtoId,
...props,
},
DB_ENDPOINTS.howtos,
)
}

describe('updateDocuments', () => {
it('howto rejected', async () => {
// Arrange
const userId = uuid()
const howtoId = uuid()
const howtoTitle = 'Testing Howto'
const wrapped = test.wrap(howtoUpdate)

// Act
await wrapped(
await test.makeChange(
stubbedHowtoSnapshot(howtoId, {
_createdBy: userId,
title: howtoTitle,
slug: howtoId,
moderation: IModerationStatus.AWAITING_MODERATION,
}),
stubbedHowtoSnapshot(howtoId, {
_createdBy: userId,
title: howtoTitle,
slug: howtoId,
moderation: IModerationStatus.REJECTED,
}),
),
)

// Assert
const expectedMessageStart = `📓 Yeah! New How To **${howtoTitle}** by *${userId}*`
const expectedMessageEnd = `/how-to/${howtoId}>`
const discordMessages = await getDiscordMessages()
const containsTestMessage = discordMessages.some((message) => {
return (
message.content.startsWith(expectedMessageStart) &&
message.content.endsWith(expectedMessageEnd)
)
})
expect(containsTestMessage).not.toBe(true)
})

it('howto approved', async () => {
// Arrange
const userId = uuid()
const howtoId = uuid()
const howtoTitle = 'Testing Howto'
const wrapped = test.wrap(howtoUpdate)

// Act
await wrapped(
await test.makeChange(
stubbedHowtoSnapshot(howtoId, {
_createdBy: userId,
title: howtoTitle,
slug: howtoId,
moderation: IModerationStatus.AWAITING_MODERATION,
}),
stubbedHowtoSnapshot(howtoId, {
_createdBy: userId,
title: howtoTitle,
slug: howtoId,
moderation: IModerationStatus.ACCEPTED,
}),
),
)

// Assert
const expectedMessageStart = `📓 Yeah! New How To **${howtoTitle}** by *${userId}*`
const expectedMessageEnd = `/how-to/${howtoId}>`
const discordMessages = await getDiscordMessages()
const containsTestMessage = discordMessages.some((message) => {
return (
message.content.startsWith(expectedMessageStart) &&
message.content.endsWith(expectedMessageEnd)
)
})
expect(containsTestMessage).toBe(true)
})
})
})
33 changes: 33 additions & 0 deletions functions/src/howtoUpdates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as functions from 'firebase-functions'
import { IHowtoDB } from '../models'
import { IModerationStatus } from 'oa-shared'
import { sendDiscordNotification } from '../Integrations/firebase-discord'
import { CONFIG } from '../config/config'

const SITE_URL = CONFIG.deployment.site_url

export const howtoUpdate = functions
.runWith({
memory: '512MB',
secrets: ['DISCORD_WEBHOOK_URL', 'DISCORD_CHANNEL_ID', 'DISCORD_BOT_TOKEN'],
})
.firestore.document('v3_howtos/{id}')
.onUpdate(async (change, context) => {
const currentState = (change.after.data() as IHowtoDB) || null
const previousState = (change.before.data() as IHowtoDB) || null

if (shouldNotify(currentState, previousState)) {
const { _createdBy, title, slug } = currentState
const content = `📓 Yeah! New How To **${title}** by *${_createdBy}* \n check it out: <${SITE_URL}/how-to/${slug}>`
await sendDiscordNotification({ content })
}
})

const shouldNotify = (currentState: IHowtoDB, previousState: IHowtoDB) => {
const previouslyAccepted =
previousState?.moderation === IModerationStatus.ACCEPTED
return (
currentState.moderation === IModerationStatus.ACCEPTED &&
!previouslyAccepted
)
}
4 changes: 4 additions & 0 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import * as Admin from './admin'
import * as UserUpdates from './userUpdates'
import * as DiscussionUpdates from './discussionUpdates'
import * as QuestionUpdates from './questionUpdates'
import * as HowtoUpdates from './howtoUpdates'
import * as MapPinUpdates from './mapPinUpdates'
import * as Messages from './messages/messages'

// the following endpoints are exposed for use by various triggers
Expand All @@ -20,6 +22,8 @@ exports.database = require('./database')
exports.userUpdates = UserUpdates.handleUserUpdates

exports.discussionUpdates = DiscussionUpdates.handleDiscussionUpdate
exports.howtoUpdates = HowtoUpdates.howtoUpdate
exports.mapPinUpdates = MapPinUpdates.mapPinUpdate

exports.questionCreate = QuestionUpdates.handleQuestionCreate
exports.questionUpdate = QuestionUpdates.handleQuestionUpdate
Expand Down
Loading
Loading