avatar del bot: búho con gafas, generado y aplicado por API
Build & Deploy ResearchOwl / build-and-push (push) Successful in 8s

Mismo patrón que hermes-bot y roswell-corpus: la foto de perfil se genera con
PIL (dibujado a 4× y reducido) en vez de ser un PNG opaco en el repo. Telegram
recorta en círculo y lo enseña a 48 px en la lista de chats, así que el dibujo
es una silueta y no una ilustración.

Las gafas son el único guiño a la investigación que sobrevive al tamaño chico
—una lupa o un libro se vuelven una mancha—, y la montura va en tono oscuro:
en ámbar sobre ámbar el aro se fundía con el plumaje y a 48 px desaparecía.

Documentado en el README, con el gotcha de `setMyProfilePhoto`: el parámetro
`photo` no admite el fichero suelto (responde `photo isn't specified`), hay que
pasarle un InputProfilePhoto que apunte al adjunto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ChemaVX
2026-07-27 19:57:18 +00:00
co-authored by Claude Opus 5
parent 9653898366
commit a43bbc752d
3 changed files with 172 additions and 0 deletions
+26
View File
@@ -100,6 +100,32 @@ git add . && git commit -m "feat: add researchowl" && git push
- Set `MAX_DEPTH` to 1-2
- Higher `QUALITY_THRESHOLD` to 0.6
## Bot avatar
The profile picture of `@chemavx_researchowl_bot` is not an opaque binary
checked into the repo: `assets/make_avatar.py` draws it with PIL at 4× and
scales it down, so the emblem can be retouched without hunting for an original.
Telegram crops avatars to a **circle**, so everything that matters lives inside
the inscribed circle; verified legible at 48 px.
```bash
python3 assets/make_avatar.py # writes assets/avatar.png
```
It is applied **over the API with the token from the secret, no BotFather**.
Watch out for `setMyProfilePhoto`: its `photo` parameter is not the file, it is
an `InputProfilePhoto` object pointing at the attachment. Posting the file on
its own gets you a baffling `photo isn't specified`.
```bash
TOK=$(kubectl get secret researchowl-secrets-infisical -n researchowl \
-o jsonpath='{.data.telegram-bot-token}' | base64 -d)
curl -s -F 'photo={"type":"static","photo":"attach://av"}' \
-F "av=@assets/avatar.png" \
"https://api.telegram.org/bot$TOK/setMyProfilePhoto"
unset TOK
```
## Notes
- Uses **qwen2.5:7b** (scoring) and **bge-m3** (embeddings) on your existing Ollama — zero API cost
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Genera el avatar de @chemavx_researchowl_bot: búho con gafas de lectura.
Mismo método que los avatares de hermes-bot y roswell-corpus: se dibuja a 4× y
se reduce, que es la forma barata de tener bordes suaves sin antialiasing
propio en PIL. Telegram recorta en CÍRCULO, así que todo lo que importa vive
dentro del círculo inscrito.
Por qué gafas y no un búho a secas: el búho solo dice "búho". El aro sobre cada
ojo, con su puente, se lee como gafas de leer al tamaño grande y como disco
facial al pequeño, y ninguna de las dos lecturas es errónea. Es el único guiño
a la investigación que sobrevive a 48 px; una lupa o un libro se convierten en
una mancha.
python3 make_avatar.py # deja el PNG en assets/avatar.png
"""
import math
import os
from PIL import Image, ImageDraw, ImageFilter
SIZE = 640
SS = 4 # supersampling
W = SIZE * SS
CX = CY = W // 2
AMBAR = (236, 173, 84) # plumaje
AMBAR_OSC = (184, 116, 52) # alas
MONTURA = (46, 26, 30) # gafas y pico: tienen que contrastar con el plumaje
CREMA = (255, 246, 230) # ojos
TINTA = (30, 16, 36) # pupilas y huecos
AQUI = os.path.dirname(os.path.abspath(__file__))
def fondo() -> Image.Image:
"""Degradado radial ciruela, dibujado pequeño y ampliado."""
n = 128
g = Image.new("RGB", (n, n))
px = g.load()
for y in range(n):
for x in range(n):
d = min(1.0, math.hypot(x - n / 2, y - n / 2) / (n / 2 * 1.10))
px[x, y] = (int(56 + (11 - 56) * d),
int(33 + (6 - 33) * d),
int(66 + (16 - 66) * d))
return g.resize((W, W), Image.BICUBIC).convert("RGBA")
def tinta(mascara: Image.Image, color) -> Image.Image:
"""Convierte una máscara L en una capa RGBA del color dado."""
capa = Image.new("RGBA", mascara.size, color + (0,))
capa.putalpha(mascara)
return capa
def caja(x0, y0, x1, y1):
"""Rectángulo en fracciones de W → píxeles."""
return [x0 * W, y0 * W, x1 * W, y1 * W]
def cuerpo() -> Image.Image:
"""Silueta completa: penachos + cuerpo. Se reutiliza como máscara."""
m = Image.new("L", (W, W), 0)
d = ImageDraw.Draw(m)
for signo in (-1, 1):
d.polygon([
(CX + signo * 0.262 * W, 0.352 * W),
(CX + signo * 0.108 * W, 0.300 * W),
(CX + signo * 0.196 * W, 0.148 * W),
], fill=255)
d.ellipse(caja(0.20, 0.28, 0.80, 0.84), fill=255)
return m
def alas(mascara: Image.Image) -> Image.Image:
"""Dos alas plegadas a los lados, recortadas contra la silueta.
Van recortadas y no dibujadas a pelo porque el ala tiene que morir justo en
el borde del cuerpo: si asoma, el búho deja de tener contorno limpio y a
tamaño pequeño se convierte en un borrón con orejas.
"""
capa = Image.new("RGBA", (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(capa)
for signo in (-1, 1):
cx = CX + signo * 0.205 * W
d.ellipse([cx - 0.115 * W, 0.455 * W, cx + 0.115 * W, 0.855 * W],
fill=AMBAR_OSC + (255,))
return Image.composite(capa, Image.new("RGBA", (W, W), (0, 0, 0, 0)), mascara)
def cara() -> Image.Image:
"""Ojos, gafas y pico."""
capa = Image.new("RGBA", (W, W), (0, 0, 0, 0))
d = ImageDraw.Draw(capa)
y = 0.425
r = 0.105
grosor = int(W * 0.019)
for signo in (-1, 1):
cx = CX + signo * 0.125 * W
d.ellipse([cx - r * W, (y - r) * W, cx + r * W, (y + r) * W],
fill=CREMA + (255,))
d.ellipse([cx - r * W, (y - r) * W, cx + r * W, (y + r) * W],
outline=MONTURA + (255,), width=grosor)
pr = 0.046 * W
d.ellipse([cx - pr, y * W - pr, cx + pr, y * W + pr], fill=TINTA + (255,))
br = 0.017 * W # reflejo: sin él la mirada es de muñeco
bx, by = cx - 0.020 * W, y * W - 0.030 * W
d.ellipse([bx - br, by - br, bx + br, by + br], fill=(255, 255, 255, 210))
# puente de las gafas
d.rectangle([CX - 0.022 * W, y * W - grosor / 2, CX + 0.022 * W, y * W + grosor / 2],
fill=MONTURA + (255,))
d.polygon([(CX - 0.042 * W, 0.500 * W), (CX + 0.042 * W, 0.500 * W),
(CX, 0.575 * W)], fill=MONTURA + (255,))
return capa
def main():
img = fondo()
silueta = cuerpo()
# resplandor: la silueta desenfocada por debajo, para despegar al búho del
# fondo cuando el icono se ve pequeño
brillo = tinta(silueta.filter(ImageFilter.GaussianBlur(W * 0.030)), AMBAR)
img.alpha_composite(Image.blend(Image.new("RGBA", (W, W), (0, 0, 0, 0)), brillo, 0.5))
img.alpha_composite(tinta(silueta, AMBAR))
img.alpha_composite(alas(silueta))
img.alpha_composite(cara())
# aro fino: le da borde al icono cuando Telegram lo recorta en círculo
m = int(W * 0.045)
ImageDraw.Draw(img).ellipse([m, m, W - m, W - m],
outline=AMBAR + (110,), width=int(W * 0.008))
salida = os.path.join(AQUI, "avatar.png")
img.convert("RGB").resize((SIZE, SIZE), Image.LANCZOS).save(salida, "PNG")
print(salida)
if __name__ == "__main__":
main()