Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,30 @@ image_gen:

## QQ 和私聊接入

### 获取身份并配置主人或白名单

在飞书或 Telegram 私聊发送 `/sid`;在 QQ 私聊发送 `/sid`,或在 QQ、飞书群里 **@机器人并发送 `/sid`**。Telegram 群里可用 `/sid@机器人用户名`。机器人会直接回复当前平台可信事件中的用户认证 ID 和当前会话 ID,不调用聊天模型。QQ 的群成员 ID 和私聊用户 ID、不同群中的成员 ID 可能不同;请在需要配置的场景分别查询。飞书如有备用用户 ID,也会标明。

**群里的查询结果对群成员可见。** ID 是平台标识,不是验证码;只分享给负责配置的管理员,不应作为登录凭据。`/sid` 只读,不会自动添加主人、授权聊天或更改白名单,也不接受指定其他人的参数。

默认允许已通过 Hermes 授权的用户查询。若需要为尚未配对的用户获取 ID,可由管理员在对应 profile 的 `state/maomao-companion/config.json` 增加:

```json
"identity": {"sid_enabled": true, "allow_unpaired_lookup": true}
```

将这一项合并到已有 JSON 的顶层;启用后只开放身份查询,不开放其他指令或聊天。查询有频率限制。要关闭未配对查询,设置 `allow_unpaired_lookup: false`;要关闭所有查询,设置 `sid_enabled: false`。平台必须实际向机器人投递消息:如果飞书或 Telegram 的连接器在网关之前丢弃未配对用户消息,需要先按对应平台的配对流程开放消息入口;QQ 的独立连接器支持上述开关下的 `/sid` 引导查询。

管理员拿到 ID 后,在运行环境设置 `QQ_OWNER_OPENIDS`(多个 QQ 成员 ID 用逗号分隔);原有的单个 `QQ_OWNER_OPENID` 仍可使用,两者取并集。修改环境变量后重启对应网关。**主人身份与访问许可是两回事**:还需要按 Hermes 当前版本配置 QQ 的用户访问白名单和群访问策略;QQ 群策略中的 `group_allow_from` / `QQ_GROUP_ALLOWED_USERS` 使用群会话 ID,主人配置使用群成员用户 ID。不要把群 ID 填进主人列表。

飞书、Telegram 私聊的主人绑定使用前面介绍的 `target` 和 `private_members`:将 `/sid` 返回的用户 ID 写入 `user_id`,会话 ID 写入 `chat_id`,对应平台写入 `platform`。只有管理员编辑配置才能绑定;不同平台 ID 不可互换。

### 查看状态

已通过 Hermes 授权的用户可在私聊发送 `/status`,或在群里 @机器人发送 `/status`。命令使用 Hermes 当前会话的状态数据,显示模型、上下文占用和会话状态;占用可能是估算值。财务相关额度只在已绑定主人的私聊中显示,不会发到群聊。

OpenRouter 密钥限额来自 `OPENROUTER_API_KEY` 对应的只读 `/api/v1/key` 接口。未设置密钥限额不等于账户余额无限。账户余额使用 `/api/v1/credits`:如果当前密钥无权限,可以在服务器受保护环境中单独设置 `MAOMAO_OPENROUTER_MANAGEMENT_KEY`;不要把密钥写进配置文件或仓库。请求失败或其他提供商不支持时会显示不可用,具体额度以提供商控制台为准。额度查询不经过聊天模型。

QQ 使用独立 profile 和网关,只启用一个 QQ 连接器,不能同时运行内置 QQ 连接器。群内希望各成员共享一段对话,可在该独立 QQ profile 的 `config.yaml` 顶层设置 `group_sessions_per_user: false`;不同群和私聊仍应保持隔离。迁移既有会话前备份配置及状态,核对来源和身份。

先按 Hermes 的 QQ 平台配置方式设置应用凭据(`QQ_APP_ID`、`QQ_CLIENT_SECRET`)、群访问策略及允许的群。QQ 机器人还需要有接收群全部消息的权限,并实际收到 `GROUP_MESSAGE_CREATE`;仅收到 @ 事件时无法判断未 @ 消息。只有取得群内参与者同意后才启用主动判断,因为近期群消息片段会发送给配置的模型:
Expand All @@ -117,7 +141,7 @@ QQ 使用独立 profile 和网关,只启用一个 QQ 连接器,不能同时

