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

1""" 

2Avatar Management for HevolveBot Integration. 

3 

4This module provides AvatarManager for managing bot avatars 

5across different channels and contexts. 

6""" 

7 

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 

15 

16logger = logging.getLogger(__name__) 

17 

18 

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" 

27 

28 

29@dataclass 

30class Avatar: 

31 """Represents an avatar configuration.""" 

32 

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) 

41 

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 } 

54 

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) 

63 

64 

65class AvatarManager: 

66 """ 

67 Manages avatars for agent identities. 

68 

69 Supports: 

70 - Multiple avatar types (URL, base64, gravatar, generated) 

71 - Channel-specific avatars 

72 - Avatar generation 

73 - Fallback handling 

74 """ 

75 

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 } 

83 

84 DICEBEAR_STYLES = [ 

85 'bottts', 'avataaars', 'identicon', 'pixel-art', 

86 'shapes', 'thumbs', 'lorelei', 'notionists' 

87 ] 

88 

89 def __init__(self, default_avatar: Optional[Avatar] = None): 

90 """ 

91 Initialize the avatar manager. 

92 

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]] = {} 

105 

106 # Register default generators 

107 self._register_default_generators() 

108 

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) 

116 

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) 

120 

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) 

124 

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 ) 

137 

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 ) 

151 

152 def get_avatar(self, avatar_id: Optional[str] = None) -> Optional[Avatar]: 

153 """ 

154 Get an avatar by ID. 

155 

156 Args: 

157 avatar_id: Optional avatar ID. Returns default if None. 

158 

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) 

165 

166 def set_avatar(self, avatar: Avatar) -> None: 

167 """ 

168 Register or update an avatar. 

169 

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})") 

175 

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. 

184 

185 Priority: avatar_id > channel > identity_id > default 

186 

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 

191 

192 Returns: 

193 Avatar URL string (or emoji/initials for non-URL types) 

194 """ 

195 avatar = None 

196 

197 # Direct avatar lookup 

198 if avatar_id: 

199 avatar = self._avatars.get(avatar_id) 

200 

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]) 

204 

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]) 

208 

209 # Fall back to default 

210 if avatar is None: 

211 avatar = self._default_avatar 

212 

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 

229 

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. 

238 

239 Args: 

240 seed: Seed for avatar generation 

241 style: Avatar style/generator to use 

242 name: Optional name for the avatar 

243 

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' 

250 

251 url = self._generators[style](seed) 

252 

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 ) 

262 

263 self.set_avatar(avatar) 

264 return avatar 

265 

266 def set_avatar_for_identity(self, identity_id: str, avatar_id: str) -> bool: 

267 """ 

268 Associate an avatar with an identity. 

269 

270 Args: 

271 identity_id: The identity ID 

272 avatar_id: The avatar ID 

273 

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 

280 

281 self._identity_avatars[identity_id] = avatar_id 

282 logger.info(f"Avatar {avatar_id} set for identity {identity_id}") 

283 return True 

284 

285 def set_avatar_for_channel(self, channel: str, avatar_id: str) -> bool: 

286 """ 

287 Set a channel-specific avatar. 

288 

289 Args: 

290 channel: The channel identifier 

291 avatar_id: The avatar ID 

292 

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 

299 

300 self._channel_avatars[channel] = avatar_id 

301 logger.info(f"Avatar {avatar_id} set for channel {channel}") 

302 return True 

303 

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 

310 

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 

317 

318 def delete_avatar(self, avatar_id: str) -> bool: 

319 """ 

320 Delete an avatar. 

321 

322 Args: 

323 avatar_id: ID of the avatar to delete 

324 

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 

331 

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 } 

342 

343 del self._avatars[avatar_id] 

344 logger.info(f"Avatar deleted: {avatar_id}") 

345 return True 

346 

347 return False 

348 

349 def list_avatars(self) -> List[Avatar]: 

350 """Get all registered avatars.""" 

351 return list(self._avatars.values()) 

352 

353 def list_generators(self) -> List[str]: 

354 """Get available avatar generator names.""" 

355 return list(self._generators.keys()) 

356 

357 def register_generator(self, name: str, generator: Callable[[str], str]) -> None: 

358 """ 

359 Register a custom avatar generator. 

360 

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}") 

367 

368 def create_avatar_from_url(self, url: str, name: Optional[str] = None) -> Avatar: 

369 """ 

370 Create an avatar from a URL. 

371 

372 Args: 

373 url: The avatar URL 

374 name: Optional name 

375 

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 

386 

387 def create_avatar_from_emoji(self, emoji: str, name: Optional[str] = None) -> Avatar: 

388 """ 

389 Create an emoji-based avatar. 

390 

391 Args: 

392 emoji: The emoji to use 

393 name: Optional name 

394 

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