mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 00:02:12 +00:00
353 lines
11 KiB
Go
353 lines
11 KiB
Go
package slackapp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// interaction is an interactivity payload (block_actions, view_submission,
|
|
// message_action).
|
|
type interaction struct {
|
|
Type string `json:"type"`
|
|
TriggerID string `json:"trigger_id"`
|
|
CallbackID string `json:"callback_id"`
|
|
APIAppID string `json:"api_app_id"`
|
|
Team struct {
|
|
ID string `json:"id"`
|
|
} `json:"team"`
|
|
User struct {
|
|
ID string `json:"id"`
|
|
TeamID string `json:"team_id"`
|
|
} `json:"user"`
|
|
Channel struct {
|
|
ID string `json:"id"`
|
|
} `json:"channel"`
|
|
Container struct {
|
|
Type string `json:"type"`
|
|
ChannelID string `json:"channel_id"`
|
|
MessageTS string `json:"message_ts"`
|
|
} `json:"container"`
|
|
Message struct {
|
|
TS string `json:"ts"`
|
|
ThreadTS string `json:"thread_ts"`
|
|
Text string `json:"text"`
|
|
User string `json:"user"`
|
|
Blocks []Block `json:"blocks"`
|
|
} `json:"message"`
|
|
Actions []struct {
|
|
ActionID string `json:"action_id"`
|
|
Value string `json:"value"`
|
|
ActionTS string `json:"action_ts"`
|
|
} `json:"actions"`
|
|
View struct {
|
|
ID string `json:"id"`
|
|
CallbackID string `json:"callback_id"`
|
|
PrivateMetadata string `json:"private_metadata"`
|
|
State struct {
|
|
Values map[string]map[string]struct {
|
|
Value string `json:"value"`
|
|
} `json:"values"`
|
|
} `json:"state"`
|
|
} `json:"view"`
|
|
}
|
|
|
|
func (p *interaction) teamID() string {
|
|
if p.Team.ID != "" {
|
|
return p.Team.ID
|
|
}
|
|
return p.User.TeamID
|
|
}
|
|
|
|
func (p *interaction) channelID() string {
|
|
if p.Container.ChannelID != "" {
|
|
return p.Container.ChannelID
|
|
}
|
|
return p.Channel.ID
|
|
}
|
|
|
|
// value reads one plain_text_input from a submitted view.
|
|
func (p *interaction) value(block string) string {
|
|
return strings.TrimSpace(p.View.State.Values[block][block].Value)
|
|
}
|
|
|
|
var errBadPayload = errors.New("slack: unreadable interactivity payload")
|
|
|
|
// HandleInteractivity answers a verified interactivity request. A view
|
|
// submission is answered synchronously (Slack needs errors or "clear");
|
|
// everything else is acknowledged and handled in the background.
|
|
func (s *Service) HandleInteractivity(ctx context.Context, body []byte) (any, error) {
|
|
form, err := url.ParseQuery(string(body))
|
|
if err != nil {
|
|
return nil, errBadPayload
|
|
}
|
|
var p interaction
|
|
if err := json.Unmarshal([]byte(form.Get("payload")), &p); err != nil {
|
|
return nil, errBadPayload
|
|
}
|
|
switch p.Type {
|
|
case "view_submission":
|
|
return s.handleViewSubmission(ctx, &p), nil
|
|
case "block_actions":
|
|
if len(p.Actions) == 0 {
|
|
return nil, nil
|
|
}
|
|
s.spawn("slack_action", agentRunTimeout, func(ctx context.Context) {
|
|
s.handleBlockAction(ctx, &p)
|
|
})
|
|
case "message_action":
|
|
if p.CallbackID == CallbackMessageShortcut {
|
|
s.spawn("slack_message_shortcut", agentRunTimeout, func(ctx context.Context) {
|
|
s.askAboutMessage(ctx, &p)
|
|
})
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *Service) handleBlockAction(ctx context.Context, p *interaction) {
|
|
act := p.Actions[0]
|
|
switch {
|
|
case act.ActionID == ActionApprove:
|
|
s.handleApproval(ctx, p, act.Value, decisionApprove)
|
|
case act.ActionID == ActionDeny:
|
|
s.handleApproval(ctx, p, act.Value, decisionDeny)
|
|
case act.ActionID == ActionAlwaysAllow:
|
|
s.handleApproval(ctx, p, act.Value, decisionAlways)
|
|
case act.ActionID == ActionDraftReply:
|
|
s.handleNotificationDraft(ctx, p, act.Value, act.ActionTS)
|
|
case act.ActionID == ActionInboxReply || act.ActionID == ActionInboxReview:
|
|
s.openReplyModal(ctx, p, act.Value)
|
|
case act.ActionID == ActionInboxDraft:
|
|
s.inboxDraft(ctx, p, act.Value, act.ActionTS)
|
|
case act.ActionID == ActionInboxInterested:
|
|
s.inboxInterest(ctx, p, act.Value, true)
|
|
case act.ActionID == ActionInboxNotInterested:
|
|
s.inboxInterest(ctx, p, act.Value, false)
|
|
case act.ActionID == ActionInboxAssign:
|
|
s.inboxAssign(ctx, p, act.Value)
|
|
case act.ActionID == ActionHomeUnlink:
|
|
s.homeUnlink(ctx, p)
|
|
}
|
|
}
|
|
|
|
// whisper answers the clicker privately where they clicked; in the App Home,
|
|
// where there is no channel, it does nothing.
|
|
func (s *Service) whisper(ctx context.Context, token string, p *interaction, text string) {
|
|
channel := p.channelID()
|
|
if channel == "" || token == "" {
|
|
return
|
|
}
|
|
m := plainMessage(text)
|
|
m.Channel, m.User = channel, p.User.ID
|
|
if p.Message.ThreadTS != "" {
|
|
m.ThreadTS = p.Message.ThreadTS
|
|
}
|
|
if err := s.client.PostEphemeral(ctx, token, m); err != nil {
|
|
log.Warn().Err(err).Msg("slack: ephemeral reply failed")
|
|
}
|
|
}
|
|
|
|
// requireLinked resolves the clicker and answers for them when they cannot act.
|
|
func (s *Service) requireLinked(ctx context.Context, p *interaction) *actor {
|
|
a := s.resolveActor(ctx, p.teamID(), p.User.ID)
|
|
if a == nil {
|
|
return nil
|
|
}
|
|
if a.link == nil {
|
|
channel := p.channelID()
|
|
dm := strings.HasPrefix(channel, "D")
|
|
if channel == "" {
|
|
dmChannel, err := s.client.OpenDM(ctx, a.token, a.userID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
channel, dm = dmChannel, true
|
|
}
|
|
s.promptLink(ctx, a, channel, p.Message.ThreadTS, dm)
|
|
return nil
|
|
}
|
|
return a
|
|
}
|
|
|
|
// handleApproval resumes a paused run; only the session owner's linked Slack
|
|
// member may decide, and each card is decided once.
|
|
func (s *Service) handleApproval(ctx context.Context, p *interaction, value, decision string) {
|
|
rowID, err := uuid.Parse(value)
|
|
if err != nil {
|
|
return
|
|
}
|
|
a := s.requireLinked(ctx, p)
|
|
if a == nil {
|
|
return
|
|
}
|
|
row, err := s.repo.GetAgentThreadByID(ctx, rowID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if row == nil || row.OrganizationID != a.link.OrganizationID || row.UserID != a.link.UserID {
|
|
s.whisper(ctx, a.token, p, "Only the person who asked can approve this.")
|
|
return
|
|
}
|
|
token, err := s.integ.SlackBotToken(ctx, row.OrganizationID, row.ConnectionID)
|
|
if err != nil {
|
|
return
|
|
}
|
|
cardTS := p.Container.MessageTS
|
|
if row.ApprovalMessageTS == "" || row.ApprovalMessageTS != cardTS ||
|
|
!s.guard.first(ctx, "slack:approval:"+row.ID.String()+":"+cardTS, time.Hour) {
|
|
_ = s.client.UpdateMessage(ctx, token, Message{
|
|
Channel: row.ChannelID, TS: cardTS, Text: "No longer waiting",
|
|
Blocks: blocks(contextBlock("This request is no longer waiting for approval.")),
|
|
})
|
|
return
|
|
}
|
|
verb := map[string]string{decisionApprove: "Approved", decisionDeny: "Denied", decisionAlways: "Always allowed"}[decision]
|
|
_ = s.client.UpdateMessage(ctx, token, Message{
|
|
Channel: row.ChannelID, TS: cardTS, Text: verb,
|
|
Blocks: blocks(contextBlock(verb + " by <@" + p.User.ID + ">")),
|
|
})
|
|
_ = s.repo.SetAgentThreadApproval(ctx, row.OrganizationID, row.ID, "")
|
|
s.resumeTurn(ctx, token, row, invocation(a.member, a.link), decision)
|
|
}
|
|
|
|
// channelRefusal applies workspace settings and Slack Connect to an explicit
|
|
// request in a channel.
|
|
func (s *Service) channelRefusal(ctx context.Context, a *actor, channel string) string {
|
|
dm := strings.HasPrefix(channel, "D")
|
|
ext := false
|
|
if !dm {
|
|
if info, err := s.client.ConversationInfo(ctx, a.token, channel); err == nil {
|
|
ext = info.IsExtShared
|
|
}
|
|
}
|
|
r := decideRoute(routeFacts{DM: dm, Mention: true, ExtShared: ext, Settings: settingsFrom(a.conn)})
|
|
if r == routeAgent {
|
|
return ""
|
|
}
|
|
return refusalText(r)
|
|
}
|
|
|
|
// handleNotificationDraft starts the assistant under a reply notification for
|
|
// the clicker, to read the inbound thread and draft (never send) a reply.
|
|
func (s *Service) handleNotificationDraft(ctx context.Context, p *interaction, value, actionTS string) {
|
|
var v draftReplyValue
|
|
if json.Unmarshal([]byte(value), &v) != nil || (v.EmailID == "" && v.ThreadID == "") {
|
|
return
|
|
}
|
|
a := s.requireLinked(ctx, p)
|
|
if a == nil {
|
|
return
|
|
}
|
|
channel, ts := p.channelID(), p.Container.MessageTS
|
|
if refusal := s.channelRefusal(ctx, a, channel); refusal != "" {
|
|
s.whisper(ctx, a.token, p, refusal)
|
|
return
|
|
}
|
|
ref, _ := json.Marshal(map[string]string{"thread_id": v.ThreadID, "message_id": v.EmailID})
|
|
text := "A reply just arrived in the unified inbox (" + string(ref) + "). Read the conversation with get_thread, " +
|
|
"then write a reply draft with the draft_reply tool. Do not send anything. Show me the draft."
|
|
s.runTurn(ctx, agentTurn{
|
|
conn: a.conn, token: a.token, link: a.link, inv: invocation(a.member, a.link),
|
|
channel: channel, threadTS: ts, messageID: "slack:action:" + ts + ":" + actionTS,
|
|
text: text, dm: strings.HasPrefix(channel, "D"), reassign: true,
|
|
})
|
|
}
|
|
|
|
// askAboutMessage starts the assistant about one message: in its thread, or
|
|
// in the member's DM when the bot cannot answer where the message is.
|
|
func (s *Service) askAboutMessage(ctx context.Context, p *interaction) {
|
|
a := s.requireLinked(ctx, p)
|
|
if a == nil {
|
|
return
|
|
}
|
|
channel, msgTS := p.channelID(), p.Message.TS
|
|
threadTS := p.Message.ThreadTS
|
|
if threadTS == "" {
|
|
threadTS = msgTS
|
|
}
|
|
st := settingsFrom(a.conn)
|
|
if st.AssistantDisabled {
|
|
s.whisper(ctx, a.token, p, refusalText(routeRefuseDisabled))
|
|
return
|
|
}
|
|
info, err := s.client.ConversationInfo(ctx, a.token, channel)
|
|
useDM := err != nil || info.IsIM || info.IsMpIM || (info.IsPrivate && !info.IsMember) || st.AssistantDMOnly
|
|
if err == nil && info.IsExtShared {
|
|
s.whisper(ctx, a.token, p, refusalText(routeRefuseExternal))
|
|
return
|
|
}
|
|
|
|
quoted := formatThreadContext([]slackMessage{{User: p.Message.User, Text: p.Message.Text, TS: msgTS}}, "")
|
|
if !useDM && p.Message.ThreadTS != "" {
|
|
if c := s.threadContext(ctx, a.token, channel, threadTS, ""); c != "" {
|
|
quoted = c
|
|
}
|
|
}
|
|
question := quoted + "Help me with the quoted Slack message: summarize what it says and suggest what to do next in Warmbly."
|
|
turn := agentTurn{
|
|
conn: a.conn, token: a.token, link: a.link, inv: invocation(a.member, a.link),
|
|
channel: channel, threadTS: threadTS, messageID: "slack:shortcut:" + p.TriggerID, text: question,
|
|
}
|
|
if useDM {
|
|
dm, ts, err := s.startDMThread(ctx, a, "You asked about a message. Here is what I found.")
|
|
if err != nil {
|
|
return
|
|
}
|
|
turn.channel, turn.threadTS, turn.dm = dm, ts, true
|
|
}
|
|
s.runTurn(ctx, turn)
|
|
}
|
|
|
|
// startDMThread posts a header in the member's DM with the bot and returns
|
|
// its channel and ts, so a run can answer under it.
|
|
func (s *Service) startDMThread(ctx context.Context, a *actor, header string) (string, string, error) {
|
|
dm, err := s.client.OpenDM(ctx, a.token, a.userID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
m := plainMessage(header)
|
|
m.Channel = dm
|
|
ts, err := s.client.PostMessage(ctx, a.token, m)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return dm, ts, nil
|
|
}
|
|
|
|
// handleViewSubmission answers a modal submit with errors or "clear".
|
|
func (s *Service) handleViewSubmission(ctx context.Context, p *interaction) any {
|
|
switch p.View.CallbackID {
|
|
case CallbackInboxReplyModal:
|
|
return s.submitReply(ctx, p)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func viewErrors(block, msg string) map[string]any {
|
|
return map[string]any{"response_action": "errors", "errors": map[string]string{block: msg}}
|
|
}
|
|
|
|
func viewClear() map[string]any {
|
|
return map[string]any{"response_action": "clear"}
|
|
}
|
|
|
|
// homeUnlink removes the clicker's own link from the App Home.
|
|
func (s *Service) homeUnlink(ctx context.Context, p *interaction) {
|
|
link, err := s.repo.GetLinkBySlackUser(ctx, p.teamID(), p.User.ID)
|
|
if err != nil || link == nil {
|
|
return
|
|
}
|
|
if _, err := s.repo.DeleteLinkBySlackUser(ctx, link.OrganizationID, link.SlackTeamID, link.SlackUserID); err != nil {
|
|
return
|
|
}
|
|
s.auditUnlink(ctx, link)
|
|
s.publishHome(ctx, p.teamID(), p.APIAppID, p.User.ID)
|
|
}
|