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