makeplane / plane

🔥 🔥 🔥 Open Source JIRA, Linear, Monday, and Asana Alternative. Plane helps you track your issues, epics, and product roadmaps in the simplest way possible.
http://plane.so
GNU Affero General Public License v3.0
29.02k stars 1.59k forks source link

[feature]: Discord webhook notifications #1183

Open Unyxos opened 1 year ago

Unyxos commented 1 year ago

Is there an existing issue for this?

Summary

Allow to receive projects' updates in a Discord server using a webhook integration

Why should this be worked on?

The idea would be to be able to setup a discord webhook to receive updates from projects in a discord server, similarly to how Gitlab does it when new changes happen in projects : https://docs.gitlab.com/ee/user/project/integrations/discord_notifications.html

It'd be helpful to "track" updates from other team members when using Discord as the team's discussion tool :)

Pdzly commented 1 year ago

Documentation to that: https://discord.com/developers/docs/resources/webhook

I could imagine that it could be used as general webhook system. ( That it isnt discord.com.... locked ) But its on the discords format. ( well known everywhere )

rhea0110 commented 1 year ago

Hey @Unyxos, thank you for raising the feature request. Our team will brainstorm around it, and we will keep you updated on any developments.

Thanks!

dongphuchaitrieu commented 9 months ago

Really need webhook to integrate Plane to my system.

allen-munsch commented 6 months ago

+1

allen-munsch commented 6 months ago

Here's a serverless framework cloudfunction function that i wrote, haven't tested it yet, but something like this please, like 200 events "free" per month, and x amount after per unit cost to run the cloud function, plus whatever your cut would be:

index.mjs:

'use strict';

import axios from 'axios';
import striptags from 'striptags';

// https://discord.com/developers/docs/resources/webhook#execute-webhook
// https://docs.plane.so/webhooks/introduction#how-webhook-works

// https://app.plane.so/<your-org>/projects/<project-id>/modules/<item-id>
// {
//   "event": "module",
//   "action": "create",
//   "webhook_id": "xyz",
//   "workspace_id": "blah",
//   "data": {
//     "id": "<item-id>",
//     "created_at": "2024-03-03T18:04:25.779869Z",
//     "updated_at": "2024-03-03T18:04:25.779883Z",
//     "name": "test webhook / module create",
//     "description": "",
//     "description_text": null,
//     "description_html": null,
//     "start_date": null,
//     "target_date": null,
//     "status": "backlog",
//     "view_props": {},
//     "sort_order": 15535,
//     "external_source": null,
//     "external_id": null,
//     "created_by": "<your-user-id>",
//     "updated_by": "<your-user-id>",
//     "project": "<project-id>",
//     "workspace": "blah",
//     "lead": null,
//     "members": []
//   }
// }

let ORG_NAME = 'xxx'
const discordWebhookId = process.env['DISCORD_WEBHOOK_ID'] || 'xxx';
const discordWebhookToken = process.env['DISCORD_WEBHOOK_ID'] || 'xxx';

export const http = async (request, response) => {
  console.log(JSON.stringify(request.body))
  console.log(JSON.stringify(request.headers))
  if (
    request.headers['x-plane-delivery']
    && request.headers['x-plane-event']
    && request.headers['x-plane-signature']
    && request.body.webhook_id === "xxx"
  ) {
    let {event, action, data} = request.body;
    if (action == 'create' || action == 'update') {
      let discordResponse = await sendPlaneEventToDiscord(event, action, data, discordWebhookId, discordWebhookToken);
    }
    response.status(200).send(request.body);  
  }
};

export const event = (event, callback) => {
  callback();
};

/**
 * Transforms a Plane.so webhook event to a Discord message format and sends it.
 * 
 * @param {string} planeEvent - The Plane.so webhook event: "issue", "module", "project", ???
 * @param {string} planeAction - The Plane.so webhook event object.
 * @param {object} planeData - The Plane.so webhook event object.
 * @param {string} discordWebhookId - The Discord webhook ID.
 * @param {string} discordWebhookToken - The Discord webhook token.
 */
