Files
hermes-agent/gateway/platforms/yuanbao_sticker.py

219 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Yuanbao sticker (TIMFaceElem) support. Ported from yuanbao-openclaw-plugin/src/sticker/.
TIMFaceElem wire format: {"msg_type": "TIMFaceElem", "msg_content": {"index": 0, "data": "<json>"}}
index is always 0 per Yuanbao convention; data is serialised sticker metadata so the receiver
can look up the asset in the emoji pack.
"""
from __future__ import annotations
import json
import random
import re
import unicodedata
from collections import Counter
from typing import Optional
# Sticker catalogue – ported from builtin-stickers.json. Every builtin sticker is in
# package 1003, 128x128 png, with name == key; only (name, sticker_id, description) vary.
_STICKERS: list[tuple[str, str, str]] = [
("六六六", "278", "666 厉害 牛 棒 绝了 好强 awesome"),
("我想开了", "262", "想开 佛系 释怀 顿悟 看淡了 无所谓"),
("害羞", "130", "腼腆 不好意思 脸红 娇羞 羞涩 捂脸"),
("比心", "252", "笔芯 爱你 爱心手势 love heart 喜欢你"),
("委屈", "125", "难过 想哭 可怜巴巴 瘪嘴 受伤 被欺负"),
("亲亲", "146", "么么 mua 亲一下 kiss 飞吻 啵"),
("酷", "131", "帅 墨镜 cool 高冷 有型 swagger"),
("睡", "145", "睡觉 困 zzZ 打盹 躺平 休眠 sleepy"),
("发呆", "152", "懵 愣住 放空 呆滞 出神 脑子空白"),
("可怜", "157", "卖萌 求饶 委屈巴巴 弱小 拜托 眼巴巴"),
("摊手", "200", "无奈 没办法 耸肩 随便 那咋整 whatever"),
("头大", "213", "头疼 烦恼 郁闷 难搞 崩溃 一团乱"),
("吓", "256", "害怕 惊恐 震惊 吓一跳 恐怖 怂"),
("吐血", "203", "无语 崩溃 被雷 内伤 一口老血 屮"),
("哼", "185", "傲娇 生气 不满 撇嘴 不理 赌气"),
("嘿嘿", "220", "坏笑 猥琐笑 偷笑 憨笑 得意 你懂的"),
("头秃", "218", "程序员 加班 焦虑 没头发 秃了 肝爆"),
("暗中观察", "221", "窥屏 潜水 偷偷看 角落 围观 屏住呼吸"),
("我酸了", "224", "嫉妒 柠檬精 羡慕 吃柠檬 眼红 恰柠檬"),
("打call", "246", "应援 加油 支持 喝彩 助威 call"),
("庆祝", "251", "祝贺 开心 耶 party 胜利 干杯"),
("奋斗", "151", "努力 加油 拼搏 冲 干劲 卷起来"),
("惊讶", "143", "震惊 哇 不敢相信 OMG 居然 这么离谱"),
("疑问", "144", "问号 不懂 啥 为什么 啥情况 懵逼问"),
("仔细分析", "248", "思考 推敲 认真 研究 琢磨 让我想想"),
("撅嘴", "184", "嘟嘴 卖萌 不高兴 撒娇 嘴翘"),
("泪奔", "199", "大哭 伤心 破防 感动哭 泪流满面 呜呜"),
("尊嘟假嘟", "276", "真的假的 真假 可爱问 你骗我 是不是"),
("略略略", "113", "调皮 吐舌 不服 略 气死你 鬼脸"),
("困", "180", "想睡 倦 打哈欠 睁不开眼 好困啊 sleepy"),
("折磨", "181", "难受 痛苦 煎熬 蚌埠住了 受不了 要命"),
("抠鼻", "182", "不屑 无聊 淡定 无所谓 鄙视 挖鼻"),
("鼓掌", "183", "拍手 叫好 赞同 666 喝彩 掌声"),
("斜眼笑", "204", "滑稽 坏笑 doge 意味深长 阴阳怪气 嘿嘿嘿"),
("辣眼睛", "216", "看不下去 cringe 毁三观 太丑了 瞎了"),
("哦哟", "217", "惊讶 起哄 哇哦 有戏 不简单 哟"),
("吃瓜", "222", "围观 看戏 八卦 路人 看热闹 板凳"),
("狗头", "225", "doge 保命 开玩笑 滑稽 反讽 懂的都懂"),
("敬礼", "227", "salute 尊重 收到 遵命 致敬 报告"),
("哦", "231", "知道了 明白 敷衍 嗯 这样啊 收到"),
("拿到红包", "236", "红包 谢谢老板 发财 开心 抢到了 欧气"),
("牛吖", "239", "牛 厉害 强 666 佩服 大佬"),
("贴贴", "272", "抱抱 亲昵 蹭蹭 亲密 靠靠 撒娇贴"),
("爱心", "138", "心 love 喜欢你 红心 示爱 么么哒"),
("晚安", "170", "好梦 睡了 night 早点休息 安啦 moon"),
("太阳", "176", "晴天 早上好 阳光 morning 好天气 日"),
("柠檬", "266", "酸 嫉妒 柠檬精 羡慕 我酸 恰柠檬"),
("大冤种", "267", "倒霉 吃亏 自嘲 好心没好报 背锅 工具人"),
("吐了", "132", "恶心 yue 受不了 嫌弃 想吐 生理不适"),
("怒", "134", "生气 愤怒 火大 暴躁 气炸 怼"),
("玫瑰", "165", "花 示爱 表白 浪漫 送你花 情人节"),
("凋谢", "119", "花谢 失恋 难过 枯萎 心碎 凉了"),
("点赞", "159", "赞 认同 好棒 good like 大拇指 顶"),
("握手", "164", "合作 你好 商务 hello deal 成交 友好"),
("抱拳", "163", "谢谢 失敬 江湖 承让 拜托 有礼"),
("ok", "169", "好的 收到 没问题 okay 行 可以 懂了"),
("拳头", "174", "加油 干 冲 fight 力量 击拳 硬气"),
("鞭炮", "191", "过年 喜庆 爆竹 春节 噼里啪啦 红"),
("烟花", "258", "庆典 漂亮 新年 嘭 绽放 节日快乐"),
]
STICKER_MAP: dict[str, dict] = {
name: {
"sticker_id": sid, "package_id": "1003", "name": name, "description": desc,
"width": 128, "height": 128, "formats": "png",
}
for name, sid, desc in _STICKERS
}
def get_sticker_by_name(name: str) -> Optional[dict]:
"""完全相等 → name 与查询词互为子串 → description 包含查询词 → search_stickers 最高分;找不到返回 None。"""
if not name:
return None
query = name.strip()
if query in STICKER_MAP:
return STICKER_MAP[query]
for key, sticker in STICKER_MAP.items():
if query in key or key in query:
return sticker
for sticker in STICKER_MAP.values():
if query in sticker["description"]:
return sticker
matches = search_stickers(query, limit=1)
return matches[0] if matches else None
def get_random_sticker(category: str = None) -> dict:
"""随机贴纸;指定 category 时优先在 description/name 含该词的贴纸中选取。"""
candidates = [s for s in STICKER_MAP.values() if category in s["description"] or category in s["name"]] if category else []
return random.choice(candidates or list(STICKER_MAP.values()))
def get_sticker_by_id(sticker_id: str) -> Optional[dict]:
if not sticker_id:
return None
sid = str(sticker_id).strip()
return next((s for s in STICKER_MAP.values() if s["sticker_id"] == sid), None)
# 模糊搜索(对齐 chatbot-web yuanbao-openclaw-plugin/sticker-cache.ts.searchStickers)
_PUNCT_RE = re.compile(r"[\s\u3000\-_·.,,。!!??\"“”'‘’、/\\]+")
def _normalize_text(raw: str) -> str:
return unicodedata.normalize("NFKC", str(raw or "")).strip().lower()
def _compact_text(raw: str) -> str:
return _PUNCT_RE.sub("", _normalize_text(raw))
def _multiset_char_hit_ratio(needle: str, haystack: str) -> float:
return sum((Counter(needle) & Counter(haystack)).values()) / len(needle) if needle else 0.0
def _bigram_jaccard(a: str, b: str) -> float:
if len(a) < 2 or len(b) < 2:
return 0.0
A, B = ({x[i:i + 2] for i in range(len(x) - 1)} for x in (a, b))
return len(A & B) / len(A | B)
def _longest_subsequence_ratio(needle: str, haystack: str) -> float:
if not needle:
return 0.0
j = 0
for ch in haystack:
if j >= len(needle):
break
j += ch == needle[j]
return j / len(needle)
def _score_field(haystack: str, query: str) -> float:
hay = _normalize_text(haystack)
q = _normalize_text(query)
if not hay or not q:
return 0.0
hay_c = _compact_text(haystack)
q_c = _compact_text(query)
return max(
100.0 if hay == q else 0.0,
92 + min(6, len(q)) if q in hay else 0.0,
88.0 if len(q) >= 2 and hay.startswith(q) else 0.0,
86.0 if q_c and q_c in hay_c else 0.0,
_multiset_char_hit_ratio(q_c, hay_c) * 62,
_bigram_jaccard(q_c, hay_c) * 58,
_longest_subsequence_ratio(q_c, hay_c) * 52,
68.0 if len(q) == 1 and q in hay else 0.0,
)
def search_stickers(query: str, limit: int = 10) -> list[dict]:
"""内置贴纸表按模糊匹配排序返回前 N 条。
评分综合 name/description 的子串、字符多重集覆盖、bigram Jaccard、子序列比例,
name 权重高于 description(×0.88);sticker_id 精确/子串命中另计。空 query 按表顺序返回。
"""
safe_limit = max(1, min(500, int(limit) if limit else 10))
q_norm = _normalize_text(query)
if not query or not q_norm:
return list(STICKER_MAP.values())[:safe_limit]
scored: list[tuple[float, dict]] = []
for sticker in STICKER_MAP.values():
sid_norm = _normalize_text(sticker["sticker_id"].strip())
id_s = 100.0 if sid_norm == q_norm else 84.0 if sid_norm and q_norm in sid_norm else 0.0
scored.append((max(_score_field(sticker["name"], query), _score_field(sticker["description"], query) * 0.88, id_s), sticker))
scored.sort(key=lambda x: x[0], reverse=True)
top = scored[0][0] if scored else 0
if top <= 0:
return [s for _, s in scored[:safe_limit]]
floor = 18.0 if top >= 22 else max(10.0, top * 0.5) if top >= 12 else max(6.0, top * 0.35)
filtered = [pair for pair in scored if pair[0] >= floor]
return [s for _, s in (filtered or scored)[:safe_limit]]
def build_face_msg_body(face_index: int, face_type: int = 1, data: Optional[str] = None) -> list:
"""TIMFaceElem 消息体。Yuanbao 约定 index 固定 0(服务端通过 data 字段识别表情);face_index > 0 视为旧版 QQ
表情 ID。face_type 为兼容旧接口保留,不影响 wire format。data 为 None 时仅传 index。"""
msg_content: dict = {"index": face_index}
if data is not None:
msg_content["data"] = data
return [{"msg_type": "TIMFaceElem", "msg_content": msg_content}]
def build_sticker_msg_body(sticker: dict) -> list:
"""从 STICKER_MAP 的 sticker dict 构造 TIMFaceElem 消息体(data 字段与原始 JS 插件一致)。"""
data_payload = json.dumps(
{
"sticker_id": sticker["sticker_id"], "package_id": sticker["package_id"],
"width": sticker.get("width", 128), "height": sticker.get("height", 128),
"formats": sticker.get("formats", "png"), "name": sticker["name"],
},
ensure_ascii=False, separators=(",", ":"),
)
return build_face_msg_body(face_index=0, data=data_payload)