QQ 连接器对已准入的群消息会在本地按群保留近期文本观察记录(最多 1000 条、7 天),用于对话上下文;停止主动判断不会自动删除这些记录。请按群内约定管理该 profile 的 `state/maomao-qq/` 数据。

可在 QQ profile 的受保护环境中设置 `QQ_OWNER_OPENID` 为平台提供的主人**成员 openid**,区分主人和群友;这是机器人收到的成员标识,不是可由聊天内容证明的身份。留空时不识别主人。插件在每轮使用准入事件的当前发送者,拒绝仅凭昵称或正文冒充主人;群会话共享时也不复用上一位发言人的身份。
QQ 主人列表里的值必须是平台提供的**成员 openid**,不是可由聊天内容证明的身份。留空时不识别主人。插件在每轮使用准入事件的当前发送者,拒绝仅凭昵称或正文冒充主人;群会话共享时也不复用上一位发言人的身份。

图片识别、收藏、Embedding 分别在 QQ profile 的 `state/maomao-qq/config.json` 中使用 `vision_enabled`、`collect_enabled`、`embedding_enabled` 控制,默认关闭。开始前须配置识图服务、获取参与者授权,并为图库和群聊数据制定保留策略。

Expand Down
8 changes: 8 additions & 0 deletions plugins/maomao-companion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ def persona(session_id="",platform="",**kwargs):
from .maomao_companion.providers import register as register_providers
register_providers(ctx)
companion = CompanionContext(ctx)
from .maomao_companion.sid import IdentityLookup
sid=IdentityLookup(home())
ctx.register_hook("pre_gateway_dispatch",sid.hook)
if hasattr(ctx,"on_unload"):ctx.on_unload(sid.close)
from .maomao_companion.status import StatusLookup
status=StatusLookup(home())
ctx.register_hook("pre_gateway_dispatch",status.hook)
if hasattr(ctx,"on_unload"):ctx.on_unload(status.close)
def authorize_dm(session_key="",source=None,**kwargs):
from .maomao_companion.authorization import admit
admit(home(),companion.config(),session_key,source)
Expand Down
101 changes: 101 additions & 0 deletions plugins/maomao-companion/maomao_companion/sid.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""Identity lookup from trusted gateway metadata; never grants authorization."""
import asyncio
import json
import re
import time
from pathlib import Path

COMMAND=re.compile(r'^/sid(?:@[A-Za-z0-9_]{3,32})?$',re.I)
PLATFORMS={'qqbot','feishu','telegram'}


def is_sid(text):
return isinstance(text,str) and bool(COMMAND.fullmatch(text.strip()))


def strip_qq_mention(text,mentions,group_at=False):
mentions=mentions if isinstance(mentions,list) else []
bots={str(m.get('member_openid') or m.get('id') or '') for m in mentions if isinstance(m,dict) and m.get('is_you')}
value=re.sub(r'<@!?([A-Za-z0-9]+)>',lambda m:'' if m.group(1) in bots else m.group(0),str(text or ''))
if group_at:
value=re.sub(r'^\s*<@!?[A-Za-z0-9]+>\s*','',value)
value=re.sub(r'^\s*@\S+\s*','',value)
return value.strip()


def settings(root):
try:
value=json.loads((Path(root)/'state/maomao-companion/config.json').read_text())
item=value.get('identity',{})
return item if isinstance(item,dict) else {}
except (OSError,ValueError,TypeError,AttributeError):return {}


def authorized(gateway,source):
for name in ('_is_user_authorized_for_source','_is_user_authorized'):
method=getattr(gateway,name,None)
if callable(method):return bool(method(source))
return False


def delivery_adapter(gateway,source):
# Modern Hermes resolves the receiving transport/profile; older versions
# use _adapter_for_source. A missing adapter must not fall back across profiles.
for name in ('_delivery_adapter_for','_adapter_for_source'):
method=getattr(gateway,name,None)
if callable(method):return method(source)
return None


def response(source):
if getattr(source,'is_bot',False):return None
platform=getattr(getattr(source,'platform',None),'value',getattr(source,'platform',None))
kind=getattr(source,'chat_type',None)
uid=getattr(source,'user_id',None)
chat=getattr(source,'chat_id',None)
if platform not in PLATFORMS or kind not in {'group','dm'} or not isinstance(uid,str) or not uid or not isinstance(chat,str) or not chat:return None
if not all(re.fullmatch(r'[A-Za-z0-9_:@.-]{1,200}',value) for value in (uid,chat)):return None
# Only platform-created metadata is used; never interpret the message text as an ID.
lines=['平台:'+platform,'会话类型:'+('群聊' if kind=='group' else '私聊'),'你的认证 ID:'+uid,'当前会话 ID:'+chat]
alt=getattr(source,'user_id_alt',None)
if isinstance(alt,str) and alt!=uid and re.fullmatch(r'[A-Za-z0-9_:@.-]{1,200}',alt):lines.append('平台备用用户 ID:'+alt)
if platform=='qqbot':lines.append('QQ 群成员 ID 与私聊用户 ID 可能不同,请在对应场景分别查询。')
if kind=='group':lines.append('这条回复对群成员可见;请勿把 ID 当密码或验证码。')
lines.append('此命令只查询身份,主人或白名单由管理员配置。')
return '\n'.join(lines)


class IdentityLookup:
def __init__(self,root,clock=time.monotonic):self.root=Path(root);self.clock=clock;self.seen={};self.tasks=set()

def allowed(self,source):
platform=getattr(getattr(source,'platform',None),'value',getattr(source,'platform',None))
key=(platform,str(getattr(source,'chat_id','')),str(getattr(source,'user_id','')))
now=self.clock();self.seen={k:t for k,t in self.seen.items() if 0<=now-t<60}
if key in self.seen and now-self.seen[key]<10:return False
if sum(k[:2]==key[:2] for k in self.seen)>=30:return False
self.seen[key]=now
while len(self.seen)>512:self.seen.pop(next(iter(self.seen)))
return True

def hook(self,event=None,gateway=None,**kwargs):
if event is None or gateway is None or getattr(event,'internal',False) or not is_sid(getattr(event,'text',None)):return
cfg=settings(self.root)
if cfg.get('sid_enabled',True) is not True:return
source=getattr(event,'source',None);answer=response(source)
if not answer:return
try:
admitted=authorized(gateway,source)
if not admitted and cfg.get('allow_unpaired_lookup') is not True:return
adapter=delivery_adapter(gateway,source)
if adapter is None or not self.allowed(source):return {'action':'skip','reason':'identity lookup unavailable or rate limited'}
anchor=getattr(event,'message_id',None)
async def deliver():
try:await adapter.send(source.chat_id,answer,reply_to=anchor)
except Exception:pass
task=asyncio.get_running_loop().create_task(deliver());self.tasks.add(task);task.add_done_callback(self.tasks.discard)
return {'action':'skip','reason':'identity lookup handled'}
except (AttributeError,RuntimeError,TypeError):return

def close(self):
for task in list(self.tasks):task.cancel()
121 changes: 121 additions & 0 deletions plugins/maomao-companion/maomao_companion/status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Read-only session status, with provider limits visible only to bound private users."""
import asyncio
import json
import os
import re
import time
import urllib.request
from decimal import Decimal,InvalidOperation
from pathlib import Path