async function sendPlaneEventToDiscord(planeEvent, planeAction, planeData, discordWebhookId, discordWebhookToken) {
    // Construct the Discord webhook URL
    const webhookUrl = `https://discord.com/api/webhooks/${discordWebhookId}/${discordWebhookToken}`;
    let url = ''
    if (planeEvent == 'issue' || planeEvent == 'module') {
      url = `https://app.plane.so/${ORG_NAME}/projects/${planeData.project}/${planeEvent}s/${planeData.id}`;
    } else if (planeEvent == 'issue_comment') {
      url = `https://app.plane.so/${ORG_NAME}/projects/${planeData.project}/issues/${planeData.issue}`;
    }
    let projects = {
      'e5e79a51xxx9': 'ACME',
      '05f9c563xxx9ac': 'project2',
    }
    let PROJECT = projects[planeData.project] || 'unknown';

    // Transform the Plane.so event to a Discord message format
    const discordMessage = {
        content: `${planeEvent} ${planeAction}`,
        embeds: [{
            title: `${planeEvent} ${planeAction}`,
            description: `Project **${PROJECT}**\n\n**Description:** ${planeData.description_stripped || planeData.name || striptags(planeData.comment_html) || 'No description provided.'}`,
            color: 3447003, // You can set a color code for the embed
            fields: [
                { name: "Updated At", value: planeData.updated_at || 'None', inline: true },
                { name: "Event ID", value: planeData.id  || 'None', inline: true },
                { name: "Url", value: url  || 'None', inline: true},
                { name: "User ID", value: planeData.updated_by  || 'None', inline: true},
            ]
        }],
    };

    try {
        // Make a POST request to the Discord webhook URL
        // console.log(`Send data to: ${webhookUrl} : ${JSON.stringify(discordMessage)}`)
        const response = await axios.post(webhookUrl, discordMessage);
        console.log('Message sent to Discord:', response.data);
        return response;
    } catch (error) {
        console.error('Error sending message to Discord:', error.response ? error.response.data : error.message);
    }
}

package.json:

{
  "name": "plane-to-discord-webhook",
  "version": "0.1.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "serverless.com",
  "license": "MIT",
  "devDependencies": {
    "serverless-google-cloudfunctions": "*"
  },
  "dependencies": {
    "axios": "^1.6.7"
  }
}

serverless.yml:

service: plane-to-discord-webhook

provider:
  name: google
  stage: dev
  runtime: nodejs20
  region: us-central1
  project: <your-google-project-id>
  # The GCF credentials can be a little tricky to set up. Luckily we've documented this for you here:
  # https://serverless.com/framework/docs/providers/google/guide/credentials/
  #
  # the path to the credentials file needs to be absolute
  credentials: ~/.gcloud/keyfile.json

frameworkVersion: '3'

plugins:
  - serverless-google-cloudfunctions

# needs more granular excluding in production as only the serverless provider npm
# package should be excluded (and not the whole node_modules directory)
package:
  exclude:
    - node_modules/**
    - .gitignore
    - .git/**

functions:
  exampleplanetodiscord:
    handler: http
    events:
      - http: path
  # NOTE: the following uses an "event" event (pubSub event in this case).
  # Please create the corresponding resources in the Google Cloud
  # before deploying this service through Serverless
  #second:
  #  handler: event
  #  events:
  #    - event:
  #        eventType: providers/cloud.pubsub/eventTypes/topic.publish
  #        resource: projects/*/topics/my-topic
# you can define resources, templates etc. the same way you would in a
# Google Cloud deployment configuration
#resources:
#  resources:
#    - type: storage.v1.bucket
#      name: my-serverless-service-bucket
#  imports:
#    - path: my_template.jinja
allen-munsch commented 6 months ago

Okay, tested it, Here it is in action:

2024-03-04_18-08

Notes:

1) unable to add credentials to the webhook, so the endpoints are unauthenticated and open to the public internet :(. 2) The id's being passed to the webhook don't make it very appealing, so have to hardcode usernames, etc in a object/dictionary 3) the views do not have a consistent url path scheme, so it's not possible to directly link issue comments 4) the webhooks do not have a manual retry mechanism for me to use, or response log to look at to debug it