0

Slack Events API handler (HTTP route template)

by
Published today

Template for an HTTP route trigger with Slack signature auth: answers Slack's url_verification challenge, then dispatches message, reaction_added, app_mention and member_joined_channel events, skipping bot echoes and timeout retries. Run it from a synchronous HTTP route, not by hand.

Script slack Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 2 hours ago
1
//native
2

3
// Template for receiving Slack Events API callbacks on a Windmill HTTP route.
4
//
5
// Setup:
6
// 1. Save this script, then add an HTTP route trigger on it: method POST, request type
7
//    Sync (Slack needs the challenge answered in the response), authentication
8
//    "Signature" with the Slack preset and your app's Signing Secret
9
//    (api.slack.com/apps → your app → Basic Information → App Credentials).
10
//    Windmill then rejects any request Slack did not sign; this script never sees it.
11
// 2. In the Slack app, open Event Subscriptions, turn it on and paste the route URL as
12
//    the Request URL. Slack sends a url_verification challenge, which main() answers.
13
// 3. Subscribe to bot events and add the scope each one needs, then reinstall the app:
14
//      message.channels       → channels:history   (the bot must be in the channel)
15
//      reaction_added         → reactions:read
16
//      app_mention            → app_mentions:read
17
//      member_joined_channel  → channels:read
18
//    Saving the events in the Slack UI adds missing scopes for you (it also adds
19
//    groups:read and mpim:read for member_joined_channel). Or paste this into the app manifest (App Manifest → YAML), merged with what is there:
20
//
21
//      oauth_config:
22
//        scopes:
23
//          bot:
24
//            - app_mentions:read
25
//            - channels:history
26
//            - channels:read
27
//            - reactions:read
28
//      settings:
29
//        event_subscriptions:
30
//          request_url: https://<your-windmill>/api/r/<route_path>
31
//          bot_events:
32
//            - app_mention
33
//            - member_joined_channel
34
//            - message.channels
35
//            - reaction_added
36
//
37
// Slack waits 3 seconds for a 2xx and retries up to 3 times otherwise. Keep the handlers
38
// below short, or hand long work to another job (wmill.runScriptByPathAsync) and return.
39
// This script is //native so it starts in milliseconds; that needs a native worker,
40
// which the default deployment includes.
41

42
// Runs before main() on trigger calls: keeps the body and the two retry headers.
43
export async function preprocessor(event: {
44
  kind: string
45
  body: { [key: string]: any }
46
  headers: { [key: string]: string }
47
}) {
48
  if (event.kind !== "http") {
49
    throw new Error(`Expected an HTTP route trigger, got ${event.kind}`)
50
  }
51
  return {
52
    body: event.body,
53
    retry_num: event.headers["x-slack-retry-num"] ?? "",
54
    retry_reason: event.headers["x-slack-retry-reason"] ?? "",
55
  }
56
}
57

58
/**
59
 * Slack Events API handler (HTTP route template)
60
 * Answers Slack's url_verification challenge, then dispatches message, reaction_added, app_mention and member_joined_channel events, skipping bot echoes and timeout retries.
61
 */
62
export async function main(
63
  body: { [key: string]: any },
64
  retry_num: string | undefined,
65
  retry_reason: string | undefined
66
) {
67
  if (body.type === "url_verification") {
68
    return { challenge: body.challenge }
69
  }
70

71
  // A timeout retry means the first delivery reached Windmill and its job is queued or
72
  // running, so handling it again would duplicate the work. Other retry reasons
73
  // (connection or HTTP errors) mean the first delivery was lost: let those through.
74
  if (retry_num && retry_reason === "http_timeout") {
75
    return { skipped: "retry", retry_num }
76
  }
77

78
  if (body.type !== "event_callback") {
79
    return { skipped: body.type }
80
  }
81

82
  const event = body.event
83
  // Messages posted through any app, this one included (even with a user token):
84
  // without this a bot that replies triggers itself in a loop.
85
  if (event.bot_id) {
86
    return { skipped: "bot" }
87
  }
88

89
  switch (event.type) {
90
    case "message":
91
      // Edits, deletions and joins arrive as message events with a subtype.
92
      if (event.subtype) {
93
        return { skipped: `message.${event.subtype}` }
94
      }
95
      return {
96
        type: "message",
97
        channel: event.channel,
98
        user: event.user,
99
        text: event.text,
100
        ts: event.ts,
101
        thread_ts: event.thread_ts,
102
      }
103
    case "app_mention":
104
      // With message.channels also subscribed, a mention arrives twice: once as
105
      // app_mention and once as message. React to one of them only.
106
      return {
107
        type: "app_mention",
108
        channel: event.channel,
109
        user: event.user,
110
        text: event.text,
111
        ts: event.ts,
112
        thread_ts: event.thread_ts,
113
      }
114
    case "reaction_added":
115
      return {
116
        type: "reaction_added",
117
        reaction: event.reaction,
118
        user: event.user,
119
        channel: event.item?.channel,
120
        ts: event.item?.ts,
121
      }
122
    case "member_joined_channel":
123
      return {
124
        type: "member_joined_channel",
125
        channel: event.channel,
126
        user: event.user,
127
        inviter: event.inviter,
128
      }
129
    default:
130
      return { skipped: event.type }
131
  }
132
}
133