from .sid import authorized,delivery_adapter,IdentityLookup

COMMAND=re.compile(r'^/status(?:@[A-Za-z0-9_]{3,32})?$',re.I)


def _matches(source,target):
return (isinstance(target,dict) and source['platform']==target.get('platform')
and source['chat_type']==target.get('chat_type')=='dm'
and bool(source['chat_id']) and source['chat_id']==target.get('chat_id')
and bool(target.get('user_id'))
and target['user_id'] in {source['user_id'],source['user_id_alt']})


def private_owner(root,source):
platform=getattr(getattr(source,'platform',None),'value',getattr(source,'platform',None))
if getattr(source,'chat_type',None)!='dm':return False
uid=str(getattr(source,'user_id','') or '')
if platform=='qqbot':
values=[os.environ.get('QQ_OWNER_OPENID',''),os.environ.get('QQ_OWNER_OPENIDS','')]
try:
for line in (Path(root)/'.env').read_text().splitlines():
key,sep,value=line.partition('=')
if sep and key.strip() in {'QQ_OWNER_OPENID','QQ_OWNER_OPENIDS'}:values.append(value.strip().strip("\"'"))
except OSError:pass
return bool(uid) and uid.upper() in {part.strip().upper() for value in values for part in value.split(',') if part.strip()}
if platform not in {'feishu','telegram'}:return False
try:
cfg=json.loads((Path(root)/'state/maomao-companion/config.json').read_text())
members=cfg.get('private_members') or [cfg.get('target')]
data={'platform':platform,'chat_type':'dm','chat_id':getattr(source,'chat_id',None),
'user_id':getattr(source,'user_id',None),'user_id_alt':getattr(source,'user_id_alt',None)}
return isinstance(members,list) and any(_matches(data,item) for item in members)
except (OSError,ValueError,TypeError,AttributeError):return False


