feat: auto-avatar con proveedores gratuitos (DiceBear, RoboHash, Multiavatar)
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 <id> — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <agent-id>",
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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/<seed>.<ext>.
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user