ForcePilot/backend/package/yuxi/channel/extensions/line/config.py
Kris 7fd658af57 feat(channel): 添加 LINE 渠道扩展
新增 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: 类型定义
2026-05-21 11:16:16 +08:00

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,
}