新增 LINE 渠道扩展,支持在 Yuxi 平台中集成 LINE 即时通讯渠道。 包含以下功能模块: - bot: LINE Bot 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - signature: 请求签名验证 - token_manager: Token 管理 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - session: 会话管理 - flex_templates: Flex 模板消息 - card_command: 卡片指令处理 - template_messages: 模板消息 - rich_menu: 富菜单管理 - actions: 动作处理 - directives: 指令处理 - delivery: 消息送达确认 - loading: 加载动画 - media: 媒体资源处理 - types: 类型定义
281 lines
12 KiB
Python
281 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from yuxi.channel.extensions.line.types import LineAccountConfig, LineTokenSource, LineThreadBindingsConfig
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ENV_CHANNEL_ACCESS_TOKEN = "LINE_CHANNEL_ACCESS_TOKEN"
|
|
ENV_CHANNEL_SECRET = "LINE_CHANNEL_SECRET"
|
|
|
|
_token_manager = None
|
|
|
|
|
|
def _get_token_manager():
|
|
global _token_manager
|
|
if _token_manager is None:
|
|
from yuxi.channel.extensions.line.token_manager import LineTokenManager
|
|
|
|
_token_manager = LineTokenManager()
|
|
return _token_manager
|
|
|
|
|
|
class LineConfigAdapter:
|
|
|
|
def __init__(self):
|
|
self._config: dict = {}
|
|
|
|
def list_account_ids(self, config: dict) -> list[str]:
|
|
self._config = config
|
|
line_cfg = self._get_line_config()
|
|
accounts = line_cfg.get("accounts", {}) if isinstance(line_cfg, dict) else {}
|
|
if accounts:
|
|
return list(accounts.keys())
|
|
return ["default"]
|
|
|
|
async def resolve_account(self, account_id: str) -> dict:
|
|
raw = self._load_raw_config(account_id)
|
|
line_account = self._build_account(account_id, raw)
|
|
result = self._to_dict(line_account)
|
|
|
|
if not result.get("channel_access_token") and result.get("channel_id") and result.get("channel_secret"):
|
|
token = await _get_token_manager().get_token(
|
|
result["channel_id"],
|
|
result["channel_secret"],
|
|
)
|
|
if token:
|
|
result["channel_access_token"] = token
|
|
result["token_source"] = LineTokenSource.ENV.value
|
|
|
|
return result
|
|
|
|
def _get_line_config(self) -> dict:
|
|
channels = self._config.get("channels", {})
|
|
return channels.get("line", {}) if channels else self._config
|
|
|
|
def is_configured(self, account: dict) -> bool:
|
|
return bool(account.get("channel_access_token") and account.get("channel_secret"))
|
|
|
|
def is_enabled(self, account: dict) -> bool:
|
|
return account.get("enabled", True)
|
|
|
|
def disabled_reason(self, account: dict) -> str:
|
|
if not self.is_configured(account):
|
|
return "Channel Access Token and Channel Secret are required"
|
|
return ""
|
|
|
|
async def resolve_allow_from(self, config: dict, account_id: str) -> list[str] | None:
|
|
account = await self.resolve_account(account_id)
|
|
return account.get("allow_from", [])
|
|
|
|
def resolve_thread_bindings(self) -> LineThreadBindingsConfig:
|
|
line_cfg = self._get_line_config()
|
|
tb = line_cfg.get("thread_bindings", {}) if isinstance(line_cfg, dict) else {}
|
|
if not isinstance(tb, dict):
|
|
tb = {}
|
|
return LineThreadBindingsConfig(
|
|
enabled=tb.get("enabled", True),
|
|
idle_hours=tb.get("idle_hours", 24),
|
|
max_age_hours=tb.get("max_age_hours", 168),
|
|
spawn_subagent_sessions=tb.get("spawn_subagent_sessions", True),
|
|
)
|
|
|
|
def resolve_group_configs(self) -> dict[str, dict]:
|
|
line_cfg = self._get_line_config()
|
|
groups = line_cfg.get("groups", {}) if isinstance(line_cfg, dict) else {}
|
|
return groups if isinstance(groups, dict) else {}
|
|
|
|
def describe_account(self, account: dict) -> dict:
|
|
return {
|
|
"account_id": account.get("account_id", ""),
|
|
"name": account.get("name", ""),
|
|
"configured": self.is_configured(account),
|
|
"token_source": account.get("token_source", "none"),
|
|
}
|
|
|
|
def default_account_id(self, config: dict) -> str:
|
|
channels = config.get("channels", {})
|
|
line_cfg = channels.get("line", {}) if channels else config
|
|
if isinstance(line_cfg, dict):
|
|
return line_cfg.get("default_account", "default")
|
|
return "default"
|
|
|
|
def config_schema(self) -> dict:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": True},
|
|
"channel_id": {"type": "string", "title": "Channel ID (for Token v2.1)"},
|
|
"channel_access_token": {"type": "string", "title": "Channel Access Token"},
|
|
"channel_secret": {"type": "string", "title": "Channel Secret"},
|
|
"token_file": {"type": "string", "title": "Token File Path"},
|
|
"secret_file": {"type": "string", "title": "Secret File Path"},
|
|
"dm_policy": {
|
|
"type": "string",
|
|
"enum": ["pairing", "allowlist", "open", "disabled"],
|
|
"default": "pairing",
|
|
},
|
|
"group_policy": {
|
|
"type": "string",
|
|
"enum": ["open", "allowlist", "disabled"],
|
|
"default": "allowlist",
|
|
},
|
|
"allow_from": {"type": "array", "items": {"type": "string"}},
|
|
"group_allow_from": {"type": "array", "items": {"type": "string"}},
|
|
"webhook_path": {"type": "string", "default": "/line/webhook"},
|
|
"text_chunk_limit": {"type": "integer", "default": 5000},
|
|
"media_max_mb": {"type": "integer", "default": 10},
|
|
"accounts": {
|
|
"type": "object",
|
|
"additionalProperties": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"channel_id": {"type": "string", "title": "Channel ID (for Token v2.1)"},
|
|
"channel_access_token": {"type": "string"},
|
|
"channel_secret": {"type": "string"},
|
|
"token_file": {"type": "string"},
|
|
"secret_file": {"type": "string"},
|
|
"dm_policy": {"type": "string", "enum": ["pairing", "allowlist", "open", "disabled"]},
|
|
"group_policy": {"type": "string", "enum": ["open", "allowlist", "disabled"]},
|
|
"allow_from": {"type": "array", "items": {"type": "string"}},
|
|
"group_allow_from": {"type": "array", "items": {"type": "string"}},
|
|
"webhook_path": {"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
"groups": {
|
|
"type": "object",
|
|
"additionalProperties": {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": True},
|
|
"require_mention": {"type": "boolean", "default": True},
|
|
"allow_from": {"type": "array", "items": {"type": "string"}},
|
|
"group_policy": {"type": "string", "enum": ["open", "allowlist", "disabled"]},
|
|
"system_prompt": {"type": "string"},
|
|
"skills": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
},
|
|
},
|
|
"default_account": {"type": "string", "default": "default"},
|
|
"thread_bindings": {
|
|
"type": "object",
|
|
"properties": {
|
|
"enabled": {"type": "boolean", "default": True},
|
|
"idle_hours": {"type": "integer", "default": 24},
|
|
"max_age_hours": {"type": "integer", "default": 168},
|
|
"spawn_subagent_sessions": {"type": "boolean", "default": True},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
def _load_raw_config(self, account_id: str) -> dict:
|
|
line_cfg = self._get_line_config()
|
|
if account_id == "__base__":
|
|
return line_cfg if isinstance(line_cfg, dict) else {}
|
|
accounts = line_cfg.get("accounts", {}) if isinstance(line_cfg, dict) else {}
|
|
return accounts.get(account_id, {}) if isinstance(accounts, dict) else {}
|
|
|
|
def _build_account(self, account_id: str, raw: dict) -> LineAccountConfig:
|
|
base_raw = self._load_raw_config("__base__")
|
|
|
|
token, token_source = self._resolve_token(account_id, raw, base_raw)
|
|
secret, secret_source = self._resolve_secret(account_id, raw, base_raw)
|
|
channel_id = raw.get("channel_id", base_raw.get("channel_id", ""))
|
|
|
|
return LineAccountConfig(
|
|
account_id=account_id,
|
|
channel_id=channel_id,
|
|
channel_access_token=token,
|
|
channel_secret=secret,
|
|
token_source=token_source,
|
|
secret_source=secret_source,
|
|
token_file=raw.get("token_file", base_raw.get("token_file", "")),
|
|
secret_file=raw.get("secret_file", base_raw.get("secret_file", "")),
|
|
name=raw.get("name", account_id),
|
|
dm_policy=raw.get("dm_policy", base_raw.get("dm_policy", "pairing")),
|
|
group_policy=raw.get("group_policy", base_raw.get("group_policy", "allowlist")),
|
|
allow_from=raw.get("allow_from", base_raw.get("allow_from", [])),
|
|
group_allow_from=raw.get("group_allow_from", base_raw.get("group_allow_from", [])),
|
|
webhook_path=raw.get("webhook_path", base_raw.get("webhook_path", "/line/webhook")),
|
|
text_chunk_limit=raw.get("text_chunk_limit", base_raw.get("text_chunk_limit", 5000)),
|
|
media_max_mb=raw.get("media_max_mb", base_raw.get("media_max_mb", 10)),
|
|
)
|
|
|
|
@staticmethod
|
|
def _resolve_token(account_id: str, raw: dict, base_raw: dict) -> tuple[str, LineTokenSource]:
|
|
if raw.get("channel_access_token"):
|
|
return raw["channel_access_token"], LineTokenSource.ACCOUNT
|
|
if raw.get("token_file"):
|
|
token = LineConfigAdapter._read_file(raw["token_file"])
|
|
if token:
|
|
return token, LineTokenSource.TOKEN_FILE
|
|
if account_id == "default":
|
|
if base_raw.get("channel_access_token"):
|
|
return base_raw["channel_access_token"], LineTokenSource.BASE
|
|
if base_raw.get("token_file"):
|
|
token = LineConfigAdapter._read_file(base_raw["token_file"])
|
|
if token:
|
|
return token, LineTokenSource.BASE_TOKEN_FILE
|
|
env_token = os.environ.get(ENV_CHANNEL_ACCESS_TOKEN, "")
|
|
if env_token:
|
|
return env_token, LineTokenSource.ENV
|
|
return "", LineTokenSource.NONE
|
|
|
|
@staticmethod
|
|
def _resolve_secret(account_id: str, raw: dict, base_raw: dict) -> tuple[str, LineTokenSource]:
|
|
if raw.get("channel_secret"):
|
|
return raw["channel_secret"], LineTokenSource.ACCOUNT
|
|
if raw.get("secret_file"):
|
|
secret = LineConfigAdapter._read_file(raw["secret_file"])
|
|
if secret:
|
|
return secret, LineTokenSource.TOKEN_FILE
|
|
if account_id == "default":
|
|
if base_raw.get("channel_secret"):
|
|
return base_raw["channel_secret"], LineTokenSource.BASE
|
|
if base_raw.get("secret_file"):
|
|
secret = LineConfigAdapter._read_file(base_raw["secret_file"])
|
|
if secret:
|
|
return secret, LineTokenSource.BASE_TOKEN_FILE
|
|
env_secret = os.environ.get(ENV_CHANNEL_SECRET, "")
|
|
if env_secret:
|
|
return env_secret, LineTokenSource.ENV
|
|
return "", LineTokenSource.NONE
|
|
|
|
@staticmethod
|
|
def _read_file(filepath: str) -> str | None:
|
|
try:
|
|
path = Path(filepath)
|
|
if path.exists():
|
|
return path.read_text().strip()
|
|
except OSError:
|
|
pass
|
|
return None
|
|
|
|
@staticmethod
|
|
def _to_dict(account: LineAccountConfig) -> dict:
|
|
return {
|
|
"account_id": account.account_id,
|
|
"channel_id": account.channel_id,
|
|
"channel_access_token": account.channel_access_token,
|
|
"channel_secret": account.channel_secret,
|
|
"token_source": account.token_source.value,
|
|
"secret_source": account.secret_source.value,
|
|
"token_file": account.token_file,
|
|
"secret_file": account.secret_file,
|
|
"name": account.name,
|
|
"dm_policy": account.dm_policy,
|
|
"group_policy": account.group_policy,
|
|
"allow_from": account.allow_from,
|
|
"group_allow_from": account.group_allow_from,
|
|
"webhook_path": account.webhook_path,
|
|
"text_chunk_limit": account.text_chunk_limit,
|
|
"media_max_mb": account.media_max_mb,
|
|
"enabled": True,
|
|
}
|