feat: Go backend, enhanced search, new widgets, Docker deploy
Major changes: - Add Go backend (backend/) with microservices architecture - Enhanced master-agents-svc: reranker, content-classifier, stealth-crawler, proxy-manager, media-search, fastClassifier, language detection - New web-svc widgets: KnowledgeCard, ProductCard, ProfileCard, VideoCard, UnifiedCard, CardGallery, InlineImageGallery, SourcesPanel, RelatedQuestions - Improved discover-svc with discover-db integration - Docker deployment improvements (Caddyfile, vendor.sh, BUILD.md) - Library-svc: project_id schema migration - Remove deprecated finance-svc and travel-svc - Localization improvements across services Made-with: Cursor
This commit is contained in:
104
backend/internal/computer/connectors/connector.go
Normal file
104
backend/internal/computer/connectors/connector.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Connector interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Description() string
|
||||
Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error)
|
||||
GetActions() []Action
|
||||
Validate(params map[string]interface{}) error
|
||||
}
|
||||
|
||||
type Action struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Schema map[string]interface{} `json:"schema"`
|
||||
Required []string `json:"required"`
|
||||
}
|
||||
|
||||
type ConnectorHub struct {
|
||||
connectors map[string]Connector
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewConnectorHub() *ConnectorHub {
|
||||
return &ConnectorHub{
|
||||
connectors: make(map[string]Connector),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) Register(connector Connector) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.connectors[connector.ID()] = connector
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) Unregister(id string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
delete(h.connectors, id)
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) Get(id string) (Connector, error) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
connector, ok := h.connectors[id]
|
||||
if !ok {
|
||||
return nil, errors.New("connector not found: " + id)
|
||||
}
|
||||
return connector, nil
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) List() []Connector {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make([]Connector, 0, len(h.connectors))
|
||||
for _, c := range h.connectors {
|
||||
result = append(result, c)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) Execute(ctx context.Context, connectorID, action string, params map[string]interface{}) (interface{}, error) {
|
||||
connector, err := h.Get(connectorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := connector.Validate(params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return connector.Execute(ctx, action, params)
|
||||
}
|
||||
|
||||
type ConnectorInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
func (h *ConnectorHub) GetInfo() []ConnectorInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
result := make([]ConnectorInfo, 0, len(h.connectors))
|
||||
for _, c := range h.connectors {
|
||||
result = append(result, ConnectorInfo{
|
||||
ID: c.ID(),
|
||||
Name: c.Name(),
|
||||
Description: c.Description(),
|
||||
Actions: c.GetActions(),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
215
backend/internal/computer/connectors/email.go
Normal file
215
backend/internal/computer/connectors/email.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type EmailConfig struct {
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
Username string
|
||||
Password string
|
||||
FromAddress string
|
||||
FromName string
|
||||
UseTLS bool
|
||||
AllowHTML bool
|
||||
}
|
||||
|
||||
type EmailConnector struct {
|
||||
cfg EmailConfig
|
||||
}
|
||||
|
||||
func NewEmailConnector(cfg EmailConfig) *EmailConnector {
|
||||
return &EmailConnector{cfg: cfg}
|
||||
}
|
||||
|
||||
func (e *EmailConnector) ID() string {
|
||||
return "email"
|
||||
}
|
||||
|
||||
func (e *EmailConnector) Name() string {
|
||||
return "Email"
|
||||
}
|
||||
|
||||
func (e *EmailConnector) Description() string {
|
||||
return "Send emails via SMTP"
|
||||
}
|
||||
|
||||
func (e *EmailConnector) GetActions() []Action {
|
||||
return []Action{
|
||||
{
|
||||
Name: "send",
|
||||
Description: "Send an email",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"to": map[string]interface{}{"type": "string", "description": "Recipient email address"},
|
||||
"subject": map[string]interface{}{"type": "string", "description": "Email subject"},
|
||||
"body": map[string]interface{}{"type": "string", "description": "Email body"},
|
||||
"html": map[string]interface{}{"type": "boolean", "description": "Whether body is HTML"},
|
||||
"cc": map[string]interface{}{"type": "string", "description": "CC recipients (comma-separated)"},
|
||||
"bcc": map[string]interface{}{"type": "string", "description": "BCC recipients (comma-separated)"},
|
||||
},
|
||||
},
|
||||
Required: []string{"to", "subject", "body"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailConnector) Validate(params map[string]interface{}) error {
|
||||
if _, ok := params["to"]; !ok {
|
||||
return errors.New("'to' is required")
|
||||
}
|
||||
if _, ok := params["subject"]; !ok {
|
||||
return errors.New("'subject' is required")
|
||||
}
|
||||
if _, ok := params["body"]; !ok {
|
||||
return errors.New("'body' is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EmailConnector) Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "send":
|
||||
return e.send(ctx, params)
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EmailConnector) send(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
to := params["to"].(string)
|
||||
subject := params["subject"].(string)
|
||||
body := params["body"].(string)
|
||||
|
||||
isHTML := false
|
||||
if html, ok := params["html"].(bool); ok {
|
||||
isHTML = html && e.cfg.AllowHTML
|
||||
}
|
||||
|
||||
var cc, bcc []string
|
||||
if ccStr, ok := params["cc"].(string); ok && ccStr != "" {
|
||||
cc = strings.Split(ccStr, ",")
|
||||
for i := range cc {
|
||||
cc[i] = strings.TrimSpace(cc[i])
|
||||
}
|
||||
}
|
||||
if bccStr, ok := params["bcc"].(string); ok && bccStr != "" {
|
||||
bcc = strings.Split(bccStr, ",")
|
||||
for i := range bcc {
|
||||
bcc[i] = strings.TrimSpace(bcc[i])
|
||||
}
|
||||
}
|
||||
|
||||
from := e.cfg.FromAddress
|
||||
if e.cfg.FromName != "" {
|
||||
from = fmt.Sprintf("%s <%s>", e.cfg.FromName, e.cfg.FromAddress)
|
||||
}
|
||||
|
||||
var msg strings.Builder
|
||||
msg.WriteString(fmt.Sprintf("From: %s\r\n", from))
|
||||
msg.WriteString(fmt.Sprintf("To: %s\r\n", to))
|
||||
if len(cc) > 0 {
|
||||
msg.WriteString(fmt.Sprintf("Cc: %s\r\n", strings.Join(cc, ", ")))
|
||||
}
|
||||
msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
msg.WriteString("MIME-Version: 1.0\r\n")
|
||||
|
||||
if isHTML {
|
||||
msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n")
|
||||
} else {
|
||||
msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n")
|
||||
}
|
||||
|
||||
msg.WriteString("\r\n")
|
||||
msg.WriteString(body)
|
||||
|
||||
recipients := []string{to}
|
||||
recipients = append(recipients, cc...)
|
||||
recipients = append(recipients, bcc...)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", e.cfg.SMTPHost, e.cfg.SMTPPort)
|
||||
|
||||
var auth smtp.Auth
|
||||
if e.cfg.Username != "" && e.cfg.Password != "" {
|
||||
auth = smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.SMTPHost)
|
||||
}
|
||||
|
||||
var err error
|
||||
if e.cfg.UseTLS {
|
||||
err = e.sendWithTLS(addr, auth, e.cfg.FromAddress, recipients, []byte(msg.String()))
|
||||
} else {
|
||||
err = smtp.SendMail(addr, auth, e.cfg.FromAddress, recipients, []byte(msg.String()))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return map[string]interface{}{
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"recipients": len(recipients),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *EmailConnector) sendWithTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: e.cfg.SMTPHost,
|
||||
}
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, e.cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, recipient := range to {
|
||||
if err := client.Rcpt(recipient); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
432
backend/internal/computer/connectors/storage.go
Normal file
432
backend/internal/computer/connectors/storage.go
Normal file
@@ -0,0 +1,432 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
)
|
||||
|
||||
type StorageConfig struct {
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
BucketName string
|
||||
UseSSL bool
|
||||
Region string
|
||||
PublicURL string
|
||||
}
|
||||
|
||||
type StorageConnector struct {
|
||||
cfg StorageConfig
|
||||
client *minio.Client
|
||||
}
|
||||
|
||||
func NewStorageConnector(cfg StorageConfig) (*StorageConnector, error) {
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: cfg.Region,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
|
||||
return &StorageConnector{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) ID() string {
|
||||
return "storage"
|
||||
}
|
||||
|
||||
func (s *StorageConnector) Name() string {
|
||||
return "Storage"
|
||||
}
|
||||
|
||||
func (s *StorageConnector) Description() string {
|
||||
return "Store and retrieve files from S3-compatible storage"
|
||||
}
|
||||
|
||||
func (s *StorageConnector) GetActions() []Action {
|
||||
return []Action{
|
||||
{
|
||||
Name: "upload",
|
||||
Description: "Upload a file",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Storage path/key"},
|
||||
"content": map[string]interface{}{"type": "string", "description": "File content (base64 or text)"},
|
||||
"content_type": map[string]interface{}{"type": "string", "description": "MIME type"},
|
||||
"public": map[string]interface{}{"type": "boolean", "description": "Make file publicly accessible"},
|
||||
},
|
||||
},
|
||||
Required: []string{"path", "content"},
|
||||
},
|
||||
{
|
||||
Name: "download",
|
||||
Description: "Download a file",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Storage path/key"},
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
{
|
||||
Name: "delete",
|
||||
Description: "Delete a file",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Storage path/key"},
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Description: "List files in a directory",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"prefix": map[string]interface{}{"type": "string", "description": "Path prefix"},
|
||||
"limit": map[string]interface{}{"type": "integer", "description": "Max results"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "get_url",
|
||||
Description: "Get a presigned URL for a file",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"path": map[string]interface{}{"type": "string", "description": "Storage path/key"},
|
||||
"expires": map[string]interface{}{"type": "integer", "description": "URL expiry in seconds"},
|
||||
},
|
||||
},
|
||||
Required: []string{"path"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StorageConnector) Validate(params map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "upload":
|
||||
return s.upload(ctx, params)
|
||||
case "download":
|
||||
return s.download(ctx, params)
|
||||
case "delete":
|
||||
return s.deleteFile(ctx, params)
|
||||
case "list":
|
||||
return s.list(ctx, params)
|
||||
case "get_url":
|
||||
return s.getURL(ctx, params)
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *StorageConnector) upload(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
path := params["path"].(string)
|
||||
content := params["content"].(string)
|
||||
|
||||
contentType := "application/octet-stream"
|
||||
if ct, ok := params["content_type"].(string); ok {
|
||||
contentType = ct
|
||||
}
|
||||
|
||||
if contentType == "" {
|
||||
contentType = s.detectContentType(path)
|
||||
}
|
||||
|
||||
reader := bytes.NewReader([]byte(content))
|
||||
size := int64(len(content))
|
||||
|
||||
info, err := s.client.PutObject(ctx, s.cfg.BucketName, path, reader, size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upload failed: %w", err)
|
||||
}
|
||||
|
||||
url := ""
|
||||
if s.cfg.PublicURL != "" {
|
||||
url = fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(s.cfg.PublicURL, "/"), s.cfg.BucketName, path)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"path": path,
|
||||
"size": info.Size,
|
||||
"etag": info.ETag,
|
||||
"url": url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) UploadBytes(ctx context.Context, path string, content []byte, contentType string) (string, error) {
|
||||
if contentType == "" {
|
||||
contentType = s.detectContentType(path)
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(content)
|
||||
size := int64(len(content))
|
||||
|
||||
_, err := s.client.PutObject(ctx, s.cfg.BucketName, path, reader, size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if s.cfg.PublicURL != "" {
|
||||
return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(s.cfg.PublicURL, "/"), s.cfg.BucketName, path), nil
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) download(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
path := params["path"].(string)
|
||||
|
||||
obj, err := s.client.GetObject(ctx, s.cfg.BucketName, path, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
content, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read failed: %w", err)
|
||||
}
|
||||
|
||||
stat, _ := obj.Stat()
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"path": path,
|
||||
"content": string(content),
|
||||
"size": len(content),
|
||||
"content_type": stat.ContentType,
|
||||
"modified": stat.LastModified,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) DownloadBytes(ctx context.Context, path string) ([]byte, error) {
|
||||
obj, err := s.client.GetObject(ctx, s.cfg.BucketName, path, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
return io.ReadAll(obj)
|
||||
}
|
||||
|
||||
func (s *StorageConnector) deleteFile(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
path := params["path"].(string)
|
||||
|
||||
err := s.client.RemoveObject(ctx, s.cfg.BucketName, path, minio.RemoveObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"path": path,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) list(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
prefix := ""
|
||||
if p, ok := params["prefix"].(string); ok {
|
||||
prefix = p
|
||||
}
|
||||
|
||||
limit := 100
|
||||
if l, ok := params["limit"].(float64); ok {
|
||||
limit = int(l)
|
||||
}
|
||||
|
||||
objects := s.client.ListObjects(ctx, s.cfg.BucketName, minio.ListObjectsOptions{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
})
|
||||
|
||||
var files []map[string]interface{}
|
||||
count := 0
|
||||
|
||||
for obj := range objects {
|
||||
if obj.Err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
files = append(files, map[string]interface{}{
|
||||
"path": obj.Key,
|
||||
"size": obj.Size,
|
||||
"modified": obj.LastModified,
|
||||
"etag": obj.ETag,
|
||||
})
|
||||
|
||||
count++
|
||||
if count >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"files": files,
|
||||
"count": len(files),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) getURL(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
path := params["path"].(string)
|
||||
|
||||
expires := 3600
|
||||
if e, ok := params["expires"].(float64); ok {
|
||||
expires = int(e)
|
||||
}
|
||||
|
||||
url, err := s.client.PresignedGetObject(ctx, s.cfg.BucketName, path, time.Duration(expires)*time.Second, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate URL: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"url": url.String(),
|
||||
"expires": expires,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *StorageConnector) GetPublicURL(path string) string {
|
||||
if s.cfg.PublicURL != "" {
|
||||
return fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(s.cfg.PublicURL, "/"), s.cfg.BucketName, path)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *StorageConnector) detectContentType(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
contentTypes := map[string]string{
|
||||
".html": "text/html",
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".xml": "application/xml",
|
||||
".pdf": "application/pdf",
|
||||
".zip": "application/zip",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".mp3": "audio/mpeg",
|
||||
".txt": "text/plain",
|
||||
".md": "text/markdown",
|
||||
".csv": "text/csv",
|
||||
".py": "text/x-python",
|
||||
".go": "text/x-go",
|
||||
".rs": "text/x-rust",
|
||||
}
|
||||
|
||||
if ct, ok := contentTypes[ext]; ok {
|
||||
return ct
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
func (s *StorageConnector) EnsureBucket(ctx context.Context) error {
|
||||
exists, err := s.client.BucketExists(ctx, s.cfg.BucketName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return s.client.MakeBucket(ctx, s.cfg.BucketName, minio.MakeBucketOptions{
|
||||
Region: s.cfg.Region,
|
||||
})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewLocalStorageConnector(basePath string) *LocalStorageConnector {
|
||||
return &LocalStorageConnector{basePath: basePath}
|
||||
}
|
||||
|
||||
type LocalStorageConnector struct {
|
||||
basePath string
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) ID() string {
|
||||
return "local_storage"
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) Name() string {
|
||||
return "Local Storage"
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) Description() string {
|
||||
return "Store files on local filesystem"
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) GetActions() []Action {
|
||||
return []Action{
|
||||
{Name: "upload", Description: "Upload a file"},
|
||||
{Name: "download", Description: "Download a file"},
|
||||
{Name: "delete", Description: "Delete a file"},
|
||||
{Name: "list", Description: "List files"},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) Validate(params map[string]interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *LocalStorageConnector) Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "upload":
|
||||
path := params["path"].(string)
|
||||
content := params["content"].(string)
|
||||
fullPath := filepath.Join(l.basePath, path)
|
||||
os.MkdirAll(filepath.Dir(fullPath), 0755)
|
||||
err := os.WriteFile(fullPath, []byte(content), 0644)
|
||||
return map[string]interface{}{"success": err == nil, "path": path}, err
|
||||
|
||||
case "download":
|
||||
path := params["path"].(string)
|
||||
fullPath := filepath.Join(l.basePath, path)
|
||||
content, err := os.ReadFile(fullPath)
|
||||
return map[string]interface{}{"success": err == nil, "content": string(content)}, err
|
||||
|
||||
case "delete":
|
||||
path := params["path"].(string)
|
||||
fullPath := filepath.Join(l.basePath, path)
|
||||
err := os.Remove(fullPath)
|
||||
return map[string]interface{}{"success": err == nil}, err
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown action")
|
||||
}
|
||||
}
|
||||
263
backend/internal/computer/connectors/telegram.go
Normal file
263
backend/internal/computer/connectors/telegram.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TelegramConfig struct {
|
||||
BotToken string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type TelegramConnector struct {
|
||||
cfg TelegramConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewTelegramConnector(cfg TelegramConfig) *TelegramConnector {
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
|
||||
return &TelegramConnector{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) ID() string {
|
||||
return "telegram"
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) Name() string {
|
||||
return "Telegram"
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) Description() string {
|
||||
return "Send messages via Telegram Bot API"
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) GetActions() []Action {
|
||||
return []Action{
|
||||
{
|
||||
Name: "send_message",
|
||||
Description: "Send a text message",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "description": "Chat ID or @username"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "Message text"},
|
||||
"parse_mode": map[string]interface{}{"type": "string", "enum": []string{"HTML", "Markdown", "MarkdownV2"}},
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id", "text"},
|
||||
},
|
||||
{
|
||||
Name: "send_document",
|
||||
Description: "Send a document/file",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "description": "Chat ID"},
|
||||
"document": map[string]interface{}{"type": "string", "description": "File path or URL"},
|
||||
"caption": map[string]interface{}{"type": "string", "description": "Document caption"},
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id", "document"},
|
||||
},
|
||||
{
|
||||
Name: "send_photo",
|
||||
Description: "Send a photo",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"chat_id": map[string]interface{}{"type": "string", "description": "Chat ID"},
|
||||
"photo": map[string]interface{}{"type": "string", "description": "Photo URL or file_id"},
|
||||
"caption": map[string]interface{}{"type": "string", "description": "Photo caption"},
|
||||
},
|
||||
},
|
||||
Required: []string{"chat_id", "photo"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) Validate(params map[string]interface{}) error {
|
||||
if _, ok := params["chat_id"]; !ok {
|
||||
return errors.New("'chat_id' is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "send_message":
|
||||
return t.sendMessage(ctx, params)
|
||||
case "send_document":
|
||||
return t.sendDocument(ctx, params)
|
||||
case "send_photo":
|
||||
return t.sendPhoto(ctx, params)
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) sendMessage(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
chatID := params["chat_id"].(string)
|
||||
text := params["text"].(string)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
}
|
||||
|
||||
if parseMode, ok := params["parse_mode"].(string); ok {
|
||||
payload["parse_mode"] = parseMode
|
||||
}
|
||||
|
||||
return t.apiCall(ctx, "sendMessage", payload)
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) sendDocument(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
chatID := params["chat_id"].(string)
|
||||
document := params["document"].(string)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"document": document,
|
||||
}
|
||||
|
||||
if caption, ok := params["caption"].(string); ok {
|
||||
payload["caption"] = caption
|
||||
}
|
||||
|
||||
return t.apiCall(ctx, "sendDocument", payload)
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) sendPhoto(ctx context.Context, params map[string]interface{}) (interface{}, error) {
|
||||
chatID := params["chat_id"].(string)
|
||||
photo := params["photo"].(string)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"photo": photo,
|
||||
}
|
||||
|
||||
if caption, ok := params["caption"].(string); ok {
|
||||
payload["caption"] = caption
|
||||
}
|
||||
|
||||
return t.apiCall(ctx, "sendPhoto", payload)
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) apiCall(ctx context.Context, method string, payload map[string]interface{}) (interface{}, error) {
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", t.cfg.BotToken, method)
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ok, exists := result["ok"].(bool); exists && !ok {
|
||||
desc := "unknown error"
|
||||
if d, exists := result["description"].(string); exists {
|
||||
desc = d
|
||||
}
|
||||
return result, errors.New("Telegram API error: " + desc)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) SendFileFromBytes(ctx context.Context, chatID string, filename string, content []byte, caption string) (interface{}, error) {
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", t.cfg.BotToken)
|
||||
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
|
||||
w.WriteField("chat_id", chatID)
|
||||
|
||||
if caption != "" {
|
||||
w.WriteField("caption", caption)
|
||||
}
|
||||
|
||||
fw, err := w.CreateFormFile("document", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fw.Write(content)
|
||||
|
||||
w.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
|
||||
resp, err := t.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *TelegramConnector) GetChatID(chatIDOrUsername interface{}) string {
|
||||
switch v := chatIDOrUsername.(type) {
|
||||
case string:
|
||||
return v
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
275
backend/internal/computer/connectors/webhook.go
Normal file
275
backend/internal/computer/connectors/webhook.go
Normal file
@@ -0,0 +1,275 @@
|
||||
package connectors
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebhookConfig struct {
|
||||
Timeout time.Duration
|
||||
MaxRetries int
|
||||
RetryDelay time.Duration
|
||||
DefaultSecret string
|
||||
}
|
||||
|
||||
type WebhookConnector struct {
|
||||
cfg WebhookConfig
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewWebhookConnector(cfg WebhookConfig) *WebhookConnector {
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if cfg.MaxRetries == 0 {
|
||||
cfg.MaxRetries = 3
|
||||
}
|
||||
if cfg.RetryDelay == 0 {
|
||||
cfg.RetryDelay = time.Second
|
||||
}
|
||||
|
||||
return &WebhookConnector{
|
||||
cfg: cfg,
|
||||
client: &http.Client{
|
||||
Timeout: timeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) ID() string {
|
||||
return "webhook"
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) Name() string {
|
||||
return "Webhook"
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) Description() string {
|
||||
return "Send HTTP webhooks to external services"
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) GetActions() []Action {
|
||||
return []Action{
|
||||
{
|
||||
Name: "post",
|
||||
Description: "Send POST request",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "Webhook URL"},
|
||||
"body": map[string]interface{}{"type": "object", "description": "Request body (JSON)"},
|
||||
"headers": map[string]interface{}{"type": "object", "description": "Custom headers"},
|
||||
"secret": map[string]interface{}{"type": "string", "description": "HMAC secret for signing"},
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
},
|
||||
{
|
||||
Name: "get",
|
||||
Description: "Send GET request",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "Request URL"},
|
||||
"params": map[string]interface{}{"type": "object", "description": "Query parameters"},
|
||||
"headers": map[string]interface{}{"type": "object", "description": "Custom headers"},
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
},
|
||||
{
|
||||
Name: "put",
|
||||
Description: "Send PUT request",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"url": map[string]interface{}{"type": "string", "description": "Request URL"},
|
||||
"body": map[string]interface{}{"type": "object", "description": "Request body (JSON)"},
|
||||
"headers": map[string]interface{}{"type": "object", "description": "Custom headers"},
|
||||
},
|
||||
},
|
||||
Required: []string{"url"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) Validate(params map[string]interface{}) error {
|
||||
urlStr, ok := params["url"].(string)
|
||||
if !ok {
|
||||
return errors.New("'url' is required")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return errors.New("URL must use http or https scheme")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) Execute(ctx context.Context, action string, params map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "post":
|
||||
return w.doRequest(ctx, "POST", params)
|
||||
case "get":
|
||||
return w.doRequest(ctx, "GET", params)
|
||||
case "put":
|
||||
return w.doRequest(ctx, "PUT", params)
|
||||
case "delete":
|
||||
return w.doRequest(ctx, "DELETE", params)
|
||||
case "patch":
|
||||
return w.doRequest(ctx, "PATCH", params)
|
||||
default:
|
||||
return nil, errors.New("unknown action: " + action)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) doRequest(ctx context.Context, method string, params map[string]interface{}) (interface{}, error) {
|
||||
urlStr := params["url"].(string)
|
||||
|
||||
if method == "GET" {
|
||||
if queryParams, ok := params["params"].(map[string]interface{}); ok {
|
||||
parsedURL, _ := url.Parse(urlStr)
|
||||
q := parsedURL.Query()
|
||||
for k, v := range queryParams {
|
||||
q.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
parsedURL.RawQuery = q.Encode()
|
||||
urlStr = parsedURL.String()
|
||||
}
|
||||
}
|
||||
|
||||
var bodyReader io.Reader
|
||||
var bodyBytes []byte
|
||||
|
||||
if body, ok := params["body"]; ok && method != "GET" {
|
||||
var err error
|
||||
bodyBytes, err = json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= w.cfg.MaxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
time.Sleep(w.cfg.RetryDelay * time.Duration(attempt))
|
||||
if bodyBytes != nil {
|
||||
bodyReader = bytes.NewReader(bodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, urlStr, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "GooSeek-Computer/1.0")
|
||||
|
||||
if headers, ok := params["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, fmt.Sprintf("%v", v))
|
||||
}
|
||||
}
|
||||
|
||||
if bodyBytes != nil {
|
||||
secret := w.cfg.DefaultSecret
|
||||
if s, ok := params["secret"].(string); ok {
|
||||
secret = s
|
||||
}
|
||||
if secret != "" {
|
||||
signature := w.signPayload(bodyBytes, secret)
|
||||
req.Header.Set("X-Signature-256", "sha256="+signature)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := w.client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
"headers": w.headersToMap(resp.Header),
|
||||
}
|
||||
|
||||
var jsonBody interface{}
|
||||
if err := json.Unmarshal(respBody, &jsonBody); err == nil {
|
||||
result["body"] = jsonBody
|
||||
} else {
|
||||
result["body"] = string(respBody)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
result["success"] = true
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
|
||||
continue
|
||||
}
|
||||
|
||||
result["success"] = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": false,
|
||||
"error": lastErr.Error(),
|
||||
}, lastErr
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) signPayload(payload []byte, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) headersToMap(headers http.Header) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for k, v := range headers {
|
||||
result[k] = strings.Join(v, ", ")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) PostJSON(ctx context.Context, webhookURL string, data interface{}) (interface{}, error) {
|
||||
return w.Execute(ctx, "post", map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"body": data,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *WebhookConnector) GetJSON(ctx context.Context, webhookURL string, params map[string]interface{}) (interface{}, error) {
|
||||
return w.Execute(ctx, "get", map[string]interface{}{
|
||||
"url": webhookURL,
|
||||
"params": params,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user