Slack & ChatGPT Integration
Build your own ChatGPT OpenAI API for Slack App (Google Cloud Functions & Python)
ℹ️ℹ️ℹ️ The following content is translated by OpenAI.
Click here to view the original Chinese version. | 點此查看本文中文版
Slack & ChatGPT Integration
Build your own ChatGPT OpenAI API for Slack App (Google Cloud Functions & Python)
Background
Recently, we have been promoting the use of Generative AI within the team to enhance work efficiency. Initially, we aimed to achieve an AI Assistant (ChatGPT functionality) to reduce the time spent on daily data inquiries, organizing cumbersome data, and manual data processing, thereby improving productivity. The goal is for engineers, designers, PMs, marketers, etc., to be able to use it freely.
The simplest method would be to purchase the ChatGPT Team plan, which costs $25 per seat per year; however, since we are unsure about everyone’s usage frequency (volume) and hope to integrate with more external collaboration and development processes, we opted for the OpenAI API approach, encapsulating it through other services for team members to use.
You can generate an OpenAI API Key from this page. The Key does not correspond to a specific Model version; you need to specify the Model version to use when making requests, which will incur the corresponding Token costs.
We need a service that allows us to set our own OpenAI API Key and use that Key for ChatGPT-like functionality.
Whether it’s a Chrome Extension or a Slack App, it’s quite difficult to find services that allow you to set your own OpenAI API Key. Most services are designed to sell their own subscription plans, and allowing users to customize the API Key means they can’t make money and are essentially doing charity work.
[Chrome Extension] SidebarGPT
After installation, you can go to Settings -> General -> enter your OpenAI API Key.
You can directly call up the chat interface from the browser toolbar or side icon and use it immediately:
[Chrome Extension] OpenAI Translator
If you only need translation, you can use this one, which allows you to customize the OpenAI API Key for translation.
Additionally, it is an open-source project and also provides desktop applications for macOS/Windows:
The advantage of the Chrome Extension is that it is quick, simple, and convenient—just install and use it directly. The downside is that you need to provide the API Key to all members, making it difficult to control leakage issues, and using third-party services also makes it hard to guarantee everyone’s data security.
[Self-hosted] LibreChat
This is an OpenAI API Chat encapsulation service recommended by colleagues in the R&D department, providing authentication and almost replicating the ChatGPT interface, with features that are more powerful than ChatGPT in this open-source project.
You only need to set up the project, install Docker, configure the .env
, and start the Docker service to use it directly through the website.
I tried it out, and it’s simply flawless—it’s like a local version of the ChatGPT service. The only downside is that it requires server deployment; if there are no other considerations, you can directly use this open-source project.
Slack App
In fact, deploying the LibreChat service on a server already achieves the desired effect, but I had a sudden thought: wouldn’t it be more convenient to integrate it into daily tools? Additionally, the company’s server has strict permission settings, making it difficult to start services at will.
At that time, I didn’t think too much; I thought there should be many OpenAI API integration services for Slack Apps, so I just needed to find one and set it up. However, things turned out to be more complicated than expected.
A Google search only yielded one official press release from Slack x OpenAI dated March 2023, titled “Why we built the ChatGPT app for Slack,” along with some Beta images:
https://www.salesforce.com/news/stories/chatgpt-app-for-slack/
It looks like the functionality is very comprehensive and could greatly enhance work efficiency. However, as of January 2024, there has been no release news, and the Beta registration link provided at the end of the article has already expired, leaving no further updates. (Is Microsoft trying to let Teams support it first?)
[2024/02/14 Update]:
- According to Slack’s official news, it seems that the integration with ChatGPT (OpenAI) has likely been abandoned or restructured into Slack AI.
Slack Apps
Due to the lack of an official App, I turned to search for third-party developer Apps. After searching and trying several, I hit a wall; there weren’t many suitable Apps, and none offered the ability to customize the Key. Each was designed to sell services and make money.
Implementing ChatGPT OpenAI API for Slack App
Having some prior experience in developing Slack Apps, I decided to create one myself.
⚠️Disclaimer⚠️
This article demonstrates how to build a Slack App and quickly use Google Cloud Functions to meet the needs by connecting to the OpenAI API. There are many applications for Slack Apps, and everyone is free to explore.
⚠️⚠️ Google Cloud Functions, as a Function as a Service (FaaS), offers the advantages of convenience, speed, and free quotas. Once the program is written, it can be deployed and executed directly, with automatic scaling. The downside is that the service environment is controlled by GCP, and if the service is not called for a long time, it will enter a sleep mode. When called again, it will experience a Cold Start with longer response times. Additionally, it is more challenging to run multiple services that interact with each other.
For more extensive or high-demand usage, it is still recommended to set up a VM (App Engine) to run the service.
Final Result Image
The complete Cloud Functions Python code and Slack App settings are attached at the end of the article, so those who don’t want to read step by step can quickly check it out.
Step 1. Create a Slack App
Go to Slack App:
Click “Create New App”
Choose “From scratch”
Enter “App Name” and select the Workspace to add it to.
Once created, go to “OAuth & Permissions” to add the necessary permissions for the Bot.
Scroll down to find the “Scopes” section, click “Add an OAuth Scope,” and search to add the following permissions:
- chat:write
- im:history
- im:read
- im:write
After adding the Bot permissions, click on the left “Install App” -> “Install to Workspace”
If the Slack App adds any other permissions later, you will need to click “Reinstall” again for them to take effect.
But rest assured, the Bot Token will not change due to reinstallation.
After setting the Slack Bot Token permissions, go to “App Home”:
Scroll down to find the “Show Tabs” section, enable “Messages Tab” and “Allow users to send Slash commands and messages from the messages tab” (if this is not checked, you still won’t be able to send messages, and it will display “Sending messages to this app has been turned off.”)
Return to the Slack Workspace, press “Command+R” to refresh the page, and you will see the newly created Slack App and the message input box:
At this point, sending messages to the App does not have any functionality yet.
Enable Event Subscriptions
Next, we need to enable the event subscription feature for the Slack App, which will hit the API at the specified URL when designated events occur.
Add Google Cloud Functions
For the Request URL, Google Cloud Functions comes into play.
After setting up the project and billing information, click “Create Function”
Enter the project name for Function name, and select “Allow unauthenticated invocations” for Authentication, meaning that anyone with the URL can access it.
If you cannot create a Function or change Authentication, it means your GCP account does not have complete Google Cloud Functions permissions. You will need to ask your organization administrator to add Cloud Functions Admin permissions in addition to your original role to use it.
Runtime: Python 3.8 or higher
main.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import functions_framework
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# You can use print to log execution logs, which can be viewed in Logs
# For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
print(request_json)
# Due to the limitations of FAAS (Cloud Functions), if the service is not called for a long time, it will enter a cold start state upon the next call
# Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length, it may take nearly a minute to complete)
# If Slack does not receive a response within the time limit, it will consider the request lost and will call again
# This can cause duplicate requests and responses, so we can set the Response Headers to X-Slack-No-Retry: 1 to inform Slack that even if it does not receive a response within the time limit, it does not need to retry
headers = {'X-Slack-No-Retry':1}
# If this is a Slack Retry request... ignore it
if request_headers and 'X-Slack-Retry-Num' in request_headers:
return ('OK!', 200, headers)
# Slack App Event Subscriptions Verify
# https://api.slack.com/events/url_verification
if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
challenge = ""
if 'challenge' in request_json:
challenge = request_json['challenge']
return (challenge, 200, headers)
return ("Access Denied!", 400, headers)
requirements.txt
should include the following dependencies:
1
2
3
functions-framework==3.*
requests==2.31.0
openai==1.9.0
Currently, it doesn’t have much functionality; it just allows the Slack App to pass the Event Subscriptions verification. You can click “Deploy” to complete the first deployment.
⚠️If you are not familiar with the Cloud Functions editor, you can scroll down to the bottom of the article to see supplementary content.
After waiting for the deployment to complete (green check mark), copy the Cloud Functions URL:
Paste the Request URL back into the Slack App Enable Events.
If there are no issues, “Verified” will appear, completing the verification.
What we are doing here is responding to the verification request sent by Slack:
1
2
3
4
5
{
"token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
"type": "url_verification"
}
By responding with the content of the challenge
field, we can pass the verification.
After successful activation, scroll down to find the “Subscribe to bot events” section, click “Add Bot User Event,” and add the “message.im” permission.
After adding all the necessary permissions, click the “reinstall your app” link at the top to reinstall the Slack App to the Workspace, and the Slack App setup will be complete.
You can also go to “App Home” or “Basic Information” to customize the Slack App’s name and profile picture.
Basic Information
Step 2. Complete the Integration of OpenAI API and Slack App (Direct Messages)
First, we need to obtain the two essential keys: OPENAI API KEY
and Bot User OAuth Token
.
OPENAI API KEY
: OpenAI API key page.
Bot User OAuth Token
: OAuth Tokens for Your Workspace
Handling Direct Message (IM) Events & Integrating OpenAI API Responses
When a user sends a message to the Slack App, the following Event JSON Payload will be received:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
{
"token": "XXX",
"team_id": "XXX",
"context_team_id": "XXX",
"context_enterprise_id": null,
"api_app_id": "XXX",
"event": {
"client_msg_id": "XXX",
"type": "message",
"text": "Hello",
"user": "XXX",
"ts": "1707920753.115429",
"blocks": [
{
"type": "rich_text",
"block_id": "orfng",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "Hello"
}
]
}
]
}
],
"team": "XXX",
"channel": "XXX",
"event_ts": "1707920753.115429",
"channel_type": "im"
},
"type": "event_callback",
"event_id": "XXX",
"event_time": 1707920753,
"authorizations": [
{
"enterprise_id": null,
"team_id": "XXX",
"user_id": "XXX",
"is_bot": true,
"is_enterprise_install": false
}
],
"is_ext_shared_channel": false,
"event_context": "4-XXX"
}
Based on the above JSON Payload, we can enhance the Slack message to integrate with the OpenAI API and respond back to the Slack message.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
**Cloud Functions `main.py`:**
```python
import functions_framework
import requests
import asyncio
import json
import time
from openai import AsyncOpenAI
OPENAI_API_KEY = "OPENAI API KEY"
SLACK_BOT_TOKEN = "Bot User OAuth Token"
# The OPENAI API Model being used
# https://platform.openai.com/docs/models
OPENAI_MODEL = "gpt-4-1106-preview"
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# You can simply use print to log runtime logs, which can be viewed in Logs
# For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
print(request_json)
# Due to the limitations of FAAS (Cloud Functions), if the service is not called for a long time,
# subsequent calls will enter a cold start state, which may not respond within the 3 seconds required by Slack.
# Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length,
# it may take nearly a minute to complete).
# If Slack does not receive a response within the time limit, it considers the request lost and will call again.
# This can cause issues with duplicate requests and responses, so we can set the Response Headers to
# X-Slack-No-Retry: 1 to inform Slack that even if it does not receive a response within the time limit,
# it does not need to retry.
headers = {'X-Slack-No-Retry': 1}
# If this is a Slack Retry request... ignore it
if request_headers and 'X-Slack-Retry-Num' in request_headers:
return ('OK!', 200, headers)
# Slack App Event Subscriptions Verify
# https://api.slack.com/events/url_verification
if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
challenge = ""
if 'challenge' in request_json:
challenge = request_json['challenge']
return (challenge, 200, headers)
# Handle Event Subscriptions Events...
if request_json and 'event' in request_json and 'type' in request_json['event']:
# If the event source is the App and the App ID == Slack App ID, it means it is an event triggered by its own Slack App
# Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']:
return ('OK!', 200, headers)
# Event name, e.g., message (related to messages), app_mention (mentioned)....
eventType = request_json['event']['type']
# SubType, e.g., message_changed (edited message), message_deleted (deleted message)...
# New messages have no Sub Type
eventSubType = None
if 'subtype' in request_json['event']:
eventSubType = request_json['event']['subtype']
if eventType == 'message':
# Messages with Sub Type are all message edits, deletions, responses...
# Ignore and do not process
if eventSubType is not None:
return ("OK!", 200, headers)
# Sender of the event message
eventUser = request_json['event']['user']
# Channel of the event message
eventChannel = request_json['event']['channel']
# Content of the event message
eventText = request_json['event']['text']
# TS of the event message (message ID)
eventTS = request_json['event']['event_ts']
# TS of the parent message in the thread of the event message (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
return ("Access Denied!", 400, headers)
def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText):
# Set Custom instructions
# Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
messages = [
{"role": "system", "content": "I only understand Traditional Chinese and English."},
{"role": "system", "content": "I do not understand Simplified Chinese."},
{"role": "system", "content": "When I speak Chinese, I will respond in Traditional Chinese and must conform to commonly used phrases in Taiwan."},
{"role": "system", "content": "When I speak English, I will respond in English."},
{"role": "system", "content": "Do not respond with pleasantries."},
{"role": "system", "content": "There must be a space between Chinese and English. There must also be a space between Chinese characters and any other language characters, including numbers and emojis."},
{"role": "system", "content": "If you do not know the answer, or your knowledge is too outdated, please search online before responding."},
{"role": "system", "content": "I will tip you 200 USD if you answer well."}
]
messages.append({
"role": "user", "content": eventText
})
replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
async def openAIRequestAsync(eventChannel, eventTS, messages):
client = AsyncOpenAI(
api_key=OPENAI_API_KEY,
)
# Stream Response (chunked response)
stream = await client.chat.completions.create(
model=OPENAI_MODEL,
messages=messages,
stream=True,
)
result = ""
try:
debounceSlackUpdateTime = None
async for chunk in stream:
result += chunk.choices[0].delta.content or ""
# Update the message every 0.8 seconds to avoid frequent calls to the Slack Update message API, which could lead to failures or waste Cloud Functions request counts
if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
response = slackUpdateMessage(eventChannel, eventTS, None, result + "...")
debounceSlackUpdateTime = time.time()
except Exception as e:
print(e)
result += "...*[An error occurred]*"
slackUpdateMessage(eventChannel, eventTS, None, result)
### Slack ###
def slackUpdateMessage(channel, ts, metadata, text):
endpoint = "/chat.update"
payload = {
"channel": channel,
"ts": ts
}
if metadata is not None:
payload['metadata'] = metadata
payload['text'] = text
response = slackRequest(endpoint, "POST", payload)
return response
def slackRequestPostMessage(channel, target_ts, text):
endpoint = "/chat.postMessage"
payload = {
"channel": channel,
"text": text,
}
if target_ts is not None:
payload['thread_ts'] = target_ts
response = slackRequest(endpoint, "POST", payload)
if response is not None and 'ts' in response:
return response['ts']
return None
def slackRequest(endpoint, method, payload):
url = "https://slack.com/api" + endpoint
headers = {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
}
response = None
if method == "POST":
response = requests.post(url, headers=headers, data=json.dumps(payload))
elif method == "GET":
response = requests.post(url, headers=headers)
if response and response.status_code == 200:
result = response.json()
return result
else:
return None
Return to Slack to test:
Now you can conduct Q&A with ChatGPT and the OpenAI API.
Adding Stream Response Interruption Feature to Save Tokens
There are many ways to implement this. If a user inputs a new message while a response is still being generated in the same thread, the previous response can be interrupted, or a shortcut can be clicked to interrupt the response.
This article takes adding a “Message Interruption” shortcut as an example.
Regardless of the interruption method, the core principle is the same. Since we do not have a database to store the generated messages and their statuses, the implementation relies on the Slack message’s metadata field (which can store custom information within a specified message).
When we use the chat.update API Endpoint, if the call is successful, it will return the current message’s text content and metadata. Therefore, we add a check in the OpenAI API Stream -> Slack Update Message code to see if the metadata in the response indicates “interrupt.” If it does, we interrupt the OpenAI Stream Response.
First, we need to add a Slack App message Shortcut
Go to the Slack App management interface, find the “Interactivity & Shortcuts” section, click to enable it, and use the same Cloud Functions URL.
Click “Create New Shortcut” to add a new message Shortcut.
Select “On messages.”
- Name of the action:
Stop OpenAI API Response Generation
- Short Description:
Stop OpenAI API Response Generation
- Callback ID:
abort_openai_api
(customizable for program identification)
After clicking “Create,” remember to click “Save Changes” in the bottom right to save the settings.
Click the “reinstall your app” button at the top again for the changes to take effect.
Back in Slack, click the “…” in the upper right corner of the message to see the “Stop OpenAI API Response Generation” Shortcut (clicking it now will have no effect).
When a user presses the Shortcut on the message, it will send an Event JSON Payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
{
"type": "message_action",
"token": "XXXXXX",
"action_ts": "1706188005.387646",
"team": {
"id": "XXXXXX",
"domain": "XXXXXX-XXXXXX"
},
"user": {
"id": "XXXXXX",
"username": "zhgchgli",
"team_id": "XXXXXX",
"name": "zhgchgli"
},
"channel": {
"id": "XXXXXX",
"name": "directmessage"
},
"is_enterprise_install": false,
"enterprise": null,
"callback_id": "abort_openai_api",
"trigger_id": "XXXXXX",
"response_url": "https://hooks.slack.com/app/XXXXXX/XXXXXX/XXXXXX",
"message_ts": "1706178957.161109",
"message": {
"bot_id": "XXXXXX",
"type": "message",
"text": "The English translation of 高麗菜包 is \"cabbage wrap\". If you are using it as a dish name, it may sometimes be specified based on the contents of the dish, such as \"pork cabbage wrap\" or \"vegetable cabbage wrap.\"",
"user": "XXXXXX",
"ts": "1706178957.161109",
"app_id": "XXXXXX",
"blocks": [
{
"type": "rich_text",
"block_id": "eKgaG",
"elements": [
{
"type": "rich_text_section",
"elements": [
{
"type": "text",
"text": "The English translation of 高麗菜包 is \"cabbage wrap\". If you are using it as a dish name, it may sometimes be specified based on the contents of the dish, such as \"pork cabbage wrap\" or \"vegetable cabbage wrap.\""
}
]
}
]
}
],
"team": "XXXXXX",
"bot_profile": {
"id": "XXXXXX",
"deleted": false,
"name": "Rick C-137",
"updated": 1706001605,
"app_id": "XXXXXX",
"icons": {
"image_36": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_36.png",
"image_48": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_48.png",
"image_72": "https://avatars.slack-edge.com/2024-01-23/6517244582244_0c708dfa3f893c72d4c2_72.png"
},
"team_id": "XXXXXX"
},
"edited": {
"user": "XXXXXX",
"ts": "1706187989.000000"
},
"thread_ts": "1706178832.102439",
"parent_user_id": "XXXXXX"
}
}
Complete Cloud Functions main.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
import functions_framework
import requests
import asyncio
import json
import time
from openai import AsyncOpenAI
OPENAI_API_KEY = "OPENAI API KEY"
SLACK_BOT_TOKEN = "Bot User OAuth Token"
# The OPENAI API Model being used
# https://platform.openai.com/docs/models
OPENAI_MODEL = "gpt-4-1106-preview"
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# The Event for the Shortcut will be given from the post payload field
# https://api.slack.com/reference/interaction-payloads/shortcuts
payload = request.form.get('payload')
if payload is not None:
payload = json.loads(payload)
# You can simply use print to log runtime logs, which can be viewed in Logs
# For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
print(payload)
# Due to the limitations of FAAS (Cloud Functions), if the service is not called for a long time,
# subsequent calls will enter a cold start state, which may not respond within the 3 seconds required by Slack.
# Additionally, the OpenAI API request takes a certain amount of time to respond (depending on the response length,
# it may take nearly a minute to complete).
# If Slack does not receive a response within the time limit, it considers the request lost and will call again.
# This can cause issues with duplicate requests and responses, so we can set the Response Headers to
# X-Slack-No-Retry: 1 to inform Slack that even if it does not receive a response within the time limit,
# it does not need to retry.
headers = {'X-Slack-No-Retry': 1}
# If this is a Slack Retry request... ignore it
if request_headers and 'X-Slack-Retry-Num' in request_headers:
return ('OK!', 200, headers)
# Slack App Event Subscriptions Verify
# https://api.slack.com/events/url_verification
if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
challenge = ""
if 'challenge' in request_json:
challenge = request_json['challenge']
return (challenge, 200, headers)
# Handle Event Subscriptions Events...
if request_json and 'event' in request_json and 'type' in request_json['event']:
# If the event source is the App and the App ID == Slack App ID, it means it is an event triggered by its own Slack App
# Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
if 'api_app_id' in request_json and 'app_id' in request_json['event'] and request_json['api_app_id'] == request_json['event']['app_id']:
return ('OK!', 200, headers)
# Event name, e.g., message (related to messages), app_mention (mentioned)....
eventType = request_json['event']['type']
# SubType, e.g., message_changed (edited message), message_deleted (deleted message)...
# New messages have no Sub Type
eventSubType = None
if 'subtype' in request_json['event']:
eventSubType = request_json['event']['subtype']
if eventType == 'message':
# Messages with Sub Type are all message edits, deletions, responses...
# Ignore and do not process
if eventSubType is not None:
return ("OK!", 200, headers)
# Sender of the event message
eventUser = request_json['event']['user']
# Channel of the event message
eventChannel = request_json['event']['channel']
# Content of the event message
eventText = request_json['event']['text']
# TS of the event message (message ID)
eventTS = request_json['event']['event_ts']
# TS of the parent message in the thread of the event message (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
return ("Access Denied!", 400, headers)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
```markdown
# SubType, for example: message_changed (edit message), message_deleted (delete message)...
# New messages have no Sub Type
eventSubType = None
if 'subtype' in request_json['event']:
eventSubType = request_json['event']['subtype']
if eventType == 'message':
# Messages with Sub Type are all about editing, deleting, or being replied to...
# Ignore and do not process
if eventSubType is not None:
return ("OK!", 200, headers)
# Sender of the event message
eventUser = request_json['event']['user']
# Channel of the event message
eventChannel = request_json['event']['channel']
# Content of the event message
eventText = request_json['event']['text']
# TS of the event message (message ID)
eventTS = request_json['event']['event_ts']
# TS of the parent message in the thread (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
# Handle Shortcut
if payload and 'type' in payload:
payloadType = payload['type']
# If it is a message Shortcut
if payloadType == 'message_action':
print(payloadType)
callbackID = None
channel = None
ts = None
text = None
triggerID = None
if 'callback_id' in payload:
callbackID = payload['callback_id']
if 'channel' in payload:
channel = payload['channel']['id']
if 'message' in payload:
ts = payload['message']['ts']
text = payload['message']['text']
if 'trigger_id' in payload:
triggerID = payload['trigger_id']
if channel is not None and ts is not None and text is not None:
# If it is the Shortcut to stop OpenAI API responses
if callbackID == "abort_openai_api":
slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": {}}, text)
if triggerID is not None:
slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API responses!")
return ("OK!", 200, headers)
return ("OK!", 200, headers)
return ("Access Denied!", 400, headers)
def openAIRequest(eventChannel, eventTS, eventThreadTS, eventText):
# Set Custom instructions
# Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
messages = [
{"role": "system", "content": "I only understand Traditional Chinese and English."},
{"role": "system", "content": "I do not understand Simplified Chinese."},
{"role": "system", "content": "When I speak Chinese, I will respond in Traditional Chinese and must conform to commonly used Taiwanese expressions."},
{"role": "system", "content": "When I speak English, I will respond in English."},
{"role": "system", "content": "Do not respond with pleasantries."},
{"role": "system", "content": "There must be a space between Chinese and English. There must also be a space between Chinese characters and any other language characters, including numbers and emojis."},
{"role": "system", "content": "If you do not know the answer, or your knowledge is too outdated, please search online and then respond."},
{"role": "system", "content": "I will tip you 200 USD if you answer well."}
]
messages.append({
"role": "user", "content": eventText
})
replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
async def openAIRequestAsync(eventChannel, eventTS, messages):
client = AsyncOpenAI(
api_key=OPENAI_API_KEY,
)
# Stream Response
stream = await client.chat.completions.create(
model=OPENAI_MODEL,
messages=messages,
stream=True,
)
result = ""
try:
debounceSlackUpdateTime = None
async for chunk in stream:
result += chunk.choices[0].delta.content or ""
# Update the message every 0.8 seconds to avoid frequent calls to Slack Update message API, which may lead to failures or waste Cloud Functions request counts
if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
response = slackUpdateMessage(eventChannel, eventTS, None, result + "...")
debounceSlackUpdateTime = time.time()
# If the message has metadata & metadata event_type == aborted, it means this response has been marked as terminated by the user
if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == "aborted":
break
result += "...*[Terminated]*"
# Message has been deleted
elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
break
await stream.close()
except Exception as e:
print(e)
result += "...*[An error occurred]*"
slackUpdateMessage(eventChannel, eventTS, None, result)
### Slack ###
def slackOpenModal(trigger_id, callback_id, text):
slackRequest("/views.open", "POST", {
"trigger_id": trigger_id,
"view": {
"type": "modal",
"callback_id": callback_id,
"title": {
"type": "plain_text",
"text": "Prompt"
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": text
}
}
]
}
})
def slackUpdateMessage(channel, ts, metadata, text):
endpoint = "/chat.update"
payload = {
"channel": channel,
"ts": ts
}
if metadata is not None:
payload['metadata'] = metadata
payload['text'] = text
response = slackRequest(endpoint, "POST", payload)
return response
def slackRequestPostMessage(channel, target_ts, text):
endpoint = "/chat.postMessage"
payload = {
"channel": channel,
"text": text,
}
if target_ts is not None:
payload['thread_ts'] = target_ts
response = slackRequest(endpoint, "POST", payload)
if response is not None and 'ts' in response:
return response['ts']
return None
def slackRequest(endpoint, method, payload):
url = "https://slack.com/api" + endpoint
headers = {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
}
response = None
if method == "POST":
response = requests.post(url, headers=headers, data=json.dumps(payload))
elif method == "GET":
response = requests.post(url, headers=headers)
if response and response.status_code == 200:
result = response.json()
return result
else:
return None
Return to Slack to test:
Success! After we complete the Stop OpenAI API
Shortcut, the ongoing response will be terminated, and it will respond [Terminated].
Similarly, you can create a Shortcut to delete messages, implementing the deletion of messages sent by the Slack App.
Add Context Functionality in the Same Thread
If a new message is sent in the same thread, it can be treated as a follow-up question to the same issue. At this point, a feature can be added to supplement the new prompt with the previous conversation content.
Add slackGetReplies
& fill the content into the OpenAI API Prompt:
Enhance Cloud Functions main.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import functions_framework
import requests
import asyncio
import json
import time
from openai import AsyncOpenAI
OPENAI_API_KEY = "OPENAI API KEY"
SLACK_BOT_TOKEN = "Bot User OAuth Token"
# The OPENAI API Model used
# https://platform.openai.com/docs/models
OPENAI_MODEL = "gpt-4-1106-preview"
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# The Event of the Shortcut will be given from the post payload field
# https://api.slack.com/reference/interaction-payloads/shortcuts
payload = request.form.get('payload')
if payload is not None:
payload = json.loads(payload)
# You can simply use print to log the execution log, which can be viewed in Logs
# For advanced Logging Level usage, refer to: https://cloud.google.com/logging/docs/reference/libraries
print(payload)
# Due to the limitations of FAAS (Cloud Functions), if the service is not called for a long time, the next call may enter a cold start feature, which may not respond within Slack's specified 3 seconds
# Additionally, the request to the OpenAI API takes a certain amount of time (depending on the response length, it may take nearly a minute to finish)
# If Slack does not receive a response within the time limit, it considers the request lost, and Slack will call again
# This can cause duplicate requests and responses, so we can set the Response Headers to X-Slack-No-Retry: 1 to inform Slack that even if it does not receive a response within the time limit, it does not need to retry
headers = {'X-Slack-No-Retry': 1}
# If it is a Slack Retry request... ignore
if request_headers and 'X-Slack-Retry-Num' in request_headers:
return ('OK!', 200, headers)
# Slack App Event Subscriptions Verify
# https://api.slack.com/events/url_verification
if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
challenge = ""
if 'challenge' in request_json:
challenge = request_json['challenge']
return (challenge, 200, headers)
# Handle Event Subscriptions Events...
if request_json and 'event' in request_json and 'type' in request_json['event']:
apiAppID = None
if 'api_app_id' in request_json:
apiAppID = request_json['api_app_id']
# If the Event source is App and App ID == Slack App ID, it means it is an event triggered by its own Slack App
# Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']:
return ('OK!', 200, headers)
# Event name, for example: message (related to messages), app_mention (mentioned)....
eventType = request_json['event']['type']
# SubType, for example: message_changed (edit message), message_deleted (delete message)...
# New messages have no Sub Type
eventSubType = None
if 'subtype' in request_json['event']:
eventSubType = request_json['event']['subtype']
if eventType == 'message':
# Messages with Sub Type are all about editing, deleting, or being replied to...
# Ignore and do not process
if eventSubType is not None:
return ("OK!", 200, headers)
# Sender of the event message
eventUser = request_json['event']['user']
# Channel of the event message
eventChannel = request_json['event']['channel']
# Content of the event message
eventText = request_json['event']['text']
# TS of the event message (message ID)
eventTS = request_json['event']['event_ts']
# TS of the parent message in the thread (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
# Handle Shortcut (message)
if payload and 'type' in payload:
payloadType = payload['type']
# If it is a message Shortcut
if payloadType == 'message_action':
callbackID = None
channel = None
ts = None
text = None
triggerID = None
if 'callback_id' in payload:
callbackID = payload['callback_id']
if 'channel' in payload:
channel = payload['channel']['id']
if 'message' in payload:
ts = payload['message']['ts']
text = payload['message']['text']
if 'trigger_id' in payload:
triggerID = payload['trigger_id']
if channel is not None and ts is not None and text is not None:
# If it is the Shortcut to stop OpenAI API responses
if callbackID == "abort_openai_api":
slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": {}}, text)
if triggerID is not None:
slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API responses!")
return ("OK!", 200, headers)
return ("Access Denied!", 400, headers)
def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText):
# Set Custom instructions
# Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
messages = [
{"role": "system", "content": "I only understand Traditional Chinese and English."},
{"role": "system", "content": "I do not understand Simplified Chinese."},
{"role": "system", "content": "When I speak Chinese, I will respond in Traditional Chinese and must conform to commonly used Taiwanese expressions."},
{"role": "system", "content": "When I speak English, I will respond in English."},
{"role": "system", "content": "Do not respond with pleasantries."},
{"role": "system", "content": "There must be a space between Chinese and English. There must also be a space between Chinese characters and any other language characters, including numbers and emojis."},
{"role": "system", "content": "If you do not know the answer, or your knowledge is too outdated, please search online and then respond."},
{"role": "system", "content": "I will tip you 200 USD if you answer well."}
]
if eventThreadTS is not None:
threadMessages = slackGetReplies(eventTS, eventThreadTS)
if threadMessages is not None:
for threadMessage in threadMessages:
appID = None
if 'app_id' in threadMessage:
appID = threadMessage['app_id']
threadMessageText = threadMessage['text']
threadMessageTs = threadMessage['ts']
# If it is a Slack App (OpenAI API Response), mark it as assistant
if appID and appID == apiAppID:
messages.append({
"role": "assistant", "content": threadMessageText
})
else:
# User's message content is marked as user
messages.append({
"role": "user", "content": threadMessageText
})
messages.append({
"role": "user", "content": eventText
})
replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
async def openAIRequestAsync(eventChannel, eventTS, messages):
client = AsyncOpenAI(
api_key=OPENAI_API_KEY,
)
# Stream Response (Segmented Response)
stream = await client.chat.completions.create(
model=OPENAI_MODEL,
messages=messages,
stream=True,
)
result = ""
try:
debounceSlackUpdateTime = None
async for chunk in stream:
result += chunk.choices[0].delta.content or ""
# Update the message every 0.8 seconds to avoid frequent calls to the Slack Update Message API, which could lead to failures or waste Cloud Functions request counts
if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
debounceSlackUpdateTime = time.time()
# If the message has metadata & metadata event_type == aborted, it means this response has been marked as terminated by the user
if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == "aborted":
break
result += "...*[Terminated]*"
# The message has been deleted
elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
break
await stream.close()
except Exception as e:
print(e)
result += "...*[An error occurred]*"
slackUpdateMessage(eventChannel, eventTS, None, result)
### Slack ###
def slackGetReplies(channel, ts):
endpoint = "/conversations.replies?channel="+channel+"&ts="+ts
response = slackRequest(endpoint, "GET", None)
if response is not None and 'messages' in response:
return response['messages']
return None
def slackOpenModal(trigger_id, callback_id, text):
slackRequest("/views.open", "POST", {
"trigger_id": trigger_id,
"view": {
"type": "modal",
"callback_id": callback_id,
"title": {
"type": "plain_text",
"text": "Prompt"
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": text
}
}
]
}
})
def slackUpdateMessage(channel, ts, metadata, text):
endpoint = "/chat.update"
payload = {
"channel": channel,
"ts": ts
}
if metadata is not None:
payload['metadata'] = metadata
payload['text'] = text
response = slackRequest(endpoint, "POST", payload)
return response
def slackRequestPostMessage(channel, target_ts, text):
endpoint = "/chat.postMessage"
payload = {
"channel": channel,
"text": text,
}
if target_ts is not None:
payload['thread_ts'] = target_ts
response = slackRequest(endpoint, "POST", payload)
if response is not None and 'ts' in response:
return response['ts']
return None
def slackRequest(endpoint, method, payload):
url = "https://slack.com/api"+endpoint
headers = {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
}
response = None
if method == "POST":
response = requests.post(url, headers=headers, data=json.dumps(payload))
elif method == "GET":
response = requests.post(url, headers=headers)
if response and response.status_code == 200:
result = response.json()
return result
else:
return None
Return to Slack for testing:
- The left image shows a new conversation when a question is asked without providing context.
- The right image demonstrates that adding context allows for understanding the entire conversation scenario along with the new question.
Completed!
At this point, we have created our own ChatGPT (via OpenAI API) Slack App Bot.
You can also refer to the Slack API and OpenAI API Custom instructions to customize your Cloud Functions Python program according to your needs, such as training a channel specifically to answer team questions and find project documents, a channel dedicated to translations, or a channel focused on data analysis, etc.
Supplement
Mentioning the bot to answer questions outside of 1:1 messages
- You can mention the bot in any channel (the bot needs to be added to the channel) to ask it questions.
First, you need to add the app_mention
Event Subscription:
After adding, click “Save Changes” to save, then “reinstall your app” to complete the process.
In the main.py
program above, add a new Event Type check in the #Handle Event Subscriptions Events...
Code Block:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Mention Event (@SlackApp hello)
if eventType == 'app_mention':
# Sender of the event message
eventUser = request_json['event']['user']
# Channel of the event message
eventChannel = request_json['event']['channel']
# Content of the event message, removing the leading mention string <@SLACKAPPID>
eventText = re.sub(r"<@\w+>\W*", "", request_json['event']['text'])
# TS (message ID) of the event message
eventTS = request_json['event']['event_ts']
# TS (message ID) of the parent message in the thread of the event message
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
After deployment, it will be complete.
Deleting messages sent by the Slack App
You cannot directly delete messages sent by the Slack App on Slack. You can refer to the “Stop OpenAI API from generating responses” shortcut method to add a “Delete Message” shortcut.
In the Cloud Functions main.py
program:
In the # Handle Shortcut Code Block
, add a callback_id check that equals your defined “Delete Message” Shortcut Callback ID, and then pass the parameters into the following method to complete the deletion:
1
2
3
4
5
6
7
8
9
def slackDeleteMessage(channel, ts):
endpoint = "/chat.delete"
payload = {
"channel": channel,
"ts": ts
}
response = slackRequest(endpoint, "POST", payload)
return response
Slack App not responding
- Check if the Token is correct
- Check Cloud Functions Logs for errors
- Ensure Cloud Functions is fully deployed
- Make sure the Slack App is in the channel where you are asking (if not in a 1:1 conversation with the Slack App, you need to add the bot to the channel for it to work)
- Log the Slack API Response in the SlackRequest method
Cloud Functions Public URL not secure enough
- If you are concerned about the security of the Cloud Functions URL, you can add a query token for verification
1
2
3
4
5
6
7
8
9
10
SAFE_ACCESS_TOKEN = "nF4JwxfG9abqPZCJnBerwwhtodC28BuC"
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# Verify if the token parameter is valid
if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN):
return ('', 400, headers)
Cloud Functions Related Issues
Billing Method
Prices vary based on different regions, CPU, RAM, capacity, traffic, etc. Please refer to the official pricing table.
The free tier is as follows: (as of 2024/02/15)
1
2
3
4
5
6
7
8
9
10
Cloud Functions offers a permanent free plan for computing time resources,
which includes GB/second and GHz/second allocation methods. In addition to 2 million calls,
this free plan also provides 400,000 GB/seconds and 200,000 GHz/seconds of computing time,
as well as 5 GB of internet data transfer per month.
The usage quota for the free plan is calculated based on the equivalent dollar amount of the level 1 pricing mentioned above.
Regardless of whether the function execution region uses level 1 and/or level 2 pricing, the system will allocate the equivalent dollar amount to you.
However, when deducting the free plan quota, the system will base it on the level of the function execution region (level 1 or level 2).
Please note that even if you are using the free plan, you must have a valid billing account.
btw. The Slack App is free and does not require a Premium account to use.
Slack App response too slow, timing out
(Aside from the issue of slower responses during peak times for the OpenAI API), if it is a bottleneck in Cloud Functions, you can expand the settings on the first page of the Cloud Function editor:
You can adjust CPU, RAM, timeout duration, concurrent numbers, etc., to improve request processing speed.
*However, this may incur charges
Development Phase Testing & Debugging
Clicking “Test Function” will bring up the Cloud Shell window in the lower toolbar, waiting about 3–5 minutes (the first startup takes longer). After the build is complete and you agree to the following authorization:
Once you see “Function is ready to test,” you can click “Run Test” to execute the method for debugging.
You can use the “Triggering event” block on the right to input a JSON Body that will be passed into the request_json
parameter for testing, or directly modify the program to inject a test object for testing.
*Please note that Cloud Shell/Cloud Run may incur additional costs.
It is recommended to run a test before deployment to ensure that the build can succeed.
Build failed, and the code is missing, what to do?
If you accidentally write incorrect code that causes the Cloud Function Deploy Build to fail, an error message will appear. At this point, clicking “EDIT AND REDEPLOY” will return to the editor, but you will find that the code you just modified is gone!!!
Don’t worry, at this point, click on the left “Source Code” and select “Last Failed Deployment” to restore the code that just failed to build:
View runtime print
Logs
*Please note that Cloud Logging and Querying Logs may incur additional costs.
Final Code (Python 3.8)
Cloud Functions
main.py
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import functions_framework
import requests
import re
import asyncio
import json
import time
from openai import AsyncOpenAI
OPENAI_API_KEY = "OPENAI API KEY"
SLACK_BOT_TOKEN = "Bot User OAuth Token"
# Custom security verification token
# The URL must include the ?token=SAFE_ACCESS_TOKEN parameter to accept requests
SAFE_ACCESS_TOKEN = "nF4JwxfG9abqPZCJnBerwwhtodC28BuC"
# The OPENAI API Model being used
# https://platform.openai.com/docs/models
OPENAI_MODEL = "gpt-4-1106-preview"
@functions_framework.http
def hello_http(request):
request_json = request.get_json(silent=True)
request_args = request.args
request_headers = request.headers
# The event from the shortcut will be given from the post payload field
# https://api.slack.com/reference/interaction-payloads/shortcuts
payload = request.form.get('payload')
if payload is not None:
payload = json.loads(payload)
# You can use print to log runtime logs, which can be viewed in Logs
# For advanced logging levels, refer to: https://cloud.google.com/logging/docs/reference/libraries
# print(payload)
# Due to the limitations of FAAS (Cloud Functions), if the service is not called for a long time, the next call will enter a cold start feature, which may not respond within Slack's 3-second limit
# Additionally, the OpenAI API request to response takes a certain amount of time (depending on the response length, it may take close to a minute to finish)
# If Slack does not receive a response within the time limit, it considers the request lost, and Slack will repeat the call
# This can cause duplicate requests and responses, so we can set the Response Headers to X-Slack-No-Retry: 1 to inform Slack that even if it does not receive a response within the time limit, it does not need to retry
headers = {'X-Slack-No-Retry':1}
# Verify if the token parameter is valid
if not(request_args and 'token' in request_args and request_args['token'] == SAFE_ACCESS_TOKEN):
return ('', 400, headers)
# If it is a Slack Retry request... ignore
if request_headers and 'X-Slack-Retry-Num' in request_headers:
return ('OK!', 200, headers)
# Slack App Event Subscriptions Verify
# https://api.slack.com/events/url_verification
if request_json and 'type' in request_json and request_json['type'] == 'url_verification':
challenge = ""
if 'challenge' in request_json:
challenge = request_json['challenge']
return (challenge, 200, headers)
# Handle Event Subscriptions Events...
if request_json and 'event' in request_json and 'type' in request_json['event']:
apiAppID = None
if 'api_app_id' in request_json:
apiAppID = request_json['api_app_id']
# If the event source is the App and the App ID == Slack App ID, it means it is an event triggered by our own Slack App
# Ignore and do not process, otherwise it will fall into an infinite loop Slack App -> Cloud Functions -> Slack App -> Cloud Functions...
if 'app_id' in request_json['event'] and apiAppID == request_json['event']['app_id']:
return ('OK!', 200, headers)
# Event name, e.g., message (related to messages), app_mention (mentioned)....
eventType = request_json['event']['type']
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# SubType, for example: message_changed (edit message), message_deleted (delete message)...
# New messages have no Sub Type
eventSubType = None
if 'subtype' in request_json['event']:
eventSubType = request_json['event']['subtype']
# Message type Event
if eventType == 'message':
# All Sub Types are message edits, deletions, responses...
# Ignore and do not process
if eventSubType is not None:
return ("OK!", 200, headers)
# Event message sender
eventUser = request_json['event']['user']
# Event message channel
eventChannel = request_json['event']['channel']
# Event message content
eventText = request_json['event']['text']
# Event message TS (message ID)
eventTS = request_json['event']['event_ts']
# Event message thread parent message TS (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
# Mention type Event (@SlackApp hello)
if eventType == 'app_mention':
# Event message sender
eventUser = request_json['event']['user']
# Event message channel
eventChannel = request_json['event']['channel']
# Event message content, remove the leading mention string <@SLACKAPPID>
eventText = re.sub(r"<@\w+>\W*", "", request_json['event']['text'])
# Event message TS (message ID)
eventTS = request_json['event']['event_ts']
# Event message thread parent message TS (message ID)
# Only new messages in the thread will have this data
eventThreadTS = None
if 'thread_ts' in request_json['event']:
eventThreadTS = request_json['event']['thread_ts']
openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText)
return ("OK!", 200, headers)
# Handle Shortcut (message)
if payload and 'type' in payload:
payloadType = payload['type']
# If it is a message Shortcut
if payloadType == 'message_action':
callbackID = None
channel = None
ts = None
text = None
triggerID = None
if 'callback_id' in payload:
callbackID = payload['callback_id']
if 'channel' in payload:
channel = payload['channel']['id']
if 'message' in payload:
ts = payload['message']['ts']
text = payload['message']['text']
if 'trigger_id' in payload:
triggerID = payload['trigger_id']
if channel is not None and ts is not None and text is not None:
# If it is the Stop OpenAI API response Shortcut
if callbackID == "abort_openai_api":
slackUpdateMessage(channel, ts, {"event_type": "aborted", "event_payload": { }}, text)
if triggerID is not None:
slackOpenModal(triggerID, callbackID, "Successfully stopped OpenAI API response!")
return ("OK!", 200, headers)
# If it is to delete a message
if callbackID == "delete_message":
slackDeleteMessage(channel, ts)
if triggerID is not None:
slackOpenModal(triggerID, callbackID, "Successfully deleted Slack App message!")
return ("OK!", 200, headers)
return ("Access Denied!", 400, headers)
def openAIRequest(apiAppID, eventChannel, eventTS, eventThreadTS, eventText):
# Set Custom instructions
# Thanks to my colleague (https://twitter.com/je_suis_marku) for the support
messages = [
{"role": "system", "content": "I only understand Traditional Chinese and English."},
{"role": "system", "content": "I do not understand Simplified Chinese."},
{"role": "system", "content": "When I speak Chinese, I will respond in Traditional Chinese and must conform to commonly used Taiwanese expressions."},
{"role": "system", "content": "When I speak English, I will respond in English."},
{"role": "system", "content": "Do not respond with pleasantries."},
{"role": "system", "content": "There should be a space between Chinese and English. There should also be a space between Chinese characters and any other language characters, including numbers and emojis."},
{"role": "system", "content": "If you do not know the answer, or your knowledge is too outdated, please search online before responding."},
{"role": "system", "content": "I will tip you 200 USD if you answer well."}
]
if eventThreadTS is not None:
threadMessages = slackGetReplies(eventChannel, eventThreadTS)
if threadMessages is not None:
for threadMessage in threadMessages:
appID = None
if 'app_id' in threadMessage:
appID = threadMessage['app_id']
threadMessageText = threadMessage['text']
threadMessageTs = threadMessage['ts']
# If it is a Slack App (OpenAI API Response), mark it as assistant
if appID and appID == apiAppID:
messages.append({
"role": "assistant", "content": threadMessageText
})
else:
# User's message content is marked as user
messages.append({
"role": "user", "content": threadMessageText
})
messages.append({
"role": "user", "content": eventText
})
replyMessageTS = slackRequestPostMessage(eventChannel, eventTS, "Generating response...")
asyncio.run(openAIRequestAsync(eventChannel, replyMessageTS, messages))
async def openAIRequestAsync(eventChannel, eventTS, messages):
client = AsyncOpenAI(
api_key=OPENAI_API_KEY,
)
# Stream Response
stream = await client.chat.completions.create(
model=OPENAI_MODEL,
messages=messages,
stream=True,
)
result = ""
try:
debounceSlackUpdateTime = None
async for chunk in stream:
result += chunk.choices[0].delta.content or ""
# Update the message every 0.8 seconds to avoid frequent calls to Slack Update message API which may lead to failures or waste Cloud Functions request counts
if debounceSlackUpdateTime is None or time.time() - debounceSlackUpdateTime >= 0.8:
response = slackUpdateMessage(eventChannel, eventTS, None, result+"...")
debounceSlackUpdateTime = time.time()
# If the message has metadata & metadata event_type == aborted, it means this response has been marked as terminated by the user
if response and 'ok' in response and response['ok'] == True and 'message' in response and 'metadata' in response['message'] and 'event_type' in response['message']['metadata'] and response['message']['metadata']['event_type'] == "aborted":
break
result += "...*[Terminated]*"
# Message has been deleted
elif response and 'ok' in response and response['ok'] == False and 'error' in response and response['error'] == "message_not_found":
break
await stream.close()
except Exception as e:
print(e)
result += "...*[An error occurred]*"
slackUpdateMessage(eventChannel, eventTS, None, result)
### Slack ###
def slackGetReplies(channel, ts):
endpoint = "/conversations.replies?channel="+channel+"&ts="+ts
response = slackRequest(endpoint, "GET", None)
if response is not None and 'messages' in response:
return response['messages']
return None
def slackOpenModal(trigger_id, callback_id, text):
slackRequest("/views.open", "POST", {
"trigger_id": trigger_id,
"view": {
"type": "modal",
"callback_id": callback_id,
"title": {
"type": "plain_text",
"text": "Prompt"
},
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": text
}
}
]
}
})
def slackDeleteMessage(channel, ts):
endpoint = "/chat.delete"
payload = {
"channel": channel,
"ts": ts
}
response = slackRequest(endpoint, "POST", payload)
return response
def slackUpdateMessage(channel, ts, metadata, text):
endpoint = "/chat.update"
payload = {
"channel": channel,
"ts": ts
}
if metadata is not None:
payload['metadata'] = metadata
payload['text'] = text
response = slackRequest(endpoint, "POST", payload)
return response
def slackRequestPostMessage(channel, target_ts, text):
endpoint = "/chat.postMessage"
payload = {
"channel": channel,
"text": text,
}
if target_ts is not None:
payload['thread_ts'] = target_ts
response = slackRequest(endpoint, "POST", payload)
if response is not None and 'ts' in response:
return response['ts']
return None
def slackRequest(endpoint, method, payload):
url = "https://slack.com/api"+endpoint
headers = {
"Authorization": f"Bearer {SLACK_BOT_TOKEN}",
"Content-Type": "application/json",
}
response = None
if method == "POST":
response = requests.post(url, headers=headers, data=json.dumps(payload))
elif method == "GET":
response = requests.post(url, headers=headers)
if response and response.status_code == 200:
result = response.json()
return result
else:
return None
requirements.txt
:
1
2
3
functions-framework==3.*
requests==2.31.0
openai==1.9.0
Slack App Settings
OAuth & Permissions
- The grayed-out items for the delete button are permissions automatically added by Slack after adding the Shortcut.
Interactivity & Shortcuts
- Interactivity: Enable
- Request URL:
https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC
- Subscribe to bot events:
Interactivity & Shortcuts
- Interactivity: Enable
- Request URL:
https://us-central1-xxx-xxx.cloudfunctions.net/SlackBot-Rick-C-137?token=nF4JwxfG9abqPZCJnBerwwhtodC28BuC
- Shortcuts:
App Home
- Always Show My Bot as Online: Enable
- Messages Tab: Enable
- Allow users to send Slash commands and messages from the messages tab: ✅
Basic Information
Rick & Morty 🤘🤘🤘
Promotion Time
If you and your team have automation tools or process integration needs, whether it’s Slack App development, Notion, Asana, Google Sheets, Google Forms, GA data, or various integration needs, feel free to contact me for development.
If you have any questions or suggestions, please contact me.
```
This article was first published on Medium ➡️ Click Here
Automatically converted and synchronized using ZMediumToMarkdown and Medium-to-jekyll-starter.