Skip to content

Commit 7ec16f7

Browse files
committed
Feature: Support slack integration through Obot
Signed-off-by: Daishan Peng <[email protected]>
1 parent 59db081 commit 7ec16f7

18 files changed

+1129
-4
lines changed

apiclient/types/oauthapp.go

+2
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ type OAuthAppManifest struct {
4242
Global *bool `json:"global,omitempty"`
4343
// This field is only used by Salesforce
4444
InstanceURL string `json:"instanceURL,omitempty"`
45+
// SigningSecret is only used by Slack
46+
SigningSecret string `json:"signingSecret,omitempty"`
4547
}
4648

4749
type OAuthAppList List[OAuthApp]

apiclient/types/slacktrigger.go

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package types
2+
3+
type SlackTrigger struct {
4+
Metadata
5+
SlackTriggerManifest
6+
}
7+
8+
// SlackTriggerManifest defines the configuration for a Slack trigger
9+
type SlackTriggerManifest struct {
10+
// WorkflowName is the name of the workflow to trigger
11+
WorkflowName string `json:"workflowName"`
12+
13+
// TeamID is the Slack team/workspace ID
14+
TeamID string `json:"teamID"`
15+
16+
// AppID is the Slack app ID
17+
AppID string `json:"appID"`
18+
}
19+
20+
type SlackTriggerList List[SlackTrigger]

apiclient/types/zz_generated.deepcopy.go

+54
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/api/handlers/projects.go

+16
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,22 @@ func (h *ProjectsHandler) authenticate(req api.Context, local bool) (err error)
673673
return err
674674
}
675675

