Unipile pkg files
/*Project Structure: ├── adapter.go ├── auth.go ├── client.go ├── email_webhook_handler.go ├── helper.go ├── jobs_webhook.go ├── models │ ├── account.go │ ├── calendar.go │ ├── common.go │ ├── email.go │ ├── linkedin.go │ ├── messaging.go │ ├── page.go │ ├── post_types.go │ └── webhooks.go ├── pg_store.go ├── services │ ├── account_service.go │ ├── calendar_service.go │ ├── email_service.go │ ├── linkedin_service.go │ ├── messaging_service.go │ ├── page_service.go │ ├── post_service.go │ └── webhook_service.go ├── sync_job.go ├── webhook_registration.go
*/
// File: adapter.go
//pkg/providers/unipile/adapter.go
package unipile
import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "log" "log/slog" "net/http" "net/url" "os" "strings" "sync" "time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
_ "github.com/lib/pq"
"github.com/mitchellh/mapstructure"
core "gitlab.com/webigniter/slop-server/internal/core"
"gitlab.com/webigniter/slop-server/internal/jobmanager"
"gitlab.com/webigniter/slop-server/internal/providers" // Ensure this is the correct path for providers.Store
"gitlab.com/webigniter/slop-server/internal/pubsub"
"gitlab.com/webigniter/slop-server/internal/resources"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/services"
)
const ( ProviderID = "unipile" // Define provider ID constant unipileAccountSyncProcessID = "unipile-account-sync" // Define the process ID for the sync job )
func init() { log.Println("DEBUG: unipile init() registering factory") // Register the factory function directly. The registry will call this. if err := providers.RegisterFactory(ProviderID, newUnipileAdapterFactory); err != nil { // Use log.Fatalf or panic in init() for critical registration errors log.Fatalf("CRITICAL: Failed to register provider factory for '%s': %v", ProviderID, err) } log.Printf("DEBUG: Provider factory for '%s' registration submitted.", ProviderID) }
// newUnipileAdapterFactory is the factory function called by the registry. func newUnipileAdapterFactory(deps providers.ProviderDependencies) (providers.Provider, error) { log.Printf("DEBUG: Factory called for provider: %s", ProviderID)
// Check for required dependencies
if deps.CoreStore == nil {
return nil, errors.New("factory for unipile requires CoreStore dependency")
}
if deps.EncryptionKey == "" {
return nil, errors.New("factory for unipile requires EncryptionKey dependency")
}
if deps.PgxPool == nil {
return nil, errors.New("factory for unipile requires PgxPool dependency")
}
// Check for new dependencies
if deps.ResourceManager == nil {
return nil, errors.New("factory for unipile requires ResourceManager dependency")
}
if deps.Publisher == nil { // Use the new Publisher field
return nil, errors.New("factory for unipile requires Publisher dependency")
}
// Call the constructor with all dependencies
adapter, err := NewUnipileAdapter(deps.CoreStore, deps.EncryptionKey, deps.PgxPool, deps.ResourceManager, deps.Publisher, deps.JobManager)
if err != nil {
return nil, fmt.Errorf("failed to create unipile adapter via factory: %w", err)
}
log.Printf("DEBUG: Instance created for provider: %s", ProviderID)
return adapter, nil
}
// UnipileAdapter implements the providers.Provider interface for Unipile type UnipileAdapter struct { config map[string]interface{} initialized bool slopAdapter providers.SlopAdapter accounts map[string][]models.AccountInfo // Map user_id to their accounts store UnipileStoreInterface // Changed to interface type accountMutex sync.RWMutex coreStore core.PgStore encryptionKey string dbPool *pgxpool.Pool
// Dependencies injected via Initialize
StoreDB *sql.DB // Added for consistency if needed, though dbPool might suffice
resourceMgr resources.Manager
notifier pubsub.Publisher
jobMgr jobmanager.Manager // Corrected field name from jobManager to jobMgr
// Configuration loaded in Initialize
baseURL *url.URL
apiKey string
clientSecret string
client *UnipileClient // Reverted back to custom client type
}
// Compile-time check to ensure UnipileAdapter implements SchemaInitializer var _ providers.SchemaInitializer = (*UnipileAdapter)(nil)
// Compile-time check to ensure UnipileAdapter implements EventInfoProvider var _ providers.EventInfoProvider = (*UnipileAdapter)(nil)
// NewUnipileAdapter creates a new Unipile adapter // Added resourceMgr and notifier (Publisher) parameters func NewUnipileAdapter( coreStore core.PgStore, encryptionKey string, pool pgxpool.Pool, resourceMgr resources.Manager, // Added notifier pubsub.Publisher, // Added (using notifier internally) jobMgr jobmanager.Manager, // Added job manager dependency here ) (*UnipileAdapter, error) { if pool == nil { return nil, errors.New("database pool cannot be nil for UnipileAdapter") } if resourceMgr == nil { return nil, errors.New("resource manager cannot be nil for UnipileAdapter") } if notifier == nil { return nil, errors.New("notifier (publisher) cannot be nil for UnipileAdapter") } if jobMgr == nil { // Add check for job manager return nil, errors.New("job manager cannot be nil for UnipileAdapter") }
// Create the store using the provided pool
store := NewUnipileStoreWithPool(pool)
// Initialize schema here after creating the store
initCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := store.InitializeDatabase(initCtx); err != nil {
store.Close()
return nil, fmt.Errorf("failed to initialize unipile database schema: %w", err)
}
return &UnipileAdapter{
store: store,
coreStore: coreStore,
encryptionKey: encryptionKey,
dbPool: pool,
resourceMgr: resourceMgr, // Store the resource manager
notifier: notifier, // Store the notifier (publisher)
jobMgr: jobMgr, // Store the job manager
}, nil
}
// InitializeSchema implements the providers.SchemaInitializer interface func (a UnipileAdapter) InitializeSchema(ctx context.Context, pool pgxpool.Pool) error { // The schema is now initialized in NewUnipileAdapter when the store is created. // This method fulfills the interface but doesn't need to do anything extra // if the store was already initialized. // We could add a check here if needed, but assuming NewUnipileAdapter is called first. log.Printf("Unipile schema initialization check (already done in NewUnipileAdapter)...") // We could potentially re-run initialization or verify here if needed, // but for now, we assume it's done. // err := a.store.InitializeDatabase(ctx) // Re-running might be okay or cause issues depending on idempotency // if err != nil { // log.Printf("ERROR: Failed to re-initialize Unipile database schema: %v", err) // return fmt.Errorf("failed to re-initialize unipile schema: %w", err) // } return nil }
// Add a Shutdown method to close the store func (a *UnipileAdapter) Shutdown() error { if a.store != nil { a.store.Close() } return nil }
func (a *UnipileAdapter) GetRoutes() []providers.Route { return []providers.Route{ { Path: "/webhooks/unipile/auth-link", Method: "POST", Handler: http.HandlerFunc(fiberHandlerToHTTP(a.GenerateAuthLinkHandler())), Description: "Generate Unipile auth link", Auth: true, }, { Path: "/webhooks/unipile/auth-callback", Method: "POST", Handler: http.HandlerFunc(fiberHandlerToHTTP(a.AuthCallbackHandler())), Description: "Receive Unipile auth callback", Auth: false, // No auth for webhooks }, } }
// GetID returns the provider ID func (a *UnipileAdapter) GetID() string { return "unipile" }
// GetName returns the human-readable name func (a *UnipileAdapter) GetName() string { return "Unipile Integration" }
// GetDescription returns the provider description func (a *UnipileAdapter) GetDescription() string { return "Provides access to Unipile communication features." }
// GetCompactDescription returns the compact description of this provider func (a *UnipileAdapter) GetCompactDescription() string { return "Unipile integration for communication data." }
// Initialize configures the adapter based on the provided configuration map. // This method now conforms to the providers.Provider interface. func (a *UnipileAdapter) Initialize(config map[string]interface{}) error { ctx := context.Background() // Use a background context for initialization steps slog.InfoContext(ctx, "Initializing UnipileAdapter with config")
// Store the config
a.config = config
// Verify dependencies assigned in constructor are present
if a.store == nil || a.resourceMgr == nil || a.notifier == nil || a.jobMgr == nil {
return errors.New("UnipileAdapter Initialize failed: core dependencies (store, resourceMgr, notifier, jobMgr) are nil")
}
// Add debug log to see keys passed in config map
keys := make([]string, 0, len(config))
for k := range config {
keys = append(keys, k)
}
slog.DebugContext(ctx, "Config keys received in Initialize", "keys", keys)
// --- Configure Base URL (from config or env) ---
baseURLStr := ""
baseURLVal, ok := config["base_url"].(string)
if ok && baseURLVal != "" {
baseURLStr = baseURLVal
slog.InfoContext(ctx, "Unipile base_url loaded from config map.")
} else {
slog.WarnContext(ctx, "Unipile base_url not found or empty in config map, checking environment variable UNIPILE_BASE_URL...")
baseURLStr = os.Getenv("UNIPILE_BASE_URL")
if baseURLStr != "" {
slog.InfoContext(ctx, "Unipile base_url loaded from environment variable UNIPILE_BASE_URL.")
} else {
slog.ErrorContext(ctx, "Unipile base_url missing in both config map and environment variable UNIPILE_BASE_URL")
return fmt.Errorf("unipile base URL is required but not configured")
}
}
parsedBaseURL, err := url.ParseRequestURI(baseURLStr)
if err != nil {
slog.ErrorContext(ctx, "Failed to parse Unipile base URL", "url", baseURLStr, "error", err)
return fmt.Errorf("invalid Unipile base URL '%s': %w", baseURLStr, err)
}
a.baseURL = parsedBaseURL
slog.InfoContext(ctx, "Unipile base URL configured", "url", a.baseURL.String())
// --- Configure API Key (from config or env) ---
apiKeyStr := ""
apiKeyVal, ok := config["api_key"].(string)
if ok && apiKeyVal != "" {
apiKeyStr = apiKeyVal
slog.InfoContext(ctx, "Unipile api_key loaded from config map.")
} else {
slog.WarnContext(ctx, "Unipile api_key not found or empty in config map, checking environment variable UNIPILE_API_KEY...")
apiKeyStr = os.Getenv("UNIPILE_API_KEY")
if apiKeyStr != "" {
slog.InfoContext(ctx, "Unipile api_key loaded from environment variable UNIPILE_API_KEY.")
} else {
slog.ErrorContext(ctx, "Unipile api_key missing in both config map and environment variable UNIPILE_API_KEY")
return fmt.Errorf("unipile API key is required but not configured")
}
}
if len(apiKeyStr) < 10 { // Basic sanity check
slog.WarnContext(ctx, "Unipile API key seems short, please verify.")
}
a.apiKey = apiKeyStr
slog.InfoContext(ctx, "Unipile API key configured (length check passed).")
// --- Configure Client Secret (from config or env) ---
clientSecretStr := ""
clientSecretVal, ok := config["client_secret"].(string)
if ok && clientSecretVal != "" {
clientSecretStr = clientSecretVal
slog.InfoContext(ctx, "Unipile client_secret loaded from config map.")
} else {
slog.WarnContext(ctx, "Unipile client_secret not found or empty in config map, checking environment variable UNIPILE_CLIENT_SECRET...")
clientSecretStr = os.Getenv("UNIPILE_CLIENT_SECRET")
if clientSecretStr != "" {
slog.InfoContext(ctx, "Unipile client_secret loaded from environment variable UNIPILE_CLIENT_SECRET.")
} else {
slog.WarnContext(ctx, "Unipile client_secret missing in both config map and environment variable UNIPILE_CLIENT_SECRET")
}
}
a.clientSecret = clientSecretStr
if clientSecretStr != "" {
slog.InfoContext(ctx, "Unipile client secret configured.")
} else {
slog.WarnContext(ctx, "Unipile client secret is not configured.")
}
// --- Initialize Unipile Client ---
a.client = NewUnipileClient(baseURLStr, a.apiKey)
a.client.InitServices()
slog.InfoContext(ctx, "Unipile client initialized and services registered.")
// --- Create SLOP adapter ---
a.slopAdapter = providers.NewSlopAdapter(a.client.Registry)
slog.InfoContext(ctx, "SLOP adapter created for Unipile.")
// --- Schedule Account Sync Job (if enabled) ---
enableSyncJob, _ := config["enable_account_sync"].(bool)
if enableSyncJob {
syncIntervalStr, _ := config["account_sync_interval"].(string)
if syncIntervalStr == "" {
syncIntervalStr = "1h" // Default sync interval
slog.WarnContext(ctx, "account_sync_interval not set in config, defaulting to 1h")
}
_, err := time.ParseDuration(syncIntervalStr) // Validate duration format
if err != nil {
slog.ErrorContext(ctx, "Invalid account_sync_interval format", "interval", syncIntervalStr, "error", err)
return fmt.Errorf("invalid account_sync_interval '%s': %w", syncIntervalStr, err)
}
slog.InfoContext(ctx, "Account synchronization job is enabled", "schedule", syncIntervalStr)
err = a.scheduleAccountSyncJob(ctx, syncIntervalStr)
if err != nil {
slog.ErrorContext(ctx, "Failed to schedule initial account sync job", "error", err)
// Decide if this should be a fatal error for initialization
return fmt.Errorf("failed to schedule account sync job: %w", err)
}
} else {
slog.InfoContext(ctx, "Account synchronization job is disabled.")
}
a.initialized = true // Mark as initialized
slog.InfoContext(ctx, "UnipileAdapter initialized successfully")
return nil
}
// GetProviderTools returns all tools provided by this provider func (a *UnipileAdapter) GetProviderTools() []providers.ProviderTool { if !a.initialized { return nil }
// Get all services from the registry
services := a.client.Registry.GetAll()
var tools []providers.ProviderTool
providerID := a.GetID() // Get provider ID ("unipile")
for serviceName, service := range services {
// Get capabilities for this service
for _, capability := range service.GetCapabilities() {
tool := providers.ProviderTool{
// Set ID to provider_service_toolname format
ID: fmt.Sprintf("%s_%s_%s", providerID, serviceName, capability.Name),
Name: capability.Name, // Keep simple name for display
Scope: "", // Explicitly empty for local providers
Description: capability.Description,
Parameters: capability.Parameters,
Examples: capability.Examples,
}
tools = append(tools, tool)
}
}
return tools
}
// ExecuteTool executes a specific tool with the given parameters func (a *UnipileAdapter) ExecuteTool(ctx context.Context, toolID string, params map[string]interface{}) (interface{}, error) { if !a.initialized { return nil, errors.New("adapter not initialized") }
// toolID should now be in format "unipile_service_capability"
parts := strings.SplitN(toolID, "_", 3) // Split into 3 parts
if len(parts) != 3 || parts[0] != a.GetID() {
return nil, fmt.Errorf("invalid or mismatched tool ID format: %s", toolID)
}
// providerName := parts[0] // We know this is "unipile"
serviceName := parts[1]
capabilityName := parts[2]
// Execute the capability based on service type
switch serviceName {
case "linkedin":
return a.executeLinkedInCapability(ctx, capabilityName, params)
case "email":
return a.executeEmailCapability(ctx, capabilityName, params)
case "messaging":
return a.executeMessagingCapability(ctx, capabilityName, params)
case "calendar":
return a.executeCalendarCapability(ctx, capabilityName, params)
case "page":
return a.executePageCapability(ctx, capabilityName, params)
case "post":
return a.executePostCapability(ctx, capabilityName, params)
case "account":
return a.executeAccountCapability(ctx, capabilityName, params)
case "webhook": // Added case for webhook service
return a.executeWebhookCapability(ctx, capabilityName, params)
default:
// Attempt to execute via the generic service registry if the switch misses something
log.Printf("WARN: Service '%s' not explicitly handled in ExecuteTool switch, trying generic registry lookup.", serviceName)
service, exists := a.client.Registry.Get(serviceName)
if !exists {
return nil, fmt.Errorf("unsupported service: %s", serviceName)
}
// Check if the capability exists within the service (optional but good practice)
foundCapability := false
for _, cap := range service.GetCapabilities() {
if cap.Name == capabilityName {
foundCapability = true
break
}
}
if !foundCapability {
return nil, fmt.Errorf("capability '%s' not found within service '%s'", capabilityName, serviceName)
}
// Execute using the generic service interface
return service.Execute(ctx, params)
}
}
// ProcessWebhook handles incoming webhook events for Unipile func (a *UnipileAdapter) ProcessWebhook(ctx context.Context, webhookID string, payload map[string]interface{}) (interface{}, error) { if !a.initialized { return nil, errors.New("adapter not initialized") }
log.Printf("Received webhook for ID '%s'", webhookID)
// Delegate based on webhookID
switch webhookID {
case "emails":
log.Println("Delegating to email webhook processing...")
var emailPayload models.WebhookPayload
// Use mapstructure to decode the generic map into the specific struct
// We will need to add 'mapstructure:\"...\"' tags to the models if not present
// and handle potential 'time.Time' decoding issues.
config := &mapstructure.DecoderConfig{
Result: &emailPayload,
// Add hook for time.Time decoding from string
DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339Nano), // Use RFC3339Nano to support milliseconds
WeaklyTypedInput: true, // Allows some flexibility in types
Squash: true, // Allows embedding struct fields
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
log.Printf("Error creating mapstructure decoder for email: %v", err)
return nil, fmt.Errorf("internal configuration error processing email webhook")
}
if err := decoder.Decode(payload); err != nil {
log.Printf("Error decoding email webhook payload: %v. Payload: %v", err, payload)
// Return a specific error? Or just log and acknowledge?
// Let's acknowledge receipt but log the error.
return map[string]string{"status": "webhook received, payload decoding error"}, nil
}
// Log the decoded struct to verify mapstructure results
log.Printf("Decoded emailPayload: %+v", emailPayload)
// --- Start: Logic from processEmailWebhook ---
log.Printf("Processing Email webhook: Subject='%s' Event='%s'", emailPayload.Subject, emailPayload.FromAttendee.DisplayName)
// Process based on event type
switch emailPayload.Event {
case "email_received":
log.Printf("New email received")
// Additional processing for received emails
case "email_sent":
log.Printf("Email sent")
// Additional processing for sent emails
case "email_read":
log.Printf("Email marked as read")
// Process read events
case "email_deleted":
log.Printf("Email deleted")
// Process deletion events
case "email_draft":
log.Printf("Email draft saved")
// Process draft events
default:
log.Printf("Unknown email event type: %s", emailPayload.Event)
}
// Save to database
err = a.store.SaveEmail(ctx, &emailPayload)
if err != nil {
log.Printf("Error saving email webhook from adapter: %v", err)
// Don't return error here as processing is best-effort after acknowledgement
}
// a.processEmailNotifications(&emailPayload) // Call notification logic if needed
// --- End: Logic from processEmailWebhook ---
return map[string]string{"status": "email webhook processed"}, nil // Indicate processing is done
case "messages":
log.Println("Delegating to message webhook processing...")
var messagePayload models.MessageWebhookPayload
config := &mapstructure.DecoderConfig{
Result: &messagePayload,
WeaklyTypedInput: true,
Squash: true,
// Add hook for time.Time decoding (assuming Timestamp field is similar)
DecodeHook: mapstructure.StringToTimeHookFunc(time.RFC3339Nano),
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
log.Printf("Error creating mapstructure decoder for message: %v", err)
return nil, fmt.Errorf("internal configuration error processing message webhook")
}
if err := decoder.Decode(payload); err != nil {
log.Printf("Error decoding message webhook payload: %v. Payload: %v", err, payload)
return map[string]string{"status": "webhook received, payload decoding error"}, nil
}
// Log the decoded struct to verify mapstructure results
log.Printf("Decoded messagePayload: %+v", messagePayload)
// --- Start: Logic from processMessageWebhook ---
log.Printf("Processing Message webhook: ChatID='%s' Event='%s'", messagePayload.ChatID, messagePayload.Event)
// Check if the sender is the connected user
isSentByConnectedUser := messagePayload.AccountInfo.UserID == messagePayload.Sender.AttendeeProviderID
if isSentByConnectedUser {
log.Printf("Message sent by the connected user")
} else {
log.Printf("Message received from another user")
}
// Process based on event type
switch messagePayload.Event {
case "message_received":
log.Printf("New message received")
// Additional processing for received messages
case "message_reaction":
log.Printf("Reaction to message: %s by %s",
messagePayload.Reaction, messagePayload.ReactionSender.AttendeeName)
// Process reaction events
case "message_read":
log.Printf("Message marked as read")
// Process read events
case "message_edited":
log.Printf("Message edited")
// Process edit events
case "message_deleted":
log.Printf("Message deleted")
// Process deletion events
default:
log.Printf("Unknown message event type: %s", messagePayload.Event)
}
// Save to database
err = a.store.SaveMessage(ctx, &messagePayload)
if err != nil {
log.Printf("Error saving message webhook from adapter: %v", err)
// Don't return error here as processing is best-effort after acknowledgement
}
// --- End: Logic from processMessageWebhook ---
return map[string]string{"status": "message webhook processed"}, nil
case "account-status":
log.Println("Delegating to account status webhook processing...")
// The simulation payload uses a "data" key, not "AccountStatus"
statusData, ok := payload["data"].(map[string]interface{}) // Changed from payload["AccountStatus"]
if !ok {
log.Printf("Error: 'data' field not found or not a map in account status payload: %v", payload)
return map[string]string{"status": "webhook received, invalid account status payload structure"}, nil
}
var accountStatusPayload models.AccountStatus
config := &mapstructure.DecoderConfig{
Result: &accountStatusPayload,
WeaklyTypedInput: true,
Squash: true,
// No time.Time field in AccountStatus, hook not needed here
}
decoder, err := mapstructure.NewDecoder(config)
if err != nil {
log.Printf("Error creating mapstructure decoder for account status: %v", err)
return nil, fmt.Errorf("internal configuration error processing account status webhook")
}
// Decode the nested map
if err := decoder.Decode(statusData); err != nil {
log.Printf("Error decoding account status webhook payload: %v. Payload: %v", err, statusData)
return map[string]string{"status": "webhook received, payload decoding error"}, nil
}
// Log the decoded struct to verify mapstructure results
log.Printf("Decoded accountStatusPayload: %+v", accountStatusPayload)
// --- Start: Logic from processAccountStatusWebhook ---
log.Printf("Processing Account Status webhook: AccountID='%s' Status='%s'", accountStatusPayload.AccountID, accountStatusPayload.Message)
// Save to database
err = a.store.SaveAccountStatus(ctx, &accountStatusPayload)
if err != nil {
log.Printf("Error saving account status webhook: %v", err)
// return // Decide if we should stop processing on save error
}
// Update account status in the adapter's state
err = a.store.UpdateAccountStatus(ctx, accountStatusPayload.AccountID, accountStatusPayload.Message)
if err != nil {
log.Printf("Error updating account status: %v", err)
}
// Handle specific statuses
a.handleAccountStatus(&accountStatusPayload) // Call helper method (to be added below)
// --- End: Logic from processAccountStatusWebhook ---
return map[string]string{"status": "account status webhook processed"}, nil
default:
log.Printf("Received webhook for unknown or unhandled ID '%s'", webhookID)
// Return a success message acknowledging receipt for unhandled IDs for now
return map[string]string{"status": fmt.Sprintf("webhook for ID '%s' received by adapter, no specific handler", webhookID)}, nil
}
}
// ListWebhooks returns all registered webhooks (STUB) func (a *UnipileAdapter) ListWebhooks(ctx context.Context) ([]providers.WebhookInfo, error) { // TODO: Implement actual listing logic by potentially querying Unipile API log.Println("ListWebhooks called on UnipileAdapter (Not Implemented)") // For now, return an empty list or an error return nil, errors.New("ListWebhooks not implemented for Unipile provider") }
// CreateWebhook creates a new webhook (STUB) func (a *UnipileAdapter) CreateWebhook(ctx context.Context, definition providers.WebhookDefinition) (providers.WebhookInfo, error) { // TODO: Implement actual creation logic by calling Unipile API log.Printf("CreateWebhook called on UnipileAdapter (Not Implemented) with definition: %+v", definition) // For now, return an empty info struct and an error return providers.WebhookInfo{}, errors.New("CreateWebhook not implemented for Unipile provider") }
// DeleteWebhook deletes a webhook (STUB) func (a *UnipileAdapter) DeleteWebhook(ctx context.Context, webhookID string) error { // TODO: Implement actual deletion logic by calling Unipile API log.Printf("DeleteWebhook called on UnipileAdapter (Not Implemented) for ID: %s", webhookID) // For now, return an error return errors.New("DeleteWebhook not implemented for Unipile provider") }
// Implementation of service-specific tool execution methods
func (a *UnipileAdapter) executeLinkedInCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { // Get the user ID from context // userID, ok := ctx.Value("user_id").(string) // if !ok { // return nil, errors.New("user ID not found in context") // }
// Get the account ID from params or use a default
accountID, _ := params["account_id"].(string)
if accountID == "" {
// In a real implementation, you might look up the user's LinkedIn account
accountID = "default_linkedin_account"
}
// Get the LinkedIn service
// service, exists := a.client.Registry.Get("linkedin")
// if !exists {
// return nil, errors.New("LinkedIn service not available")
// }
// Cast to the specific service type
linkedInService := services.NewLinkedInService(a.client)
// Execute the capability
switch capability {
case "get_profile":
profileID, _ := params["profile_id"].(string)
if profileID == "" {
return nil, errors.New("profile_id is required")
}
return linkedInService.GetProfile(ctx, accountID, profileID)
case "search_profiles":
// Convert params to search request
searchRequest := &models.LinkedInSearchRequest{}
// Populate searchRequest from params
return linkedInService.SearchProfiles(ctx, accountID, searchRequest)
case "get_company":
companyID, _ := params["company_id"].(string)
if companyID == "" {
return nil, errors.New("company_id is required")
}
return linkedInService.GetCompany(ctx, accountID, companyID)
case "send_invitation":
recipientID, _ := params["recipient_id"].(string)
message, _ := params["message"].(string)
if recipientID == "" {
return nil, errors.New("recipient_id is required")
}
return linkedInService.SendInvitation(ctx, accountID, recipientID, message)
case "send_message":
recipientID, _ := params["recipient_id"].(string)
message, _ := params["message"].(string)
if recipientID == "" || message == "" {
return nil, errors.New("recipient_id and message are required")
}
return linkedInService.SendMessage(ctx, accountID, recipientID, message)
case "create_post":
content, _ := params["content"].(string)
if content == "" {
return nil, errors.New("content is required")
}
// Create a post draft from params
postDraft := &models.LinkedInPostDraft{
Content: content,
}
// Add other fields from params
return linkedInService.CreatePost(ctx, accountID, postDraft)
case "search_companies":
query, _ := params["query"].(string)
industry, _ := params["industry"].(string)
limit := 10 // Default
if limitParam, ok := params["limit"].(float64); ok {
limit = int(limitParam)
}
cursor, _ := params["cursor"].(string)
return linkedInService.SearchCompanies(ctx, accountID, query, industry, limit, cursor)
case "list_invitations":
status, _ := params["status"].(string) // Optional
limit := 10 // Default
if limitParam, ok := params["limit"].(float64); ok {
limit = int(limitParam)
}
cursor, _ := params["cursor"].(string)
return linkedInService.ListInvitations(ctx, accountID, status, limit, cursor)
case "get_invitation":
invitationID, _ := params["invitation_id"].(string)
if invitationID == "" {
return nil, errors.New("invitation_id parameter is required")
}
return linkedInService.GetInvitation(ctx, accountID, invitationID)
default:
return nil, fmt.Errorf("unsupported LinkedIn capability: %s", capability)
}
}
func (a *UnipileAdapter) executeEmailCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { // Get the user ID from context // userID, ok := ctx.Value("user_id").(string) // if !ok { // return nil, errors.New("user ID not found in context") // }
// Get the account ID from params or use a default
accountID, _ := params["account_id"].(string)
if accountID == "" {
// In a real implementation, you might look up the user's email account
accountID = "default_email_account"
}
// Get the Email service
// service, exists := a.client.Registry.Get("email")
// if !exists {
// return nil, errors.New("Email service not available")
// }
// Cast to the specific service type
emailService := services.NewEmailService(a.client)
// Execute the capability
switch capability {
case "send_email":
// Validate required parameters
to, ok := params["to"].([]interface{})
if !ok || len(to) == 0 {
return nil, errors.New("to recipients are required")
}
subject, _ := params["subject"].(string)
if subject == "" {
return nil, errors.New("subject is required")
}
body, _ := params["body"].(string)
if body == "" {
return nil, errors.New("body is required")
}
// Convert to string slices
toAddresses := make([]string, len(to))
for i, addr := range to {
toAddresses[i] = addr.(string)
}
// Handle optional parameters
var ccAddresses []string
cc, ok := params["cc"].([]interface{})
if ok {
ccAddresses = make([]string, len(cc))
for i, addr := range cc {
ccAddresses[i] = addr.(string)
}
}
var bccAddresses []string
bcc, ok := params["bcc"].([]interface{})
if ok {
bccAddresses = make([]string, len(bcc))
for i, addr := range bcc {
bccAddresses[i] = addr.(string)
}
}
// Create email draft
draft := &models.EmailDraft{
AccountID: accountID,
To: toAddresses,
Cc: ccAddresses,
Bcc: bccAddresses,
Subject: subject,
Body: body,
}
// Handle thread ID if provided
threadID, _ := params["thread_id"].(string)
if threadID != "" {
draft.ThreadID = threadID
}
// Send the email
return emailService.SendEmail(ctx, draft)
case "list_emails":
// Create query options
options := models.QueryOptions{}
limit, ok := params["limit"].(float64)
if ok {
options.Limit = int(limit)
}
cursor, _ := params["cursor"].(string)
options.Cursor = cursor
return emailService.ListEmails(ctx, options)
case "get_email":
emailID, _ := params["email_id"].(string)
if emailID == "" {
return nil, errors.New("email_id is required")
}
return emailService.GetEmail(ctx, emailID)
case "delete_email":
emailID, _ := params["email_id"].(string)
if emailID == "" {
return nil, errors.New("email_id is required")
}
return emailService.DeleteEmail(ctx, emailID)
case "search_emails":
// Convert params to search request
searchRequest := &models.EmailSearchRequest{}
// Populate search request from params
query, _ := params["query"].(string)
searchRequest.Query = query
// Add other search parameters...
return emailService.SearchEmails(ctx, searchRequest)
case "list_email_threads":
options := models.QueryOptions{}
if limit, ok := params["limit"].(float64); ok { // JSON numbers are often float64
options.Limit = int(limit)
}
options.Cursor, _ = params["cursor"].(string)
return emailService.ListEmailThreads(ctx, options)
case "get_email_thread":
threadID, _ := params["thread_id"].(string)
if threadID == "" {
return nil, errors.New("thread_id parameter is required")
}
return emailService.GetEmailThread(ctx, threadID)
case "modify_email":
emailID, _ := params["email_id"].(string)
if emailID == "" {
return nil, errors.New("email_id parameter is required")
}
// Create modifications map, only including provided fields
modifications := make(map[string]interface{})
if isRead, ok := params["is_read"].(bool); ok {
modifications["is_read"] = isRead
}
if isArchived, ok := params["is_archived"].(bool); ok {
modifications["is_archived"] = isArchived
}
if len(modifications) == 0 {
return nil, errors.New("at least one modification (is_read or is_archived) is required")
}
return emailService.ModifyEmail(ctx, emailID, modifications)
case "upload_attachment":
accountID, _ := params["account_id"].(string)
if accountID == "" {
// Maybe default account ID can be retrieved from context or config?
return nil, errors.New("account_id parameter is required")
}
filename, _ := params["filename"].(string)
if filename == "" {
return nil, errors.New("filename parameter is required")
}
contentType, _ := params["content_type"].(string)
if contentType == "" {
return nil, errors.New("content_type parameter is required")
}
contentBase64, _ := params["content_base64"].(string)
if contentBase64 == "" {
return nil, errors.New("content_base64 parameter is required")
}
attachment := &models.AttachmentContent{
Filename: filename,
ContentType: contentType,
Data: contentBase64,
}
return emailService.UploadAttachment(ctx, accountID, attachment)
case "create_draft":
// Note: The EmailService.CreateDraft has a fake implementation currently
// This case assumes the params match the *service* method signature, not capability definition fully
threadID, _ := params["thread_id"].(string) // From capability definition
draftText, _ := params["body"].(string) // Using 'body' from capability definition for draft text
// Extract emails (to, cc, bcc) - combine them for the fake implementation?
var allEmails []string
if to, ok := params["to"].([]interface{}); ok {
for _, e := range to {
if emailStr, ok := e.(string); ok {
allEmails = append(allEmails, emailStr)
}
}
}
// Add cc and bcc if needed for the real implementation later
// Call the fake implementation
log.Printf("WARN: Calling fake CreateDraft implementation in EmailService for thread %s", threadID)
return emailService.CreateDraft(ctx, threadID, draftText, allEmails)
case "tag_thread":
// Note: The EmailService.TagThread has a fake implementation currently
threadID, _ := params["thread_id"].(string)
if threadID == "" {
return nil, errors.New("thread_id parameter is required")
}
tagName, _ := params["tag_name"].(string)
if tagName == "" {
return nil, errors.New("tag_name parameter is required")
}
log.Printf("WARN: Calling fake TagThread implementation in EmailService for thread %s with tag %s", threadID, tagName)
return emailService.TagThread(ctx, threadID, tagName)
case "untag_thread":
// Note: The EmailService.UntagThread has a fake implementation currently
threadID, _ := params["thread_id"].(string)
if threadID == "" {
return nil, errors.New("thread_id parameter is required")
}
tagName, _ := params["tag_name"].(string)
if tagName == "" {
return nil, errors.New("tag_name parameter is required")
}
log.Printf("WARN: Calling fake UntagThread implementation in EmailService for thread %s with tag %s", threadID, tagName)
return emailService.UntagThread(ctx, threadID, tagName)
default:
return nil, fmt.Errorf("unsupported Email capability: %s", capability)
}
}
func (a *UnipileAdapter) executeMessagingCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { msgService := services.NewMessagingService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
switch capability {
case "list_chats":
return msgService.ListChats(ctx, options)
case "get_chat":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
return msgService.GetChat(ctx, chatID)
case "create_chat":
var participantIDs []string
if pIDs, ok := params["participant_ids"].([]interface{}); ok {
for _, id := range pIDs {
if idStr, ok := id.(string); ok {
participantIDs = append(participantIDs, idStr)
}
}
}
if len(participantIDs) == 0 {
return nil, errors.New("participant_ids parameter is required")
}
name, _ := params["name"].(string)
description, _ := params["description"].(string)
return msgService.CreateChat(ctx, participantIDs, name, description)
case "list_messages":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
return msgService.ListMessages(ctx, chatID, options)
case "get_message":
messageID, _ := params["message_id"].(string)
if messageID == "" {
return nil, errors.New("message_id parameter is required")
}
return msgService.GetMessage(ctx, messageID)
case "send_message":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
text, _ := params["text"].(string)
if text == "" {
return nil, errors.New("text parameter is required")
}
// TODO: Handle attachments and other draft fields if added
draft := &models.MessageDraft{Text: text}
return msgService.SendMessage(ctx, chatID, draft)
case "list_chat_participants":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
return msgService.ListChatParticipants(ctx, chatID, options)
case "add_chat_participant":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
accountID, _ := params["account_id"].(string)
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
role, _ := params["role"].(string)
return msgService.AddChatParticipant(ctx, chatID, accountID, role)
case "remove_chat_participant":
chatID, _ := params["chat_id"].(string)
if chatID == "" {
return nil, errors.New("chat_id parameter is required")
}
participantID, _ := params["participant_id"].(string)
if participantID == "" {
return nil, errors.New("participant_id parameter is required")
}
return msgService.RemoveChatParticipant(ctx, chatID, participantID)
case "update_message":
messageID, _ := params["message_id"].(string)
if messageID == "" {
return nil, errors.New("message_id parameter is required")
}
text, _ := params["text"].(string)
if text == "" {
return nil, errors.New("text parameter is required")
}
return msgService.UpdateMessage(ctx, messageID, text)
case "delete_message":
messageID, _ := params["message_id"].(string)
if messageID == "" {
return nil, errors.New("message_id parameter is required")
}
return msgService.DeleteMessage(ctx, messageID)
case "search_chats":
var searchRequest models.ChatSearchRequest
if err := mapstructure.Decode(params, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_chats params: %w", err)
}
return msgService.SearchChats(ctx, &searchRequest)
case "search_messages":
var searchRequest models.MessageSearchRequest
if err := mapstructure.Decode(params, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_messages params: %w", err)
}
return msgService.SearchMessages(ctx, &searchRequest)
default:
return nil, fmt.Errorf("unsupported Messaging capability: %s", capability)
}
}
func (a *UnipileAdapter) executeCalendarCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { calService := services.NewCalendarService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
eventID, _ := params["event_id"].(string)
switch capability {
case "list_events":
return calService.ListEvents(ctx, options)
case "get_event":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
return calService.GetEvent(ctx, eventID)
case "create_event":
var eventDraft models.CalendarEventDraft
if eventData, ok := params["event_data"].(map[string]interface{}); ok {
if err := mapstructure.Decode(eventData, &eventDraft); err != nil {
return nil, fmt.Errorf("error decoding event_data: %w", err)
}
} else {
return nil, errors.New("event_data parameter is required and must be an object")
}
return calService.CreateEvent(ctx, &eventDraft)
case "update_event":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
updates, ok := params["updates"].(map[string]interface{})
if !ok || len(updates) == 0 {
return nil, errors.New("updates parameter is required and must be a non-empty object")
}
return calService.UpdateEvent(ctx, eventID, updates)
case "delete_event":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
return calService.DeleteEvent(ctx, eventID)
case "respond_to_event":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
var rsvp models.CalendarEventRSVP
if responseData, ok := params["response"].(map[string]interface{}); ok {
if err := mapstructure.Decode(responseData, &rsvp); err != nil {
return nil, fmt.Errorf("error decoding response data: %w", err)
}
} else {
return nil, errors.New("response parameter is required and must be an object")
}
return calService.RespondToEvent(ctx, eventID, &rsvp)
case "search_events":
var searchRequest models.CalendarSearchRequest
if criteria, ok := params["search_criteria"].(map[string]interface{}); ok {
if err := mapstructure.Decode(criteria, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_criteria: %w", err)
}
} else {
return nil, errors.New("search_criteria parameter is required and must be an object")
}
return calService.SearchEvents(ctx, &searchRequest)
default:
return nil, fmt.Errorf("unsupported Calendar capability: %s", capability)
}
}
func (a *UnipileAdapter) executePageCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { pageService := services.NewPageService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
pageID, _ := params["page_id"].(string)
websiteID, _ := params["website_id"].(string)
switch capability {
case "list_pages":
return pageService.ListPages(ctx, options)
case "get_page":
if pageID == "" {
return nil, errors.New("page_id parameter is required")
}
return pageService.GetPage(ctx, pageID)
case "create_page":
var pageDraft models.PageDraft
if draftData, ok := params["pageDraft"].(map[string]interface{}); ok {
if err := mapstructure.Decode(draftData, &pageDraft); err != nil {
return nil, fmt.Errorf("error decoding pageDraft: %w", err)
}
} else {
return nil, errors.New("pageDraft parameter is required and must be an object")
}
return pageService.CreatePage(ctx, &pageDraft)
case "update_page":
if pageID == "" {
return nil, errors.New("page_id parameter is required")
}
updates, ok := params["updates"].(map[string]interface{})
if !ok || len(updates) == 0 {
return nil, errors.New("updates parameter is required and must be a non-empty object")
}
return pageService.UpdatePage(ctx, pageID, updates)
case "delete_page":
if pageID == "" {
return nil, errors.New("page_id parameter is required")
}
return pageService.DeletePage(ctx, pageID)
case "list_websites":
return pageService.ListWebsites(ctx, options)
case "get_website":
if websiteID == "" {
return nil, errors.New("website_id parameter is required")
}
return pageService.GetWebsite(ctx, websiteID)
case "get_menu":
if websiteID == "" {
return nil, errors.New("website_id parameter is required")
}
return pageService.GetMenu(ctx, websiteID)
case "search_pages":
var searchRequest models.PageSearchRequest
if criteria, ok := params["search_criteria"].(map[string]interface{}); ok {
if err := mapstructure.Decode(criteria, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_criteria: %w", err)
}
} else {
// Allow search without specific criteria? Check API behavior.
// For now, assume criteria object might be optional but structure needed if present.
if params["search_criteria"] != nil {
return nil, errors.New("search_criteria parameter must be an object if provided")
}
// If nil, proceed with empty request
}
return pageService.SearchPages(ctx, &searchRequest)
default:
return nil, fmt.Errorf("unsupported Page capability: %s", capability)
}
}
func (a *UnipileAdapter) executePostCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { postService := services.NewPostService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
postID, _ := params["post_id"].(string)
commentID, _ := params["comment_id"].(string)
reactionID, _ := params["reaction_id"].(string)
reactionType, _ := params["reaction_type"].(string)
switch capability {
case "list_posts":
return postService.ListPosts(ctx, options)
case "get_post":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
return postService.GetPost(ctx, postID)
case "create_post":
var postDraft models.PostDraft
if draftData, ok := params["postDraft"].(map[string]interface{}); ok { // Assuming param name matches capability def
if err := mapstructure.Decode(draftData, &postDraft); err != nil {
return nil, fmt.Errorf("error decoding postDraft: %w", err)
}
} else {
// Maybe extract individual fields if postDraft is not passed directly
return nil, errors.New("postDraft parameter is required and must be an object")
}
return postService.CreatePost(ctx, &postDraft)
case "delete_post":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
return postService.DeletePost(ctx, postID)
case "list_comments":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
return postService.ListComments(ctx, postID, options)
case "get_comment":
if commentID == "" {
return nil, errors.New("comment_id parameter is required")
}
return postService.GetComment(ctx, commentID)
case "create_comment":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
var commentDraft models.PostCommentDraft
if draftData, ok := params["commentDraft"].(map[string]interface{}); ok { // Assuming param name
if err := mapstructure.Decode(draftData, &commentDraft); err != nil {
return nil, fmt.Errorf("error decoding commentDraft: %w", err)
}
} else {
// Extract text directly? Assume 'text' param for now
text, ok := params["text"].(string)
if !ok || text == "" {
return nil, errors.New("comment text parameter is required")
}
commentDraft.Content = text // Corrected field name from Text to Content
}
return postService.CreateComment(ctx, postID, &commentDraft)
case "delete_comment":
if commentID == "" {
return nil, errors.New("comment_id parameter is required")
}
return postService.DeleteComment(ctx, commentID)
case "list_reactions":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
return postService.ListReactions(ctx, postID, options)
case "add_reaction":
if postID == "" {
return nil, errors.New("post_id parameter is required")
}
if reactionType == "" {
return nil, errors.New("reaction_type parameter is required")
}
return postService.AddReaction(ctx, postID, reactionType)
case "delete_reaction":
if reactionID == "" {
return nil, errors.New("reaction_id parameter is required")
}
return postService.DeleteReaction(ctx, reactionID)
case "search_posts":
var searchRequest models.PostSearchRequest
if criteria, ok := params["search_criteria"].(map[string]interface{}); ok {
if err := mapstructure.Decode(criteria, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_criteria: %w", err)
}
} else {
// Allow search without specific criteria? Check API behavior.
if params["search_criteria"] != nil {
return nil, errors.New("search_criteria parameter must be an object if provided")
}
// If nil, proceed with empty request
}
return postService.SearchPosts(ctx, &searchRequest)
default:
return nil, fmt.Errorf("unsupported Post capability: %s", capability)
}
}
func (a *UnipileAdapter) executeAccountCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { accService := services.NewAccountService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
accountID, _ := params["account_id"].(string)
connectionID, _ := params["connection_id"].(string)
switch capability {
case "list_accounts":
return accService.ListAccounts(ctx, options)
case "get_account":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
return accService.GetAccount(ctx, accountID)
case "update_account":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
var updateRequest models.AccountUpdate
// Use mapstructure to decode directly from params to the struct
if err := mapstructure.Decode(params, &updateRequest); err != nil {
return nil, fmt.Errorf("error decoding update_account params: %w", err)
}
// Ensure AccountID is not part of the update payload itself if it's in path
// updateRequest.AccountID = "" // Clear if necessary based on API
return accService.UpdateAccount(ctx, accountID, &updateRequest)
case "list_connections":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
return accService.ListConnections(ctx, accountID, options)
case "get_connection":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
if connectionID == "" {
return nil, errors.New("connection_id parameter is required")
}
return accService.GetConnection(ctx, accountID, connectionID)
case "create_connection_request":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
var connRequest models.AccountConnectionRequest
// Use mapstructure to decode directly from params to the struct
if err := mapstructure.Decode(params, &connRequest); err != nil {
return nil, fmt.Errorf("error decoding create_connection_request params: %w", err)
}
// Ensure required fields from capability definition are present
if connRequest.Provider == "" {
return nil, errors.New("provider parameter is required")
}
if connRequest.Type == "" {
return nil, errors.New("type parameter is required")
}
return accService.CreateConnectionRequest(ctx, accountID, &connRequest)
case "refresh_oauth":
if accountID == "" {
return nil, errors.New("account_id parameter is required")
}
return accService.RefreshOAuth(ctx, accountID)
case "search_accounts":
var searchRequest models.AccountSearchRequest
if err := mapstructure.Decode(params, &searchRequest); err != nil {
return nil, fmt.Errorf("error decoding search_accounts params: %w", err)
}
return accService.SearchAccounts(ctx, &searchRequest)
default:
return nil, fmt.Errorf("unsupported Account capability: %s", capability)
}
}
// GetServiceRegistry returns the underlying service registry func (a UnipileAdapter) GetServiceRegistry() providers.ServiceRegistry { return a.client.Registry }
// GenerateAuthLink generates a hosted auth link func (a *UnipileAdapter) GenerateAuthLink(ctx context.Context, userID string, providers []string, linkType string, accountID string) (string, error) { if !a.initialized { return "", errors.New("adapter not initialized") }
// Create expiration time (e.g., 1 hour from now)
expiresOn := time.Now().Add(1 * time.Hour).Format(time.RFC3339)
// Prepare request body
requestBody := map[string]interface{}{
"type": linkType,
"providers": providers,
"api_url": a.config["base_url"],
"expiresOn": expiresOn,
"name": userID, // This will be passed back in the notification
}
// Add notify_url from config
if notifyURL, ok := a.config["notify_url"].(string); ok && notifyURL != "" {
requestBody["notify_url"] = notifyURL
}
// Add success_redirect_url and failure_redirect_url if available
if successURL, ok := a.config["success_redirect_url"].(string); ok && successURL != "" {
requestBody["success_redirect_url"] = successURL
}
if failureURL, ok := a.config["failure_redirect_url"].(string); ok && failureURL != "" {
requestBody["failure_redirect_url"] = failureURL
}
// For reconnect, add the account_id
if linkType == "reconnect" && accountID != "" {
requestBody["reconnect_account"] = accountID
}
// Make request to Unipile API
endpoint := "/api/v1/hosted/accounts/link"
var response struct {
Object string `json:"object"`
URL string `json:"url"`
}
err := a.client.Request(ctx, http.MethodPost, endpoint, requestBody, &response)
if err != nil {
return "", fmt.Errorf("error generating auth link: %w", err)
}
return response.URL, nil
}
// GetUserAccounts returns all accounts for a user from the store func (a UnipileAdapter) GetUserAccounts(ctx context.Context, userID string) ([]models.AccountInfo, error) { // Use the store to get accounts accounts, err := a.store.GetUserAccounts(ctx, userID) if err != nil { log.Printf("Error getting user accounts for %s from store: %v", userID, err) // Return an empty slice on error, or handle differently if needed return []*models.AccountInfo{}, fmt.Errorf("failed to get user accounts: %w", err) } return accounts, nil
// Remove reliance on in-memory map
// a.accountMutex.RLock()
// defer a.accountMutex.RUnlock()
// if accounts, ok := a.accounts[userID]; ok {
// return accounts
// }
// return []models.AccountInfo{}
}
// GetAccountByID returns a specific account by ID from the store func (a UnipileAdapter) GetAccountByID(ctx context.Context, accountID string) (models.AccountInfo, error) { // Use the store to get the account account, err := a.store.GetAccount(ctx, accountID) if err != nil { log.Printf("Error getting account %s from store: %v", accountID, err) // Handle not found specifically? pgx might return pgx.ErrNoRows if errors.Is(err, pgx.ErrNoRows) { return nil, nil // Return nil, nil to indicate not found } return nil, fmt.Errorf("failed to get account by ID: %w", err) } return account, nil
// Remove reliance on in-memory map
// a.accountMutex.RLock()
// defer a.accountMutex.RUnlock()
// for _, accounts := range a.accounts {
// for _, account := range accounts {
// if account.AccountID == accountID {
// // Need to return a pointer if changing signature
// accCopy := account // Make a copy to return pointer safely
// return &accCopy, true
// }
// }
// }
// return nil, false
}
// HandleChat implements the providers.Provider interface. // Unipile itself doesn't provide a direct chat interface; specific // messaging actions are handled via ExecuteTool. func (a UnipileAdapter) HandleChat(ctx context.Context, messages []providers.ChatMessage, threadID string) (providers.ChatResponse, error) { return nil, errors.New("direct chat interaction is not supported by the Unipile provider; use specific tools instead") }
// handleAccountStatus handles different account statuses func (a UnipileAdapter) handleAccountStatus(status models.AccountStatus) { switch status.Message { case "OK": log.Printf("✅ Account %s is functioning normally", status.AccountID) case "ERROR", "STOPPED": log.Printf("❌ Account %s encountered an error and has stopped: %s", status.AccountID, status.Message) // TODO: Notify team or user about the error case "CREDENTIALS": log.Printf("🔑 Account %s has credential issues", status.AccountID) // TODO: Notify user to update credentials case "CONNECTING": log.Printf("🔄 Account %s is connecting", status.AccountID) case "DELETED": log.Printf("🗑️ Account %s has been deleted", status.AccountID) case "CREATION_SUCCESS": log.Printf("➕ Account %s has been created successfully", status.AccountID) case "RECONNECTED": log.Printf("🔄 Account %s has been reconnected successfully", status.AccountID) case "SYNC_SUCCESS": log.Printf("🔄 Account %s synchronization successful", status.AccountID) default: log.Printf("ℹ️ Unknown status for account %s: %s", status.AccountID, status.Message) } }
// GetAvailableServices returns the list of services offered by the Unipile provider. func (a *UnipileAdapter) GetAvailableServices() []providers.ServiceDefinition { if !a.initialized || a.client == nil || a.client.Registry == nil { log.Println("Warning: UnipileAdapter not initialized or client/registry is nil when calling GetAvailableServices") return nil }
registeredServices := a.client.Registry.GetAll() // Get map[string]Service
definitions := make([]providers.ServiceDefinition, 0, len(registeredServices))
providerID := a.GetID()
for serviceName, serviceInstance := range registeredServices {
// We need a way to get the *definition* (name, description, schemas)
// Currently, the Service interface only gives GetCapabilities and Execute.
// Assuming capabilities somehow relate to a service definition or can be derived.
// For now, create a basic definition based on the registered name and capabilities.
capabilities := serviceInstance.GetCapabilities()
firstCapabilityName := ""
if len(capabilities) > 0 {
firstCapabilityName = capabilities[0].Name // Use first capability name as a proxy
}
def := providers.ServiceDefinition{
ID: fmt.Sprintf("%s.%s", providerID, serviceName), // e.g., "unipile.email"
Name: strings.Title(serviceName), // e.g., "Email"
Description: fmt.Sprintf("Provides %s services via Unipile", serviceName),
// InputSchema and OutputSchema would ideally come from the service definition
// or be derived more intelligently from capabilities.
InputSchema: map[string]interface{}{"capability_name": firstCapabilityName}, // Placeholder
OutputSchema: map[string]interface{}{}, // Placeholder
}
definitions = append(definitions, def)
}
return definitions
}
// GetVersion returns the version of the Unipile provider adapter. func (a *UnipileAdapter) GetVersion() string { // TODO: Define a proper version, possibly as a constant return "0.1.0" }
// GetService returns a specific service instance by its ID. func (a *UnipileAdapter) GetService(serviceID string) (providers.Service, error) { // Implementation for getting a specific service instance return nil, fmt.Errorf("service retrieval not implemented for Unipile provider") }
// --- New Event Handling Logic ---
// HandleReceivedEmailEvent is called by the central event listener when a 'unipile.email.received' event occurs. // It expects the eventPayload to contain webhookPayloadResourceID and userID (framework user ID). // It fetches the full thread, creates a new resource for it (associated with userID), // and publishes an event containing the necessary info for the job creator. func (a *UnipileAdapter) HandleReceivedEmailEvent(ctx context.Context, eventPayload map[string]interface{}) error {
// Extract required info from the event payload
webhookPayloadResourceID, _ := eventPayload["webhookPayloadResourceId"].(string)
userID, _ := eventPayload["userID"].(string) // Expecting framework userID from the publisher
if webhookPayloadResourceID == "" {
log.Printf("ERROR: HandleReceivedEmailEvent missing webhookPayloadResourceId in event payload")
return errors.New("missing webhookPayloadResourceId in event payload")
}
if userID == "" {
// If the userID is critical for fetching/creating resources, we must have it.
log.Printf("ERROR: HandleReceivedEmailEvent missing userID in event payload for resource %s", webhookPayloadResourceID)
return errors.New("missing userID in event payload")
}
log.Printf("HandleReceivedEmailEvent triggered for webhook payload resource: %s, User: %s", webhookPayloadResourceID, userID)
// 1. Check Dependencies (Store is needed now for account check)
if a.resourceMgr == nil || a.jobMgr == nil || a.client == nil || a.store == nil || a.notifier == nil {
errMsg := "cannot handle email event: missing dependencies (ResourceManager, JobManager, UnipileClient, UnipileStore, or Notifier)"
log.Printf("ERROR: %s", errMsg)
return errors.New(errMsg)
}
// 2. Fetch the original webhook payload resource using userID
payloadReader, _, err := a.resourceMgr.GetResourceContent(ctx, userID, webhookPayloadResourceID)
if err != nil {
log.Printf("ERROR fetching webhook payload resource %s for user %s: %v", webhookPayloadResourceID, userID, err)
return fmt.Errorf("failed to fetch webhook payload resource: %w", err)
}
defer payloadReader.Close()
// 3. Unmarshal the webhook payload
var webhookPayload models.WebhookPayload
if err := json.NewDecoder(payloadReader).Decode(&webhookPayload); err != nil {
log.Printf("ERROR decoding webhook payload resource %s: %v", webhookPayloadResourceID, err)
return fmt.Errorf("failed to decode webhook payload JSON: %w", err)
}
// Extract necessary IDs (redundant with eventPayload, but safer to use payload)
accountID := webhookPayload.AccountID
messageID := webhookPayload.MessageID // Use MessageID from payload if available, might differ from EmailID
if accountID == "" {
log.Printf("ERROR: Missing accountID in webhook payload from resource %s", webhookPayloadResourceID)
return errors.New("invalid webhook payload: missing accountID")
}
// Use EmailID as fallback for messageID if needed by downstream?
if messageID == "" {
messageID = webhookPayload.EmailID
}
if messageID == "" {
log.Printf("ERROR: Missing messageID/emailID in webhook payload from resource %s", webhookPayloadResourceID)
return errors.New("invalid webhook payload: missing messageID/emailID")
}
// 4. **Verify Account Mapping** (Crucial Step)
accountInfo, err := a.store.GetAccount(ctx, accountID)
if err != nil {
log.Printf("ERROR checking account mapping for %s: %v. Cannot proceed.", accountID, err)
return fmt.Errorf("failed to check account mapping for %s: %w", accountID, err)
}
if accountInfo == nil || accountInfo.UserID == "" {
log.Printf("WARN: Unipile account %s is not mapped to an internal user in store. Skipping further processing.", accountID)
// It's important *not* to proceed if the mapping is missing/invalid.
return nil // Or return an error if this situation requires intervention
}
// Double-check consistency: Does the mapped UserID match the one from the event?
if accountInfo.UserID != userID {
log.Printf("CRITICAL: UserID mismatch for account %s! Event UserID: '%s', Stored UserID: '%s'. Aborting.", accountID, userID, accountInfo.UserID)
return fmt.Errorf("user ID mismatch for account %s", accountID)
}
// --- Mapping Confirmed --- > Proceed with userID
// 5. Fetch the full thread details using the internal client
log.Printf("Fetching full thread details for account=%s, message=%s...", accountID, messageID)
fullThread, err := a.GetEmailThreadForMessage(ctx, accountID, messageID) // Use the real implementation now
if err != nil {
log.Printf("ERROR fetching full thread for account=%s, message=%s: %v", accountID, messageID, err)
// Don't create resource or event if thread fetch fails
return fmt.Errorf("failed to fetch full thread: %w", err)
}
if fullThread == nil || len(fullThread.Emails) == 0 {
log.Printf("WARN: Fetched thread (Account: %s, Message: %s) is nil or empty. Skipping resource/event creation.", accountID, messageID)
return nil // Not an error, just nothing to process
}
actualThreadID := fullThread.ID // Get the real ThreadID
// 6. Marshal full thread data to JSON
threadJSON, err := json.Marshal(fullThread)
if err != nil {
log.Printf("ERROR marshaling full thread %s to JSON: %v", actualThreadID, err)
return fmt.Errorf("failed to marshal full thread JSON: %w", err)
}
// 7. Create a *new* Resource for the raw full thread data, associated with the userID
resourceTitle := fmt.Sprintf("Raw Unipile Email Thread: %s", fullThread.Subject)
resourceType := "raw_email_thread_unipile" // Use the type expected by the preparator
contentType := "application/json"
// Attempt to get source event from payload, handle if not string
sourceEvent := "unknown"
if se, ok := eventPayload["source_event"].(string); ok {
sourceEvent = se
}
resourceMetadata := map[string]string{
"unipile_account_id": accountID,
"unipile_thread_id": actualThreadID,
"unipile_message_id": messageID, // Include the triggering message ID
"original_subject": fullThread.Subject,
"source_event": sourceEvent, // Use extracted source event
"source_payload_resource_id": webhookPayloadResourceID, // Link back
}
log.Printf("Creating resource '%s' for full thread %s (User: %s)...", resourceType, actualThreadID, userID)
// Pass pointer to userID, ensuring it's not empty
var userIDPtr *string
if userID != "" {
userIDPtr = &userID
}
createdResource, err := a.resourceMgr.CreateResource(ctx, bytes.NewReader(threadJSON), resourceTitle, resourceType, contentType, nil /* tags */, resourceMetadata, userIDPtr)
if err != nil {
log.Printf("ERROR creating resource for full thread %s for user %s: %v", actualThreadID, userID, err)
return fmt.Errorf("failed to create resource for full thread: %w", err)
}
log.Printf("Successfully created full thread resource %s for thread %s (User: %s)", createdResource.ID, actualThreadID, userID)
// 8. Publish Event: Notify listeners that a raw thread resource is ready
const eventChannel = "unipile.email.thread.created" // Define a channel name
eventPayloadOut := map[string]interface{}{
"rawThreadResourceId": createdResource.ID,
"unipileAccountId": accountID,
"unipileThreadId": actualThreadID,
"unipileMessageId": messageID,
"userID": userID, // Include the verified framework User ID
"sourcePayloadResourceId": webhookPayloadResourceID,
// Include other potentially useful info from fullThread or webhookPayload if needed
}
log.Printf("Publishing event '%s' for resource %s (Thread: %s, User: %s)...", eventChannel, createdResource.ID, actualThreadID, userID)
if err := a.notifier.Publish(ctx, eventChannel, eventPayloadOut); err != nil {
// Log the error, but potentially don't fail the entire handler?
// Depends on whether notification is critical path.
log.Printf("ERROR publishing event '%s' for resource %s: %v", eventChannel, createdResource.ID, err)
// Consider returning the error if publishing is critical
// return fmt.Errorf("failed to publish event '%s': %w", eventChannel, err)
}
log.Printf("HandleReceivedEmailEvent completed for webhook payload resource %s. Published event for thread resource %s.", webhookPayloadResourceID, createdResource.ID)
return nil // Indicate successful handling of the event
}
// GetEmailThreadForMessage fetches the full thread details for a given message ID. func (a UnipileAdapter) GetEmailThreadForMessage(ctx context.Context, accountID, messageID string) (models.EmailThread, error) { log.Printf("GetEmailThreadForMessage: Fetching details for Account=%s, Message=%s", accountID, messageID)
// 1. Get the EmailService instance from the registry
if a.client == nil || a.client.Registry == nil {
return nil, errors.New("Unipile client or service registry not initialized")
}
service, exists := a.client.Registry.Get("email")
if !exists {
return nil, errors.New("email service not found in Unipile client registry")
}
emailService, ok := service.(*services.EmailService) // Type assertion
if !ok {
return nil, errors.New("failed to cast to EmailService type")
}
// 2. Fetch the email details using the messageID
log.Printf("Fetching email %s to get thread ID...", messageID)
emailDetails, err := emailService.GetEmail(ctx, messageID)
if err != nil {
// Handle potential errors like Not Found vs API error
log.Printf("Error fetching email %s: %v", messageID, err)
return nil, fmt.Errorf("failed to fetch email details for message %s: %w", messageID, err)
}
if emailDetails == nil {
log.Printf("Email %s not found.", messageID)
return nil, fmt.Errorf("email with ID %s not found", messageID)
}
// 3. Extract the ThreadID
threadID := emailDetails.ThreadID
if threadID == "" {
log.Printf("WARN: Email %s does not have an associated ThreadID. Cannot fetch full thread.", messageID)
// What should happen here? Return an error? Or maybe construct a minimal thread?
// For now, return an error as the draft preparator expects a thread.
return nil, fmt.Errorf("email %s has no ThreadID", messageID)
}
log.Printf("Found ThreadID %s for message %s. Fetching full thread...", threadID, messageID)
// 4. Fetch the full thread details using the threadID
fullThread, err := emailService.GetEmailThread(ctx, threadID)
if err != nil {
log.Printf("Error fetching email thread %s: %v", threadID, err)
return nil, fmt.Errorf("failed to fetch email thread %s: %w", threadID, err)
}
if fullThread == nil {
// Should be unlikely if GetEmail succeeded, but handle defensively
log.Printf("Email thread %s not found after fetching email %s.", threadID, messageID)
return nil, fmt.Errorf("email thread %s not found", threadID)
}
log.Printf("Successfully fetched email thread %s (Subject: %s)", threadID, fullThread.Subject)
return fullThread, nil
}
// GetDetailedEventInfo returns detailed information about all registered Unipile notification events. func (a UnipileAdapter) GetDetailedEventInfo() []providers.DetailedEventInfo { // Note: Event patterns like "email." cover multiple specific events (e.g., email_received, email_sent). // Descriptions might need refinement based on actual Unipile docs for each sub-event. return []providers.DetailedEventInfo{ { EventPattern: "unipile.email.", // Use a provider-specific prefix if desired SourceProvider: a.GetID(), Description: "Notifications related to email activities (received, sent, read, deleted, etc.). Triggered by Unipile webhooks.", PayloadIDs: []string{"webhookPayloadResourceId", "accountId", "messageId", "emailId", "subject", "from"}, // Based on HandleReceivedEmailEvent publication RetrievalNotes: "The 'webhookPayloadResourceId' points to the raw webhook payload. Use 'accountId' and 'emailId'/'messageId' with Unipile EmailService (e.g., GetEmail or GetEmailThreadForMessage) to fetch full details.", }, { EventPattern: "unipile.message.", SourceProvider: a.GetID(), Description: "Notifications for chat message activities (received, read, reaction, edited, deleted). Triggered by Unipile webhooks.", PayloadIDs: []string{"AccountID", "ChatID", "MessageID", "Event"}, // From webhook payload struct RetrievalNotes: "Webhook payload is light. Use 'AccountID' and 'MessageID' (or 'ChatID') with the Unipile MessagingService (e.g., GetMessage or GetChat) to fetch the full message or chat details.", }, { EventPattern: "unipile.account.*", SourceProvider: a.GetID(), Description: "Notifications about changes in linked account status (OK, ERROR, CREDENTIALS, etc.). Triggered by Unipile webhooks.", PayloadIDs: []string{"AccountID", "AccountType", "Message"}, // From webhook payload struct RetrievalNotes: "The payload contains the core status ('Message'). To get full account details associated with the 'AccountID', use the Unipile AccountService (e.g., GetAccount).", }, } }
// Implementation for webhook service tool execution func (a *UnipileAdapter) executeWebhookCapability(ctx context.Context, capability string, params map[string]interface{}) (interface{}, error) { whService := services.NewWebhookService(a.client) options := models.QueryOptions{} if limitParam, ok := params["limit"].(float64); ok { options.Limit = int(limitParam) } options.Cursor, _ = params["cursor"].(string)
webhookID, _ := params["webhook_id"].(string)
eventID, _ := params["event_id"].(string)
deliveryID, _ := params["delivery_id"].(string)
switch capability {
case "list_webhooks":
return whService.ListWebhooks(ctx, options)
case "get_webhook":
if webhookID == "" {
return nil, errors.New("webhook_id parameter is required")
}
return whService.GetWebhook(ctx, webhookID)
case "create_webhook":
var whDraft models.WebhookDraft
// Use mapstructure to decode directly from params to the struct
if err := mapstructure.Decode(params, &whDraft); err != nil {
return nil, fmt.Errorf("error decoding create_webhook params: %w", err)
}
// Ensure required fields from capability definition are present
if whDraft.URL == "" {
return nil, errors.New("url parameter is required")
}
if len(whDraft.Events) == 0 {
return nil, errors.New("events parameter is required")
}
return whService.CreateWebhook(ctx, &whDraft)
case "update_webhook":
if webhookID == "" {
return nil, errors.New("webhook_id parameter is required")
}
var whUpdate models.WebhookUpdate
// Use mapstructure to decode directly from params to the struct
if err := mapstructure.Decode(params, &whUpdate); err != nil {
return nil, fmt.Errorf("error decoding update_webhook params: %w", err)
}
return whService.UpdateWebhook(ctx, webhookID, &whUpdate)
case "delete_webhook":
if webhookID == "" {
return nil, errors.New("webhook_id parameter is required")
}
return whService.DeleteWebhook(ctx, webhookID)
case "list_webhook_events":
if webhookID == "" {
return nil, errors.New("webhook_id parameter is required")
}
return whService.ListWebhookEvents(ctx, webhookID, options)
case "get_webhook_event":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
return whService.GetWebhookEvent(ctx, eventID)
case "list_webhook_event_deliveries":
if eventID == "" {
return nil, errors.New("event_id parameter is required")
}
return whService.ListWebhookEventDeliveries(ctx, eventID, options)
case "resend_webhook_delivery":
if deliveryID == "" {
return nil, errors.New("delivery_id parameter is required")
}
return whService.ResendWebhookDelivery(ctx, deliveryID)
default:
return nil, fmt.Errorf("unsupported Webhook capability: %s", capability)
}
}
// SyncUserAccounts reconciles the status of locally stored Unipile accounts // for a given framework user with the actual status from the Unipile API. func (a *UnipileAdapter) SyncUserAccounts(ctx context.Context, userID string) error { if !a.initialized { return errors.New("adapter not initialized") } if a.store == nil { return errors.New("unipile store not available") } if a.client == nil { return errors.New("unipile client not available") }
log.Printf("Starting Unipile account sync for user %s", userID)
// 1. Get locally stored accounts for the user
localAccounts, err := a.store.GetUserAccounts(ctx, userID)
if err != nil {
log.Printf("Error fetching local Unipile accounts for user %s: %v", userID, err)
return fmt.Errorf("failed to get local accounts for sync: %w", err)
}
if len(localAccounts) == 0 {
log.Printf("No local Unipile accounts found for user %s to sync.", userID)
return nil // Nothing to sync
}
// Get Unipile Account Service
accService := services.NewAccountService(a.client)
syncedCount := 0
updatedCount := 0
notFoundCount := 0
errorCount := 0
// 2. Iterate and check each account against Unipile API
for _, localAccount := range localAccounts {
accountID := localAccount.AccountID
log.Printf("Syncing account %s for user %s...", accountID, userID)
// 3. Fetch current info from Unipile API
apiCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
unipileAccount, err := accService.GetAccount(apiCtx, accountID) // Returns *models.Account
cancel()
if err != nil {
// TODO: Refine error checking based on actual client implementation for 404.
// For now, we log the error and assume it might be a 404 if GetAccount returns error.
// A more robust check would inspect the error type or code.
log.Printf("Error fetching account %s from Unipile API: %v. Assuming it might be deleted/unlinked.", accountID, err)
// Attempt to update status to deleted_externally
notFoundCount++
newStatus := "deleted_externally"
if localAccount.Status != newStatus {
if updateErr := a.store.UpdateAccountStatus(ctx, accountID, newStatus); updateErr != nil {
log.Printf("ERROR updating local status for account %s (presumed not found externally): %v", accountID, updateErr)
errorCount++
} else {
updatedCount++
}
} else {
log.Printf("Local status for account %s already reflects external deletion.", accountID)
}
continue // Move to the next account
}
if unipileAccount == nil {
// Unipile GetAccount returned nil error and nil account info - should not happen
log.Printf("WARN: Unipile API returned nil info and nil error for account %s. Skipping.", accountID)
errorCount++
continue // Move to the next account
}
// --- DEBUG: Log the received account info as JSON ---
accountJSON, jsonErr := json.MarshalIndent(unipileAccount, "", " ") // Indent for readability
if jsonErr != nil {
log.Printf("DEBUG: Failed to marshal account info %s to JSON for logging: %v", accountID, jsonErr)
} else {
log.Printf("DEBUG: Received account info for %s from API:\n%s", accountID, string(accountJSON))
}
// --- END DEBUG ---
// 4. Account found, process it
syncedCount++
apiStatus := unipileAccount.Status // Status from models.Account
if apiStatus == "" {
apiStatus = "active" // Default if status is empty
log.Printf("WARN: Unipile API returned account %s without an explicit status. Assuming 'active'.", accountID)
}
if localAccount.Status != apiStatus {
log.Printf("Status mismatch for account %s. Local: '%s', API: '%s'. Updating local status.", accountID, localAccount.Status, apiStatus)
// Update local status only
if updateErr := a.store.UpdateAccountStatus(ctx, accountID, apiStatus); updateErr != nil {
log.Printf("ERROR updating local status for account %s: %v", accountID, updateErr)
errorCount++
} else {
updatedCount++
}
} else {
// Status matches, refresh metadata and timestamp by calling StoreAccount
log.Printf("Status for account %s matches ('%s'). Refreshing metadata/timestamp.", accountID, apiStatus)
// Create a temporary map for StoreAccount, passing the marshaled bytes
// StoreAccount expects map[string]interface{}, but we pass marshaled bytes which it handles internally.
// Correction: StoreAccount actually expects map[string]interface{} and marshals *itself*.
// We should pass the map directly.
if updateErr := a.store.StoreAccount(ctx, userID, accountID, unipileAccount.Provider, apiStatus, unipileAccount.Metadata /* pass map directly */); updateErr != nil {
log.Printf("ERROR refreshing local metadata/timestamp for account %s: %v", accountID, updateErr)
errorCount++
}
}
}
log.Printf("Unipile account sync completed for user %s. Total Checked: %d, Synced (API Found): %d, API Not Found/Error: %d, Updated Locally: %d, Errors During Update: %d",
userID, len(localAccounts), syncedCount, notFoundCount, updatedCount, errorCount)
if errorCount > 0 {
return fmt.Errorf("sync encountered %d errors during update/refresh", errorCount)
}
return nil
}
// TODO: Refine error checking for API Not Found based on how the unipile client signals it. // TODO: Verify the structure of models.Account returned by accService.GetAccount, especially Metadata field.
// scheduleAccountSyncJob schedules the recurring job to sync Unipile accounts. // This is a simplified placeholder implementation. func (a *UnipileAdapter) scheduleAccountSyncJob(ctx context.Context, interval string) error { slog.InfoContext(ctx, "Attempting to schedule Unipile account sync job", "interval", interval)
if a.jobMgr == nil {
return fmt.Errorf("cannot schedule sync job: JobManager is not initialized")
}
// Check if a job with this process ID and a similar name already exists (optional, prevents duplicates)
// listOpts := &jobmanager.ListJobsOptions{ ProcessID: &unipileAccountSyncProcessID } // Assuming ProcessID is filterable
// existingJobs, err := a.jobMgr.ListJobs(ctx, listOpts)
// if err != nil { ... handle error ... }
// if len(existingJobs) > 0 { ... job already exists ... }
job := &jobmanager.Job{
Name: "Unipile Account Sync",
ProcessID: unipileAccountSyncProcessID, // Ensure this constant is defined
Status: jobmanager.JobStatusPending,
Schedule: &interval, // Use pointer to string for schedule
MaxRetries: 3,
// Params: could include batch size, etc. if needed
}
createdJob, err := a.jobMgr.CreateJob(ctx, job)
if err != nil {
slog.ErrorContext(ctx, "Failed to create Unipile account sync job", "error", err)
return fmt.Errorf("failed to create account sync job: %w", err)
}
slog.InfoContext(ctx, "Successfully created Unipile account sync job", "job_id", createdJob.ID, "schedule", *createdJob.Schedule)
return nil
}
// --- End of adapter.go ---
// File: auth.go
//pkg/providers/unipile/auth.go
package unipile
import ( "context" "fmt" "log" "net/http" "time"
"github.com/gofiber/fiber/v2"
"gitlab.com/webigniter/slop-server/internal/auth"
)
// GenerateAuthLinkHandler creates a handler for generating Unipile auth links func (a UnipileAdapter) GenerateAuthLinkHandler() fiber.Handler { return func(c fiber.Ctx) error { // Get user ID from JWT token userID, err := auth.GetUserIDFromToken(c) if err != nil { return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ "error": "Unauthorized", "message": "Invalid token", }) }
// Parse request
var req struct {
Providers []string `json:"providers"`
Type string `json:"type"` // create or reconnect
AccountID string `json:"account_id,omitempty"` // for reconnect
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "Invalid request format",
})
}
// Validate request
if req.Type == "" {
req.Type = "create" // Default to create
}
if req.Type != "create" && req.Type != "reconnect" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "Type must be 'create' or 'reconnect'",
})
}
if req.Type == "reconnect" && req.AccountID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "Account ID is required for reconnect",
})
}
// Default to all providers if none specified
if len(req.Providers) == 0 {
req.Providers = []string{"*"}
}
// Generate auth link
link, err := a.GenerateAuthLink(c.Context(), userID, req.Providers, req.Type, req.AccountID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Internal Server Error",
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"url": link,
})
}
}
// AuthCallbackHandler handles callbacks from Unipile auth func (a UnipileAdapter) AuthCallbackHandler() fiber.Handler { return func(c fiber.Ctx) error { // Parse notification payload var notification struct { Status string json:"status"
AccountID string json:"account_id"
Name string json:"name"
// This is the userID passed in the auth link request }
if err := c.BodyParser(¬ification); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "Invalid notification format",
})
}
// Validate notification
if notification.AccountID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "Account ID is required",
})
}
if notification.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Bad Request",
"message": "User ID (name) is required",
})
}
// Process the notification
err := a.ProcessAuthCallback(c.Context(), notification.Name, notification.AccountID, notification.Status)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Internal Server Error",
"message": err.Error(),
})
}
return c.JSON(fiber.Map{
"status": "success",
})
}
}
// ProcessAuthCallback handles callbacks from Unipile auth func (a *UnipileAdapter) ProcessAuthCallback(ctx context.Context, userID, accountID, status string) error { // Get account type from Unipile API var accountInfo struct { Provider string json:"provider"
}
// Use a context with timeout for the API call
apiCtx, cancel := context.WithTimeout(ctx, 15*time.Second) // Use incoming ctx, add timeout
defer cancel()
err := a.client.Request(apiCtx, http.MethodGet, fmt.Sprintf("/accounts/%s", accountID), nil, &accountInfo)
if err != nil {
return fmt.Errorf("error getting account info for %s: %w", accountID, err)
}
// Store in database
err = a.store.StoreAccount(ctx, userID, accountID, accountInfo.Provider, status, nil) // Use incoming ctx
if err != nil {
return fmt.Errorf("error storing account %s for user %s: %w", accountID, userID, err)
}
log.Printf("Successfully stored account %s for user %s with status %s", accountID, userID, status)
return nil
}
// --- End of auth.go ---
// File: client.go
//pkg/providers/unipile/client.go
package unipile
import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time"
"gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/services"
)
// UnipileClient is an HTTP client for the Unipile API type UnipileClient struct { BaseURL string APIKey string HTTPClient http.Client Registry providers.ServiceRegistry }
// NewUnipileClient creates a new Unipile client func NewUnipileClient(baseURL, apiKey string) *UnipileClient { client := &UnipileClient{ BaseURL: baseURL, APIKey: apiKey, HTTPClient: &http.Client{ Timeout: 30 * time.Second, }, }
// Create service registry
client.Registry = providers.NewServiceRegistry()
return client
}
// Request sends a request to the Unipile API func (c *UnipileClient) Request(ctx context.Context, method, path string, body interface{}, response interface{}) error { url := c.BaseURL + path
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("error serializing request body: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// Add headers
req.Header.Set("X-API-KEY", c.APIKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
// Send request
resp, err := c.HTTPClient.Do(req)
if err != nil {
return fmt.Errorf("error executing request: %w", err)
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("error reading response: %w", err)
}
// Check status code
if resp.StatusCode >= 400 {
var errorResp ErrorResponse
if err := json.Unmarshal(respBody, &errorResp); err != nil {
// If error response can't be parsed, return raw error
return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody))
}
return fmt.Errorf("API error (%d): %s - %s", errorResp.Status, errorResp.Title, errorResp.Detail)
}
// Parse response
if response != nil {
if err := json.Unmarshal(respBody, response); err != nil {
return fmt.Errorf("error parsing response: %w", err)
}
}
return nil
}
// ErrorResponse represents an error response from the Unipile API type ErrorResponse struct { Title string json:"title"
Detail string json:"detail"
Status int json:"status"
Type string json:"type,omitempty"
Instance string json:"instance,omitempty"
}
// InitServices initializes all available services in the registry func (c *UnipileClient) InitServices() { // Create and register services linkedInService := services.NewLinkedInService(c) emailService := services.NewEmailService(c) messagingService := services.NewMessagingService(c) webhookService := services.NewWebhookService(c) accountService := services.NewAccountService(c) calendarService := services.NewCalendarService(c) pageService := services.NewPageService(c) postService := services.NewPostService(c)
// Register services with the registry
c.Registry.Register("linkedin", linkedInService)
c.Registry.Register("email", emailService)
c.Registry.Register("messaging", messagingService)
c.Registry.Register("webhook", webhookService)
c.Registry.Register("account", accountService)
c.Registry.Register("calendar", calendarService)
c.Registry.Register("page", pageService)
c.Registry.Register("post", postService)
}
// GetSlopAdapter returns a new SLOP adapter for this client func (c UnipileClient) GetSlopAdapter() providers.SlopAdapter { return providers.NewSlopAdapter(c.Registry) }
// SetTimeout sets the timeout for the HTTP client func (c *UnipileClient) SetTimeout(timeout time.Duration) { c.HTTPClient.Timeout = timeout }
// --- End of client.go ---
// File: email_webhook_handler.go
//pkg/providers/unipile/email_webhook_handler.go
package unipile
import ( "bytes" "context" "encoding/json" "fmt" "log" "strings"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
const ( // Define constants for resource type and event channel name resourceTypeWebhookPayload = "unipile_email_webhook_payload" eventEmailReceived = "unipile.email.received" )
// processEmailWebhook processes the email webhook payload func (a UnipileAdapter) processEmailWebhook(ctx context.Context, payload models.WebhookPayload) { log.Printf("Email webhook received: Event='%s', Subject='%s', From='%s', AccountID='%s', MessageID='%s'", payload.Event, payload.Subject, payload.FromAttendee.Identifier, payload.AccountID, payload.MessageID)
// Save the raw webhook payload to unipile's own store first
if err := a.store.SaveEmail(ctx, payload); err != nil {
log.Printf("ERROR saving email webhook payload (MessageID: %s): %v", payload.MessageID, err)
// Decide if we should stop or continue if save fails. For now, continue.
}
// Process based on event type
switch payload.Event {
case "email_received":
log.Printf("Processing 'email_received' for message %s, account %s", payload.MessageID, payload.AccountID)
// Ensure dependencies are available
if a.resourceMgr == nil || a.notifier == nil {
log.Printf("ERROR: ResourceManager or Notifier not initialized in UnipileAdapter. Cannot process event for message %s.", payload.MessageID)
return
}
// 1. Marshal the raw webhook payload to JSON
payloadJSON, err := json.Marshal(payload)
if err != nil {
log.Printf("ERROR marshaling webhook payload for message %s: %v", payload.MessageID, err)
return // Cannot proceed without JSON data
}
// 2. Look up framework UserID from AccountID
accountInfo, err := a.store.GetAccount(ctx, payload.AccountID)
if err != nil {
log.Printf("ERROR looking up account mapping for %s: %v. Cannot process webhook resource/event.", payload.AccountID, err)
return
}
if accountInfo == nil || accountInfo.UserID == "" {
log.Printf("WARN: Unipile account %s is not mapped to a framework user. Cannot process webhook resource/event.", payload.AccountID)
return // Skip resource creation if no user mapping
}
userID := accountInfo.UserID // Framework user ID
// 3. Create a Resource for the raw webhook payload, associated with the user
resourceTitle := fmt.Sprintf("Unipile Email Webhook Payload: %s", payload.Subject)
contentType := "application/json"
resourceMetadata := map[string]string{
"unipile_account_id": payload.AccountID,
"unipile_message_id": payload.MessageID,
"unipile_email_id": payload.EmailID,
"webhook_event": payload.Event,
}
log.Printf("Creating resource '%s' for message %s (User: %s)...", resourceTypeWebhookPayload, payload.MessageID, userID)
// Pass nil for tags, and pointer to userID
createdResource, err := a.resourceMgr.CreateResource(ctx, bytes.NewReader(payloadJSON), resourceTitle, resourceTypeWebhookPayload, contentType, nil /* tags */, resourceMetadata, &userID)
if err != nil {
log.Printf("ERROR creating resource for webhook payload (MessageID: %s, User: %s): %v", payload.MessageID, userID, err)
return // Cannot publish event without resource ID
}
log.Printf("Successfully created resource %s for webhook payload (MessageID: %s, User: %s)", createdResource.ID, payload.MessageID, userID)
// 4. Publish an event with the resource ID and framework userID
eventPayload := map[string]interface{}{
"webhookPayloadResourceId": createdResource.ID,
"accountId": payload.AccountID,
"messageId": payload.MessageID,
"emailId": payload.EmailID,
"subject": payload.Subject,
"from": payload.FromAttendee.Identifier,
"userID": userID, // Include framework user ID
}
log.Printf("Publishing event '%s' for resource %s (MessageID: %s, User: %s)...", eventEmailReceived, createdResource.ID, payload.MessageID, userID)
if err := a.notifier.Publish(ctx, eventEmailReceived, eventPayload); err != nil {
// Log the error, but maybe don't stop processing other webhook types?
log.Printf("ERROR publishing event '%s' for resource %s (MessageID: %s, User: %s): %v", eventEmailReceived, createdResource.ID, payload.MessageID, userID, err)
}
// Handle other event types if needed...
case "email_sent":
log.Printf("Email sent for message %s", payload.MessageID)
case "email_read":
log.Printf("Email read for message %s", payload.MessageID)
case "email_deleted":
log.Printf("Email deleted: %s", payload.EmailID)
case "email_draft":
log.Printf("Email draft saved: %s", payload.EmailID)
default:
log.Printf("Unknown email event type '%s' for message %s", payload.Event, payload.MessageID)
}
// Run general notifications (original logic)
a.processEmailNotifications(payload)
}
// processEmailNotifications handles notifications based on email content func (a UnipileAdapter) processEmailNotifications(payload models.WebhookPayload) { // Implement notification logic based on email content or metadata // For example: high priority emails, specific senders, keyword matching, etc.
// Example: Check for high priority or specific subjects
if payload.Subject != "" {
if contains(payload.Subject, "urgent") || contains(payload.Subject, "important") {
log.Printf("High priority email detected: %s", payload.Subject)
// TODO: Send notification or create alert
}
}
}
// Helper function to check if a string contains a substring (case-insensitive) func contains(str, substr string) bool { s, sub := strings.ToLower(str), strings.ToLower(substr) return strings.Contains(s, sub) }
// --- End of email_webhook_handler.go ---
// File: helper.go
//pkg/providers/unipile/helper.go
package unipile
import ( "bytes" "io" "net/http" "net/http/httptest" "strings"
"github.com/gofiber/fiber/v2"
)
// fiberHandlerToHTTP converts a Fiber handler to a standard Go http.HandlerFunc. // It achieves this by creating a temporary Fiber app, running the request through it using app.Test, // and then writing the response back to the http.ResponseWriter. func fiberHandlerToHTTP(fiberHandler fiber.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Create a new Fiber app instance for this request app := fiber.New()
// Register the specific handler for all methods and paths for this temporary app
app.Use(fiberHandler) // Use .Use to catch all paths and methods
// Perform the request against the Fiber app
resp, err := app.Test(r)
if err != nil {
// Handle potential errors during the test request
http.Error(w, "Internal Server Error during handler conversion", http.StatusInternalServerError)
// Log the error for debugging purposes
// log.Printf("Error testing Fiber handler: %v", err)
return
}
defer resp.Body.Close()
// Copy the status code from Fiber response to http.ResponseWriter
w.WriteHeader(resp.StatusCode)
// Copy headers from Fiber response to http.ResponseWriter
for k, vv := range resp.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
// Copy the body from Fiber response to http.ResponseWriter
if _, err := io.Copy(w, resp.Body); err != nil {
// Log error during body copy
// log.Printf("Error copying response body: %v", err)
// Avoid writing header again if already written
if w.Header().Get("Content-Type") == "" {
// Maybe send an error status if nothing has been sent yet
}
}
}
}
// CreateFiberHandler converts a standard Go http.Handler to a Fiber handler. func CreateFiberHandler(handler http.Handler) fiber.Handler { return func(c *fiber.Ctx) error { // Create a response recorder w := httptest.NewRecorder()
// Create a request from Fiber context
req, err := http.NewRequest(
c.Method(),
c.OriginalURL(),
bytes.NewReader(c.Body()),
)
if err != nil {
return err
}
// Copy headers
for k, v := range c.GetReqHeaders() {
req.Header.Set(k, strings.Join(v, ","))
}
// Serve the request with the original handler
handler.ServeHTTP(w, req)
// Copy status code
c.Status(w.Code)
// Copy headers
for k, v := range w.Header() {
c.Set(k, v[0]) // Note: This might lose multiple values for the same header key
}
// Write the response body
return c.Send(w.Body.Bytes())
}
}
// --- End of helper.go ---
// File: jobs_webhook.go
//pkg/providers/unipile/jobs_webhook.go
package unipile
import ( "context" "fmt" "net/http" "time"
"gitlab.com/webigniter/slop-server/internal/pubsub"
)
// Channel name for new email events const NewEmailChannel = "emails.new"
// NewEmailPayload defines the structure of the event payload type NewEmailPayload struct { EmailID string json:"emailId"
// Add other relevant fields like UserID, AccountID, etc. }
// Assume 'pubsubManager' is an initialized instance of pubsub.Publisher passed or accessible here var pubsubMgr pubsub.Publisher
func HandleEmailWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context()
if pubsubMgr == nil {
fmt.Println("Error: PubSub Manager not initialized in Unipile webhook handler")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
// 1. Process webhook payload
// ... parse request ...
// 2. Store the email data in your database
newEmailID, err := storeReceivedEmail(ctx /* email data */)
if err != nil {
// Log error
fmt.Printf("Error storing received email: %v\n", err)
http.Error(w, "Failed to store email", http.StatusInternalServerError)
return
}
fmt.Printf("Stored new email with ID: %s\n", newEmailID)
// 3. Define the event payload
payload := NewEmailPayload{
EmailID: newEmailID,
// Populate other fields if added to the struct
}
// 4. Publish the event using the PubSub manager
err = pubsubMgr.Publish(ctx, NewEmailChannel, payload)
if err != nil {
// Log error - Job creation will not be triggered if publish fails
fmt.Printf("Error publishing NewEmail event for email %s: %v\n", newEmailID, err)
// Decide how to handle - failure here means the job won't be triggered
http.Error(w, "Failed processing event", http.StatusInternalServerError) // Example
return
}
fmt.Printf("Successfully published event for new email %s to channel %s\n", newEmailID, NewEmailChannel)
// 5. Respond to the webhook source
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "Email received and event published.")
}
func storeReceivedEmail(ctx context.Context, data ...any) (string, error) { // Placeholder: implement your DB logic to store the email emailID := fmt.Sprintf("email-%d", time.Now().UnixNano()) fmt.Printf("Placeholder: Storing email, generated ID: %s\n", emailID) return emailID, nil }
// SetupPubSub needs to be called during app initialization to inject the publisher func SetupPubSub(publisher pubsub.Publisher) { if publisher == nil { // Handle error - perhaps panic or log fatal if pubsub is essential panic("PubSub publisher cannot be nil") } pubsubMgr = publisher fmt.Println("Unipile PubSub publisher initialized.") }
// --- End of jobs_webhook.go ---
// File: models/account.go
//pkg/providers/unipile/models/account.go
package models
import "time"
// Account représente un compte utilisateur dans l'API Unipile type Account struct { ID string json:"id"
Provider string json:"provider"
Type string json:"type,omitempty"
// personal, business, organization Status string json:"status,omitempty"
// active, disabled, pending Email string json:"email,omitempty"
Username string json:"username,omitempty"
Name string json:"name,omitempty"
FirstName string json:"first_name,omitempty"
LastName string json:"last_name,omitempty"
DisplayName string json:"display_name,omitempty"
AvatarURL string json:"avatar_url,omitempty"
URL string json:"url,omitempty"
Bio string json:"bio,omitempty"
Location string json:"location,omitempty"
Language string json:"language,omitempty"
Timezone string json:"timezone,omitempty"
IsVerified bool json:"is_verified,omitempty"
CreatedAt time.Time json:"created_at,omitempty"
UpdatedAt time.Time json:"updated_at,omitempty"
LastLoginAt time.Time json:"last_login_at,omitempty"
Capabilities []string json:"capabilities,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// AccountInfo represents stored account information for Unipile type AccountInfo struct { ID int64 json:"id"
UserID string json:"user_id"
AccountID string json:"account_id"
Provider string json:"provider"
// LINKEDIN, GMAIL, etc. Status string json:"status"
// OK, CREDENTIALS, etc. Metadata []byte json:"metadata,omitempty"
LastUpdate time.Time json:"last_update"
CreatedAt time.Time json:"created_at"
}
// AccountList représente une liste paginée de comptes type AccountList struct { Object string json:"object"
Accounts []Account json:"accounts"
Paging Paging json:"paging,omitempty"
}
// AccountConnection représente une connexion entre comptes (suivi, abonnement, etc.) type AccountConnection struct { ID string json:"id,omitempty"
Provider string json:"provider"
SourceID string json:"source_id"
// ID du compte qui suit TargetID string json:"target_id"
// ID du compte suivi Type string json:"type,omitempty"
// follow, friend, connection Status string json:"status,omitempty"
// pending, accepted, blocked CreatedAt time.Time json:"created_at,omitempty"
AcceptedAt time.Time json:"accepted_at,omitempty"
Source Account json:"source,omitempty"
Target Account json:"target,omitempty"
}
// AccountConnectionList représente une liste paginée de connexions type AccountConnectionList struct { Object string json:"object"
Connections []AccountConnection json:"connections"
Paging Paging json:"paging,omitempty"
}
// AccountConnectionRequest représente une demande de connexion entre comptes type AccountConnectionRequest struct { Provider string json:"provider"
SourceID string json:"source_id,omitempty"
// ID du compte qui demande TargetID string json:"target_id"
// ID du compte ciblé Type string json:"type,omitempty"
// follow, friend, connection Message string json:"message,omitempty"
}
// AccountConnectionResponse représente la réponse après une demande de connexion type AccountConnectionResponse struct { ID string json:"id,omitempty"
Provider string json:"provider"
SourceID string json:"source_id"
TargetID string json:"target_id"
Status string json:"status"
// pending, accepted, rejected, failed CreatedAt time.Time json:"created_at,omitempty"
Connection AccountConnection json:"connection,omitempty"
}
// AccountUpdate représente une mise à jour de compte type AccountUpdate struct { Name string json:"name,omitempty"
FirstName string json:"first_name,omitempty"
LastName string json:"last_name,omitempty"
DisplayName string json:"display_name,omitempty"
Email string json:"email,omitempty"
Username string json:"username,omitempty"
AvatarURL string json:"avatar_url,omitempty"
Bio string json:"bio,omitempty"
Location string json:"location,omitempty"
Language string json:"language,omitempty"
Timezone string json:"timezone,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// AccountUpdateResponse représente la réponse après une mise à jour de compte type AccountUpdateResponse struct { ID string json:"id"
Provider string json:"provider"
Status string json:"status"
// updated, failed UpdatedAt time.Time json:"updated_at"
Changes map[string]interface{} json:"changes,omitempty"
Account Account json:"account,omitempty"
}
// OAuth représente les informations d'authentification OAuth type OAuth struct { Provider string json:"provider"
AccessToken string json:"access_token"
RefreshToken string json:"refresh_token,omitempty"
ExpiresAt time.Time json:"expires_at,omitempty"
TokenType string json:"token_type,omitempty"
Scope string json:"scope,omitempty"
Account Account json:"account,omitempty"
}
// OAuthRefreshResponse représente la réponse après un rafraîchissement de token type OAuthRefreshResponse struct { Provider string json:"provider"
Status string json:"status"
// success, failed AccessToken string json:"access_token,omitempty"
RefreshToken string json:"refresh_token,omitempty"
ExpiresAt time.Time json:"expires_at,omitempty"
TokenType string json:"token_type,omitempty"
Scope string json:"scope,omitempty"
}
// AccountSearchRequest représente une requête de recherche de comptes type AccountSearchRequest struct { Provider string json:"provider,omitempty"
Query string json:"query"
Type string json:"type,omitempty"
Status string json:"status,omitempty"
Keywords []string json:"keywords,omitempty"
Location string json:"location,omitempty"
Distance int json:"distance,omitempty"
Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/account.go ---
// File: models/calendar.go
//pkg/providers/unipile/models/calendar.go
package models
import "time"
// CalendarEvent représente un événement de calendrier dans l'API Unipile type CalendarEvent struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Title string json:"title"
Description string json:"description,omitempty"
Location string json:"location,omitempty"
StartAt time.Time json:"start_at"
EndAt time.Time json:"end_at"
TimeZone string json:"time_zone,omitempty"
IsAllDay bool json:"is_all_day,omitempty"
IsRecurring bool json:"is_recurring,omitempty"
RecurrenceRule string json:"recurrence_rule,omitempty"
Status string json:"status,omitempty"
// confirmed, tentative, cancelled Visibility string json:"visibility,omitempty"
// public, private, default Organizer Attendee json:"organizer,omitempty"
Attendees []Attendee json:"attendees,omitempty"
Attachments []Attachment json:"attachments,omitempty"
URL string json:"url,omitempty"
CreatedAt time.Time json:"created_at,omitempty"
UpdatedAt time.Time json:"updated_at,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// Attendee représente un participant à un événement type Attendee struct { ID string json:"id,omitempty"
Name string json:"name,omitempty"
Email string json:"email"
Status string json:"status,omitempty"
// accepted, declined, tentative, needs_action Optional bool json:"optional,omitempty"
Comment string json:"comment,omitempty"
}
// CalendarEventList représente une liste paginée d'événements type CalendarEventList struct { Object string json:"object"
Events []CalendarEvent json:"events"
Paging Paging json:"paging,omitempty"
}
// CalendarEventDraft représente un brouillon d'événement à créer type CalendarEventDraft struct { AccountID string json:"account_id,omitempty"
Title string json:"title"
Description string json:"description,omitempty"
Location string json:"location,omitempty"
StartAt string json:"start_at"
// ISO 8601 date EndAt string json:"end_at"
// ISO 8601 date TimeZone string json:"time_zone,omitempty"
IsAllDay bool json:"is_all_day,omitempty"
IsRecurring bool json:"is_recurring,omitempty"
RecurrenceRule string json:"recurrence_rule,omitempty"
Status string json:"status,omitempty"
// confirmed, tentative, cancelled Visibility string json:"visibility,omitempty"
// public, private, default Attendees []Attendee json:"attendees,omitempty"
Attachments []Attachment json:"attachments,omitempty"
}
// CalendarEventCreated représente la réponse après la création d'un événement type CalendarEventCreated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// created, failed CreatedAt time.Time json:"created_at"
Event CalendarEvent json:"event,omitempty"
}
// CalendarEventUpdated représente la réponse après la mise à jour d'un événement type CalendarEventUpdated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// updated, failed UpdatedAt time.Time json:"updated_at"
Changes map[string]interface{} json:"changes,omitempty"
Event CalendarEvent json:"event,omitempty"
}
// CalendarEventDeleted représente la réponse après la suppression d'un événement type CalendarEventDeleted struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// deleted, failed }
// CalendarEventRSVP représente la réponse à une invitation à un événement type CalendarEventRSVP struct { EventID string json:"event_id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Status string json:"status"
// accepted, declined, tentative Comment string json:"comment,omitempty"
}
// CalendarEventRSVPResponse représente la réponse après l'envoi d'un RSVP type CalendarEventRSVPResponse struct { EventID string json:"event_id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Status string json:"status"
// success, failed UpdatedAt time.Time json:"updated_at,omitempty"
}
// CalendarSearchRequest représente une requête de recherche d'événements type CalendarSearchRequest struct { AccountID string json:"account_id,omitempty"
Query string json:"query,omitempty"
StartAfter string json:"start_after,omitempty"
// ISO 8601 date EndBefore string json:"end_before,omitempty"
// ISO 8601 date Status string json:"status,omitempty"
Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/calendar.go ---
// File: models/common.go
//pkg/providers/unipile/models/common.go
package models
import ( "context" )
// Types communs pour le package unipile
// UnipileRequester définit l'interface pour effectuer des requêtes à l'API Unipile type UnipileRequester interface { // Request envoie une requête HTTP à l'API Unipile et décode la réponse JSON dans l'objet response Request(ctx context.Context, method, path string, body interface{}, response interface{}) error }
// QueryOptions représente les options de pagination pour les requêtes d'API type QueryOptions struct { Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// Paging contient les informations de pagination type Paging struct { Start int json:"start,omitempty"
PageCount int json:"page_count"
TotalCount int json:"total_count,omitempty"
Cursor string json:"cursor,omitempty"
}
// Author représente l'auteur d'un contenu (utilisé par différents types) type Author struct { ID string json:"id"
Name string json:"name"
Username string json:"username,omitempty"
Email string json:"email,omitempty"
Avatar string json:"avatar,omitempty"
PublicIdentifier string json:"public_identifier,omitempty"
IsCompany bool json:"is_company,omitempty"
Headline string json:"headline,omitempty"
Type string json:"type,omitempty"
ProfileURL string json:"profile_url,omitempty"
NetworkDistance string json:"network_distance,omitempty"
}
// Attachment représente une pièce jointe à différentes ressources type Attachment struct { Type string json:"type"
URL string json:"url"
PreviewURL string json:"preview_url,omitempty"
Title string json:"title,omitempty"
Description string json:"description,omitempty"
Size int64 json:"size,omitempty"
URLExpiresAt string json:"url_expires_at,omitempty"
}
// AttachmentContent représente le contenu d'une pièce jointe type AttachmentContent struct { Type string json:"type"
Data string json:"data"
Filename string json:"filename,omitempty"
ContentType string json:"content_type,omitempty"
}
// Poll représente un sondage type Poll struct { ID string json:"id"
TotalVotesCount int json:"total_votes_count"
Question string json:"question"
IsOpen bool json:"is_open"
Options []struct { ID string json:"id"
Text string json:"text"
Win bool json:"win"
VotesCount int json:"votes_count"
} json:"options"
}
// Group représente un groupe type Group struct { ID string json:"id"
Name string json:"name"
Private bool json:"private"
}
// ErrorResponse représente une réponse d'erreur détaillée de l'API Unipile type ErrorResponse struct { Title string json:"title"
Detail string json:"detail"
Instance string json:"instance"
Type string json:"type"
Status int json:"status"
ConnectionParams struct { IMAPHost string json:"imap_host,omitempty"
IMAPEncryption string json:"imap_encryption,omitempty"
IMAPPort int json:"imap_port,omitempty"
IMAPUser string json:"imap_user,omitempty"
SMTPHost string json:"smtp_host,omitempty"
SMTPPort int json:"smtp_port,omitempty"
SMTPUser string json:"smtp_user,omitempty"
} json:"connectionParams,omitempty"
}
// --- End of models/common.go ---
// File: models/email.go
//pkg/providers/unipile/models/email.go
package models
import "time"
// Email représente un email dans l'API Unipile type Email struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
ThreadID string json:"thread_id,omitempty"
From EmailAddress json:"from"
To []EmailAddress json:"to"
Cc []EmailAddress json:"cc,omitempty"
Bcc []EmailAddress json:"bcc,omitempty"
ReplyTo []EmailAddress json:"reply_to,omitempty"
Subject string json:"subject"
Body string json:"body"
BodyType string json:"body_type,omitempty"
// html, text Attachments []Attachment json:"attachments,omitempty"
Date string json:"date"
ReceivedAt time.Time json:"received_at,omitempty"
SentAt time.Time json:"sent_at,omitempty"
Status string json:"status,omitempty"
// draft, sent, received IsRead bool json:"is_read,omitempty"
IsFlagged bool json:"is_flagged,omitempty"
IsDeleted bool json:"is_deleted,omitempty"
Labels []string json:"labels,omitempty"
Folders []string json:"folders,omitempty"
Flags []string json:"flags,omitempty"
}
// EmailAddress représente une adresse email type EmailAddress struct { Name string json:"name,omitempty"
Address string json:"address"
}
// EmailList représente une liste paginée d'emails type EmailList struct { Object string json:"object"
Emails []Email json:"emails"
Paging Paging json:"paging,omitempty"
}
// EmailThread représente un fil de discussion d'emails type EmailThread struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Subject string json:"subject"
Snippet string json:"snippet,omitempty"
Emails []Email json:"emails,omitempty"
IsRead bool json:"is_read,omitempty"
Participants []EmailAddress json:"participants,omitempty"
LastMessageAt time.Time json:"last_message_at,omitempty"
MessageCount int json:"message_count,omitempty"
Labels []string json:"labels,omitempty"
Folders []string json:"folders,omitempty"
}
// EmailThreadList représente une liste paginée de fils de discussion type EmailThreadList struct { Object string json:"object"
Threads []EmailThread json:"threads"
Paging Paging json:"paging,omitempty"
}
// EmailDraft représente un brouillon d'email à envoyer type EmailDraft struct { AccountID string json:"account_id,omitempty"
ThreadID string json:"thread_id,omitempty"
To []string json:"to"
Cc []string json:"cc,omitempty"
Bcc []string json:"bcc,omitempty"
Subject string json:"subject"
Body string json:"body"
BodyType string json:"body_type,omitempty"
// html, text Attachments []Attachment json:"attachments,omitempty"
ReplyToID string json:"reply_to_id,omitempty"
}
// EmailSent représente la réponse après l'envoi d'un email type EmailSent struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
ThreadID string json:"thread_id,omitempty"
Status string json:"status"
// sent, failed SentAt time.Time json:"sent_at,omitempty"
Email Email json:"email,omitempty"
}
// EmailDeleted représente la réponse après la suppression d'un email type EmailDeleted struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// deleted, failed }
// EmailModified représente la réponse après la modification d'un email type EmailModified struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// modified, failed Changes map[string]interface{} json:"changes,omitempty"
}
// EmailAttachmentUploaded représente la réponse après le téléversement d'une pièce jointe type EmailAttachmentUploaded struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Name string json:"name"
Size int64 json:"size"
Type string json:"type"
URL string json:"url,omitempty"
UploadedAt time.Time json:"uploaded_at"
}
// EmailSearchRequest représente une requête de recherche d'emails type EmailSearchRequest struct { AccountID string json:"account_id,omitempty"
Query string json:"query"
Folder string json:"folder,omitempty"
Labels []string json:"labels,omitempty"
From string json:"from,omitempty"
To string json:"to,omitempty"
Subject string json:"subject,omitempty"
HasAttachment bool json:"has_attachment,omitempty"
After string json:"after,omitempty"
// ISO 8601 date Before string json:"before,omitempty"
// ISO 8601 date IsRead *bool json:"is_read,omitempty"
Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/email.go ---
// File: models/linkedin.go
//pkg/providers/unipile/models/linkedin.go
package models
// LinkedInProfile représente un profil LinkedIn type LinkedInProfile struct { ID string json:"id"
Provider string json:"provider"
Name string json:"name"
Headline string json:"headline,omitempty"
Summary string json:"summary,omitempty"
PublicIdentifier string json:"public_identifier,omitempty"
Industry string json:"industry,omitempty"
Location string json:"location,omitempty"
Country string json:"country,omitempty"
ProfileURL string json:"profile_url,omitempty"
PictureURL string json:"picture_url,omitempty"
NetworkDistance string json:"network_distance,omitempty"
Email string json:"email,omitempty"
Phone string json:"phone,omitempty"
Positions []Position json:"positions,omitempty"
Education []Education json:"education,omitempty"
Skills []Skill json:"skills,omitempty"
Languages []Language json:"languages,omitempty"
Certifications []Certification json:"certifications,omitempty"
}
// Position représente une expérience professionnelle sur LinkedIn type Position struct { Title string json:"title"
CompanyName string json:"company_name"
CompanyID string json:"company_id,omitempty"
Location string json:"location,omitempty"
StartDate string json:"start_date,omitempty"
EndDate string json:"end_date,omitempty"
Description string json:"description,omitempty"
Current bool json:"current,omitempty"
CompanyPictureURL string json:"company_picture_url,omitempty"
}
// Education représente une formation sur LinkedIn type Education struct { SchoolName string json:"school_name"
SchoolID string json:"school_id,omitempty"
Degree string json:"degree,omitempty"
FieldOfStudy string json:"field_of_study,omitempty"
StartDate string json:"start_date,omitempty"
EndDate string json:"end_date,omitempty"
Description string json:"description,omitempty"
Activities string json:"activities,omitempty"
SchoolPictureURL string json:"school_picture_url,omitempty"
}
// Skill représente une compétence LinkedIn type Skill struct { Name string json:"name"
Endorsements int json:"endorsements,omitempty"
}
// Language représente une langue parlée sur LinkedIn type Language struct { Name string json:"name"
Proficiency string json:"proficiency,omitempty"
}
// Certification représente une certification sur LinkedIn type Certification struct { Name string json:"name"
Organization string json:"organization,omitempty"
IssueDate string json:"issue_date,omitempty"
ExpirationDate string json:"expiration_date,omitempty"
CredentialID string json:"credential_id,omitempty"
CredentialURL string json:"credential_url,omitempty"
}
// LinkedInProfileList représente une liste paginée de profils LinkedIn type LinkedInProfileList struct { Object string json:"object"
Profiles []LinkedInProfile json:"profiles"
Paging Paging json:"paging,omitempty"
}
// LinkedInCompany représente une entreprise LinkedIn type LinkedInCompany struct { ID string json:"id"
Provider string json:"provider"
Name string json:"name"
Description string json:"description,omitempty"
Industry string json:"industry,omitempty"
Size string json:"size,omitempty"
Location string json:"location,omitempty"
Website string json:"website,omitempty"
LogoURL string json:"logo_url,omitempty"
BannerURL string json:"banner_url,omitempty"
Founded string json:"founded,omitempty"
Specialties []string json:"specialties,omitempty"
Type string json:"type,omitempty"
FollowerCount int json:"follower_count,omitempty"
EmployeeCount int json:"employee_count,omitempty"
CompanyURL string json:"company_url,omitempty"
PublicIdentifier string json:"public_identifier,omitempty"
}
// LinkedInCompanyList représente une liste paginée d'entreprises LinkedIn type LinkedInCompanyList struct { Object string json:"object"
Companies []LinkedInCompany json:"companies"
Paging Paging json:"paging,omitempty"
}
// LinkedInInvitation représente une invitation de connexion LinkedIn type LinkedInInvitation struct { ID string json:"id"
Provider string json:"provider"
Type string json:"type"
// sent, received Status string json:"status"
// pending, accepted, ignored CreatedAt string json:"created_at"
Message string json:"message,omitempty"
SenderID string json:"sender_id,omitempty"
SenderName string json:"sender_name,omitempty"
RecipientID string json:"recipient_id,omitempty"
RecipientName string json:"recipient_name,omitempty"
}
// LinkedInInvitationList représente une liste paginée d'invitations LinkedIn type LinkedInInvitationList struct { Object string json:"object"
Invitations []LinkedInInvitation json:"invitations"
Paging Paging json:"paging,omitempty"
}
// LinkedInInvitationSent représente la réponse après l'envoi d'une invitation type LinkedInInvitationSent struct { ID string json:"id"
Provider string json:"provider"
SenderID string json:"sender_id"
RecipientID string json:"recipient_id"
Message string json:"message,omitempty"
Status string json:"status"
CreatedAt string json:"created_at"
}
// LinkedInSearchRequest représente une requête de recherche LinkedIn type LinkedInSearchRequest struct { Query string json:"query,omitempty"
Keywords []string json:"keywords,omitempty"
Location string json:"location,omitempty"
Distance int json:"distance,omitempty"
CurrentCompany string json:"current_company,omitempty"
PastCompany string json:"past_company,omitempty"
School string json:"school,omitempty"
Industry string json:"industry,omitempty"
ConnectionDegree int json:"connection_degree,omitempty"
// 1, 2, 3 Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// LinkedInMessageSent représente la réponse après l'envoi d'un message LinkedIn type LinkedInMessageSent struct { ID string json:"id"
Provider string json:"provider"
SenderID string json:"sender_id"
RecipientID string json:"recipient_id"
Message string json:"message"
Status string json:"status"
// sent, delivered, read CreatedAt string json:"created_at"
ThreadID string json:"thread_id,omitempty"
}
// LinkedInPostDraft représente un brouillon de publication LinkedIn type LinkedInPostDraft struct { Content string json:"content"
// Texte de la publication Visibility string json:"visibility,omitempty"
// connections, public, mpany ContentType string json:"content_type,omitempty"
// text, article, image, video Media []Attachment json:"media,omitempty"
// Images, vidéos à joindre ShareURL string json:"share_url,omitempty"
// URL à partager Title string json:"title,omitempty"
// Pour les articles PublishAt string json:"publish_at,omitempty"
// Pour planifier une publication }
// LinkedInPostCreated représente la réponse après la création d'une publication LinkedIn type LinkedInPostCreated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// published, scheduled, failed PostURL string json:"post_url,omitempty"
// URL de la publication CreatedAt string json:"created_at"
PublishedAt string json:"published_at,omitempty"
}
// --- End of models/linkedin.go ---
// File: models/messaging.go
//pkg/providers/unipile/models/messaging.go
package models
import "time"
// Chat représente une conversation entre plusieurs utilisateurs type Chat struct { ID string json:"id"
Provider string json:"provider"
Name string json:"name,omitempty"
Description string json:"description,omitempty"
Type string json:"type,omitempty"
// "direct", "group", etc. CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
LastMessage *Message json:"last_message,omitempty"
UnreadCount int json:"unread_count,omitempty"
Attendees int json:"attendees,omitempty"
CreatorID string json:"creator_id,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// ChatList représente une liste paginée de chats type ChatList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*Chat json:"results"
}
// ChatCreated représente la réponse après création d'un chat type ChatCreated struct { Object string json:"object"
Chat *Chat json:"chat"
}
// Message représente un message dans un chat type Message struct { ID string json:"id"
Provider string json:"provider"
ChatID string json:"chat_id"
SenderID string json:"sender_id"
ReplyToID string json:"reply_to_id,omitempty"
Text string json:"text"
CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
ReadBy []string json:"read_by,omitempty"
Sender Account json:"sender,omitempty"
ReplyTo Message json:"reply_to,omitempty"
Attachments []Attachment json:"attachments,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// MessageList représente une liste paginée de messages type MessageList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*Message json:"results"
}
// MessageSent représente la réponse après envoi d'un message type MessageSent struct { Object string json:"object"
Message *Message json:"message"
}
// MessageDraft représente un brouillon de message à envoyer type MessageDraft struct { Text string json:"text"
ReplyToID string json:"reply_to_id,omitempty"
Attachments []AttachmentContent json:"attachments,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// MessageDeleted représente la réponse après suppression d'un message type MessageDeleted struct { Object string json:"object"
ID string json:"id"
Provider string json:"provider"
Deleted bool json:"deleted"
DeletedAt time.Time json:"deleted_at"
}
// MessageUpdated représente la réponse après mise à jour d'un message type MessageUpdated struct { Object string json:"object"
Message *Message json:"message"
UpdatedAt time.Time json:"updated_at"
}
// ChatParticipant représente un participant à un chat type ChatParticipant struct { ID string json:"id"
Provider string json:"provider"
ChatID string json:"chat_id"
AccountID string json:"account_id"
Role string json:"role,omitempty"
// "admin", "member", etc. JoinedAt time.Time json:"joined_at"
LastReadAt time.Time json:"last_read_at,omitempty"
Account *Account json:"account,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// ChatParticipantList représente une liste paginée de participants type ChatParticipantList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*ChatParticipant json:"results"
}
// ChatParticipantAdded représente la réponse après ajout d'un participant type ChatParticipantAdded struct { Object string json:"object"
Participant *ChatParticipant json:"participant"
}
// ChatParticipantRemoved représente la réponse après suppression d'un participant type ChatParticipantRemoved struct { Object string json:"object"
ID string json:"id"
Provider string json:"provider"
ChatID string json:"chat_id"
AccountID string json:"account_id"
Removed bool json:"removed"
RemovedAt time.Time json:"removed_at"
}
// MessageSearchRequest représente une requête de recherche de messages type MessageSearchRequest struct { Query string json:"query,omitempty"
ChatID string json:"chat_id,omitempty"
SenderID string json:"sender_id,omitempty"
StartDate time.Time json:"start_date,omitempty"
EndDate time.Time json:"end_date,omitempty"
HasAttachments bool json:"has_attachments,omitempty"
Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// ChatSearchRequest représente une requête de recherche de chats type ChatSearchRequest struct { Query string json:"query,omitempty"
AttendeeID string json:"attendee_id,omitempty"
Type string json:"type,omitempty"
StartDate time.Time json:"start_date,omitempty"
EndDate time.Time json:"end_date,omitempty"
Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/messaging.go ---
// File: models/page.go
//pkg/providers/unipile/models/page.go
package models
import "time"
// Page représente une page web ou un article dans l'API Unipile type Page struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Title string json:"title"
Content string json:"content"
ContentType string json:"content_type,omitempty"
// html, markdown, text Excerpt string json:"excerpt,omitempty"
URL string json:"url,omitempty"
Slug string json:"slug,omitempty"
Author Author json:"author,omitempty"
Featured bool json:"featured,omitempty"
Status string json:"status,omitempty"
// draft, published, scheduled CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
PublishedAt time.Time json:"published_at,omitempty"
Language string json:"language,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
FeaturedImage string json:"featured_image,omitempty"
Images []string json:"images,omitempty"
Attachments []Attachment json:"attachments,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// PageList représente une liste paginée de pages type PageList struct { Object string json:"object"
Pages []Page json:"pages"
Paging Paging json:"paging,omitempty"
}
// PageDraft représente un brouillon de page à créer type PageDraft struct { AccountID string json:"account_id,omitempty"
Title string json:"title"
Content string json:"content"
ContentType string json:"content_type,omitempty"
// html, markdown, text Excerpt string json:"excerpt,omitempty"
Slug string json:"slug,omitempty"
Featured bool json:"featured,omitempty"
Status string json:"status,omitempty"
// draft, published, scheduled PublishAt string json:"publish_at,omitempty"
// ISO 8601 date Language string json:"language,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
FeaturedImage string json:"featured_image,omitempty"
Images []string json:"images,omitempty"
Attachments []Attachment json:"attachments,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// PageCreated représente la réponse après la création d'une page type PageCreated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// created, scheduled, failed CreatedAt time.Time json:"created_at"
Page Page json:"page,omitempty"
}
// PageUpdated représente la réponse après la mise à jour d'une page type PageUpdated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// updated, failed UpdatedAt time.Time json:"updated_at"
Changes map[string]interface{} json:"changes,omitempty"
Page Page json:"page,omitempty"
}
// PageDeleted représente la réponse après la suppression d'une page type PageDeleted struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Status string json:"status"
// deleted, failed }
// Website représente un site web dans l'API Unipile type Website struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Name string json:"name"
Description string json:"description,omitempty"
URL string json:"url"
Domain string json:"domain,omitempty"
Status string json:"status,omitempty"
// active, inactive, maintenance Platform string json:"platform,omitempty"
// wordpress, shopify, wix, etc. CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// WebsiteList représente une liste paginée de sites web type WebsiteList struct { Object string json:"object"
Websites []Website json:"websites"
Paging Paging json:"paging,omitempty"
}
// Menu représente un menu de navigation sur un site web type Menu struct { ID string json:"id"
Provider string json:"provider"
WebsiteID string json:"website_id"
Name string json:"name"
Location string json:"location,omitempty"
// header, footer, sidebar, etc. Items []MenuItem json:"items"
}
// MenuItem représente un élément de menu type MenuItem struct { ID string json:"id,omitempty"
Title string json:"title"
URL string json:"url,omitempty"
Target string json:"target,omitempty"
// _blank, _self, etc. Order int json:"order,omitempty"
Parent string json:"parent,omitempty"
// ID du parent si c'est un sous-menu Type string json:"type,omitempty"
// page, category, custom, etc. Children []MenuItem json:"children,omitempty"
}
// PageSearchRequest représente une requête de recherche de pages type PageSearchRequest struct { AccountID string json:"account_id,omitempty"
Query string json:"query"
Author string json:"author,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
Status string json:"status,omitempty"
After string json:"after,omitempty"
// ISO 8601 date Before string json:"before,omitempty"
// ISO 8601 date Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/page.go ---
// File: models/post_types.go
//pkg/providers/unipile/models/post_types.go
package models
import "time"
// Post représente une publication dans l'API Unipile type Post struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Content string json:"content"
ContentType string json:"content_type,omitempty"
// text, html URL string json:"url,omitempty"
Author Author json:"author"
CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
PublishedAt time.Time json:"published_at,omitempty"
Status string json:"status,omitempty"
// draft, published, scheduled Visibility string json:"visibility,omitempty"
// public, private, connections Likes int json:"likes,omitempty"
Comments int json:"comments,omitempty"
Shares int json:"shares,omitempty"
Views int json:"views,omitempty"
Type string json:"type,omitempty"
// article, status, image Attachments []Attachment json:"attachments,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// PostList représente une liste paginée de publications type PostList struct { Object string json:"object"
Posts []Post json:"posts"
Paging Paging json:"paging,omitempty"
}
// PostComment représente un commentaire sur une publication type PostComment struct { ID string json:"id"
Provider string json:"provider"
PostID string json:"post_id"
ParentID string json:"parent_id,omitempty"
// ID du commentaire parent si c'est une réponse Content string json:"content"
Author Author json:"author"
CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
Likes int json:"likes,omitempty"
Replies int json:"replies,omitempty"
Attachments []Attachment json:"attachments,omitempty"
}
// PostCommentList représente une liste paginée de commentaires type PostCommentList struct { Object string json:"object"
Comments []PostComment json:"comments"
Paging Paging json:"paging,omitempty"
}
// PostReaction représente une réaction à une publication type PostReaction struct { ID string json:"id,omitempty"
Provider string json:"provider"
PostID string json:"post_id"
Type string json:"type"
// like, love, support, etc. CreatedAt time.Time json:"created_at,omitempty"
User Author json:"user,omitempty"
}
// PostReactionList représente une liste paginée de réactions type PostReactionList struct { Object string json:"object"
Reactions []PostReaction json:"reactions"
Paging Paging json:"paging,omitempty"
}
// PostDraft représente un brouillon de publication à créer type PostDraft struct { AccountID string json:"account_id,omitempty"
Content string json:"content"
ContentType string json:"content_type,omitempty"
// text, html Visibility string json:"visibility,omitempty"
// public, private, connections PublishAt string json:"publish_at,omitempty"
// ISO 8601 date Attachments []Attachment json:"attachments,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// PostCreated représente la réponse après la création d'une publication type PostCreated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id"
Status string json:"status"
// created, scheduled, failed CreatedAt time.Time json:"created_at"
Post Post json:"post,omitempty"
}
// PostDeleted représente la réponse après la suppression d'une publication type PostDeleted struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
Status string json:"status"
// deleted, failed }
// PostCommentDraft représente un brouillon de commentaire à créer type PostCommentDraft struct { AccountID string json:"account_id,omitempty"
PostID string json:"post_id"
ParentID string json:"parent_id,omitempty"
// ID du commentaire parent si c'est une réponse Content string json:"content"
Attachments []Attachment json:"attachments,omitempty"
}
// PostCommentCreated représente la réponse après la création d'un commentaire type PostCommentCreated struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
PostID string json:"post_id"
Status string json:"status"
// created, failed CreatedAt time.Time json:"created_at"
Comment PostComment json:"comment,omitempty"
}
// PostCommentDeleted représente la réponse après la suppression d'un commentaire type PostCommentDeleted struct { ID string json:"id"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
PostID string json:"post_id"
Status string json:"status"
// deleted, failed }
// PostReactionCreated représente la réponse après la création d'une réaction type PostReactionCreated struct { ID string json:"id,omitempty"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
PostID string json:"post_id"
Type string json:"type"
Status string json:"status"
// created, failed CreatedAt time.Time json:"created_at,omitempty"
Reaction PostReaction json:"reaction,omitempty"
}
// PostReactionDeleted représente la réponse après la suppression d'une réaction type PostReactionDeleted struct { ID string json:"id,omitempty"
Provider string json:"provider"
AccountID string json:"account_id,omitempty"
PostID string json:"post_id"
Type string json:"type,omitempty"
Status string json:"status"
// deleted, failed }
// PostSearchRequest représente une requête de recherche de publications type PostSearchRequest struct { AccountID string json:"account_id,omitempty"
Query string json:"query"
Author string json:"author,omitempty"
Tags []string json:"tags,omitempty"
Categories []string json:"categories,omitempty"
Status string json:"status,omitempty"
Visibility string json:"visibility,omitempty"
After string json:"after,omitempty"
// ISO 8601 date Before string json:"before,omitempty"
// ISO 8601 date Limit int json:"limit,omitempty"
Cursor string json:"cursor,omitempty"
}
// --- End of models/post_types.go ---
// File: models/webhooks.go
//pkg/providers/unipile/models/webhooks.go
package models
import "time"
// Webhook represents a webhook configured to receive events type Webhook struct { ID string json:"id"
Provider string json:"provider"
URL string json:"url"
Secret string json:"secret,omitempty"
Description string json:"description,omitempty"
Events []string json:"events"
Status string json:"status"
CreatedAt time.Time json:"created_at"
UpdatedAt time.Time json:"updated_at,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// WebhookList represents a paginated list of webhooks type WebhookList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*Webhook json:"results"
}
// WebhookCreated represents the response after creating a webhook type WebhookCreated struct { Object string json:"object"
Webhook *Webhook json:"webhook"
}
// WebhookUpdated represents the response after updating a webhook type WebhookUpdated struct { Object string json:"object"
Webhook *Webhook json:"webhook"
}
// WebhookDeleted represents the response after deleting a webhook type WebhookDeleted struct { Object string json:"object"
ID string json:"id"
Provider string json:"provider"
Deleted bool json:"deleted"
DeletedAt time.Time json:"deleted_at"
}
// WebhookEvent represents an event that triggered a webhook type WebhookEvent struct { ID string json:"id"
Provider string json:"provider"
WebhookID string json:"webhook_id"
Type string json:"type"
CreatedAt time.Time json:"created_at"
ResourceType string json:"resource_type"
ResourceID string json:"resource_id"
Data map[string]interface{} json:"data"
}
// WebhookEventList represents a paginated list of webhook events type WebhookEventList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*WebhookEvent json:"results"
}
// WebhookEventDelivery represents an attempt to deliver a webhook event type WebhookEventDelivery struct { ID string json:"id"
Provider string json:"provider"
EventID string json:"event_id"
URL string json:"url"
Status string json:"status"
// "success", "failed", "pending" CreatedAt time.Time json:"created_at"
SentAt time.Time json:"sent_at,omitempty"
Response *WebhookDeliveryResponse json:"response,omitempty"
}
// WebhookDeliveryResponse represents the response received when delivering a webhook type WebhookDeliveryResponse struct { StatusCode int json:"status_code"
Headers map[string]string json:"headers,omitempty"
Body string json:"body,omitempty"
Timestamp time.Time json:"timestamp"
}
// WebhookEventDeliveryList represents a paginated list of webhook event deliveries type WebhookEventDeliveryList struct { Object string json:"object"
Paging Paging json:"paging"
Results []*WebhookEventDelivery json:"results"
}
// WebhookResend represents the response after requesting to resend a webhook type WebhookResend struct { Object string json:"object"
Delivery *WebhookEventDelivery json:"delivery"
}
// WebhookDraft represents the data for creating a new webhook type WebhookDraft struct { URL string json:"url"
Secret string json:"secret,omitempty"
Description string json:"description,omitempty"
Events []string json:"events"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// WebhookUpdate represents the data for updating an existing webhook type WebhookUpdate struct { URL string json:"url,omitempty"
Secret string json:"secret,omitempty"
Description string json:"description,omitempty"
Events []string json:"events,omitempty"
Status string json:"status,omitempty"
Metadata map[string]interface{} json:"metadata,omitempty"
}
// EmailAttendee represents an attendee in an email webhook payload type EmailAttendee struct { DisplayName string json:"display_name" mapstructure:"display_name"
Identifier string json:"identifier" mapstructure:"identifier"
IdentifierType string json:"identifier_type" mapstructure:"identifier_type"
// Add other relevant fields if they exist in the actual payload }
// WebhookPayload represents the payload for email webhooks type WebhookPayload struct { EmailID string json:"email_id" mapstructure:"email_id"
AccountID string json:"account_id" mapstructure:"account_id"
Event string json:"event" mapstructure:"event"
WebhookName string json:"webhook_name" mapstructure:"webhook_name"
Date time.Time json:"date" mapstructure:"date"
FromAttendee EmailAttendee json:"from_attendee" mapstructure:"from_attendee"
ToAttendees []EmailAttendee json:"to_attendees" mapstructure:"to_attendees"
BccAttendees []EmailAttendee json:"bcc_attendees" mapstructure:"bcc_attendees"
CcAttendees []EmailAttendee json:"cc_attendees" mapstructure:"cc_attendees"
ReplyToAttendees []EmailAttendee json:"reply_to_attendees" mapstructure:"reply_to_attendees"
ProviderID string json:"provider_id" mapstructure:"provider_id"
MessageID string json:"message_id" mapstructure:"message_id"
HasAttachments bool json:"has_attachments" mapstructure:"has_attachments"
Subject string json:"subject" mapstructure:"subject"
Body string json:"body" mapstructure:"body"
BodyPlain string json:"body_plain" mapstructure:"body_plain"
Attachments []any json:"attachments" mapstructure:"attachments"
Folders []string json:"folders" mapstructure:"folders"
Role string json:"role" mapstructure:"role"
ReadDate *time.Time json:"read_date" mapstructure:"read_date"
IsComplete bool json:"is_complete" mapstructure:"is_complete"
Origin string json:"origin" mapstructure:"origin"
}
// AttendeeInfo represents an attendee in a chat type AttendeeInfo struct { AttendeeID string json:"attendee_id"
AttendeeName string json:"attendee_name"
AttendeeProviderID string json:"attendee_provider_id"
AttendeeProfileURL string json:"attendee_profile_url"
}
// MessageWebhookPayload represents a message webhook payload type MessageWebhookPayload struct { AccountID string json:"account_id"
AccountType string json:"account_type"
AccountInfo AccountInfo json:"account_info"
Event string json:"event"
ChatID string json:"chat_id"
Timestamp time.Time json:"timestamp"
WebhookName string json:"webhook_name"
MessageID string json:"message_id"
Message string json:"message"
Sender AttendeeInfo json:"sender"
Attendees []AttendeeInfo json:"attendees"
Attachments Attachment json:"attachments,omitempty"
Reaction string json:"reaction,omitempty"
ReactionSender AttendeeInfo json:"reaction_sender,omitempty"
}
// AccountStatus represents an account status update type AccountStatus struct { AccountID string json:"account_id"
AccountType string json:"account_type"
Message string json:"message"
}
// --- End of models/webhooks.go ---
// File: pg_store.go
//pkg/providers/unipile/pg_store.go
package unipile
import ( "context" "encoding/json" "errors" "fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// UnipileStoreInterface defines the interface for storage operations type UnipileStoreInterface interface { // Account methods StoreAccount(ctx context.Context, userID, accountID, provider, status string, metadata map[string]interface{}) error GetAccount(ctx context.Context, accountID string) (models.AccountInfo, error) GetUserAccounts(ctx context.Context, userID string) ([]models.AccountInfo, error) UpdateAccountStatus(ctx context.Context, accountID, status string) error DeleteAccount(ctx context.Context, accountID string) error GetDistinctUserIDsWithAccounts(ctx context.Context) ([]string, error) GetAllAccountIDs(ctx context.Context) ([]string, error)
// Email webhook methods
SaveEmail(ctx context.Context, payload *models.WebhookPayload) error
// Message webhook methods
SaveMessage(ctx context.Context, payload *models.MessageWebhookPayload) error
// Account status webhook methods
SaveAccountStatus(ctx context.Context, status *models.AccountStatus) error
// Close database connection
Close()
}
// UnipileStore handles persistence of Unipile account information type UnipileStore struct { pool *pgxpool.Pool }
// NewUnipileStore creates a new PostgreSQL store for Unipile accounts by connecting // Deprecated: InitializeDatabase should be called separately via the Adapter. func NewUnipileStore(connString string) (*UnipileStore, error) { // Connect to PostgreSQL pool, err := pgxpool.New(context.Background(), connString) if err != nil { return nil, err }
// Create store
store := &UnipileStore{
pool: pool,
}
// DO NOT initialize schema here anymore.
// if err := store.InitializeDatabase(context.Background()); err != nil {
// pool.Close()
// return nil, err
// }
return store, nil
}
// NewUnipileStoreWithPool creates a new PostgreSQL store using an existing pool. // Does not initialize the database schema. func NewUnipileStoreWithPool(pool pgxpool.Pool) UnipileStore { return &UnipileStore{ pool: pool, } }
// InitializeDatabase creates necessary tables if they don't exist (made public) func (s *UnipileStore) InitializeDatabase(ctx context.Context) error { // Create schema if it doesn't exist _, err := s.pool.Exec(ctx, CREATE SCHEMA IF NOT EXISTS providers_unipile;
) if err != nil { return err }
_, err = s.pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS providers_unipile.accounts (
id SERIAL PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL UNIQUE,
provider TEXT NOT NULL,
status TEXT NOT NULL,
metadata JSONB,
last_update TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_unipile_user ON providers_unipile.accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_unipile_provider ON providers_unipile.accounts(provider);
CREATE INDEX IF NOT EXISTS idx_unipile_status ON providers_unipile.accounts(status);
`)
if err != nil {
return err
}
// Create webhook tables
if err = s.createEmailsTable(ctx); err != nil {
return fmt.Errorf("failed to create emails table: %w", err)
}
if err = s.createMessagesTable(ctx); err != nil {
return fmt.Errorf("failed to create messages table: %w", err)
}
if err = s.createAccountStatusTable(ctx); err != nil {
return fmt.Errorf("failed to create account_status table: %w", err)
}
return nil
}
// createEmailsTable creates the table for email webhooks func (s *UnipileStore) createEmailsTable(ctx context.Context) error { query := CREATE TABLE IF NOT EXISTS providers_unipile.emails ( id SERIAL PRIMARY KEY, email_id VARCHAR(255) UNIQUE NOT NULL, account_id VARCHAR(255) NOT NULL, event VARCHAR(255) NOT NULL, date TIMESTAMP NOT NULL, from_name VARCHAR(255), from_email VARCHAR(255), subject TEXT, body_html TEXT, body_plain TEXT, has_attachments BOOLEAN, message_id VARCHAR(255), provider_id VARCHAR(255), to_recipients JSONB, cc_recipients JSONB, bcc_recipients JSONB, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )
_, err := s.pool.Exec(ctx, query)
return err
}
// createMessagesTable creates the table for message webhooks func (s *UnipileStore) createMessagesTable(ctx context.Context) error { query := CREATE TABLE IF NOT EXISTS providers_unipile.messages ( id SERIAL PRIMARY KEY, message_id VARCHAR(255) UNIQUE NOT NULL, account_id VARCHAR(255) NOT NULL, account_type VARCHAR(255) NOT NULL, event VARCHAR(255) NOT NULL, chat_id VARCHAR(255) NOT NULL, timestamp TIMESTAMP NOT NULL, message TEXT, sender_id VARCHAR(255), sender_name VARCHAR(255), sender_provider_id VARCHAR(255), sender_profile_url TEXT, attendees JSONB, attachments JSONB, reaction VARCHAR(255), reaction_sender JSONB, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP )
_, err := s.pool.Exec(ctx, query)
return err
}
// createAccountStatusTable creates the table for account status webhooks func (s *UnipileStore) createAccountStatusTable(ctx context.Context) error { query := CREATE TABLE IF NOT EXISTS providers_unipile.account_status ( id SERIAL PRIMARY KEY, account_id VARCHAR(255) NOT NULL, account_type VARCHAR(255) NOT NULL, message VARCHAR(255) NOT NULL, received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE(account_id, message, received_at) )
_, err := s.pool.Exec(ctx, query)
return err
}
// StoreAccount creates or updates an account func (s *UnipileStore) StoreAccount(ctx context.Context, userID, accountID, provider, status string, metadata map[string]interface{}) error { // Convert metadata to JSON var metadataBytes []byte var err error if metadata != nil { metadataBytes, err = json.Marshal(metadata) if err != nil { return fmt.Errorf("error serializing metadata: %w", err) } }
// Insert or update account
_, err = s.pool.Exec(ctx, `
INSERT INTO providers_unipile.accounts
(user_id, account_id, provider, status, metadata, last_update, created_at)
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
ON CONFLICT (account_id)
DO UPDATE SET
user_id = $1,
provider = $3,
status = $4,
metadata = $5,
last_update = NOW()
`, userID, accountID, provider, status, metadataBytes)
return err
}
// GetAccount retrieves an account by its ID func (s UnipileStore) GetAccount(ctx context.Context, accountID string) (models.AccountInfo, error) { var account models.AccountInfo var metadataBytes []byte
err := s.pool.QueryRow(ctx, `
SELECT id, user_id, account_id, provider, status, metadata, last_update, created_at
FROM providers_unipile.accounts
WHERE account_id = $1
`, accountID).Scan(
&account.ID,
&account.UserID,
&account.AccountID,
&account.Provider,
&account.Status,
&metadataBytes,
&account.LastUpdate,
&account.CreatedAt,
)
if err != nil {
if err == pgx.ErrNoRows {
return nil, errors.New("account not found")
}
return nil, err
}
account.Metadata = metadataBytes
return &account, nil
}
// GetUserAccounts retrieves all accounts for a user func (s UnipileStore) GetUserAccounts(ctx context.Context, userID string) ([]models.AccountInfo, error) { rows, err := s.pool.Query(ctx, SELECT id, user_id, account_id, provider, status, metadata, last_update, created_at FROM providers_unipile.accounts WHERE user_id = $1 ORDER BY created_at DESC
, userID) if err != nil { return nil, err } defer rows.Close()
var accounts []*models.AccountInfo
for rows.Next() {
var account models.AccountInfo
var metadataBytes []byte
if err := rows.Scan(
&account.ID,
&account.UserID,
&account.AccountID,
&account.Provider,
&account.Status,
&metadataBytes,
&account.LastUpdate,
&account.CreatedAt,
); err != nil {
return nil, err
}
account.Metadata = metadataBytes
accounts = append(accounts, &account)
}
if err := rows.Err(); err != nil {
return nil, err
}
return accounts, nil
}
// UpdateAccountStatus updates the status of an account func (s *UnipileStore) UpdateAccountStatus(ctx context.Context, accountID, status string) error { commandTag, err := s.pool.Exec(ctx, UPDATE providers_unipile.accounts SET status = $1, last_update = NOW() WHERE account_id = $2
, status, accountID)
if err != nil {
return err
}
if commandTag.RowsAffected() == 0 {
return errors.New("account not found")
}
return nil
}
// DeleteAccount removes an account func (s *UnipileStore) DeleteAccount(ctx context.Context, accountID string) error { commandTag, err := s.pool.Exec(ctx, DELETE FROM providers_unipile.accounts WHERE account_id = $1
, accountID)
if err != nil {
return err
}
if commandTag.RowsAffected() == 0 {
return errors.New("account not found")
}
return nil
}
// GetDistinctUserIDsWithAccounts retrieves a list of unique framework user IDs // that have at least one account in the providers_unipile.accounts table. func (s *UnipileStore) GetDistinctUserIDsWithAccounts(ctx context.Context) ([]string, error) { rows, err := s.pool.Query(ctx, SELECT DISTINCT user_id FROM providers_unipile.accounts
) if err != nil { return nil, fmt.Errorf("error querying distinct user IDs: %w", err) } defer rows.Close()
var userIDs []string
for rows.Next() {
var userID string
if err := rows.Scan(&userID); err != nil {
return nil, fmt.Errorf("error scanning distinct user ID: %w", err)
}
userIDs = append(userIDs, userID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating distinct user IDs: %w", err)
}
return userIDs, nil
}
// GetAllAccountIDs retrieves all account IDs stored locally. func (s *UnipileStore) GetAllAccountIDs(ctx context.Context) ([]string, error) { rows, err := s.pool.Query(ctx, SELECT account_id FROM providers_unipile.accounts
) if err != nil { return nil, fmt.Errorf("error querying all account IDs: %w", err) } defer rows.Close()
var accountIDs []string
for rows.Next() {
var accountID string
if err := rows.Scan(&accountID); err != nil {
return nil, fmt.Errorf("error scanning account ID: %w", err)
}
accountIDs = append(accountIDs, accountID)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating account IDs: %w", err)
}
return accountIDs, nil
}
// SaveEmail saves an email webhook to the database func (s UnipileStore) SaveEmail(ctx context.Context, payload models.WebhookPayload) error { // Convert to/cc/bcc to JSON toJSON, err := json.Marshal(payload.ToAttendees) if err != nil { return err }
ccJSON, err := json.Marshal(payload.CcAttendees)
if err != nil {
return err
}
bccJSON, err := json.Marshal(payload.BccAttendees)
if err != nil {
return err
}
// Insert into database
query := `
INSERT INTO providers_unipile.emails (
email_id, account_id, event, date, from_name, from_email,
subject, body_html, body_plain, has_attachments, message_id,
provider_id, to_recipients, cc_recipients, bcc_recipients
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) ON CONFLICT (email_id) DO UPDATE SET
account_id = $2,
event = $3,
date = $4,
from_name = $5,
from_email = $6,
subject = $7,
body_html = $8,
body_plain = $9,
has_attachments = $10,
message_id = $11,
provider_id = $12,
to_recipients = $13,
cc_recipients = $14,
bcc_recipients = $15,
received_at = CURRENT_TIMESTAMP
RETURNING id`
var id int
err = s.pool.QueryRow(ctx,
query,
payload.EmailID,
payload.AccountID,
payload.Event,
payload.Date,
payload.FromAttendee.DisplayName,
payload.FromAttendee.Identifier,
payload.Subject,
payload.Body,
payload.BodyPlain,
payload.HasAttachments,
payload.MessageID,
payload.ProviderID,
toJSON,
ccJSON,
bccJSON,
).Scan(&id)
if err != nil {
return fmt.Errorf("failed to save email: %w", err)
}
return nil
}
// SaveMessage saves a message webhook to the database func (s UnipileStore) SaveMessage(ctx context.Context, payload models.MessageWebhookPayload) error { // Convert attendees and attachments to JSON attendeesJSON, err := json.Marshal(payload.Attendees) if err != nil { return err }
var attachmentsJSON []byte
if payload.Attachments != (models.Attachment{}) {
attachmentsJSON, err = json.Marshal(payload.Attachments)
if err != nil {
return err
}
}
var reactionSenderJSON []byte
if payload.Event == "message_reaction" && payload.ReactionSender != (models.AttendeeInfo{}) {
reactionSenderJSON, err = json.Marshal(payload.ReactionSender)
if err != nil {
return err
}
}
// Insert into database
query := `
INSERT INTO providers_unipile.messages (
message_id, account_id, account_type, event, chat_id, timestamp,
message, sender_id, sender_name, sender_provider_id, sender_profile_url,
attendees, attachments, reaction, reaction_sender
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) ON CONFLICT (message_id) DO UPDATE SET
account_id = $2,
account_type = $3,
event = $4,
chat_id = $5,
timestamp = $6,
message = $7,
sender_id = $8,
sender_name = $9,
sender_provider_id = $10,
sender_profile_url = $11,
attendees = $12,
attachments = $13,
reaction = $14,
reaction_sender = $15,
received_at = CURRENT_TIMESTAMP
RETURNING id`
var id int
err = s.pool.QueryRow(ctx,
query,
payload.MessageID,
payload.AccountID,
payload.AccountType,
payload.Event,
payload.ChatID,
payload.Timestamp,
payload.Message,
payload.Sender.AttendeeID,
payload.Sender.AttendeeName,
payload.Sender.AttendeeProviderID,
payload.Sender.AttendeeProfileURL,
attendeesJSON,
attachmentsJSON,
payload.Reaction,
reactionSenderJSON,
).Scan(&id)
if err != nil {
return fmt.Errorf("failed to save message: %w", err)
}
return nil
}
// SaveAccountStatus saves an account status webhook to the database func (s UnipileStore) SaveAccountStatus(ctx context.Context, status models.AccountStatus) error { query := INSERT INTO providers_unipile.account_status ( account_id, account_type, message ) VALUES ( $1, $2, $3 ) RETURNING id
var id int
err := s.pool.QueryRow(ctx,
query,
status.AccountID,
status.AccountType,
status.Message,
).Scan(&id)
if err != nil {
return fmt.Errorf("failed to save account status: %w", err)
}
return nil
}
// Close closes the database connection func (s *UnipileStore) Close() { s.pool.Close() }
// --- End of pg_store.go ---
// File: services/account_service.go
//pkg/providers/unipile/services/account_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// AccountService implémente les fonctionnalités pour gérer les comptes utilisateurs type AccountService struct { client models.UnipileRequester }
// NewAccountService crée une nouvelle instance du service de comptes func NewAccountService(client models.UnipileRequester) *AccountService { return &AccountService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *AccountService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_accounts", Description: "Liste les comptes disponibles", Category: "Account Management", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum de comptes à retourner.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "Lister les 10 premiers comptes", Parameters: map[string]interface{}{ "limit": 10, }, }, }, }, { Name: "get_account", Description: "Récupère les détails d'un compte spécifique", Category: "Account Management", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte à récupérer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Récupérer le compte avec l'ID 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", }, }, }, }, { Name: "update_account", Description: "Met à jour les informations d'un compte", Category: "Account Management", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte à mettre à jour.", Required: true, Category: "path", }, "name": { Type: "string", Description: "Nouveau nom pour le compte.", Required: false, Category: "body", }, "status": { Type: "string", Description: "Nouveau statut pour le compte.", Required: false, Category: "body", }, // Add other fields from models.AccountUpdate as needed }, Examples: []providers.ToolExample{ { Description: "Renommer le compte 'acc_123' en 'Nouveau Nom'", Parameters: map[string]interface{}{ "account_id": "acc_123", "name": "Nouveau Nom", }, }, }, }, { Name: "list_connections", Description: "Liste les connexions d'un compte", Category: "Connection Management", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte pour lequel lister les connexions.", Required: true, Category: "path", }, "limit": { Type: "integer", Description: "Nombre maximum de connexions à retourner.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "Lister les 5 premières connexions du compte 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", "limit": 5, }, }, }, }, { Name: "get_connection", Description: "Récupère les détails d'une connexion spécifique", Category: "Connection Management", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte.", Required: true, Category: "path", }, "connection_id": { Type: "string", Description: "ID de la connexion à récupérer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Récupérer la connexion 'con_456' du compte 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", "connection_id": "con_456", }, }, }, }, { Name: "create_connection_request", Description: "Crée une demande de connexion", Category: "Connection Management", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte pour lequel créer la demande.", Required: true, Category: "path", }, "provider": { Type: "string", Description: "Fournisseur de la connexion (ex: 'google', 'microsoft').", Required: true, Category: "body", }, "type": { Type: "string", Description: "Type de connexion (ex: 'email', 'calendar').", Required: true, Category: "body", }, "success_redirect_uri": { Type: "string", Description: "URI de redirection en cas de succès.", Required: false, Category: "body", }, "failure_redirect_uri": { Type: "string", Description: "URI de redirection en cas d'échec.", Required: false, Category: "body", }, // Add other fields from models.AccountConnectionRequest as needed }, Examples: []providers.ToolExample{ { Description: "Créer une demande de connexion Google Email pour le compte 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", "provider": "google", "type": "email", }, }, }, }, { Name: "refresh_oauth", Description: "Rafraîchit les tokens OAuth d'un compte", Category: "Authentication", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte pour lequel rafraîchir les tokens.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Rafraîchir les tokens OAuth du compte 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", }, }, }, }, { Name: "search_accounts", Description: "Recherche dans les comptes selon différents critères", Category: "Account Management", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum de comptes à retourner.", Required: false, Category: "body", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "body", }, "name": { Type: "string", Description: "Filtrer par nom de compte.", Required: false, Category: "body", }, "status": { Type: "string", Description: "Filtrer par statut de compte.", Required: false, Category: "body", }, "email": { Type: "string", Description: "Filtrer par email associé.", Required: false, Category: "body", }, // Add other fields from models.AccountSearchRequest as needed }, Examples: []providers.ToolExample{ { Description: "Rechercher les comptes actifs contenant 'Test'", Parameters: map[string]interface{}{ "name": "Test", "status": "active", "limit": 20, }, }, }, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *AccountService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for AccountService") }
// ListAccounts récupère la liste des comptes disponibles func (s AccountService) ListAccounts(ctx context.Context, options models.QueryOptions) (models.AccountList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/accounts"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.AccountList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des comptes: %w", err)
}
return &result, nil
}
// GetAccount récupère les détails d'un compte spécifique func (s AccountService) GetAccount(ctx context.Context, accountID string) (models.Account, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
path := fmt.Sprintf("/accounts/%s", accountID)
var result models.Account
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du compte %s: %w", accountID, err)
}
return &result, nil
}
// UpdateAccount met à jour les informations d'un compte func (s AccountService) UpdateAccount(ctx context.Context, accountID string, updateRequest models.AccountUpdate) (*models.AccountUpdateResponse, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if updateRequest == nil {
return nil, fmt.Errorf("données de mise à jour requises")
}
path := fmt.Sprintf("/accounts/%s", accountID)
var result models.AccountUpdateResponse
err := s.client.Request(ctx, http.MethodPatch, path, updateRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du compte %s: %w", accountID, err)
}
return &result, nil
}
// ListConnections récupère la liste des connexions d'un compte func (s AccountService) ListConnections(ctx context.Context, accountID string, options models.QueryOptions) (models.AccountConnectionList, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
path := fmt.Sprintf("/accounts/%s/connections", accountID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.AccountConnectionList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des connexions du compte %s: %w", accountID, err)
}
return &result, nil
}
// GetConnection récupère les détails d'une connexion spécifique func (s AccountService) GetConnection(ctx context.Context, accountID string, connectionID string) (models.AccountConnection, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if connectionID == "" {
return nil, fmt.Errorf("connection ID est requis")
}
path := fmt.Sprintf("/accounts/%s/connections/%s", accountID, connectionID)
var result models.AccountConnection
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de la connexion %s du compte %s: %w", connectionID, accountID, err)
}
return &result, nil
}
// CreateConnectionRequest crée une demande de connexion func (s AccountService) CreateConnectionRequest(ctx context.Context, accountID string, request models.AccountConnectionRequest) (*models.AccountConnectionResponse, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if request == nil {
return nil, fmt.Errorf("données de demande de connexion requises")
}
path := fmt.Sprintf("/accounts/%s/connection-requests", accountID)
var result models.AccountConnectionResponse
err := s.client.Request(ctx, http.MethodPost, path, request, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de la demande de connexion pour le compte %s: %w", accountID, err)
}
return &result, nil
}
// RefreshOAuth rafraîchit les tokens OAuth d'un compte func (s AccountService) RefreshOAuth(ctx context.Context, accountID string) (models.OAuthRefreshResponse, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
path := fmt.Sprintf("/accounts/%s/oauth/refresh", accountID)
var result models.OAuthRefreshResponse
err := s.client.Request(ctx, http.MethodPost, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors du rafraîchissement OAuth pour le compte %s: %w", accountID, err)
}
return &result, nil
}
// SearchAccounts recherche dans les comptes selon différents critères func (s AccountService) SearchAccounts(ctx context.Context, searchRequest models.AccountSearchRequest) (*models.AccountList, error) { if searchRequest == nil { return nil, fmt.Errorf("critères de recherche requis") }
var result models.AccountList
err := s.client.Request(ctx, http.MethodPost, "/accounts/search", searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche de comptes: %w", err)
}
return &result, nil
}
// --- End of services/account_service.go ---
// File: services/calendar_service.go
//pkg/providers/unipile/services/calendar_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// CalendarService implémente les fonctionnalités pour gérer les événements du calendrier type CalendarService struct { client models.UnipileRequester }
// NewCalendarService crée une nouvelle instance du service de calendrier func NewCalendarService(client models.UnipileRequester) *CalendarService { return &CalendarService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *CalendarService) GetCapabilities() []providers.ProviderCapability { calendarCategory := "calendar" return []providers.ProviderCapability{ { Name: "list_events", Description: "Liste les événements du calendrier", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum d'événements à retourner.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{}, }, { Name: "get_event", Description: "Récupère les détails d'un événement spécifique", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "event_id": { Type: "string", Description: "ID de l'événement à récupérer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{}, }, { Name: "create_event", Description: "Crée un nouvel événement dans le calendrier", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "event_data": { Type: "object", Description: "Données de l'événement à créer (basé sur models.CalendarEventDraft).", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{}, }, { Name: "update_event", Description: "Met à jour les informations d'un événement", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "event_id": { Type: "string", Description: "ID de l'événement à mettre à jour.", Required: true, Category: "path", }, "updates": { Type: "object", Description: "Champs de l'événement à mettre à jour (map[string]interface{}).", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{}, }, { Name: "delete_event", Description: "Supprime un événement du calendrier", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "event_id": { Type: "string", Description: "ID de l'événement à supprimer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{}, }, { Name: "respond_to_event", Description: "Répond à une invitation à un événement (participer, décliner, etc.)", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "event_id": { Type: "string", Description: "ID de l'événement auquel répondre.", Required: true, Category: "path", }, "response": { Type: "object", Description: "Données de la réponse (basé sur models.CalendarEventRSVP).", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{}, }, { Name: "search_events", Description: "Recherche des événements selon différents critères", Category: calendarCategory, Parameters: map[string]providers.Parameter{ "search_criteria": { Type: "object", Description: "Critères de recherche (basé sur models.CalendarSearchRequest).", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{}, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *CalendarService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for CalendarService") }
// ListEvents récupère la liste des événements du calendrier func (s CalendarService) ListEvents(ctx context.Context, options models.QueryOptions) (models.CalendarEventList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/calendar/events"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.CalendarEventList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des événements: %w", err)
}
return &result, nil
}
// GetEvent récupère les détails d'un événement spécifique func (s CalendarService) GetEvent(ctx context.Context, eventID string) (models.CalendarEvent, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
path := fmt.Sprintf("/calendar/events/%s", eventID)
var result models.CalendarEvent
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'événement %s: %w", eventID, err)
}
return &result, nil
}
// CreateEvent crée un nouvel événement dans le calendrier func (s CalendarService) CreateEvent(ctx context.Context, eventDraft models.CalendarEventDraft) (*models.CalendarEventCreated, error) { if eventDraft == nil { return nil, fmt.Errorf("données d'événement requises") }
var result models.CalendarEventCreated
err := s.client.Request(ctx, http.MethodPost, "/calendar/events", eventDraft, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de l'événement: %w", err)
}
return &result, nil
}
// UpdateEvent met à jour les informations d'un événement func (s CalendarService) UpdateEvent(ctx context.Context, eventID string, updates map[string]interface{}) (models.CalendarEventUpdated, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
if updates == nil || len(updates) == 0 {
return nil, fmt.Errorf("au moins une mise à jour est requise")
}
path := fmt.Sprintf("/calendar/events/%s", eventID)
var result models.CalendarEventUpdated
err := s.client.Request(ctx, http.MethodPatch, path, updates, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour de l'événement %s: %w", eventID, err)
}
return &result, nil
}
// DeleteEvent supprime un événement du calendrier func (s CalendarService) DeleteEvent(ctx context.Context, eventID string) (models.CalendarEventDeleted, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
path := fmt.Sprintf("/calendar/events/%s", eventID)
var result models.CalendarEventDeleted
err := s.client.Request(ctx, http.MethodDelete, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la suppression de l'événement %s: %w", eventID, err)
}
return &result, nil
}
// RespondToEvent répond à une invitation à un événement func (s CalendarService) RespondToEvent(ctx context.Context, eventID string, response models.CalendarEventRSVP) (*models.CalendarEventRSVPResponse, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
if response == nil {
return nil, fmt.Errorf("données de réponse requises")
}
path := fmt.Sprintf("/calendar/events/%s/respond", eventID)
var result models.CalendarEventRSVPResponse
err := s.client.Request(ctx, http.MethodPost, path, response, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la réponse à l'événement %s: %w", eventID, err)
}
return &result, nil
}
// SearchEvents recherche des événements selon différents critères func (s CalendarService) SearchEvents(ctx context.Context, searchRequest models.CalendarSearchRequest) (*models.CalendarEventList, error) { if searchRequest == nil { return nil, fmt.Errorf("critères de recherche requis") }
var result models.CalendarEventList
err := s.client.Request(ctx, http.MethodPost, "/calendar/events/search", searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche d'événements: %w", err)
}
return &result, nil
}
// --- End of services/calendar_service.go ---
// File: services/email_service.go
//pkg/providers/unipile/services/email_service.go
package services
import ( "context" "fmt" "log" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// EmailService implémente les fonctionnalités pour gérer les emails type EmailService struct { client models.UnipileRequester }
// NewEmailService crée une nouvelle instance du service d'email func NewEmailService(client models.UnipileRequester) *EmailService { return &EmailService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *EmailService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_emails", Description: "Liste les emails disponibles via Unipile", Category: "Emails", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum d'emails à retourner", Required: false, Category: "body", }, "cursor": { Type: "string", Description: "Curseur pour la pagination", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Lister les 20 premiers emails", Parameters: map[string]interface{}{ "limit": 20, }, // Returns omitted for brevity }, }, }, { Name: "get_email", Description: "Récupère les détails d'un email spécifique via Unipile", Category: "Emails", Parameters: map[string]providers.Parameter{ "email_id": { Type: "string", Description: "ID de l'email à récupérer", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Récupérer l'email avec l'ID 'eml_12345'", Parameters: map[string]interface{}{ "email_id": "eml_12345", }, // Returns omitted for brevity }, }, }, { Name: "list_email_threads", Description: "Liste les fils de discussion d'emails via Unipile", Category: "Threads", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum de fils à retourner", Required: false, Category: "body", }, "cursor": { Type: "string", Description: "Curseur pour la pagination", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Lister les 10 premiers fils de discussion", Parameters: map[string]interface{}{ "limit": 10, }, }, }, }, { Name: "get_email_thread", Description: "Récupère les détails d'un fil de discussion spécifique via Unipile", Category: "Threads", Parameters: map[string]providers.Parameter{ "thread_id": { Type: "string", Description: "ID du fil de discussion à récupérer", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Récupérer le fil de discussion avec l'ID 'thr_67890'", Parameters: map[string]interface{}{ "thread_id": "thr_67890", }, }, }, }, { Name: "send_email", Description: "Envoie un nouvel email via Unipile", Category: "Emails", Parameters: map[string]providers.Parameter{ "to": { Type: "array", Description: "Adresse(s) email du destinataire", Required: true, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "cc": { Type: "array", Description: "Adresse(s) email en copie carbone (optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "bcc": { Type: "array", Description: "Adresse(s) email en copie carbone invisible (optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "subject": { Type: "string", Description: "Sujet de l'email", Required: true, Category: "body", }, "body": { Type: "string", Description: "Contenu HTML ou texte de l'email", Required: true, Category: "body", }, "thread_id": { Type: "string", Description: "ID du fil de discussion auquel répondre (optionnel)", Required: false, Category: "body", }, "attachment_ids": { Type: "array", Description: "Liste des IDs des pièces jointes à inclure (obtenus via upload_attachment, optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, }, Examples: []providers.ToolExample{ { Description: "Envoyer un email simple", Parameters: map[string]interface{}{ "to": []string{"recipient@example.com"}, "subject": "Sujet de l'email", "body": "Contenu de l'email.", }, }, { Description: "Répondre à un fil de discussion", Parameters: map[string]interface{}{ "thread_id": "thr_abcde", "to": []string{"sender@example.com"}, "subject": "Re: Sujet Original", "body": "Merci pour votre email.", }, }, }, }, { Name: "delete_email", Description: "Supprime un email via Unipile", Category: "Emails", Parameters: map[string]providers.Parameter{ "email_id": { Type: "string", Description: "ID de l'email à supprimer", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Supprimer l'email 'eml_abcde'", Parameters: map[string]interface{}{ "email_id": "eml_abcde", }, }, }, }, { Name: "modify_email", Description: "Modifie les attributs d'un email via Unipile (ex: lu/non lu, archivé)", Category: "Emails", Parameters: map[string]providers.Parameter{ "email_id": { Type: "string", Description: "ID de l'email à modifier", Required: true, Category: "body", }, "is_read": { Type: "boolean", Description: "Marquer l'email comme lu (true) ou non lu (false)", Required: false, Category: "body", }, "is_archived": { Type: "boolean", Description: "Archiver (true) ou désarchiver (false) l'email", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Marquer l'email 'eml_fghij' comme lu", Parameters: map[string]interface{}{ "email_id": "eml_fghij", "is_read": true, }, }, { Description: "Archiver l'email 'eml_klmno'", Parameters: map[string]interface{}{ "email_id": "eml_klmno", "is_archived": true, }, }, }, }, { Name: "upload_attachment", Description: "Télécharge une pièce jointe pour préparer un envoi d'email via Unipile", Category: "Attachments", Parameters: map[string]providers.Parameter{ "account_id": { Type: "string", Description: "ID du compte Unipile associé (nécessaire pour l'upload)", Required: true, Category: "body", }, "filename": { Type: "string", Description: "Nom du fichier de la pièce jointe", Required: true, Category: "body", }, "content_type": { Type: "string", Description: "Type MIME du fichier (ex: 'application/pdf', 'image/png')", Required: true, Category: "body", }, "content_base64": { Type: "string", Description: "Contenu du fichier encodé en Base64", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Uploader un fichier 'report.pdf' pour le compte 'acc_123'", Parameters: map[string]interface{}{ "account_id": "acc_123", "filename": "report.pdf", "content_type": "application/pdf", "content_base64": "BASE64_ENCODED_CONTENT...", }, // Example return could include the attachment ID needed for send_email }, }, }, { Name: "search_emails", Description: "Recherche des emails via Unipile selon des critères spécifiés", Category: "Emails", Parameters: map[string]providers.Parameter{ "query": { Type: "string", Description: "Terme de recherche (ex: 'sujet:important de:paul@example.com')", Required: false, Category: "body", }, "from_email": { Type: "string", Description: "Filtrer par adresse email de l'expéditeur", Required: false, Category: "body", }, "to_email": { Type: "string", Description: "Filtrer par adresse email du destinataire", Required: false, Category: "body", }, "subject": { Type: "string", Description: "Filtrer par sujet de l'email", Required: false, Category: "body", }, "limit": { Type: "integer", Description: "Nombre maximum d'emails à retourner", Required: false, Category: "body", }, "cursor": { Type: "string", Description: "Curseur pour la pagination", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Rechercher les emails importants de Paul", Parameters: map[string]interface{}{ "query": "important", "from_email": "paul@example.com", }, }, { Description: "Rechercher les emails avec le sujet 'Facture'", Parameters: map[string]interface{}{ "subject": "Facture", "limit": 50, }, }, }, }, { Name: "create_draft", Description: "Crée un brouillon d'email dans Unipile", Category: "Drafts", Parameters: map[string]providers.Parameter{ "thread_id": { Type: "string", Description: "ID du fil de discussion existant pour le brouillon (optionnel)", Required: false, Category: "body", }, "to": { Type: "array", Description: "Destinataire(s)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "cc": { Type: "array", Description: "Copie carbone (optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "bcc": { Type: "array", Description: "Copie cachée (optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "subject": { Type: "string", Description: "Sujet (optionnel)", Required: false, Category: "body", }, "body": { Type: "string", Description: "Contenu du brouillon (optionnel)", Required: false, Category: "body", }, "attachment_ids": { Type: "array", Description: "Pièces jointes (optionnel)", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, }, Examples: []providers.ToolExample{ { Description: "Créer un nouveau brouillon", Parameters: map[string]interface{}{ "subject": "Idée de projet", }, }, }, }, { Name: "tag_thread", Description: "Applique un tag à un fil de discussion d'emails via Unipile", Category: "Tags", Parameters: map[string]providers.Parameter{ "thread_id": { Type: "string", Description: "ID du fil de discussion à taguer", Required: true, Category: "body", }, "tag_name": { Type: "string", Description: "Nom du tag à appliquer (ex: 'important', 'suivi')", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Appliquer le tag 'urgent' au thread 'thr_xyz'", Parameters: map[string]interface{}{ "thread_id": "thr_xyz", "tag_name": "urgent", }, }, }, }, { Name: "untag_thread", Description: "Supprime un tag d'un fil de discussion d'emails via Unipile", Category: "Tags", Parameters: map[string]providers.Parameter{ "thread_id": { Type: "string", Description: "ID du fil de discussion", Required: true, Category: "body", }, "tag_name": { Type: "string", Description: "Nom du tag à supprimer", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Supprimer le tag 'urgent' du thread 'thr_xyz'", Parameters: map[string]interface{}{ "thread_id": "thr_xyz", "tag_name": "urgent", }, }, }, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *EmailService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for EmailService") }
// ListEmails récupère la liste des emails disponibles func (s EmailService) ListEmails(ctx context.Context, options models.QueryOptions) (models.EmailList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/emails"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.EmailList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des emails: %w", err)
}
return &result, nil
}
// GetEmail récupère les détails d'un email spécifique func (s EmailService) GetEmail(ctx context.Context, emailID string) (models.Email, error) { if emailID == "" { return nil, fmt.Errorf("email ID est requis") }
path := fmt.Sprintf("/emails/%s", emailID)
var result models.Email
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'email %s: %w", emailID, err)
}
return &result, nil
}
// ListEmailThreads récupère la liste des fils de discussion d'emails func (s EmailService) ListEmailThreads(ctx context.Context, options models.QueryOptions) (models.EmailThreadList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/email-threads"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.EmailThreadList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des fils de discussion: %w", err)
}
return &result, nil
}
// GetEmailThread récupère les détails d'un fil de discussion spécifique func (s EmailService) GetEmailThread(ctx context.Context, threadID string) (models.EmailThread, error) { if threadID == "" { return nil, fmt.Errorf("thread ID est requis") }
path := fmt.Sprintf("/email-threads/%s", threadID)
var result models.EmailThread
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du fil de discussion %s: %w", threadID, err)
}
return &result, nil
}
// SendEmail envoie un nouvel email func (s EmailService) SendEmail(ctx context.Context, draft models.EmailDraft) (*models.EmailSent, error) { if draft == nil { return nil, fmt.Errorf("données d'email requises") }
var result models.EmailSent
err := s.client.Request(ctx, http.MethodPost, "/emails", draft, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'envoi de l'email: %w", err)
}
return &result, nil
}
// DeleteEmail supprime un email func (s EmailService) DeleteEmail(ctx context.Context, emailID string) (models.EmailDeleted, error) { if emailID == "" { return nil, fmt.Errorf("email ID est requis") }
path := fmt.Sprintf("/emails/%s", emailID)
var result models.EmailDeleted
err := s.client.Request(ctx, http.MethodDelete, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la suppression de l'email %s: %w", emailID, err)
}
return &result, nil
}
// ModifyEmail modifie les attributs d'un email func (s EmailService) ModifyEmail(ctx context.Context, emailID string, modifications map[string]interface{}) (models.EmailModified, error) { if emailID == "" { return nil, fmt.Errorf("email ID est requis") }
if modifications == nil || len(modifications) == 0 {
return nil, fmt.Errorf("au moins une modification est requise")
}
path := fmt.Sprintf("/emails/%s", emailID)
var result models.EmailModified
err := s.client.Request(ctx, http.MethodPatch, path, modifications, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la modification de l'email %s: %w", emailID, err)
}
return &result, nil
}
// UploadAttachment télécharge une pièce jointe pour un email func (s EmailService) UploadAttachment(ctx context.Context, accountID string, attachment models.AttachmentContent) (*models.EmailAttachmentUploaded, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if attachment == nil {
return nil, fmt.Errorf("données de pièce jointe requises")
}
path := fmt.Sprintf("/accounts/%s/attachments", accountID)
var result models.EmailAttachmentUploaded
err := s.client.Request(ctx, http.MethodPost, path, attachment, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors du téléchargement de la pièce jointe: %w", err)
}
return &result, nil
}
// SearchEmails recherche dans les emails selon différents critères func (s EmailService) SearchEmails(ctx context.Context, searchRequest models.EmailSearchRequest) (*models.EmailList, error) { if searchRequest == nil { return nil, fmt.Errorf("critères de recherche requis") }
var result models.EmailList
err := s.client.Request(ctx, http.MethodPost, "/emails/search", searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche d'emails: %w", err)
}
return &result, nil
}
// CreateDraft prépare un brouillon de réponse 'Répondre à tous' // NOTE: Fake implementation - logs parameters and returns nil func (s *EmailService) CreateDraft(ctx context.Context, threadID string, draftText string, emails []string) (interface{}, error) { log.Printf("CreateDraft called with threadID: %s, draftText: %s", threadID, draftText) if threadID == "" { return nil, fmt.Errorf("thread ID est requis") } // Fake implementation - In a real scenario, this would interact with the Unipile API // path := fmt.Sprintf("/email-threads/%s/drafts", threadID) // payload := map[string]string{"text": draftText} // Example payload structure // var result interface{} // Define appropriate result struct // err := s.client.Request(ctx, http.MethodPost, path, payload, &result) // if err != nil { // return nil, fmt.Errorf("erreur lors de la création du brouillon pour le thread %s: %w", threadID, err) // } return nil, nil // Placeholder return }
// TagThread applique un tag à un fil de discussion // NOTE: Fake implementation - logs parameters and returns nil func (s *EmailService) TagThread(ctx context.Context, threadID string, tagName string) (interface{}, error) { log.Printf("TagThread called with threadID: %s, tagName: %s", threadID, tagName) if threadID == "" { return nil, fmt.Errorf("thread ID est requis") } if tagName == "" { return nil, fmt.Errorf("tag expression est requise") } // Fake implementation // path := fmt.Sprintf("/email-threads/%s/tags", threadID) // payload := map[string]string{"tag": tagExpression} // Example payload structure // var result interface{} // Define appropriate result struct // err := s.client.Request(ctx, http.MethodPost, path, payload, &result) // if err != nil { // return nil, fmt.Errorf("erreur lors de l'ajout du tag '%s' au thread %s: %w", tagExpression, threadID, err) // } return nil, nil // Placeholder return }
// UntagThread supprime un tag d'un fil de discussion // NOTE: Fake implementation - logs parameters and returns nil func (s *EmailService) UntagThread(ctx context.Context, threadID string, tagName string) (interface{}, error) { log.Printf("UntagThread called with threadID: %s, tagName: %s", threadID, tagName) if threadID == "" { return nil, fmt.Errorf("thread ID est requis") } if tagName == "" { return nil, fmt.Errorf("tag expression est requise") } // Fake implementation // path := fmt.Sprintf("/email-threads/%s/tags", threadID) // payload := map[string]string{"tag": tagExpression} // Assuming tag to remove is passed in body or params // var result interface{} // Define appropriate result struct // err := s.client.Request(ctx, http.MethodDelete, path, payload, &result) // Or potentially no payload if tag is a path/query param // if err != nil { // return nil, fmt.Errorf("erreur lors de la suppression du tag '%s' du thread %s: %w", tagExpression, threadID, err) // } return nil, nil // Placeholder return }
// --- End of services/email_service.go ---
// File: services/linkedin_service.go
//pkg/providers/unipile/services/linkedin_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// LinkedInService implémente les fonctionnalités pour interagir avec l'API LinkedIn via Unipile type LinkedInService struct { client models.UnipileRequester }
// NewLinkedInService crée une nouvelle instance du service LinkedIn func NewLinkedInService(client models.UnipileRequester) *LinkedInService { return &LinkedInService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *LinkedInService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "get_profile", Description: "Récupère les détails d'un profil LinkedIn", Category: "profiles", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "profile_id": {Type: "string", Description: "ID du profil LinkedIn URN (dans l'URL)", Required: true, Category: "path"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de récupération d'un profil", Parameters: map[string]interface{}{"account_id": "acc_xyz789", "profile_id": "urn:li:profile:123456789"}, }, }, }, { Name: "search_profiles", Description: "Recherche des profils LinkedIn selon différents critères", Category: "profiles", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, // Le corps de la requête contient les critères de recherche. La structure exacte dépend de l'API Unipile. // On définit un paramètre générique pour le corps pour l'instant. "requestBody": {Type: "object", Description: "Corps de la requête contenant les critères (query, location, industry, limit, cursor...)", Required: true, Category: "body"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de recherche de profils d'ingénieurs à Paris", Parameters: map[string]interface{}{ "account_id": "acc_xyz789", "requestBody": map[string]interface{}{"query": "Software Engineer", "location": "Paris", "limit": 10}, }, }, }, }, { Name: "get_company", Description: "Récupère les détails d'une entreprise LinkedIn", Category: "companies", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "company_id": {Type: "string", Description: "ID de l'entreprise LinkedIn URN (dans l'URL)", Required: true, Category: "path"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de récupération d'une entreprise", Parameters: map[string]interface{}{"account_id": "acc_xyz789", "company_id": "urn:li:company:98765"}, }, }, }, { Name: "search_companies", Description: "Recherche des entreprises LinkedIn selon différents critères", Category: "companies", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "query": {Type: "string", Description: "Terme de recherche (paramètre de requête)", Required: false, Category: "query"}, "industry": {Type: "string", Description: "Secteur d'activité (paramètre de requête)", Required: false, Category: "query"}, "limit": {Type: "integer", Description: "Nombre maximum de résultats (paramètre de requête)", Required: false, Category: "query", Default: 10}, "cursor": {Type: "string", Description: "Curseur pour la pagination (paramètre de requête)", Required: false, Category: "query"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de recherche d'entreprises tech, limité à 20 résultats", Parameters: map[string]interface{}{"account_id": "acc_xyz789", "query": "Technology", "limit": 20}, }, }, }, { Name: "send_invitation", Description: "Envoie une invitation de connexion à un utilisateur LinkedIn", Category: "networking", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "recipient_id": {Type: "string", Description: "ID du profil LinkedIn du destinataire URN (dans le corps)", Required: true, Category: "body"}, "message": {Type: "string", Description: "Message personnalisé (optionnel, dans le corps)", Required: false, Category: "body"}, }, Examples: []providers.ToolExample{ { Description: "Exemple d'envoi d'une invitation avec message", Parameters: map[string]interface{}{ "account_id": "acc_xyz789", "recipient_id": "urn:li:profile:123456789", "message": "Bonjour, j'aimerais me connecter avec vous.", }, }, }, }, { Name: "list_invitations", Description: "Liste les invitations de connexion d'un compte LinkedIn", Category: "networking", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "status": {Type: "string", Description: "Statut des invitations (pending, accepted, ignored) (paramètre de requête)", Required: false, Category: "query", Enum: []string{"pending", "accepted", "ignored"}}, "limit": {Type: "integer", Description: "Nombre maximum de résultats (paramètre de requête)", Required: false, Category: "query", Default: 10}, "cursor": {Type: "string", Description: "Curseur pour la pagination (paramètre de requête)", Required: false, Category: "query"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de listage des invitations en attente", Parameters: map[string]interface{}{"account_id": "acc_xyz789", "status": "pending", "limit": 50}, }, }, }, { Name: "get_invitation", Description: "Récupère les détails d'une invitation de connexion spécifique", Category: "networking", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "invitation_id": {Type: "string", Description: "ID de l'invitation (dans l'URL)", Required: true, Category: "path"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de récupération d'une invitation spécifique", Parameters: map[string]interface{}{"account_id": "acc_xyz789", "invitation_id": "inv_abcdef123"}, }, }, }, { Name: "send_message", Description: "Envoie un message à une connexion LinkedIn", Category: "messaging", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, "recipient_id": {Type: "string", Description: "ID du profil LinkedIn du destinataire URN (dans le corps)", Required: true, Category: "body"}, "message": {Type: "string", Description: "Contenu du message (dans le corps)", Required: true, Category: "body"}, }, Examples: []providers.ToolExample{ { Description: "Exemple d'envoi d'un message de suivi", Parameters: map[string]interface{}{ "account_id": "acc_xyz789", "recipient_id": "urn:li:profile:123456789", "message": "Juste pour faire suite à notre conversation.", }, }, }, }, { Name: "create_post", Description: "Crée une publication LinkedIn", Category: "content", Parameters: map[string]providers.Parameter{ "account_id": {Type: "string", Description: "ID du compte LinkedIn (dans l'URL)", Required: true, Category: "path"}, // Le corps de la requête contient les détails de la publication. La structure exacte dépend de l'API Unipile. "requestBody": {Type: "object", Description: "Corps de la requête contenant les détails de la publication (text, visibility...)", Required: true, Category: "body"}, }, Examples: []providers.ToolExample{ { Description: "Exemple de création d'une publication texte simple", Parameters: map[string]interface{}{ "account_id": "acc_xyz789", "requestBody": map[string]interface{}{"text": "Je partage une nouvelle mise à jour passionnante !", "visibility": "CONNECTIONS"}, }, }, }, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *LinkedInService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for LinkedInService") }
// GetProfile récupère les détails d'un profil LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // profileID: ID du profil à récupérer (obligatoire) // Retourne: Le profil LinkedIn demandé ou une erreur func (s LinkedInService) GetProfile(ctx context.Context, accountID string, profileID string) (models.LinkedInProfile, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if profileID == "" {
return nil, fmt.Errorf("profile ID est requis")
}
path := fmt.Sprintf("/accounts/%s/linkedin/profiles/%s", accountID, profileID)
var result models.LinkedInProfile
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du profil %s: %w", profileID, err)
}
return &result, nil
}
// SearchProfiles recherche des profils LinkedIn selon différents critères // accountID: ID du compte LinkedIn (obligatoire) // searchRequest: Critères de recherche (obligatoire) // Retourne: Liste de profils correspondant aux critères ou une erreur func (s LinkedInService) SearchProfiles(ctx context.Context, accountID string, searchRequest models.LinkedInSearchRequest) (*models.LinkedInProfileList, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if searchRequest == nil {
return nil, fmt.Errorf("critères de recherche requis")
}
path := fmt.Sprintf("/accounts/%s/linkedin/profiles/search", accountID)
var result models.LinkedInProfileList
err := s.client.Request(ctx, http.MethodPost, path, searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche de profils: %w", err)
}
return &result, nil
}
// GetCompany récupère les détails d'une entreprise LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // companyID: ID de l'entreprise à récupérer (obligatoire) // Retourne: L'entreprise LinkedIn demandée ou une erreur func (s LinkedInService) GetCompany(ctx context.Context, accountID string, companyID string) (models.LinkedInCompany, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if companyID == "" {
return nil, fmt.Errorf("company ID est requis")
}
path := fmt.Sprintf("/accounts/%s/linkedin/companies/%s", accountID, companyID)
var result models.LinkedInCompany
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'entreprise %s: %w", companyID, err)
}
return &result, nil
}
// SearchCompanies recherche des entreprises LinkedIn selon différents critères // accountID: ID du compte LinkedIn (obligatoire) // query: Terme de recherche (optionnel) // industry: Industrie spécifique (optionnel) // limit: Nombre maximum de résultats (optionnel) // cursor: Curseur pour la pagination (optionnel) // Retourne: Liste d'entreprises correspondant aux critères ou une erreur func (s LinkedInService) SearchCompanies(ctx context.Context, accountID string, query string, industry string, limit int, cursor string) (models.LinkedInCompanyList, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
queryParams := url.Values{}
if query != "" {
queryParams.Set("query", query)
}
if industry != "" {
queryParams.Set("industry", industry)
}
if limit > 0 {
queryParams.Set("limit", strconv.Itoa(limit))
}
if cursor != "" {
queryParams.Set("cursor", cursor)
}
path := fmt.Sprintf("/accounts/%s/linkedin/companies/search", accountID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.LinkedInCompanyList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche d'entreprises: %w", err)
}
return &result, nil
}
// SendInvitation envoie une invitation de connexion à un utilisateur LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // recipientID: ID du destinataire de l'invitation (obligatoire) // message: Message personnalisé pour l'invitation (optionnel) // Retourne: Confirmation de l'envoi de l'invitation ou une erreur func (s LinkedInService) SendInvitation(ctx context.Context, accountID string, recipientID string, message string) (models.LinkedInInvitationSent, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if recipientID == "" {
return nil, fmt.Errorf("recipient ID est requis")
}
requestBody := map[string]string{
"recipient_id": recipientID,
}
if message != "" {
requestBody["message"] = message
}
path := fmt.Sprintf("/accounts/%s/linkedin/invitations", accountID)
var result models.LinkedInInvitationSent
err := s.client.Request(ctx, http.MethodPost, path, requestBody, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'envoi de l'invitation: %w", err)
}
return &result, nil
}
// ListInvitations liste les invitations de connexion d'un compte LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // status: Statut des invitations à récupérer (pending, accepted, ignored) (optionnel) // limit: Nombre maximum de résultats (optionnel) // cursor: Curseur pour la pagination (optionnel) // Retourne: Liste des invitations correspondant aux critères ou une erreur func (s LinkedInService) ListInvitations(ctx context.Context, accountID string, status string, limit int, cursor string) (models.LinkedInInvitationList, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
queryParams := url.Values{}
if status != "" {
queryParams.Set("status", status)
}
if limit > 0 {
queryParams.Set("limit", strconv.Itoa(limit))
}
if cursor != "" {
queryParams.Set("cursor", cursor)
}
path := fmt.Sprintf("/accounts/%s/linkedin/invitations", accountID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.LinkedInInvitationList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des invitations: %w", err)
}
return &result, nil
}
// GetInvitation récupère les détails d'une invitation de connexion spécifique // accountID: ID du compte LinkedIn (obligatoire) // invitationID: ID de l'invitation à récupérer (obligatoire) // Retourne: Les détails de l'invitation demandée ou une erreur func (s LinkedInService) GetInvitation(ctx context.Context, accountID string, invitationID string) (models.LinkedInInvitation, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if invitationID == "" {
return nil, fmt.Errorf("invitation ID est requis")
}
path := fmt.Sprintf("/accounts/%s/linkedin/invitations/%s", accountID, invitationID)
var result models.LinkedInInvitation
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'invitation %s: %w", invitationID, err)
}
return &result, nil
}
// SendMessage envoie un message à une connexion LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // recipientID: ID du destinataire du message (obligatoire) // message: Contenu du message (obligatoire) // Retourne: Confirmation de l'envoi du message ou une erreur func (s LinkedInService) SendMessage(ctx context.Context, accountID string, recipientID string, message string) (models.LinkedInMessageSent, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if recipientID == "" {
return nil, fmt.Errorf("recipient ID est requis")
}
if message == "" {
return nil, fmt.Errorf("message est requis")
}
requestBody := map[string]string{
"recipient_id": recipientID,
"message": message,
}
path := fmt.Sprintf("/accounts/%s/linkedin/messages", accountID)
var result models.LinkedInMessageSent
err := s.client.Request(ctx, http.MethodPost, path, requestBody, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'envoi du message: %w", err)
}
return &result, nil
}
// CreatePost crée une publication LinkedIn // accountID: ID du compte LinkedIn (obligatoire) // postDraft: Brouillon de la publication (obligatoire) // Retourne: Confirmation de la création de la publication ou une erreur func (s LinkedInService) CreatePost(ctx context.Context, accountID string, postDraft models.LinkedInPostDraft) (*models.LinkedInPostCreated, error) { if accountID == "" { return nil, fmt.Errorf("account ID est requis") }
if postDraft == nil {
return nil, fmt.Errorf("post draft est requis")
}
path := fmt.Sprintf("/accounts/%s/linkedin/posts", accountID)
var result models.LinkedInPostCreated
err := s.client.Request(ctx, http.MethodPost, path, postDraft, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de la publication: %w", err)
}
return &result, nil
}
// --- End of services/linkedin_service.go ---
// File: services/messaging_service.go
//pkg/providers/unipile/services/messaging_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// MessagingService implémente les fonctionnalités pour gérer les chats et messages type MessagingService struct { client models.UnipileRequester }
// NewMessagingService crée une nouvelle instance du service de messagerie func NewMessagingService(client models.UnipileRequester) *MessagingService { return &MessagingService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *MessagingService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_chats", Description: "Liste les chats/conversations disponibles", Category: "messaging", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Maximum number of chats to return.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Cursor for pagination.", Required: false, Category: "query", }, }, }, { Name: "get_chat", Description: "Récupère les détails d'un chat spécifique", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat to retrieve.", Required: true, Category: "path", }, }, }, { Name: "create_chat", Description: "Crée un nouveau chat/conversation", Category: "messaging", Parameters: map[string]providers.Parameter{ "participant_ids": { Type: "array", // Assuming array of strings Description: "List of account IDs for participants.", Required: true, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "name": { Type: "string", Description: "Optional name for the chat.", Required: false, Category: "body", }, "description": { Type: "string", Description: "Optional description for the chat.", Required: false, Category: "body", }, }, }, { Name: "list_messages", Description: "Liste les messages d'un chat", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat.", Required: true, Category: "path", }, "limit": { Type: "integer", Description: "Maximum number of messages to return.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Cursor for pagination.", Required: false, Category: "query", }, }, }, { Name: "get_message", Description: "Récupère les détails d'un message spécifique", Category: "messaging", Parameters: map[string]providers.Parameter{ "message_id": { Type: "string", Description: "The ID of the message to retrieve.", Required: true, Category: "path", }, }, }, { Name: "send_message", Description: "Envoie un message dans un chat", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat to send the message to.", Required: true, Category: "path", }, "text": { // Assuming MessageDraft has at least 'text' Type: "string", Description: "The content of the message.", Required: true, Category: "body", }, // Add other potential fields from models.MessageDraft if known // "attachments": { Type: "array", ... } }, }, { Name: "list_chat_participants", Description: "Liste les participants d'un chat", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat.", Required: true, Category: "path", }, "limit": { Type: "integer", Description: "Maximum number of participants to return.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Cursor for pagination.", Required: false, Category: "query", }, }, }, { Name: "add_chat_participant", Description: "Ajoute un participant à un chat", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat.", Required: true, Category: "path", }, "account_id": { Type: "string", Description: "The account ID of the participant to add.", Required: true, Category: "body", }, "role": { Type: "string", Description: "Optional role for the participant.", Required: false, Category: "body", }, }, }, { Name: "remove_chat_participant", Description: "Supprime un participant d'un chat", Category: "messaging", Parameters: map[string]providers.Parameter{ "chat_id": { Type: "string", Description: "The ID of the chat.", Required: true, Category: "path", }, "participant_id": { Type: "string", Description: "The ID of the participant to remove.", Required: true, Category: "path", }, }, }, { Name: "update_message", Description: "Met à jour un message existant", Category: "messaging", Parameters: map[string]providers.Parameter{ "message_id": { Type: "string", Description: "The ID of the message to update.", Required: true, Category: "path", }, "text": { Type: "string", Description: "The new text content for the message.", Required: true, Category: "body", }, }, }, { Name: "delete_message", Description: "Supprime un message", Category: "messaging", Parameters: map[string]providers.Parameter{ "message_id": { Type: "string", Description: "The ID of the message to delete.", Required: true, Category: "path", }, }, }, { Name: "search_chats", Description: "Recherche de chats selon différents critères", Category: "messaging", Parameters: map[string]providers.Parameter{ // Parameters depend on models.ChatSearchRequest definition // Placeholder: "query": { Type: "string", Description: "Search query string.", Required: true, // Assuming at least some criteria required Category: "body", }, // Add other potential fields from models.ChatSearchRequest }, }, { Name: "search_messages", Description: "Recherche de messages selon différents critères", Category: "messaging", Parameters: map[string]providers.Parameter{ // Parameters depend on models.MessageSearchRequest definition // Placeholder: "query": { Type: "string", Description: "Search query string.", Required: true, // Assuming at least some criteria required Category: "body", }, "chat_id": { Type: "string", Description: "Optional chat ID to limit search.", Required: false, Category: "body", }, // Add other potential fields from models.MessageSearchRequest }, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *MessagingService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for MessagingService") }
// ListChats récupère la liste des chats/conversations disponibles func (s MessagingService) ListChats(ctx context.Context, options models.QueryOptions) (models.ChatList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/chats"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.ChatList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des chats: %w", err)
}
return &result, nil
}
// GetChat récupère les détails d'un chat spécifique func (s MessagingService) GetChat(ctx context.Context, chatID string) (models.Chat, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
path := fmt.Sprintf("/chats/%s", chatID)
var result models.Chat
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du chat %s: %w", chatID, err)
}
return &result, nil
}
// CreateChat crée un nouveau chat/conversation func (s MessagingService) CreateChat(ctx context.Context, participantIDs []string, name string, description string) (models.ChatCreated, error) { if len(participantIDs) == 0 { return nil, fmt.Errorf("au moins un participant est requis") }
requestBody := map[string]interface{}{
"participant_ids": participantIDs,
}
if name != "" {
requestBody["name"] = name
}
if description != "" {
requestBody["description"] = description
}
var result models.ChatCreated
err := s.client.Request(ctx, http.MethodPost, "/chats", requestBody, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la création du chat: %w", err)
}
return &result, nil
}
// ListMessages récupère la liste des messages d'un chat func (s MessagingService) ListMessages(ctx context.Context, chatID string, options models.QueryOptions) (models.MessageList, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
path := fmt.Sprintf("/chats/%s/messages", chatID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.MessageList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des messages du chat %s: %w", chatID, err)
}
return &result, nil
}
// GetMessage récupère les détails d'un message spécifique func (s MessagingService) GetMessage(ctx context.Context, messageID string) (models.Message, error) { if messageID == "" { return nil, fmt.Errorf("message ID est requis") }
path := fmt.Sprintf("/messages/%s", messageID)
var result models.Message
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du message %s: %w", messageID, err)
}
return &result, nil
}
// SendMessage envoie un message dans un chat func (s MessagingService) SendMessage(ctx context.Context, chatID string, draft models.MessageDraft) (*models.MessageSent, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
if draft == nil || draft.Text == "" {
return nil, fmt.Errorf("le contenu du message est requis")
}
path := fmt.Sprintf("/chats/%s/messages", chatID)
var result models.MessageSent
err := s.client.Request(ctx, http.MethodPost, path, draft, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'envoi du message dans le chat %s: %w", chatID, err)
}
return &result, nil
}
// UpdateMessage met à jour un message existant func (s MessagingService) UpdateMessage(ctx context.Context, messageID string, text string) (models.MessageUpdated, error) { if messageID == "" { return nil, fmt.Errorf("message ID est requis") }
if text == "" {
return nil, fmt.Errorf("le nouveau texte du message est requis")
}
requestBody := map[string]interface{}{
"text": text,
}
path := fmt.Sprintf("/messages/%s", messageID)
var result models.MessageUpdated
err := s.client.Request(ctx, http.MethodPatch, path, requestBody, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du message %s: %w", messageID, err)
}
return &result, nil
}
// DeleteMessage supprime un message func (s MessagingService) DeleteMessage(ctx context.Context, messageID string) (models.MessageDeleted, error) { if messageID == "" { return nil, fmt.Errorf("message ID est requis") }
path := fmt.Sprintf("/messages/%s", messageID)
var result models.MessageDeleted
err := s.client.Request(ctx, http.MethodDelete, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la suppression du message %s: %w", messageID, err)
}
return &result, nil
}
// ListChatParticipants récupère la liste des participants d'un chat func (s MessagingService) ListChatParticipants(ctx context.Context, chatID string, options models.QueryOptions) (models.ChatParticipantList, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
path := fmt.Sprintf("/chats/%s/participants", chatID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.ChatParticipantList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des participants du chat %s: %w", chatID, err)
}
return &result, nil
}
// AddChatParticipant ajoute un participant à un chat func (s MessagingService) AddChatParticipant(ctx context.Context, chatID string, accountID string, role string) (models.ChatParticipantAdded, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
if accountID == "" {
return nil, fmt.Errorf("account ID est requis")
}
requestBody := map[string]interface{}{
"account_id": accountID,
}
if role != "" {
requestBody["role"] = role
}
path := fmt.Sprintf("/chats/%s/participants", chatID)
var result models.ChatParticipantAdded
err := s.client.Request(ctx, http.MethodPost, path, requestBody, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'ajout du participant au chat %s: %w", chatID, err)
}
return &result, nil
}
// RemoveChatParticipant supprime un participant d'un chat func (s MessagingService) RemoveChatParticipant(ctx context.Context, chatID string, participantID string) (models.ChatParticipantRemoved, error) { if chatID == "" { return nil, fmt.Errorf("chat ID est requis") }
if participantID == "" {
return nil, fmt.Errorf("participant ID est requis")
}
path := fmt.Sprintf("/chats/%s/participants/%s", chatID, participantID)
var result models.ChatParticipantRemoved
err := s.client.Request(ctx, http.MethodDelete, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la suppression du participant %s du chat %s: %w", participantID, chatID, err)
}
return &result, nil
}
// SearchChats recherche des chats selon différents critères func (s MessagingService) SearchChats(ctx context.Context, searchRequest models.ChatSearchRequest) (*models.ChatList, error) { if searchRequest == nil { return nil, fmt.Errorf("critères de recherche requis") }
var result models.ChatList
err := s.client.Request(ctx, http.MethodPost, "/chats/search", searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche de chats: %w", err)
}
return &result, nil
}
// SearchMessages recherche des messages selon différents critères func (s MessagingService) SearchMessages(ctx context.Context, searchRequest models.MessageSearchRequest) (*models.MessageList, error) { if searchRequest == nil { return nil, fmt.Errorf("critères de recherche requis") }
var result models.MessageList
err := s.client.Request(ctx, http.MethodPost, "/messages/search", searchRequest, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la recherche de messages: %w", err)
}
return &result, nil
}
// --- End of services/messaging_service.go ---
// File: services/page_service.go
//pkg/providers/unipile/services/page_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// PageService implémente les fonctionnalités de gestion des pages type PageService struct { client models.UnipileRequester }
// NewPageService crée une nouvelle instance de PageService func NewPageService(client models.UnipileRequester) *PageService { return &PageService{ client: client, } }
// GetCapabilities retourne les capacités du service de pages func (s *PageService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_pages", Description: "Liste les pages avec pagination", Category: "Page Management", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Maximum number of pages to return", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Cursor for pagination", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "List the first 10 pages", Parameters: map[string]interface{}{ "limit": 10, }, }, }, }, { Name: "get_page", Description: "Récupère les détails d'une page spécifique", Category: "Page Management", Parameters: map[string]providers.Parameter{ "page_id": { Type: "string", Description: "ID of the page to retrieve", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Get page with ID 'page-123'", Parameters: map[string]interface{}{ "page_id": "page-123", }, }, }, }, { Name: "create_page", Description: "Crée une nouvelle page", Category: "Page Management", Parameters: map[string]providers.Parameter{ "pageDraft": { Type: "object", // Assuming models.PageDraft maps to a JSON object Description: "Page data for creation (structure defined by models.PageDraft)", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Create a new page with a title", Parameters: map[string]interface{}{ "pageDraft": map[string]interface{}{ "title": "New Page Title", // Add other fields from models.PageDraft as needed }, }, }, }, }, { Name: "update_page", Description: "Met à jour une page existante", Category: "Page Management", Parameters: map[string]providers.Parameter{ "page_id": { Type: "string", Description: "ID of the page to update", Required: true, Category: "path", }, "updates": { Type: "object", Description: "Fields to update (key-value pairs)", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Update the title of page 'page-123'", Parameters: map[string]interface{}{ "page_id": "page-123", "updates": map[string]interface{}{ "title": "Updated Page Title", }, }, }, }, }, { Name: "delete_page", Description: "Supprime une page existante", Category: "Page Management", Parameters: map[string]providers.Parameter{ "page_id": { Type: "string", Description: "ID of the page to delete", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Delete page with ID 'page-456'", Parameters: map[string]interface{}{ "page_id": "page-456", }, }, }, }, { Name: "list_websites", Description: "Liste les sites web avec pagination", Category: "Website Management", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Maximum number of websites to return", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Cursor for pagination", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "List the first 5 websites", Parameters: map[string]interface{}{ "limit": 5, }, }, }, }, { Name: "get_website", Description: "Récupère les détails d'un site web spécifique", Category: "Website Management", Parameters: map[string]providers.Parameter{ "website_id": { Type: "string", Description: "ID of the website to retrieve", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Get website with ID 'site-abc'", Parameters: map[string]interface{}{ "website_id": "site-abc", }, }, }, }, { Name: "get_menu", Description: "Récupère le menu d'un site web", Category: "Website Management", Parameters: map[string]providers.Parameter{ "website_id": { Type: "string", Description: "ID of the website whose menu to retrieve", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Get the menu for website 'site-xyz'", Parameters: map[string]interface{}{ "website_id": "site-xyz", }, }, }, }, { Name: "search_pages", Description: "Recherche des pages basé sur des critères spécifiques", Category: "Page Management", Parameters: map[string]providers.Parameter{ "searchRequest": { Type: "object", // Assuming models.PageSearchRequest maps to a JSON object Description: "Search criteria (structure defined by models.PageSearchRequest)", Required: true, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Search for pages containing the term 'example'", Parameters: map[string]interface{}{ "searchRequest": map[string]interface{}{ "query": "example", // Add other fields from models.PageSearchRequest as needed }, }, }, }, }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *PageService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for PageService") }
// ListPages liste les pages avec pagination func (s PageService) ListPages(ctx context.Context, options models.QueryOptions) (models.PageList, error) { endpoint := "/pages"
// Créer les paramètres de requête pour pagination
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
if len(queryParams) > 0 {
endpoint += "?" + queryParams.Encode()
}
var pageList models.PageList
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &pageList); err != nil {
return nil, fmt.Errorf("error listing pages: %w", err)
}
return &pageList, nil
}
// GetPage récupère les détails d'une page spécifique func (s PageService) GetPage(ctx context.Context, pageID string) (models.Page, error) { if pageID == "" { return nil, fmt.Errorf("page ID is required") }
endpoint := fmt.Sprintf("/pages/%s", pageID)
var page models.Page
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &page); err != nil {
return nil, fmt.Errorf("error getting page: %w", err)
}
return &page, nil
}
// CreatePage crée une nouvelle page func (s PageService) CreatePage(ctx context.Context, pageDraft models.PageDraft) (*models.PageCreated, error) { if pageDraft == nil { return nil, fmt.Errorf("page draft is required") }
endpoint := "/pages"
var pageCreated models.PageCreated
if err := s.client.Request(ctx, http.MethodPost, endpoint, pageDraft, &pageCreated); err != nil {
return nil, fmt.Errorf("error creating page: %w", err)
}
return &pageCreated, nil
}
// UpdatePage met à jour une page existante func (s PageService) UpdatePage(ctx context.Context, pageID string, updates map[string]interface{}) (models.PageUpdated, error) { if pageID == "" { return nil, fmt.Errorf("page ID is required") } if updates == nil { return nil, fmt.Errorf("updates are required") }
endpoint := fmt.Sprintf("/pages/%s", pageID)
var pageUpdated models.PageUpdated
if err := s.client.Request(ctx, http.MethodPatch, endpoint, updates, &pageUpdated); err != nil {
return nil, fmt.Errorf("error updating page: %w", err)
}
return &pageUpdated, nil
}
// DeletePage supprime une page existante func (s PageService) DeletePage(ctx context.Context, pageID string) (models.PageDeleted, error) { if pageID == "" { return nil, fmt.Errorf("page ID is required") }
endpoint := fmt.Sprintf("/pages/%s", pageID)
var pageDeleted models.PageDeleted
if err := s.client.Request(ctx, http.MethodDelete, endpoint, nil, &pageDeleted); err != nil {
return nil, fmt.Errorf("error deleting page: %w", err)
}
return &pageDeleted, nil
}
// ListWebsites liste les sites web avec pagination func (s PageService) ListWebsites(ctx context.Context, options models.QueryOptions) (models.WebsiteList, error) { endpoint := "/websites"
// Créer les paramètres de requête pour pagination
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
if len(queryParams) > 0 {
endpoint += "?" + queryParams.Encode()
}
var websiteList models.WebsiteList
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &websiteList); err != nil {
return nil, fmt.Errorf("error listing websites: %w", err)
}
return &websiteList, nil
}
// GetWebsite récupère les détails d'un site web spécifique func (s PageService) GetWebsite(ctx context.Context, websiteID string) (models.Website, error) { if websiteID == "" { return nil, fmt.Errorf("website ID is required") }
endpoint := fmt.Sprintf("/websites/%s", websiteID)
var website models.Website
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &website); err != nil {
return nil, fmt.Errorf("error getting website: %w", err)
}
return &website, nil
}
// GetMenu récupère le menu d'un site web func (s PageService) GetMenu(ctx context.Context, websiteID string) (models.Menu, error) { if websiteID == "" { return nil, fmt.Errorf("website ID is required") }
endpoint := fmt.Sprintf("/websites/%s/menu", websiteID)
var menu models.Menu
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &menu); err != nil {
return nil, fmt.Errorf("error getting menu: %w", err)
}
return &menu, nil
}
// SearchPages recherche des pages basé sur des critères spécifiques func (s PageService) SearchPages(ctx context.Context, searchRequest models.PageSearchRequest) (*models.PageList, error) { if searchRequest == nil { return nil, fmt.Errorf("search request is required") }
endpoint := "/pages/search"
var pageList models.PageList
if err := s.client.Request(ctx, http.MethodPost, endpoint, searchRequest, &pageList); err != nil {
return nil, fmt.Errorf("error searching pages: %w", err)
}
return &pageList, nil
}
// --- End of services/page_service.go ---
// File: services/post_service.go
//pkg/providers/unipile/services/post_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// PostService implémente les fonctionnalités de gestion des publications type PostService struct { client models.UnipileRequester }
// NewPostService crée une nouvelle instance de PostService func NewPostService(client models.UnipileRequester) *PostService { return &PostService{ client: client, } }
// GetCapabilities retourne les capacités du service de publications func (s *PostService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_posts", Description: "Liste les publications avec pagination", }, { Name: "get_post", Description: "Récupère les détails d'une publication spécifique", }, { Name: "create_post", Description: "Crée une nouvelle publication", }, { Name: "delete_post", Description: "Supprime une publication existante", }, { Name: "list_comments", Description: "Liste les commentaires d'une publication", }, { Name: "get_comment", Description: "Récupère les détails d'un commentaire spécifique", }, { Name: "create_comment", Description: "Crée un nouveau commentaire sur une publication", }, { Name: "delete_comment", Description: "Supprime un commentaire existant", }, { Name: "list_reactions", Description: "Liste les réactions d'une publication", }, { Name: "add_reaction", Description: "Ajoute une réaction à une publication", }, { Name: "delete_reaction", Description: "Supprime une réaction existante", }, { Name: "search_posts", Description: "Recherche des publications selon des critères spécifiques", }, } }
// Execute implements the providers.Service interface. // Placeholder implementation. func (s *PostService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { return nil, fmt.Errorf("Execute method not implemented for PostService") }
// ListPosts liste les publications avec pagination func (s PostService) ListPosts(ctx context.Context, options models.QueryOptions) (models.PostList, error) { endpoint := "/posts"
// Créer les paramètres de requête pour pagination
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
if len(queryParams) > 0 {
endpoint += "?" + queryParams.Encode()
}
var postList models.PostList
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &postList); err != nil {
return nil, fmt.Errorf("error listing posts: %w", err)
}
return &postList, nil
}
// GetPost récupère les détails d'une publication spécifique func (s PostService) GetPost(ctx context.Context, postID string) (models.Post, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") }
endpoint := fmt.Sprintf("/posts/%s", postID)
var post models.Post
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &post); err != nil {
return nil, fmt.Errorf("error getting post: %w", err)
}
return &post, nil
}
// CreatePost crée une nouvelle publication func (s PostService) CreatePost(ctx context.Context, postDraft models.PostDraft) (*models.PostCreated, error) { if postDraft == nil { return nil, fmt.Errorf("post draft is required") }
endpoint := "/posts"
var postCreated models.PostCreated
if err := s.client.Request(ctx, http.MethodPost, endpoint, postDraft, &postCreated); err != nil {
return nil, fmt.Errorf("error creating post: %w", err)
}
return &postCreated, nil
}
// DeletePost supprime une publication existante func (s PostService) DeletePost(ctx context.Context, postID string) (models.PostDeleted, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") }
endpoint := fmt.Sprintf("/posts/%s", postID)
var postDeleted models.PostDeleted
if err := s.client.Request(ctx, http.MethodDelete, endpoint, nil, &postDeleted); err != nil {
return nil, fmt.Errorf("error deleting post: %w", err)
}
return &postDeleted, nil
}
// ListComments liste les commentaires d'une publication avec pagination func (s PostService) ListComments(ctx context.Context, postID string, options models.QueryOptions) (models.PostCommentList, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") }
endpoint := fmt.Sprintf("/posts/%s/comments", postID)
// Créer les paramètres de requête pour pagination
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
if len(queryParams) > 0 {
endpoint += "?" + queryParams.Encode()
}
var commentList models.PostCommentList
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &commentList); err != nil {
return nil, fmt.Errorf("error listing comments: %w", err)
}
return &commentList, nil
}
// GetComment récupère les détails d'un commentaire spécifique func (s PostService) GetComment(ctx context.Context, commentID string) (models.PostComment, error) { if commentID == "" { return nil, fmt.Errorf("comment ID is required") }
endpoint := fmt.Sprintf("/comments/%s", commentID)
var comment models.PostComment
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &comment); err != nil {
return nil, fmt.Errorf("error getting comment: %w", err)
}
return &comment, nil
}
// CreateComment crée un nouveau commentaire sur une publication func (s PostService) CreateComment(ctx context.Context, postID string, commentDraft models.PostCommentDraft) (*models.PostCommentCreated, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") } if commentDraft == nil { return nil, fmt.Errorf("comment draft is required") }
endpoint := fmt.Sprintf("/posts/%s/comments", postID)
var commentCreated models.PostCommentCreated
if err := s.client.Request(ctx, http.MethodPost, endpoint, commentDraft, &commentCreated); err != nil {
return nil, fmt.Errorf("error creating comment: %w", err)
}
return &commentCreated, nil
}
// DeleteComment supprime un commentaire existant func (s PostService) DeleteComment(ctx context.Context, commentID string) (models.PostCommentDeleted, error) { if commentID == "" { return nil, fmt.Errorf("comment ID is required") }
endpoint := fmt.Sprintf("/comments/%s", commentID)
var commentDeleted models.PostCommentDeleted
if err := s.client.Request(ctx, http.MethodDelete, endpoint, nil, &commentDeleted); err != nil {
return nil, fmt.Errorf("error deleting comment: %w", err)
}
return &commentDeleted, nil
}
// ListReactions liste les réactions d'une publication avec pagination func (s PostService) ListReactions(ctx context.Context, postID string, options models.QueryOptions) (models.PostReactionList, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") }
endpoint := fmt.Sprintf("/posts/%s/reactions", postID)
// Créer les paramètres de requête pour pagination
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
if len(queryParams) > 0 {
endpoint += "?" + queryParams.Encode()
}
var reactionList models.PostReactionList
if err := s.client.Request(ctx, http.MethodGet, endpoint, nil, &reactionList); err != nil {
return nil, fmt.Errorf("error listing reactions: %w", err)
}
return &reactionList, nil
}
// AddReaction ajoute une réaction à une publication func (s PostService) AddReaction(ctx context.Context, postID string, reactionType string) (models.PostReactionCreated, error) { if postID == "" { return nil, fmt.Errorf("post ID is required") } if reactionType == "" { return nil, fmt.Errorf("reaction type is required") }
endpoint := fmt.Sprintf("/posts/%s/reactions", postID)
payload := map[string]string{
"type": reactionType,
}
var reactionCreated models.PostReactionCreated
if err := s.client.Request(ctx, http.MethodPost, endpoint, payload, &reactionCreated); err != nil {
return nil, fmt.Errorf("error adding reaction: %w", err)
}
return &reactionCreated, nil
}
// DeleteReaction supprime une réaction existante func (s PostService) DeleteReaction(ctx context.Context, reactionID string) (models.PostReactionDeleted, error) { if reactionID == "" { return nil, fmt.Errorf("reaction ID is required") }
endpoint := fmt.Sprintf("/reactions/%s", reactionID)
var reactionDeleted models.PostReactionDeleted
if err := s.client.Request(ctx, http.MethodDelete, endpoint, nil, &reactionDeleted); err != nil {
return nil, fmt.Errorf("error deleting reaction: %w", err)
}
return &reactionDeleted, nil
}
// SearchPosts recherche des publications basé sur des critères spécifiques func (s PostService) SearchPosts(ctx context.Context, searchRequest models.PostSearchRequest) (*models.PostList, error) { if searchRequest == nil { return nil, fmt.Errorf("search request is required") }
endpoint := "/posts/search"
var postList models.PostList
if err := s.client.Request(ctx, http.MethodPost, endpoint, searchRequest, &postList); err != nil {
return nil, fmt.Errorf("error searching posts: %w", err)
}
return &postList, nil
}
// --- End of services/post_service.go ---
// File: services/webhook_service.go
//pkg/providers/unipile/services/webhook_service.go
package services
import ( "context" "fmt" "net/http" "net/url" "strconv"
providers "gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
)
// WebhookService implémente les fonctionnalités pour gérer les webhooks type WebhookService struct { client models.UnipileRequester }
// NewWebhookService crée une nouvelle instance du service de webhook func NewWebhookService(client models.UnipileRequester) *WebhookService { return &WebhookService{ client: client, } }
// GetCapabilities retourne les capacités de ce service func (s *WebhookService) GetCapabilities() []providers.ProviderCapability { return []providers.ProviderCapability{ { Name: "list_webhooks", Description: "Liste les webhooks configurés", Category: "Webhooks", Parameters: map[string]providers.Parameter{ "limit": { Type: "integer", Description: "Nombre maximum de webhooks à retourner.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "Lister les 10 premiers webhooks", Parameters: map[string]interface{}{ "limit": 10, }, }, }, }, { Name: "get_webhook", Description: "Récupère les détails d'un webhook spécifique", Category: "Webhooks", Parameters: map[string]providers.Parameter{ "webhook_id": { Type: "string", Description: "L'ID du webhook à récupérer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Récupérer le webhook avec l'ID 'wh_123'", Parameters: map[string]interface{}{ "webhook_id": "wh_123", }, }, }, }, { Name: "create_webhook", Description: "Crée un nouveau webhook", Category: "Webhooks", Parameters: map[string]providers.Parameter{ "url": { Type: "string", Description: "L'URL de destination pour les événements webhook.", Required: true, Category: "body", }, "events": { Type: "array", Description: "Liste des types d'événements auxquels s'abonner.", Required: true, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "secret": { Type: "string", Description: "Clé secrète pour la vérification de la signature.", Required: false, Category: "body", }, "status": { Type: "string", Description: "Statut initial du webhook.", Required: false, Enum: []string{"active", "inactive"}, Default: "active", Category: "body", }, "metadata": { Type: "object", Description: "Métadonnées personnalisées associées au webhook.", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Créer un webhook pour les événements 'message.created'", Parameters: map[string]interface{}{ "url": "https://example.com/webhook", "events": []string{"message.created"}, }, }, }, }, { Name: "update_webhook", Description: "Met à jour un webhook existant", Category: "Webhooks", Parameters: map[string]providers.Parameter{ "webhook_id": { Type: "string", Description: "L'ID du webhook à mettre à jour.", Required: true, Category: "path", }, "url": { Type: "string", Description: "La nouvelle URL de destination pour les événements webhook.", Required: false, Category: "body", }, "events": { Type: "array", Description: "La nouvelle liste des types d'événements auxquels s'abonner.", Required: false, Category: "body", Items: &providers.ParameterItems{Type: "string"}, }, "secret": { Type: "string", Description: "La nouvelle clé secrète pour la vérification de la signature.", Required: false, Category: "body", }, "status": { Type: "string", Description: "Le nouveau statut du webhook.", Required: false, Enum: []string{"active", "inactive"}, Category: "body", }, "metadata": { Type: "object", Description: "Les nouvelles métadonnées personnalisées associées au webhook.", Required: false, Category: "body", }, }, Examples: []providers.ToolExample{ { Description: "Désactiver le webhook 'wh_123'", Parameters: map[string]interface{}{ "webhook_id": "wh_123", "status": "inactive", }, }, }, }, { Name: "delete_webhook", Description: "Supprime un webhook", Category: "Webhooks", Parameters: map[string]providers.Parameter{ "webhook_id": { Type: "string", Description: "L'ID du webhook à supprimer.", Required: true, Category: "path", }, }, Examples: []providers.ToolExample{ { Description: "Supprimer le webhook 'wh_123'", Parameters: map[string]interface{}{ "webhook_id": "wh_123", }, }, }, }, { Name: "list_webhook_events", Description: "Liste les événements d'un webhook", Category: "Webhook Events", Parameters: map[string]providers.Parameter{ "webhook_id": { Type: "string", Description: "L'ID du webhook pour lequel lister les événements.", Required: true, Category: "path", }, "limit": { Type: "integer", Description: "Nombre maximum d'événements à retourner.", Required: false, Category: "query", }, "cursor": { Type: "string", Description: "Curseur pour la pagination.", Required: false, Category: "query", }, }, Examples: []providers.ToolExample{ { Description: "Lister les événements pour le webhook 'wh_123'", Parameters: map[string]interface{}{ "webhook_id": "wh_123", }, }, }, }, { Name: "get_webhook_event", Description: "Récupère les détails d'un événement webhook spécifique",
Category: "Webhook Events",
Parameters: map[string]providers.Parameter{
"event_id": {
Type: "string",
Description: "L'ID de l'événement webhook à récupérer.",
Required: true,
Category: "path",
},
},
Examples: []providers.ToolExample{
{
Description: "Récupérer l'événement webhook 'evt_abc'",
Parameters: map[string]interface{}{
"event_id": "evt_abc",
},
},
},
},
{
Name: "list_webhook_event_deliveries",
Description: "Liste les tentatives de livraison d'un événement webhook",
Category: "Webhook Deliveries",
Parameters: map[string]providers.Parameter{
"event_id": {
Type: "string",
Description: "L'ID de l'événement webhook pour lequel lister les livraisons.",
Required: true,
Category: "path",
},
"limit": {
Type: "integer",
Description: "Nombre maximum de livraisons à retourner.",
Required: false,
Category: "query",
},
"cursor": {
Type: "string",
Description: "Curseur pour la pagination.",
Required: false,
Category: "query",
},
},
Examples: []providers.ToolExample{
{
Description: "Lister les livraisons pour l'événement 'evt_abc'",
Parameters: map[string]interface{}{
"event_id": "evt_abc",
},
},
},
},
{
Name: "resend_webhook_delivery",
Description: "Renvoie une livraison de webhook",
Category: "Webhook Deliveries",
Parameters: map[string]providers.Parameter{
"delivery_id": {
Type: "string",
Description: "L'ID de la livraison webhook à renvoyer.",
Required: true,
Category: "path",
},
},
Examples: []providers.ToolExample{
{
Description: "Renvoyer la livraison 'del_xyz'",
Parameters: map[string]interface{}{
"delivery_id": "del_xyz",
},
},
},
},
}
}
// Execute implements the providers.Service interface. // For WebhookService, direct execution might not be applicable as capabilities // map to specific API calls handled by dedicated methods (e.g., CreateWebhook). func (s *WebhookService) Execute(ctx context.Context, params map[string]interface{}) (result interface{}, err error) { // Determine the capability/action requested from params, if applicable // Example: action, ok := params["action"].(string) // For now, return not implemented, as specific methods should be called directly. return nil, fmt.Errorf("Execute method not implemented for WebhookService; use specific methods like CreateWebhook") }
// ListWebhooks récupère la liste des webhooks configurés func (s WebhookService) ListWebhooks(ctx context.Context, options models.QueryOptions) (models.WebhookList, error) { queryParams := url.Values{} if options.Limit > 0 { queryParams.Set("limit", strconv.Itoa(options.Limit)) } if options.Cursor != "" { queryParams.Set("cursor", options.Cursor) }
path := "/webhooks"
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.WebhookList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des webhooks: %w", err)
}
return &result, nil
}
// GetWebhook récupère les détails d'un webhook spécifique func (s WebhookService) GetWebhook(ctx context.Context, webhookID string) (models.Webhook, error) { if webhookID == "" { return nil, fmt.Errorf("webhook ID est requis") }
path := fmt.Sprintf("/webhooks/%s", webhookID)
var result models.Webhook
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération du webhook %s: %w", webhookID, err)
}
return &result, nil
}
// CreateWebhook crée un nouveau webhook func (s WebhookService) CreateWebhook(ctx context.Context, webhook models.WebhookDraft) (*models.WebhookCreated, error) { if webhook == nil { return nil, fmt.Errorf("données de webhook requises") }
if webhook.URL == "" {
return nil, fmt.Errorf("URL du webhook est requise")
}
if len(webhook.Events) == 0 {
return nil, fmt.Errorf("au moins un événement à surveiller est requis")
}
var result models.WebhookCreated
err := s.client.Request(ctx, http.MethodPost, "/webhooks", webhook, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la création du webhook: %w", err)
}
return &result, nil
}
// UpdateWebhook met à jour un webhook existant func (s WebhookService) UpdateWebhook(ctx context.Context, webhookID string, webhook models.WebhookUpdate) (*models.WebhookUpdated, error) { if webhookID == "" { return nil, fmt.Errorf("webhook ID est requis") }
if webhook == nil {
return nil, fmt.Errorf("données de mise à jour requises")
}
path := fmt.Sprintf("/webhooks/%s", webhookID)
var result models.WebhookUpdated
err := s.client.Request(ctx, http.MethodPatch, path, webhook, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du webhook %s: %w", webhookID, err)
}
return &result, nil
}
// DeleteWebhook supprime un webhook func (s WebhookService) DeleteWebhook(ctx context.Context, webhookID string) (models.WebhookDeleted, error) { if webhookID == "" { return nil, fmt.Errorf("webhook ID est requis") }
path := fmt.Sprintf("/webhooks/%s", webhookID)
var result models.WebhookDeleted
err := s.client.Request(ctx, http.MethodDelete, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la suppression du webhook %s: %w", webhookID, err)
}
return &result, nil
}
// ListWebhookEvents récupère la liste des événements d'un webhook func (s WebhookService) ListWebhookEvents(ctx context.Context, webhookID string, options models.QueryOptions) (models.WebhookEventList, error) { if webhookID == "" { return nil, fmt.Errorf("webhook ID est requis") }
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
path := fmt.Sprintf("/webhooks/%s/events", webhookID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.WebhookEventList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des événements du webhook %s: %w", webhookID, err)
}
return &result, nil
}
// GetWebhookEvent récupère les détails d'un événement webhook spécifique func (s WebhookService) GetWebhookEvent(ctx context.Context, eventID string) (models.WebhookEvent, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
path := fmt.Sprintf("/webhook-events/%s", eventID)
var result models.WebhookEvent
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération de l'événement %s: %w", eventID, err)
}
return &result, nil
}
// ListWebhookEventDeliveries récupère la liste des tentatives de livraison d'un événement webhook func (s WebhookService) ListWebhookEventDeliveries(ctx context.Context, eventID string, options models.QueryOptions) (models.WebhookEventDeliveryList, error) { if eventID == "" { return nil, fmt.Errorf("event ID est requis") }
queryParams := url.Values{}
if options.Limit > 0 {
queryParams.Set("limit", strconv.Itoa(options.Limit))
}
if options.Cursor != "" {
queryParams.Set("cursor", options.Cursor)
}
path := fmt.Sprintf("/webhook-events/%s/deliveries", eventID)
if len(queryParams) > 0 {
path += "?" + queryParams.Encode()
}
var result models.WebhookEventDeliveryList
err := s.client.Request(ctx, http.MethodGet, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livraisons de l'événement %s: %w", eventID, err)
}
return &result, nil
}
// ResendWebhookDelivery renvoie une livraison de webhook func (s WebhookService) ResendWebhookDelivery(ctx context.Context, deliveryID string) (models.WebhookResend, error) { if deliveryID == "" { return nil, fmt.Errorf("delivery ID est requis") }
path := fmt.Sprintf("/webhook-deliveries/%s/resend", deliveryID)
var result models.WebhookResend
err := s.client.Request(ctx, http.MethodPost, path, nil, &result)
if err != nil {
return nil, fmt.Errorf("erreur lors du renvoi de la livraison %s: %w", deliveryID, err)
}
return &result, nil
}
// --- End of services/webhook_service.go ---
// File: sync_job.go
//pkg/providers/unipile/sync_job.go
package unipile
import ( "context" "errors" "fmt" "log" "time"
"gitlab.com/webigniter/slop-server/internal/core"
"gitlab.com/webigniter/slop-server/internal/jobmanager"
"gitlab.com/webigniter/slop-server/internal/providers"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/services"
)
// UnipileAccountSyncProcessID is the unique identifier for this job type. const UnipileAccountSyncProcessID = "unipile-account-sync"
// UnipileAccountSyncProcessor handles the job of syncing all user accounts. type UnipileAccountSyncProcessor struct { adapter UnipileAdapter // Need access to SyncUserAccounts coreStore core.PgStore // Need access to fetch all user IDs }
// NewUnipileAccountSyncProcessor creates a new sync processor instance. func NewUnipileAccountSyncProcessor(adapter UnipileAdapter, coreStore core.PgStore) jobmanager.ExecutableProcess { if adapter == nil { log.Fatalf("CRITICAL: UnipileAdapter is nil for NewUnipileAccountSyncProcessor") } if coreStore == nil { log.Fatalf("CRITICAL: CoreStore is nil for NewUnipileAccountSyncProcessor") } return &UnipileAccountSyncProcessor{ adapter: adapter, coreStore: coreStore, } }
// GetCapabilities describes the capabilities (none specific for this background job). func (p *UnipileAccountSyncProcessor) GetCapabilities() []providers.ProviderCapability { // This processor doesn't expose direct capabilities like tools or events. return []providers.ProviderCapability{} }
// Execute performs the synchronization based on accounts fetched from the Unipile API. func (p *UnipileAccountSyncProcessor) Execute(ctx context.Context, msg jobmanager.Message) (jobmanager.ExecuteResult, error) { jobID := msg.Metadata["job_id"].(string) // Assuming job_id is in metadata log.Printf("Starting %s Job ID: %s", UnipileAccountSyncProcessID, jobID)
// Check dependencies are available
if p.adapter == nil || p.adapter.client == nil || p.adapter.store == nil || p.coreStore == nil {
errMsg := "missing dependencies for sync job (adapter, client, store, or coreStore)"
log.Printf("Job %s: ERROR - %s", jobID, errMsg)
return jobmanager.ExecuteResult{}, errors.New(errMsg)
}
// --- 1. Fetch ALL accounts from Unipile API ---
log.Printf("Job %s: Fetching all accounts from Unipile API...", jobID)
accService := services.NewAccountService(p.adapter.client)
allApiAccounts := make([]models.Account, 0)
apiAccountIDs := make(map[string]bool) // Set to track IDs seen from API
var nextCursor string
apiFetchErrors := 0
for {
options := models.QueryOptions{
Limit: 100, // Fetch in batches
Cursor: nextCursor,
}
apiCtx, cancel := context.WithTimeout(ctx, 30*time.Second) // Timeout for API call
accountList, err := accService.ListAccounts(apiCtx, options)
cancel()
if err != nil {
log.Printf("Job %s: ERROR fetching accounts from Unipile API (cursor: '%s'): %v", jobID, nextCursor, err)
apiFetchErrors++
// Decide whether to bail out completely or try to process what we have?
// Let's bail for now if fetching fails.
return jobmanager.ExecuteResult{}, fmt.Errorf("failed to fetch accounts from Unipile API: %w", err)
}
if accountList != nil && len(accountList.Accounts) > 0 {
allApiAccounts = append(allApiAccounts, accountList.Accounts...)
for _, acc := range accountList.Accounts {
apiAccountIDs[acc.ID] = true // Track ID
}
log.Printf("Job %s: Fetched %d accounts from API (total so far: %d)", jobID, len(accountList.Accounts), len(allApiAccounts))
} else {
log.Printf("Job %s: No accounts returned from API for cursor '%s'.", jobID, nextCursor)
}
// Check pagination
if accountList != nil && accountList.Paging.Cursor != "" {
nextCursor = accountList.Paging.Cursor
log.Printf("Job %s: Moving to next page with cursor: %s", jobID, nextCursor)
} else {
break // No next page cursor or accountList was nil
}
}
log.Printf("Job %s: Finished fetching from Unipile API. Total accounts retrieved: %d", jobID, len(allApiAccounts))
// --- 2. Process Each Account from API ---
log.Printf("Job %s: Processing %d accounts retrieved from API...", jobID, len(allApiAccounts))
processedCount := 0
mappedCount := 0
updateErrors := 0
for _, apiAccount := range allApiAccounts {
processedCount++
accountID := apiAccount.ID
email := apiAccount.Email // Use email as the primary lookup key
if email == "" {
log.Printf("Job %s: WARN - Skipping account %s: Missing email address for framework user lookup.", jobID, accountID)
continue
}
// --- 3. Attempt Framework User Lookup ---
// TODO: Implement coreStore.GetUserByEmail(ctx, email) string
// It should return the framework userID if found, or an empty string/error if not.
// frameworkUser, err := p.coreStore.GetUserByEmail(ctx, email)
frameworkUserID := "" // Placeholder
var userLookupErr error = errors.New("GetUserByEmail not implemented") // Placeholder error
log.Printf("Job %s: TODO - Implement lookup for user with email: %s", jobID, email) // Log placeholder action
if userLookupErr != nil {
// Handle lookup errors (e.g., DB connection issue)
log.Printf("Job %s: ERROR looking up framework user for email %s (Account: %s): %v", jobID, email, accountID, userLookupErr)
updateErrors++
continue // Skip this account if user lookup fails
}
if frameworkUserID == "" {
// No matching framework user found for this email
log.Printf("Job %s: INFO - Unipile account %s (Email: %s) does not match any known framework user. Skipping mapping.", jobID, accountID, email)
continue
}
// --- 4. Store/Update Mapping in Local DB ---
log.Printf("Job %s: Found framework user %s for Unipile account %s (Email: %s). Storing/updating mapping...", jobID, frameworkUserID, accountID, email)
mappedCount++
// Get required fields from apiAccount
provider := apiAccount.Provider
status := apiAccount.Status
if status == "" {
status = "active"
} // Default status
metadata := apiAccount.Metadata // Already map[string]interface{}
if err := p.adapter.store.StoreAccount(ctx, frameworkUserID, accountID, provider, status, metadata); err != nil {
log.Printf("Job %s: ERROR storing/updating account mapping for AccountID %s, UserID %s: %v", jobID, accountID, frameworkUserID, err)
updateErrors++
}
}
// --- 5. Detect and Mark Deletions ---
log.Printf("Job %s: Checking for accounts deleted from Unipile...", jobID)
localAccountIDs, err := p.adapter.store.GetAllAccountIDs(ctx)
if err != nil {
log.Printf("Job %s: ERROR fetching local account IDs for deletion check: %v", jobID, err)
// Proceed without deletion check if this fails? Or report error?
updateErrors++ // Count this as an error
} else {
deletedCount := 0
for _, localID := range localAccountIDs {
if !apiAccountIDs[localID] { // Check if local ID was seen in API results
log.Printf("Job %s: Local account %s not found in API results. Marking as deleted_externally.", jobID, localID)
// Check current status first to avoid unnecessary updates
localAccountInfo, getErr := p.adapter.store.GetAccount(ctx, localID)
if getErr != nil {
log.Printf("Job %s: ERROR fetching local account %s status before marking deleted: %v", jobID, localID, getErr)
updateErrors++
continue
}
if localAccountInfo.Status != "deleted_externally" {
if err := p.adapter.store.UpdateAccountStatus(ctx, localID, "deleted_externally"); err != nil {
log.Printf("Job %s: ERROR marking local account %s as deleted_externally: %v", jobID, localID, err)
updateErrors++
} else {
deletedCount++
}
} else {
log.Printf("Job %s: Local account %s already marked as deleted_externally.", jobID, localID)
}
}
}
log.Printf("Job %s: Finished deletion check. Marked %d accounts as deleted_externally.", jobID, deletedCount)
}
// --- 6. Report Results ---
log.Printf("Job %s: Finished Unipile sync. API Accounts Processed: %d, Mapped/Updated: %d, Update/Check Errors: %d",
jobID, processedCount, mappedCount, updateErrors)
resultOutputs := map[string]any{
"status": "completed",
"api_accounts_processed": processedCount,
"accounts_mapped_updated": mappedCount,
"sync_errors": updateErrors,
"api_fetch_errors": apiFetchErrors, // Report API fetch errors separately if needed
}
if updateErrors > 0 || apiFetchErrors > 0 {
return jobmanager.ExecuteResult{Output: jobmanager.ExecuteResultMessage{Outputs: resultOutputs}},
fmt.Errorf("sync job completed with %d processing errors and %d API fetch errors", updateErrors, apiFetchErrors)
}
return jobmanager.ExecuteResult{
Output: jobmanager.ExecuteResultMessage{
Outputs: resultOutputs,
},
}, nil
}
// --- End of sync_job.go ---
// File: webhook_registration.go
//pkg/providers/unipile/webhook_registration.go
package unipile
import ( "context" "errors" "fmt" "log"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/models"
"gitlab.com/webigniter/slop-server/pkg/providers/unipile/services"
)
// RegisterWebhooks registers our webhook endpoints with Unipile func (a *UnipileAdapter) RegisterWebhooks(ctx context.Context) error { // Get base URL for webhooks from config baseURL, ok := a.config["webhook_base_url"].(string) if !ok || baseURL == "" { return errors.New("webhook_base_url not configured") }
// Get webhook service
service, found := a.client.Registry.Get("webhook")
if !found {
return errors.New("webhook service not found in registry")
}
webhookService, ok := service.(*services.WebhookService)
if !ok {
return errors.New("retrieved service is not a WebhookService")
}
// Register email webhook
emailWebhook := &models.WebhookDraft{
URL: fmt.Sprintf("%s/webhook/unipile/emails", baseURL),
Description: "Email notifications webhook",
Events: []string{"email.*"},
}
_, err := webhookService.CreateWebhook(ctx, emailWebhook)
if err != nil {
return fmt.Errorf("failed to register email webhook: %w", err)
}
// Register message webhook
messageWebhook := &models.WebhookDraft{
URL: fmt.Sprintf("%s/webhook/unipile/messages", baseURL),
Description: "Message notifications webhook",
Events: []string{"message.*"},
}
_, err = webhookService.CreateWebhook(ctx, messageWebhook)
if err != nil {
return fmt.Errorf("failed to register message webhook: %w", err)
}
// Register account status webhook
accountStatusWebhook := &models.WebhookDraft{
URL: fmt.Sprintf("%s/webhook/unipile/account-status", baseURL),
Description: "Account status webhook",
Events: []string{"account.*"},
}
_, err = webhookService.CreateWebhook(ctx, accountStatusWebhook)
if err != nil {
return fmt.Errorf("failed to register account status webhook: %w", err)
}
log.Printf("Successfully registered all Unipile webhooks")
return nil
}
// --- End of webhook_registration.go ---