chore: import clean Agent Desk snapshot (3/7)

This commit is contained in:
2026-10-16 11:58:36 +00:00
parent 89c5231c4c
commit fe4a99cf4b
127 changed files with 29641 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
package services
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"agent-desk/internal/pkg/config"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/openidentity"
"agent-desk/internal/slack"
)
var SlackInboundService = newSlackInboundService()
func newSlackInboundService() *slackInboundService {
return &slackInboundService{}
}
type slackInboundService struct{}
// HandleWebhook processes an incoming Events API event from Slack.
func (s *slackInboundService) HandleWebhook(ctx context.Context, channelID string, timestampHeader, signatureHeader string, rawPayload []byte) (*string, error) {
var event slack.EventCallback
if err := json.Unmarshal(rawPayload, &event); err != nil {
return nil, fmt.Errorf("unmarshal slack event failed: %w", err)
}
// 1. URL Verification Challenge
if event.Type == "url_verification" {
return &event.Challenge, nil
}
if event.Type != "event_callback" || event.Event == nil {
return nil, nil // Ignore non-message callbacks
}
teamID := strings.TrimSpace(event.TeamID)
ev := event.Event
if ev.BotID != "" || ev.Subtype == "bot_message" || strings.TrimSpace(ev.User) == "" {
return nil, nil // Ignore bot loops
}
text := strings.TrimSpace(ev.Text)
if text == "" {
return nil, nil
}
var channel *models.Channel
channelID = strings.TrimSpace(channelID)
if channelID != "" {
channel = ChannelService.Take("channel_id = ? AND channel_type = ? AND status = ?", channelID, enums.ChannelTypeSlack, enums.StatusOk)
}
if channel == nil && teamID != "" {
channel = ChannelService.Take("channel_type = ? AND status = ? AND (channel_id = ? OR config_json LIKE ?)",
enums.ChannelTypeSlack, enums.StatusOk, teamID, "%"+teamID+"%")
}
if channel == nil {
channel = ChannelService.Take("channel_type = ? AND status = ?", enums.ChannelTypeSlack, enums.StatusOk)
}
if channel == nil {
return nil, errorsx.InvalidParam("slack channel not found or disabled")
}
cfg, err := ChannelService.ParseSlackChannelConfig(channel.ConfigJSON)
if err != nil || cfg == nil {
return nil, errorsx.InvalidParam("slack channel config invalid")
}
// Verify the Slack signing secret whenever one resolves for this channel. A
// delivery with no signature headers is rejected rather than waved through:
// Slack always signs once a signing secret exists, so a missing header means
// the sender is not Slack.
slackCfg := config.ResolveSlack(cfg.BotToken, cfg.SigningSecret)
if slackCfg.SigningSecret != "" {
if strings.TrimSpace(signatureHeader) == "" || strings.TrimSpace(timestampHeader) == "" {
slog.Warn("slack webhook rejected: missing signature headers",
"channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg))
return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature")
}
if !verifySlackSignature(slackCfg.SigningSecret, timestampHeader, signatureHeader, rawPayload) {
slog.Warn("slack webhook rejected: signature verification failed",
"channel_id", channelID, "secret_source", slackSecretSource(cfg.SigningSecret, slackCfg))
return nil, errorsx.UnauthorizedI18n("error.auth.invalidSignature")
}
}
// 1. Resolve customer identity
senderID := strings.TrimSpace(ev.User)
externalUser := openidentity.ExternalUser{
ExternalSource: enums.ExternalSourceSlack,
ExternalID: senderID,
ExternalName: fmt.Sprintf("Slack User %s", senderID),
}
// 2. Create or match Conversation
conversation, err := ConversationService.Create(externalUser, channel.ID, channel.AIAgentID)
if err != nil {
return nil, fmt.Errorf("create slack conversation failed: %w", err)
}
// 3. Send message through MessageService
msgTS := strings.TrimSpace(ev.TS)
threadTS := strings.TrimSpace(ev.ThreadTS)
if threadTS == "" {
threadTS = msgTS
}
clientMsgID := fmt.Sprintf("slack_%s_%s", ev.Channel, msgTS)
payloadMap := map[string]any{
"slack_channel": ev.Channel,
"slack_ts": msgTS,
"slack_thread_ts": threadTS,
"slack_user": senderID,
"slack_team": teamID,
}
payloadBytes, _ := json.Marshal(payloadMap)
_, err = MessageService.SendCustomerMessage(
conversation.ID,
clientMsgID,
enums.IMMessageTypeText,
text,
string(payloadBytes),
externalUser,
)
if err != nil {
return nil, fmt.Errorf("send customer message failed: %w", err)
}
return nil, nil
}
// slackSecretSource names where the verifying secret came from. A channel
// bound to a different Slack app than the deployment-wide one starts failing
// the moment the fallback secret appears, and this log field is the only way
// an operator can tell that apart from a spoofed delivery.
func slackSecretSource(channelSigningSecret string, resolved config.SlackConfig) string {
if strings.TrimSpace(channelSigningSecret) != "" {
return "channel"
}
if resolved.SigningSecret != "" {
return "deployment_fallback"
}
return "none"
}
// slackTimestampTolerance is how far a request timestamp may drift from now.
//
// Slack's own verification guide requires rejecting anything older than five
// minutes. Without the check a captured request replays indefinitely: the
// signature covers the timestamp and the body, but nothing in it expires.
const slackTimestampTolerance = 5 * time.Minute
func verifySlackSignature(signingSecret, timestampHeader, signatureHeader string, payload []byte) bool {
timestampHeader = strings.TrimSpace(timestampHeader)
timestamp, err := strconv.ParseInt(timestampHeader, 10, 64)
if err != nil || timestamp <= 0 {
return false
}
if drift := time.Since(time.Unix(timestamp, 0)); drift > slackTimestampTolerance || drift < -slackTimestampTolerance {
return false
}
sigBasestring := fmt.Sprintf("v0:%s:%s", timestampHeader, string(payload))
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(sigBasestring))
expectedSig := "v0=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(strings.TrimSpace(signatureHeader)), []byte(expectedSig))
}