676+
var oauthApps v1.OAuthAppList
677+
if err := req.List(&oauthApps); err != nil {
678+
return err
679+
}
680+
681+
for _, app := range oauthApps.Items {
682+
if app.Spec.ThreadName == thread.Name {
683+
for i, existingApp := range agent.Spec.Manifest.OAuthApps {
684+
if existingApp == string(app.Spec.Manifest.Type) {
685+
agent.Spec.Manifest.OAuthApps[i] = app.Name
686+
break
687+
}
688+
}
689+
}
690+
}
691+
676692
credContext := thread.Name
677693
if local {
678694
credContext = thread.Name + "-local"

pkg/api/handlers/slack.go

+262
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
package handlers
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"strings"
9+
10+
"github.com/gptscript-ai/go-gptscript"
11+
"github.com/obot-platform/obot/apiclient/types"
12+
"github.com/obot-platform/obot/pkg/api"
13+
gatewayTypes "github.com/obot-platform/obot/pkg/gateway/types"
14+
v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1"
15+
apierrors "k8s.io/apimachinery/pkg/api/errors"
16+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17+
)
18+
19+
var (
20+
slackOAuthURL = "https://slack.com/oauth/v2/authorize"
21+
)
22+
23+
func (p *ProjectsHandler) Configure(req api.Context) error {
24+
thread, err := getThreadForScope(req)
25+
if err != nil {
26+
return err
27+
}
28+
29+
var (
30+
input struct {
31+
ClientID string `json:"clientID"`
32+
ClientSecret string `json:"clientSecret"`
33+
SigningSecret string `json:"signingSecret"`
34+
}
35+
)
36+
37+
if err := req.Read(&input); err != nil {
38+
return err
39+
}
40+
41+
// Create OAuth app manifest for Slack
42+
appManifest := &types.OAuthAppManifest{
43+
Type: types.OAuthAppTypeSlack,
44+
ClientID: input.ClientID,
45+
Alias: getOauthAppFromThreadName(thread.Name),
46+
}
47+
48+
if err := gatewayTypes.ValidateAndSetDefaultsOAuthAppManifest(appManifest, true); err != nil {
49+
return apierrors.NewBadRequest(fmt.Sprintf("invalid OAuth app: %s", err))
50+
}
51+
52+
// Create the OAuth app
53+
app := v1.OAuthApp{
54+
ObjectMeta: metav1.ObjectMeta{
55+
Name: getOauthAppFromThreadName(thread.Name),
56+
Namespace: req.Namespace(),
57+
},
58+
Spec: v1.OAuthAppSpec{
59+
Manifest: *appManifest,
60+
ThreadName: thread.Name,
61+
},
62+
}
63+
64+
if err := req.Create(&app); err != nil {
65+
if apierrors.IsAlreadyExists(err) {
66+
var existing v1.OAuthApp
67+
if err := req.Get(&existing, app.Name); err != nil {
68+
return err
69+
}
70+
existing.Spec.Manifest = *appManifest
71+
if err := req.Update(&app); err != nil {
72+
return err
73+
}
74+
} else {
75+
return err
76+
}
77+
}
78+
79+
// Store client secret as credential
80+
credential := gptscript.Credential{
81+
Context: app.Name,
82+
ToolName: appManifest.Alias,
83+
Type: gptscript.CredentialTypeTool,
84+
Env: map[string]string{
85+
"CLIENT_SECRET": input.ClientSecret,
86+
"SIGNING_SECRET": input.SigningSecret,
87+
},
88+
}
89+
90+
if err := req.GPTClient.CreateCredential(req.Context(), credential); err != nil {
91+
return err
92+
}
93+
94+
r := types.OAuthApp{
95+
OAuthAppManifest: types.OAuthAppManifest{Name: app.Name},
96+
}
97+
98+
return req.Write(r)
99+
}
100+
101+
func (p *ProjectsHandler) DeleteConfiguration(req api.Context) error {
102+
thread, err := getThreadForScope(req)
103+
if err != nil {
104+
return err
105+
}
106+
107+
if err := req.Delete(&v1.OAuthApp{
108+
ObjectMeta: metav1.ObjectMeta{
109+
Name: "slack-" + thread.Name,
110+
Namespace: req.Namespace(),
111+
},
112+
}); err != nil && !apierrors.IsNotFound(err) {
113+
return err
114+
}
115+
116+
thread.Status.SlackConfiguration = nil
117+
118+
if err := req.Storage.Status().Update(req.Context(), thread); err != nil {
119+
return err
120+
}
121+
122+
return req.Write(struct{}{})
123+
}
124+
125+
func (p *ProjectsHandler) SlackAuthorize(req api.Context) error {
126+
thread, err := getThreadForScope(req)
127+
if err != nil {
128+
return err
129+
}
130+
131+
var app v1.OAuthApp
132+
if err := req.Get(&app, getOauthAppFromThreadName(thread.Name)); err != nil {
133+
return err
134+
}
135+
136+
scopes := []string{
137+
"app_mentions:read",
138+
"channels:history",
139+
"channels:read",
140+
"chat:write",
141+
"files:read",
142+
"groups:history",
143+
"groups:read",
144+
"groups:write",
145+
"im:history",
146+
"im:read",
147+
"im:write",
148+
"mpim:history",
149+
"mpim:write",
150+
"team:read",
151+
"users:read",
152+
"assistant:write",
153+
}
154+
userScopes := []string{
155+
"channels:history",
156+
"groups:history",
157+
"im:history",
158+
"mpim:history",
159+
"channels:read",
160+
"files:read",
161+
"im:read",
162+
"search:read",
163+
"team:read",
164+
"users:read",
165+
"groups:read",
166+
"chat:write",
167+
"groups:write",
168+
"mpim:write",
169+
"im:write",
170+
}
171+
172+
// Construct the Slack OAuth URL
173+
redirectURL := fmt.Sprintf("%s?client_id=%s&scope=%s&user_scope=%s&redirect_uri=%s",
174+
slackOAuthURL,
175+
app.Spec.Manifest.ClientID,
176+
url.QueryEscape(strings.Join(scopes, ",")),
177+
url.QueryEscape(strings.Join(userScopes, ",")),
178+
url.QueryEscape(fmt.Sprintf("https://%s/api/slack/oauth/callback/%s", req.Host, app.Name)))
179+
180+
http.Redirect(req.ResponseWriter, req.Request, redirectURL, http.StatusFound)
181+
182+
return nil
183+
}
184+
185+
func (p *ProjectsHandler) SlackCallback(req api.Context) error {
186+
oauthAppID := req.PathValue("id")
187+
188+
var app v1.OAuthApp
189+
if err := req.Get(&app, oauthAppID); err != nil {
190+
return err
191+
}
192+
193+
var thread v1.Thread
194+
if err := req.Get(&thread, app.Spec.ThreadName); err != nil {
195+
return err
196+
}
197+
198+
code := req.Request.URL.Query().Get("code")
199+
if code == "" {
200+
return types.NewErrBadRequest("missing code parameter")
201+
}
202+
203+
// Get client secret from credentials
204+
cred, err := p.gptScript.RevealCredential(req.Context(), []string{app.Name}, app.Spec.Manifest.Alias)
205+
if err != nil {
206+
return types.NewErrBadRequest("failed to reveal credential: %s", err)
207+
}
208+
209+
clientSecret := cred.Env["CLIENT_SECRET"]
210+
211+
// Exchange code for access token
212+
data := url.Values{}
213+
data.Set("client_id", app.Spec.Manifest.ClientID)
214+
data.Set("client_secret", clientSecret)
215+
data.Set("code", code)
216+
data.Set("redirect_uri", fmt.Sprintf("https://%s/api/slack/oauth/callback/%s", req.Host, app.Name))
217+
218+
resp, err := http.PostForm("https://slack.com/api/oauth.v2.access", data)
219+
if err != nil {
220+
return err
221+
}
222+
defer resp.Body.Close()
223+
224+
var result struct {
225+
Ok bool `json:"ok"`
226+
AccessToken string `json:"access_token"`
227+
AppID string `json:"app_id"`
228+
Team struct {
229+
Name string `json:"name"`
230+
ID string `json:"id"`
231+
} `json:"team"`
232+
Error string `json:"error"`
233+
}
234+
235+
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
236+
return err
237+
}
238+
239+
if !result.Ok {
240+
return fmt.Errorf("slack oauth error: %s", result.Error)
241+
}
242+
243+
// Update thread with Slack configuration
244+
thread.Status.SlackConfiguration = &v1.SlackConfiguration{
245+
Teams: v1.SlackTeam{
246+
ID: result.Team.ID,
247+
Name: result.Team.Name,
248+
},
249+
AppID: result.AppID,
250+
}
251+
252+
if err := req.Storage.Status().Update(req.Context(), &thread); err != nil {
253+
return err
254+
}
255+
256+
http.Redirect(req.ResponseWriter, req.Request, fmt.Sprintf("https://%s/login_complete", req.Host), http.StatusTemporaryRedirect)
257+
return nil
258+
}
259+
260+
func getOauthAppFromThreadName(name string) string {
261+
return "slack-" + name
262+
}

0 commit comments

Comments
 (0)