ultisuite-backend/internal/automation/dispatcher.go
R3D347HR4Y 1d063237b9
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
feat(transcription): integrate Faster Whisper for Jitsi transcriptions
- Added support for Faster Whisper transcription via Jigasi and Skynet.
- Updated .env.example to include new environment variables for transcription settings.
- Enhanced Jitsi Docker Compose configuration to include Skynet and Jigasi services.
- Introduced new API endpoints for managing organizational folders in the drive service.
- Updated Nextcloud initialization script to enable external file mounting.
- Improved error handling and response structures in the drive API.
- Added new properties for organization settings related to transcription and agenda management.
2026-06-12 19:10:18 +02:00

258 lines
6.8 KiB
Go

package automation
import (
"context"
"encoding/json"
"log/slog"
"path"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/ultisuite/ulti-backend/internal/mail/rules"
"github.com/ultisuite/ulti-backend/internal/mail/webhooks"
"github.com/ultisuite/ulti-backend/internal/nextcloud"
)
type Dispatcher struct {
db *pgxpool.Pool
rules *rules.Engine
hooks *webhooks.Executor
logger *slog.Logger
}
func NewDispatcher(db *pgxpool.Pool, rulesEngine *rules.Engine, hookExec *webhooks.Executor) *Dispatcher {
return &Dispatcher{
db: db,
rules: rulesEngine,
hooks: hookExec,
logger: slog.Default().With("component", "automation-dispatcher"),
}
}
type DrivePayload struct {
FilePath string
FileName string
MimeType string
FileSize int64
IsFolder bool
}
type ContactPayload struct {
ID string
BookID string
Name string
Email string
Phone string
Org string
Labels []string
}
func DrivePayloadFromPath(filePath string, isFolder bool) DrivePayload {
normalized := nextcloud.NormalizeClientPath(filePath)
name := path.Base(strings.TrimSuffix(normalized, "/"))
if name == "" || name == "." {
name = normalized
}
return DrivePayload{
FilePath: normalized,
FileName: name,
IsFolder: isFolder,
}
}
func (d *Dispatcher) OnMailCreated(ctx context.Context, userID, accountID, messageID string, msg *rules.Message) {
if d == nil || userID == "" || msg == nil {
return
}
evt := &rules.EventContext{Type: rules.TriggerMessageReceived}
if msg.AccountID == "" {
msg.AccountID = accountID
}
if msg.ID == "" {
msg.ID = messageID
}
d.runRules(ctx, userID, msg, evt)
d.dispatchWebhooks(ctx, userID, string(rules.TriggerMessageReceived), evt, msg, accountID, "", "", "")
}
func (d *Dispatcher) OnDriveEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload DrivePayload) {
if d == nil || externalUserID == "" {
return
}
userID, err := d.resolveUserID(ctx, externalUserID)
if err != nil {
d.logger.Error("resolve user for drive automation", "error", err, "sub", externalUserID)
return
}
evt := driveEventContext(trigger, payload)
msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt)
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", payload.FilePath, "", "")
}
func (d *Dispatcher) OnContactEvent(ctx context.Context, externalUserID string, trigger rules.TriggerType, payload ContactPayload) {
if d == nil || externalUserID == "" {
return
}
userID, err := d.resolveUserID(ctx, externalUserID)
if err != nil {
d.logger.Error("resolve user for contact automation", "error", err, "sub", externalUserID)
return
}
evt := contactEventContext(trigger, payload)
msg := &rules.Message{}
d.runRules(ctx, userID, msg, evt)
d.dispatchWebhooks(ctx, userID, string(trigger), evt, msg, "", "", payload.BookID, "")
}
func (d *Dispatcher) runRules(ctx context.Context, userID string, msg *rules.Message, evt *rules.EventContext) {
if d.rules == nil {
return
}
if err := d.rules.EvaluateMessageEvent(ctx, userID, msg, evt); err != nil {
d.logger.Error("rules evaluation", "error", err, "user_id", userID, "trigger", evt.Type)
}
}
type webhookTemplateRow struct {
id string
eventTypes []byte
mailScope []byte
driveScope []byte
contactsScope []byte
agendaScope []byte
}
func (d *Dispatcher) dispatchWebhooks(
ctx context.Context,
userID string,
eventType string,
evt *rules.EventContext,
msg *rules.Message,
accountID string,
drivePath string,
bookID string,
calendarID string,
) {
if d.hooks == nil || d.db == nil {
return
}
rows, err := d.db.Query(ctx, `
SELECT id, event_types, mail_scope, drive_scope, contacts_scope, agenda_scope
FROM webhook_templates
WHERE user_id = $1 AND is_active = true
`, userID)
if err != nil {
d.logger.Error("list webhook templates", "error", err)
return
}
defer rows.Close()
msgCtx := rules.WebhookContextFromEvent(evt, msg)
for rows.Next() {
var row webhookTemplateRow
if err := rows.Scan(&row.id, &row.eventTypes, &row.mailScope, &row.driveScope, &row.contactsScope, &row.agendaScope); err != nil {
d.logger.Error("scan webhook template", "error", err)
continue
}
if !webhookMatchesEvent(row, eventType) {
continue
}
if !webhookMatchesScope(row, accountID, drivePath, bookID, calendarID) {
continue
}
if err := d.hooks.Execute(ctx, row.id, msgCtx); err != nil {
d.logger.Error("webhook dispatch", "template_id", row.id, "error", err)
}
}
}
func webhookMatchesEvent(row webhookTemplateRow, eventType string) bool {
var types []string
if len(row.eventTypes) > 0 {
_ = json.Unmarshal(row.eventTypes, &types)
}
if len(types) == 0 {
return false
}
for _, t := range types {
if t == eventType {
return true
}
}
return false
}
func webhookMatchesScope(row webhookTemplateRow, accountID, drivePath, bookID, calendarID string) bool {
var mailScope MailScope
var driveScope DriveScope
var contactsScope ContactsScope
var agendaScope AgendaScope
_ = json.Unmarshal(row.mailScope, &mailScope)
_ = json.Unmarshal(row.driveScope, &driveScope)
_ = json.Unmarshal(row.contactsScope, &contactsScope)
_ = json.Unmarshal(row.agendaScope, &agendaScope)
if accountID != "" {
return AllowsMailScope(mailScope, accountID)
}
if drivePath != "" {
return AllowsDriveScope(driveScope, drivePath)
}
if bookID != "" {
return AllowsContactsScope(contactsScope, bookID)
}
if calendarID != "" {
return AllowsAgendaScope(agendaScope, calendarID)
}
return true
}
func (d *Dispatcher) resolveUserID(ctx context.Context, externalID string) (string, error) {
var userID string
err := d.db.QueryRow(ctx, `SELECT id FROM users WHERE external_id = $1`, externalID).Scan(&userID)
return userID, err
}
func driveEventContext(trigger rules.TriggerType, p DrivePayload) *rules.EventContext {
return &rules.EventContext{
Type: trigger,
FolderPath: path.Dir(p.FilePath),
DriveFileName: p.FileName,
DriveFilePath: p.FilePath,
DriveMimeType: p.MimeType,
DriveFileSize: p.FileSize,
DriveIsFolder: p.IsFolder,
}
}
func contactEventContext(trigger rules.TriggerType, p ContactPayload) *rules.EventContext {
return &rules.EventContext{
Type: trigger,
ContactLabel: strings.Join(p.Labels, ", "),
ContactID: p.ID,
ContactName: p.Name,
ContactEmail: p.Email,
ContactPhone: p.Phone,
ContactOrg: p.Org,
ContactBookID: p.BookID,
}
}
func EventDomain(trigger rules.TriggerType) string {
switch trigger {
case rules.TriggerDriveFileCreated, rules.TriggerDriveFileUpdated, rules.TriggerDriveFileDeleted,
rules.TriggerDriveFileMoved, rules.TriggerDriveShareUpdated:
return "drive"
case rules.TriggerContactCreated, rules.TriggerContactUpdated, rules.TriggerContactDeleted:
return "contacts"
default:
return "mail"
}
}
func NowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}