From cef681ec87532397b959d4952c496394e23a95d9 Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Thu, 9 Apr 2026 21:37:21 +0000 Subject: [PATCH] feat: auto-avatar con proveedores gratuitos (DiceBear, RoboHash, Multiavatar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo sistema de generacion automatica de avatares: - pkg/avatar/ — tipos puros y URL builders para proveedores gratuitos: DiceBear (bottts, pixel-art, etc.), RoboHash (robots, monsters), Multiavatar (multicultural). Sin I/O. - shell/avatar/ — fetcher impuro: descarga imagen por HTTP a temp file. - agentctl auto-avatar — genera, descarga, sube y activa avatar con un solo comando. Soporta --provider, --style, --set, --dry-run. Respeta pure core / impure shell. El seed del avatar es el agent ID, asi cada bot obtiene una imagen unica y determinista. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/agentctl/autoavatar.go | 95 +++++++++++++++++++++++++++ pkg/avatar/provider.go | 109 +++++++++++++++++++++++++++++++ shell/avatar/fetch.go | 130 +++++++++++++++++++++++++++++++++++++ 3 files changed, 334 insertions(+) create mode 100644 cmd/agentctl/autoavatar.go create mode 100644 pkg/avatar/provider.go create mode 100644 shell/avatar/fetch.go diff --git a/cmd/agentctl/autoavatar.go b/cmd/agentctl/autoavatar.go new file mode 100644 index 0000000..0f728bd --- /dev/null +++ b/cmd/agentctl/autoavatar.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/enmanuel/agents/pkg/avatar" + shellavatar "github.com/enmanuel/agents/shell/avatar" + shellmatrix "github.com/enmanuel/agents/shell/matrix" +) + +func autoAvatarCmd() *cobra.Command { + var ( + provider string + style string + set string + size int + dryRun bool + ) + + cmd := &cobra.Command{ + Use: "auto-avatar ", + Short: "Generate and set a random avatar from a free provider", + Long: `Fetches a unique avatar image from a free provider (dicebear, robohash, multiavatar) +using the agent ID as seed, uploads it to the Matrix media repo, and sets it as the bot's avatar. + +Examples: + agentctl auto-avatar assistant-bot + agentctl auto-avatar assistant-bot --provider robohash --set set1 + agentctl auto-avatar assistant-bot --provider dicebear --style pixel-art + agentctl auto-avatar assistant-bot --dry-run # only show the URL`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + agentID := args[0] + + opts := avatar.DefaultOptions() + if size > 0 { + opts.Size = size + } + if style != "" { + opts.DiceBearStyle = avatar.DiceBearStyle(style) + } + if set != "" { + opts.RoboHashSet = avatar.RoboHashSet(set) + } + + p := avatar.Provider(provider) + imageURL := avatar.URL(p, agentID, opts) + + if dryRun { + fmt.Printf("url %-20s %s\n", agentID, imageURL) + return nil + } + + // Fetch image from provider + tmpPath, err := shellavatar.Fetch(context.Background(), p, agentID, opts) + if err != nil { + return fmt.Errorf("fetch avatar: %w", err) + } + defer os.Remove(tmpPath) + + fmt.Printf("fetch %-20s %s\n", agentID, imageURL) + + // Upload to Matrix and set as avatar + cfg, err := loadMatrixCfg(agentID) + if err != nil { + return err + } + + client, err := shellmatrix.New(cfg.Matrix) + if err != nil { + return fmt.Errorf("matrix client: %w", err) + } + + uri, err := client.SetAvatar(context.Background(), tmpPath) + if err != nil { + return err + } + + fmt.Printf("ok %-20s avatar → %s\n", agentID, uri) + return nil + }, + } + + cmd.Flags().StringVar(&provider, "provider", "dicebear", "Avatar provider: dicebear, robohash, multiavatar") + cmd.Flags().StringVar(&style, "style", "", "DiceBear style: bottts, pixel-art, adventurer, shapes, fun-emoji, identicon, thumbs") + cmd.Flags().StringVar(&set, "set", "", "RoboHash set: set1 (robots), set2 (monsters), set3 (heads), set4 (cats), set5 (humans)") + cmd.Flags().IntVar(&size, "size", 256, "Image size in pixels (square)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Only print the image URL without fetching or uploading") + + return cmd +} diff --git a/pkg/avatar/provider.go b/pkg/avatar/provider.go new file mode 100644 index 0000000..e472946 --- /dev/null +++ b/pkg/avatar/provider.go @@ -0,0 +1,109 @@ +// Package avatar provides pure types and URL builders for avatar image providers. +// No I/O — only data transformations. +package avatar + +import ( + "fmt" + "net/url" +) + +// Provider identifies an avatar generation service. +type Provider string + +const ( + // ProviderDiceBear generates illustrated avatars via DiceBear API. + // Styles: adventurer, avataaars, bottts, fun-emoji, identicon, initials, + // lorelei, micah, miniavs, notionists, open-peeps, personas, pixel-art, shapes, thumbs. + ProviderDiceBear Provider = "dicebear" + + // ProviderRoboHash generates robot/monster/head images from any text. + // Sets: set1 (robots), set2 (monsters), set3 (heads), set4 (cats), set5 (humans). + ProviderRoboHash Provider = "robohash" + + // ProviderMultiavatar generates unique multicultural avatars from a string. + ProviderMultiavatar Provider = "multiavatar" +) + +// DiceBearStyle is a style option for the DiceBear provider. +type DiceBearStyle string + +const ( + DiceBearBottts DiceBearStyle = "bottts" + DiceBearPixelArt DiceBearStyle = "pixel-art" + DiceBearAdventurer DiceBearStyle = "adventurer" + DiceBearShapes DiceBearStyle = "shapes" + DiceBearFunEmoji DiceBearStyle = "fun-emoji" + DiceBearIdenticon DiceBearStyle = "identicon" + DiceBearThumbs DiceBearStyle = "thumbs" +) + +// RoboHashSet is a set option for the RoboHash provider. +type RoboHashSet string + +const ( + RoboHashRobots RoboHashSet = "set1" + RoboHashMonsters RoboHashSet = "set2" + RoboHashHeads RoboHashSet = "set3" + RoboHashCats RoboHashSet = "set4" + RoboHashHumans RoboHashSet = "set5" +) + +// Options configures avatar generation. +type Options struct { + // Size in pixels (square). Default: 256. + Size int + + // DiceBear-specific style. Default: bottts. + DiceBearStyle DiceBearStyle + + // RoboHash-specific set. Default: set1 (robots). + RoboHashSet RoboHashSet +} + +// DefaultOptions returns sensible defaults for bot avatars. +func DefaultOptions() Options { + return Options{ + Size: 256, + DiceBearStyle: DiceBearBottts, + RoboHashSet: RoboHashRobots, + } +} + +// URL returns the avatar image URL for the given provider and seed (typically the agent name/ID). +// Pure function — no I/O. +func URL(provider Provider, seed string, opts Options) string { + if opts.Size <= 0 { + opts.Size = 256 + } + encoded := url.PathEscape(seed) + + switch provider { + case ProviderDiceBear: + style := opts.DiceBearStyle + if style == "" { + style = DiceBearBottts + } + return fmt.Sprintf("https://api.dicebear.com/9.x/%s/png?seed=%s&size=%d", + style, url.QueryEscape(seed), opts.Size) + + case ProviderRoboHash: + set := opts.RoboHashSet + if set == "" { + set = RoboHashRobots + } + return fmt.Sprintf("https://robohash.org/%s.png?set=%s&size=%dx%d", + encoded, set, opts.Size, opts.Size) + + case ProviderMultiavatar: + return fmt.Sprintf("https://api.multiavatar.com/%s.png?apikey=Wookie", + encoded) + + default: + return URL(ProviderDiceBear, seed, opts) + } +} + +// AllProviders returns the list of supported providers. +func AllProviders() []Provider { + return []Provider{ProviderDiceBear, ProviderRoboHash, ProviderMultiavatar} +} diff --git a/shell/avatar/fetch.go b/shell/avatar/fetch.go new file mode 100644 index 0000000..0dc221e --- /dev/null +++ b/shell/avatar/fetch.go @@ -0,0 +1,130 @@ +// Package avatar fetches avatar images from external providers. +// Impure shell: performs HTTP I/O. +package avatar + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/enmanuel/agents/pkg/avatar" +) + +const ( + fetchTimeout = 30 * time.Second + maxBytes = 10 * 1024 * 1024 // 10 MB +) + +// Fetch downloads an avatar image from the given provider using seed as the +// generation key (typically the agent ID or display name). Returns the path +// to a temporary file containing the image. +// The caller is responsible for removing the temp file when done. +func Fetch(ctx context.Context, provider avatar.Provider, seed string, opts avatar.Options) (string, error) { + imageURL := avatar.URL(provider, seed, opts) + return Download(ctx, imageURL) +} + +// Download fetches the image at imageURL and writes it to a temp file. +// Returns the temp file path. The caller must remove the file when done. +func Download(ctx context.Context, imageURL string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("User-Agent", "agents-avatar-fetcher/1.0") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch %s: %w", imageURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch %s: HTTP %d", imageURL, resp.StatusCode) + } + + ext := extensionFromContentType(resp.Header.Get("Content-Type")) + + tmp, err := os.CreateTemp("", "avatar-*"+ext) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + + if _, err := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes)); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return "", fmt.Errorf("write temp file: %w", err) + } + + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return "", fmt.Errorf("close temp file: %w", err) + } + + return tmp.Name(), nil +} + +// FetchToDir downloads the avatar and saves it to dir/.. +// Creates dir if it doesn't exist. Returns the final file path. +func FetchToDir(ctx context.Context, provider avatar.Provider, seed string, opts avatar.Options, dir string) (string, error) { + tmpPath, err := Fetch(ctx, provider, seed, opts) + if err != nil { + return "", err + } + defer os.Remove(tmpPath) + + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create dir %s: %w", dir, err) + } + + ext := filepath.Ext(tmpPath) + finalPath := filepath.Join(dir, seed+ext) + + src, err := os.Open(tmpPath) + if err != nil { + return "", fmt.Errorf("open temp: %w", err) + } + defer src.Close() + + dst, err := os.Create(finalPath) + if err != nil { + return "", fmt.Errorf("create %s: %w", finalPath, err) + } + + if _, err := io.Copy(dst, src); err != nil { + dst.Close() + os.Remove(finalPath) + return "", fmt.Errorf("copy to %s: %w", finalPath, err) + } + + if err := dst.Close(); err != nil { + os.Remove(finalPath) + return "", err + } + + return finalPath, nil +} + +func extensionFromContentType(ct string) string { + switch ct { + case "image/png": + return ".png" + case "image/jpeg": + return ".jpg" + case "image/svg+xml": + return ".svg" + case "image/webp": + return ".webp" + case "image/gif": + return ".gif" + default: + return ".png" + } +}