class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self,*args,**kwargs):raise PermissionError('redirect disabled')


def _amount(value):
try:
amount=Decimal(str(value))
return f'${amount:.4f}' if amount.is_finite() else None
except (InvalidOperation,ValueError,TypeError):return None


def provider_status(key=None,management_key=None,opener=None):
"""Never log keys or provider bodies; account balance requires an explicit management key."""
if key is None:key=os.environ.get('OPENROUTER_API_KEY','')
if management_key is None:management_key=os.environ.get('MAOMAO_OPENROUTER_MANAGEMENT_KEY','')
opener=opener or urllib.request.build_opener(NoRedirect())
lines=[]
if key:
try:
request=urllib.request.Request('https://openrouter.ai/api/v1/key',headers={'Authorization':'Bearer '+key})
with opener.open(request,timeout=5) as r:data=json.loads(r.read(65537))['data']
if data.get('limit') is None:lines.append('OpenRouter 密钥限额:未设置(不代表账户余额)')
else:
remaining=_amount(data.get('limit_remaining'))
lines.append('OpenRouter 密钥剩余额度:'+remaining if remaining else 'OpenRouter 密钥剩余额度:不可用')
except Exception:lines.append('OpenRouter 密钥额度:暂不可用')
else:lines.append('OpenRouter 密钥额度:未配置')
balance_key=management_key or key
if balance_key:
try:
request=urllib.request.Request('https://openrouter.ai/api/v1/credits',headers={'Authorization':'Bearer '+balance_key})
with opener.open(request,timeout=5) as r:data=json.loads(r.read(65537))['data']
remaining=_amount(Decimal(str(data['total_credits']))-Decimal(str(data['total_usage'])))
lines.append('OpenRouter 账户余额:'+remaining if remaining else 'OpenRouter 账户余额:不可用')
except Exception:lines.append('OpenRouter 账户余额:不可用(通常需要管理密钥)')
else:lines.append('OpenRouter 账户余额:未配置管理密钥,无法读取')
lines.append('其他提供商额度:以其控制台为准')
return '\n'.join(lines)


class StatusLookup:
def __init__(self,root):
self.root=Path(root);self.rate=IdentityLookup(root);self.tasks=set();self.cached=None

def hook(self,event=None,gateway=None,**kwargs):
if event is None or gateway is None or getattr(event,'internal',False):return
text=getattr(event,'text','')
if not isinstance(text,str) or not COMMAND.fullmatch(text.strip()):return
source=getattr(event,'source',None)
platform=getattr(getattr(source,'platform',None),'value',getattr(source,'platform',None))
if platform not in {'qqbot','feishu','telegram'} or getattr(source,'is_bot',False):return
try:
if not authorized(gateway,source):return
adapter=delivery_adapter(gateway,source)
if adapter is None or len(self.tasks)>=16 or not self.rate.allowed(source):return {'action':'skip','reason':'status unavailable or rate limited'}
private=private_owner(self.root,source)
async def deliver():
try:
native=await gateway._handle_status_command(event)
if not isinstance(native,str) or not native.strip():native='当前会话状态暂不可用。'
if private:
now=time.monotonic()
if not self.cached or now-self.cached[0]>30:
details=await asyncio.to_thread(provider_status);self.cached=(now,details)
details=self.cached[1]
else:details='API 额度:仅已绑定主人的私聊可查询。'
await adapter.send(source.chat_id,native+'\n\n'+details,reply_to=getattr(event,'message_id',None))
except Exception:
try:await adapter.send(source.chat_id,'当前状态暂时不可用,请稍后再试。',reply_to=getattr(event,'message_id',None))
except Exception:pass
task=asyncio.get_running_loop().create_task(deliver());self.tasks.add(task);task.add_done_callback(self.tasks.discard)
return {'action':'skip','reason':'status lookup handled'}
except (AttributeError,RuntimeError,TypeError):return

def close(self):
for task in list(self.tasks):task.cancel()
2 changes: 1 addition & 1 deletion plugins/maomao-companion/plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
name: maomao-companion
version: "0.11.0"
version: "0.11.1"
description: "Companion contact policy, isolated memory, persistent goals and observed public sources."
Loading