Coverage for integrations / channels / identity / avatars.py: 34.5%
148 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Avatar Management for HevolveBot Integration.
4This module provides AvatarManager for managing bot avatars
5across different channels and contexts.
6"""
8from dataclasses import dataclass, field
9from typing import Dict, List, Optional, Any, Callable
10from datetime import datetime
11from enum import Enum
12import uuid
13import hashlib
14import logging
16logger = logging.getLogger(__name__)
19class AvatarType(Enum):
20 """Types of avatar sources."""
21 URL = "url"
22 BASE64 = "base64"
23 GRAVATAR = "gravatar"
24 GENERATED = "generated"
25 EMOJI = "emoji"
26 INITIALS = "initials"
29@dataclass
30class Avatar:
31 """Represents an avatar configuration."""
33 id: str = field(default_factory=lambda: str(uuid.uuid4()))
34 name: str = "Default Avatar"
35 avatar_type: AvatarType = AvatarType.URL
36 source: str = "" # URL, base64 data, email for gravatar, etc.
37 fallback_emoji: str = "🤖"
38 fallback_initials: str = "MB"
39 metadata: Dict[str, Any] = field(default_factory=dict)
40 created_at: datetime = field(default_factory=datetime.utcnow)
42 def to_dict(self) -> Dict[str, Any]:
43 """Convert avatar to dictionary."""
44 return {
45 'id': self.id,
46 'name': self.name,
47 'avatar_type': self.avatar_type.value,
48 'source': self.source,
49 'fallback_emoji': self.fallback_emoji,
50 'fallback_initials': self.fallback_initials,
51 'metadata': self.metadata,
52 'created_at': self.created_at.isoformat()
53 }
55 @classmethod
56 def from_dict(cls, data: Dict[str, Any]) -> 'Avatar':
57 """Create avatar from dictionary."""
58 if 'avatar_type' in data:
59 data['avatar_type'] = AvatarType(data['avatar_type'])
60 if 'created_at' in data and isinstance(data['created_at'], str):
61 data['created_at'] = datetime.fromisoformat(data['created_at'])
62 return cls(**data)
65class AvatarManager:
66 """
67 Manages avatars for agent identities.
69 Supports:
70 - Multiple avatar types (URL, base64, gravatar, generated)
71 - Channel-specific avatars
72 - Avatar generation
73 - Fallback handling
74 """
76 # Default avatar generation services
77 AVATAR_SERVICES = {
78 'dicebear': 'https://api.dicebear.com/7.x/{style}/svg?seed={seed}',
79 'robohash': 'https://robohash.org/{seed}?set=set{set}',
80 'ui_avatars': 'https://ui-avatars.com/api/?name={name}&background={bg}&color={fg}',
81 'gravatar': 'https://www.gravatar.com/avatar/{hash}?d={default}&s={size}'
82 }
84 DICEBEAR_STYLES = [
85 'bottts', 'avataaars', 'identicon', 'pixel-art',
86 'shapes', 'thumbs', 'lorelei', 'notionists'
87 ]
89 def __init__(self, default_avatar: Optional[Avatar] = None):
90 """
91 Initialize the avatar manager.
93 Args:
94 default_avatar: Optional default avatar
95 """
96 self._default_avatar = default_avatar or Avatar(
97 name="Default Bot Avatar",
98 avatar_type=AvatarType.EMOJI,
99 source="🤖"
100 )
101 self._avatars: Dict[str, Avatar] = {self._default_avatar.id: self._default_avatar}
102 self._identity_avatars: Dict[str, str] = {} # identity_id -> avatar_id
103 self._channel_avatars: Dict[str, str] = {} # channel -> avatar_id
104 self._generators: Dict[str, Callable[[str], str]] = {}
106 # Register default generators
107 self._register_default_generators()
109 def _register_default_generators(self) -> None:
110 """Register default avatar generators."""
111 self._generators['dicebear_bottts'] = lambda seed: self._generate_dicebear(seed, 'bottts')
112 self._generators['dicebear_avataaars'] = lambda seed: self._generate_dicebear(seed, 'avataaars')
113 self._generators['dicebear_identicon'] = lambda seed: self._generate_dicebear(seed, 'identicon')
114 self._generators['robohash'] = lambda seed: self._generate_robohash(seed)
115 self._generators['initials'] = lambda name: self._generate_initials(name)
117 def _generate_dicebear(self, seed: str, style: str = 'bottts') -> str:
118 """Generate a DiceBear avatar URL."""
119 return self.AVATAR_SERVICES['dicebear'].format(style=style, seed=seed)
121 def _generate_robohash(self, seed: str, robot_set: int = 1) -> str:
122 """Generate a RoboHash avatar URL."""
123 return self.AVATAR_SERVICES['robohash'].format(seed=seed, set=robot_set)
125 def _generate_initials(
126 self,
127 name: str,
128 background: str = 'random',
129 foreground: str = 'fff'
130 ) -> str:
131 """Generate an initials-based avatar URL."""
132 return self.AVATAR_SERVICES['ui_avatars'].format(
133 name=name.replace(' ', '+'),
134 bg=background,
135 fg=foreground
136 )
138 def _generate_gravatar(
139 self,
140 email: str,
141 size: int = 200,
142 default: str = 'identicon'
143 ) -> str:
144 """Generate a Gravatar URL."""
145 email_hash = hashlib.md5(email.lower().strip().encode()).hexdigest()
146 return self.AVATAR_SERVICES['gravatar'].format(
147 hash=email_hash,
148 default=default,
149 size=size
150 )
152 def get_avatar(self, avatar_id: Optional[str] = None) -> Optional[Avatar]:
153 """
154 Get an avatar by ID.
156 Args:
157 avatar_id: Optional avatar ID. Returns default if None.
159 Returns:
160 The avatar or None if not found
161 """
162 if avatar_id is None:
163 return self._default_avatar
164 return self._avatars.get(avatar_id)
166 def set_avatar(self, avatar: Avatar) -> None:
167 """
168 Register or update an avatar.
170 Args:
171 avatar: The avatar to register
172 """
173 self._avatars[avatar.id] = avatar
174 logger.info(f"Avatar registered: {avatar.id} ({avatar.name})")
176 def get_avatar_url(
177 self,
178 avatar_id: Optional[str] = None,
179 identity_id: Optional[str] = None,
180 channel: Optional[str] = None
181 ) -> str:
182 """
183 Get the URL for an avatar, with fallback handling.
185 Priority: avatar_id > channel > identity_id > default
187 Args:
188 avatar_id: Direct avatar ID to look up
189 identity_id: Identity to get avatar for
190 channel: Channel to get avatar for
192 Returns:
193 Avatar URL string (or emoji/initials for non-URL types)
194 """
195 avatar = None
197 # Direct avatar lookup
198 if avatar_id:
199 avatar = self._avatars.get(avatar_id)
201 # Channel-specific avatar
202 if avatar is None and channel and channel in self._channel_avatars:
203 avatar = self._avatars.get(self._channel_avatars[channel])
205 # Identity-specific avatar
206 if avatar is None and identity_id and identity_id in self._identity_avatars:
207 avatar = self._avatars.get(self._identity_avatars[identity_id])
209 # Fall back to default
210 if avatar is None:
211 avatar = self._default_avatar
213 # Return appropriate representation
214 if avatar.avatar_type == AvatarType.URL:
215 return avatar.source
216 elif avatar.avatar_type == AvatarType.EMOJI:
217 return avatar.source or avatar.fallback_emoji
218 elif avatar.avatar_type == AvatarType.INITIALS:
219 return self._generate_initials(avatar.fallback_initials)
220 elif avatar.avatar_type == AvatarType.GRAVATAR:
221 return self._generate_gravatar(avatar.source)
222 elif avatar.avatar_type == AvatarType.GENERATED:
223 generator_name = avatar.metadata.get('generator', 'dicebear_bottts')
224 if generator_name in self._generators:
225 return self._generators[generator_name](avatar.source)
226 return avatar.fallback_emoji
227 else:
228 return avatar.source or avatar.fallback_emoji
230 def generate_avatar(
231 self,
232 seed: str,
233 style: str = 'dicebear_bottts',
234 name: Optional[str] = None
235 ) -> Avatar:
236 """
237 Generate a new avatar using a specified style.
239 Args:
240 seed: Seed for avatar generation
241 style: Avatar style/generator to use
242 name: Optional name for the avatar
244 Returns:
245 The generated Avatar object
246 """
247 if style not in self._generators:
248 logger.warning(f"Unknown avatar style: {style}, using default")
249 style = 'dicebear_bottts'
251 url = self._generators[style](seed)
253 avatar = Avatar(
254 name=name or f"Generated Avatar ({style})",
255 avatar_type=AvatarType.GENERATED,
256 source=seed,
257 metadata={
258 'generator': style,
259 'url': url
260 }
261 )
263 self.set_avatar(avatar)
264 return avatar
266 def set_avatar_for_identity(self, identity_id: str, avatar_id: str) -> bool:
267 """
268 Associate an avatar with an identity.
270 Args:
271 identity_id: The identity ID
272 avatar_id: The avatar ID
274 Returns:
275 True if successful
276 """
277 if avatar_id not in self._avatars:
278 logger.warning(f"Avatar not found: {avatar_id}")
279 return False
281 self._identity_avatars[identity_id] = avatar_id
282 logger.info(f"Avatar {avatar_id} set for identity {identity_id}")
283 return True
285 def set_avatar_for_channel(self, channel: str, avatar_id: str) -> bool:
286 """
287 Set a channel-specific avatar.
289 Args:
290 channel: The channel identifier
291 avatar_id: The avatar ID
293 Returns:
294 True if successful
295 """
296 if avatar_id not in self._avatars:
297 logger.warning(f"Avatar not found: {avatar_id}")
298 return False
300 self._channel_avatars[channel] = avatar_id
301 logger.info(f"Avatar {avatar_id} set for channel {channel}")
302 return True
304 def remove_channel_avatar(self, channel: str) -> bool:
305 """Remove channel-specific avatar."""
306 if channel in self._channel_avatars:
307 del self._channel_avatars[channel]
308 return True
309 return False
311 def remove_identity_avatar(self, identity_id: str) -> bool:
312 """Remove identity-specific avatar."""
313 if identity_id in self._identity_avatars:
314 del self._identity_avatars[identity_id]
315 return True
316 return False
318 def delete_avatar(self, avatar_id: str) -> bool:
319 """
320 Delete an avatar.
322 Args:
323 avatar_id: ID of the avatar to delete
325 Returns:
326 True if deleted
327 """
328 if avatar_id == self._default_avatar.id:
329 logger.warning("Cannot delete default avatar")
330 return False
332 if avatar_id in self._avatars:
333 # Clean up references
334 self._identity_avatars = {
335 k: v for k, v in self._identity_avatars.items()
336 if v != avatar_id
337 }
338 self._channel_avatars = {
339 k: v for k, v in self._channel_avatars.items()
340 if v != avatar_id
341 }
343 del self._avatars[avatar_id]
344 logger.info(f"Avatar deleted: {avatar_id}")
345 return True
347 return False
349 def list_avatars(self) -> List[Avatar]:
350 """Get all registered avatars."""
351 return list(self._avatars.values())
353 def list_generators(self) -> List[str]:
354 """Get available avatar generator names."""
355 return list(self._generators.keys())
357 def register_generator(self, name: str, generator: Callable[[str], str]) -> None:
358 """
359 Register a custom avatar generator.
361 Args:
362 name: Generator name
363 generator: Function that takes a seed and returns a URL
364 """
365 self._generators[name] = generator
366 logger.info(f"Avatar generator registered: {name}")
368 def create_avatar_from_url(self, url: str, name: Optional[str] = None) -> Avatar:
369 """
370 Create an avatar from a URL.
372 Args:
373 url: The avatar URL
374 name: Optional name
376 Returns:
377 The created Avatar
378 """
379 avatar = Avatar(
380 name=name or "URL Avatar",
381 avatar_type=AvatarType.URL,
382 source=url
383 )
384 self.set_avatar(avatar)
385 return avatar
387 def create_avatar_from_emoji(self, emoji: str, name: Optional[str] = None) -> Avatar:
388 """
389 Create an emoji-based avatar.
391 Args:
392 emoji: The emoji to use
393 name: Optional name
395 Returns:
396 The created Avatar
397 """
398 avatar = Avatar(
399 name=name or "Emoji Avatar",
400 avatar_type=AvatarType.EMOJI,
401 source=emoji,
402 fallback_emoji=emoji
403 )
404 self.set_avatar(avatar)
405 return avatar