Files
gochat/backend/internal/service/webhook_event_processor.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

373 lines
14 KiB
Go

package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/security"
applogger "github.com/gochat/gochat/pkg/logger"
)
// --- Webhook Event Processor ---
// Reference: Chatwoot Integrations::Slack::IncomingMessageBuilder,
// Shopify webhook processing, Linear webhook processing.
// Each hook type has its own processor that parses the incoming payload
// and dispatches events (create conversation, send message, update contact, etc.)
// WebhookEventProcessor is the interface for channel-specific webhook event processing.
type WebhookEventProcessor interface {
HookType() model.HookType
ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error
VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error
}
// helper: extract signature + timestamp from request headers based on channel config
func verifySignatureWithConfig(signer *security.WebhookSignatureService, r *http.Request, body []byte, secret string, channelType string, cfg security.ChannelSignatureConfig) error {
sigHeader := r.Header.Get(cfg.SignatureHeader)
tsHeader := ""
if cfg.TimestampHeader != "" {
tsHeader = r.Header.Get(cfg.TimestampHeader)
}
return signer.VerifySignature(channelType, secret, body, sigHeader, tsHeader)
}
// --- Slack Processor ---
// Reference: Chatwoot Integrations::Slack::IncomingMessageBuilder
// Slack sends events via Events API with URL verification and event payloads.
type SlackEventProcessor struct {
hookRepo *repository.IntegrationHookRepo
accountRepo *repository.AccountRepo
signer *security.WebhookSignatureService
}
func NewSlackEventProcessor(hookRepo *repository.IntegrationHookRepo, accountRepo *repository.AccountRepo, signer *security.WebhookSignatureService) *SlackEventProcessor {
return &SlackEventProcessor{hookRepo: hookRepo, accountRepo: accountRepo, signer: signer}
}
func (p *SlackEventProcessor) HookType() model.HookType { return model.HookTypeSlack }
func (p *SlackEventProcessor) VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error {
cfg := security.ChannelSignatureConfig{
SignatureHeader: "X-Slack-Signature",
TimestampHeader: "X-Slack-Request-Timestamp",
Algorithm: "sha256",
Prefix: "v0=",
}
return verifySignatureWithConfig(p.signer, r, body, hook.AccessToken, "slack", cfg)
}
func (p *SlackEventProcessor) ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error {
if challenge, ok := payload["challenge"].(string); ok {
if eventType, _ := payload["type"].(string); eventType == "url_verification" {
applogger.L().Infof("Slack URL verification challenge for hook %d", hook.ID)
return fmt.Errorf("slack_url_verification: %s", challenge)
}
}
event, ok := payload["event"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid Slack event payload: missing 'event' field")
}
eventType, _ := event["type"].(string)
applogger.L().Infof("Processing Slack event: type=%s, hook=%d", eventType, hook.ID)
switch eventType {
case "message":
return p.processSlackMessage(ctx, hook, event)
case "link_shared":
return p.processSlackLinkShared(ctx, hook, event)
case "channel_join", "channel_leave":
applogger.L().Infof("Slack channel event %s for hook %d", eventType, hook.ID)
return nil
default:
applogger.L().Infof("Unsupported Slack event type: %s for hook %d", eventType, hook.ID)
return nil
}
}
func (p *SlackEventProcessor) processSlackMessage(ctx context.Context, hook *model.IntegrationHook, event map[string]interface{}) error {
text, _ := event["text"].(string)
userID, _ := event["user"].(string)
channelID, _ := event["channel"].(string)
ts, _ := event["ts"].(string)
if text == "" {
return fmt.Errorf("empty Slack message text")
}
applogger.L().Infof("Slack message: user=%s, channel=%s, ts=%s, hook=%d", userID, channelID, ts, hook.ID)
return nil
}
func (p *SlackEventProcessor) processSlackLinkShared(ctx context.Context, hook *model.IntegrationHook, event map[string]interface{}) error {
applogger.L().Infof("Slack link_shared event for hook %d — unfurling not yet implemented", hook.ID)
return nil
}
// --- Shopify Processor ---
// Reference: Chatwoot Integrations::Shopify::WebhookProcessor
type ShopifyEventProcessor struct {
hookRepo *repository.IntegrationHookRepo
signer *security.WebhookSignatureService
}
func NewShopifyEventProcessor(hookRepo *repository.IntegrationHookRepo, signer *security.WebhookSignatureService) *ShopifyEventProcessor {
return &ShopifyEventProcessor{hookRepo: hookRepo, signer: signer}
}
func (p *ShopifyEventProcessor) HookType() model.HookType { return model.HookTypeShopify }
func (p *ShopifyEventProcessor) VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error {
cfg := security.ChannelSignatureConfig{
SignatureHeader: "X-Shopify-Hmac-Sha256",
Algorithm: "sha256",
}
return verifySignatureWithConfig(p.signer, r, body, hook.AccessToken, "shopify", cfg)
}
func (p *ShopifyEventProcessor) ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error {
topic, _ := payload["_shopify_topic"].(string)
applogger.L().Infof("Processing Shopify webhook: topic=%s, hook=%d", topic, hook.ID)
switch topic {
case "orders/create", "orders/updated", "orders/paid":
return p.processShopifyOrder(ctx, hook, payload, topic)
case "customers/create", "customers/update":
return p.processShopifyCustomer(ctx, hook, payload, topic)
default:
applogger.L().Infof("Unsupported Shopify topic: %s for hook %d", topic, hook.ID)
return nil
}
}
func (p *ShopifyEventProcessor) processShopifyOrder(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}, topic string) error {
orderID, _ := payload["id"].(float64)
email, _ := payload["email"].(string)
applogger.L().Infof("Shopify order: topic=%s, order_id=%.0f, email=%s, hook=%d", topic, orderID, email, hook.ID)
return nil
}
func (p *ShopifyEventProcessor) processShopifyCustomer(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}, topic string) error {
custID, _ := payload["id"].(float64)
email, _ := payload["email"].(string)
applogger.L().Infof("Shopify customer: topic=%s, id=%.0f, email=%s, hook=%d", topic, custID, email, hook.ID)
return nil
}
// --- Linear Processor ---
// Reference: Chatwoot Integrations::Linear::WebhookProcessor
type LinearEventProcessor struct {
hookRepo *repository.IntegrationHookRepo
signer *security.WebhookSignatureService
}
func NewLinearEventProcessor(hookRepo *repository.IntegrationHookRepo, signer *security.WebhookSignatureService) *LinearEventProcessor {
return &LinearEventProcessor{hookRepo: hookRepo, signer: signer}
}
func (p *LinearEventProcessor) HookType() model.HookType { return model.HookTypeLinear }
func (p *LinearEventProcessor) VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error {
cfg := security.ChannelSignatureConfig{
SignatureHeader: "X-Linear-Signature",
Algorithm: "sha256",
}
return verifySignatureWithConfig(p.signer, r, body, hook.AccessToken, "linear", cfg)
}
func (p *LinearEventProcessor) ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error {
action, _ := payload["action"].(string)
data, ok := payload["data"].(map[string]interface{})
if !ok {
return fmt.Errorf("invalid Linear event payload: missing 'data' field")
}
entityType, _ := data["entityType"].(string)
applogger.L().Infof("Processing Linear event: action=%s, entityType=%s, hook=%d", action, entityType, hook.ID)
switch entityType {
case "Issue":
return p.processLinearIssue(ctx, hook, data, action)
case "Comment":
return p.processLinearComment(ctx, hook, data, action)
default:
applogger.L().Infof("Unsupported Linear entity: %s for hook %d", entityType, hook.ID)
return nil
}
}
func (p *LinearEventProcessor) processLinearIssue(ctx context.Context, hook *model.IntegrationHook, data map[string]interface{}, action string) error {
issueID, _ := data["id"].(string)
title, _ := data["title"].(string)
applogger.L().Infof("Linear issue: action=%s, id=%s, title=%s, hook=%d", action, issueID, title, hook.ID)
return nil
}
func (p *LinearEventProcessor) processLinearComment(ctx context.Context, hook *model.IntegrationHook, data map[string]interface{}, action string) error {
commentID, _ := data["id"].(string)
applogger.L().Infof("Linear comment: action=%s, id=%s, hook=%d", action, commentID, hook.ID)
return nil
}
// --- Notion Processor ---
// Reference: Notion API webhooks (beta)
type NotionEventProcessor struct {
hookRepo *repository.IntegrationHookRepo
signer *security.WebhookSignatureService
}
func NewNotionEventProcessor(hookRepo *repository.IntegrationHookRepo, signer *security.WebhookSignatureService) *NotionEventProcessor {
return &NotionEventProcessor{hookRepo: hookRepo, signer: signer}
}
func (p *NotionEventProcessor) HookType() model.HookType { return model.HookTypeNotion }
func (p *NotionEventProcessor) VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error {
cfg := security.ChannelSignatureConfig{
SignatureHeader: "X-Notion-Signature",
Algorithm: "sha256",
}
return verifySignatureWithConfig(p.signer, r, body, hook.AccessToken, "notion", cfg)
}
func (p *NotionEventProcessor) ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error {
eventType, _ := payload["type"].(string)
applogger.L().Infof("Processing Notion webhook: type=%s, hook=%d", eventType, hook.ID)
return nil
}
// --- Generic Webhook Processor ---
// Reference: Chatwoot Channel::WebWidget HMAC verification
type GenericWebhookProcessor struct {
hookRepo *repository.IntegrationHookRepo
signer *security.WebhookSignatureService
}
func NewGenericWebhookProcessor(hookRepo *repository.IntegrationHookRepo, signer *security.WebhookSignatureService) *GenericWebhookProcessor {
return &GenericWebhookProcessor{hookRepo: hookRepo, signer: signer}
}
func (p *GenericWebhookProcessor) HookType() model.HookType { return model.HookTypeWebhook }
func (p *GenericWebhookProcessor) VerifySignature(r *http.Request, body []byte, hook *model.IntegrationHook) error {
cfg := security.ChannelSignatureConfig{
SignatureHeader: "X-Webhook-Hmac-Signature",
Algorithm: "sha256",
}
return verifySignatureWithConfig(p.signer, r, body, hook.AccessToken, "web_widget", cfg)
}
func (p *GenericWebhookProcessor) ProcessEvent(ctx context.Context, hook *model.IntegrationHook, payload map[string]interface{}) error {
applogger.L().Infof("Processing generic webhook: hook=%d, keys=%v", hook.ID, payloadKeys(payload))
return nil
}
// --- Processor Registry ---
// Maps HookType → WebhookEventProcessor for dispatching events.
type WebhookProcessorRegistry struct {
processors map[model.HookType]WebhookEventProcessor
}
func NewWebhookProcessorRegistry(signer *security.WebhookSignatureService, hookRepo *repository.IntegrationHookRepo, accountRepo *repository.AccountRepo) *WebhookProcessorRegistry {
reg := &WebhookProcessorRegistry{processors: make(map[model.HookType]WebhookEventProcessor)}
reg.Register(NewSlackEventProcessor(hookRepo, accountRepo, signer))
reg.Register(NewShopifyEventProcessor(hookRepo, signer))
reg.Register(NewLinearEventProcessor(hookRepo, signer))
reg.Register(NewNotionEventProcessor(hookRepo, signer))
reg.Register(NewGenericWebhookProcessor(hookRepo, signer))
return reg
}
func (r *WebhookProcessorRegistry) Register(p WebhookEventProcessor) {
r.processors[p.HookType()] = p
}
func (r *WebhookProcessorRegistry) Get(hookType model.HookType) (WebhookEventProcessor, bool) {
p, ok := r.processors[hookType]
return p, ok
}
func (r *WebhookProcessorRegistry) ListTypes() []model.HookType {
types := make([]model.HookType, 0, len(r.processors))
for t := range r.processors {
types = append(types, t)
}
return types
}
// --- Enhanced ProcessEvent on IntegrationHookService ---
// Delegates to the appropriate channel-specific processor.
func (s *IntegrationHookService) ProcessWebhookEvent(ctx context.Context, hookID uint, payload map[string]interface{}, r *http.Request, body []byte) error {
hook, err := s.hookRepo.GetByID(ctx, hookID)
if err != nil {
return fmt.Errorf("integration hook not found: %w", err)
}
if hook.Status != model.HookStatusActive {
return fmt.Errorf("integration hook is inactive: status=%s", hook.Status)
}
processor, ok := s.registry.Get(model.HookType(hook.HookType))
if !ok {
return fmt.Errorf("no processor registered for hook type: %s", hook.HookType)
}
if err := processor.VerifySignature(r, body, hook); err != nil {
return fmt.Errorf("webhook signature verification failed: %w", err)
}
return processor.ProcessEvent(ctx, hook, payload)
}
// --- Utility functions ---
func payloadKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
func VerifyWebhookSignature(r *http.Request, body []byte, secret string, channelType string, cfg security.ChannelSignatureConfig) error {
signer := security.NewWebhookSignatureService(security.DefaultWebhookSignatureConfig())
sigHeader := r.Header.Get(cfg.SignatureHeader)
tsHeader := ""
if cfg.TimestampHeader != "" {
tsHeader = r.Header.Get(cfg.TimestampHeader)
}
return signer.VerifySignature(channelType, secret, body, sigHeader, tsHeader)
}
func ParseWebhookPayload(body []byte) (map[string]interface{}, error) {
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("invalid webhook payload JSON: %w", err)
}
return payload, nil
}
// WebhookDelivery tracks outgoing webhook delivery status.
// Reference: Chatwoot webhook delivery with retry logic.
type WebhookDelivery struct {
HookID uint `json:"hook_id"`
Status string `json:"status"` // success, failed, retrying
Attempts int `json:"attempts"`
LastAttempt time.Time `json:"last_attempt"`
Response *WebhookDeliveryResponse `json:"response,omitempty"`
Payload map[string]interface{} `json:"payload,omitempty"`
}
type WebhookDeliveryResponse struct {
StatusCode int `json:"status_code"`
Body string `json:"body"`
Error string `json:"error,omitempty"`
}