feat(instagram): 完成Instagram插件多维度功能优化与规范调整
本次提交对Instagram插件进行了全面改进: 1. 调整OAuth凭据配置项,将app_id/app_secret移至必填字段 2. 重构异常链处理逻辑,移除手动设置__cause__代码 3. 新增OAuth基础URL常量并统一API端点调用 4. 实现可配置的token刷新提前天数阈值,替代硬编码7天 5. 修复白名单适配器异常捕获逻辑,精准处理Error类型 6. 优化入站适配器对location消息、referral事件的处理 7. 重构消息缓存键,新增账户前缀支持按账户清理缓存 8. 完善OAuth回调流程,新增expires_in校验与过期时间计算 9. 修复消息窗口检查逻辑,调整重试退避策略 10. 新增凭据轮换相关辅助方法,优化验证与刷新流程 11. 移除废弃的飞书DTO文件
This commit is contained in:
parent
6c0d0c7bd8
commit
6bd13a7a8e
@ -1,59 +0,0 @@
|
||||
"""飞书插件内部 DTO。
|
||||
|
||||
仅插件内部使用的中间解析结构,不导出到契约层。所有 DTO 为 ``frozen``
|
||||
dataclass,用于飞书事件 payload 与消息内容的中间解析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuEventHeader:
|
||||
"""飞书事件头。
|
||||
|
||||
字段:
|
||||
event_id: 事件 ID。
|
||||
event_type: 事件类型。
|
||||
create_time: 事件创建时间。
|
||||
token: 校验 token。
|
||||
app_id: 应用 ID。
|
||||
tenant_key: 租户标识。
|
||||
"""
|
||||
|
||||
event_id: str
|
||||
event_type: str
|
||||
create_time: str
|
||||
token: str
|
||||
app_id: str
|
||||
tenant_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuEventPayload:
|
||||
"""飞书事件 payload 解析中间结构。
|
||||
|
||||
字段:
|
||||
schema: 协议版本(如 ``2.0``)。
|
||||
header: 事件头。
|
||||
event: 事件业务数据。
|
||||
"""
|
||||
|
||||
schema: str
|
||||
header: FeishuEventHeader
|
||||
event: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeishuMessageContent:
|
||||
"""飞书消息内容解析中间结构。
|
||||
|
||||
字段:
|
||||
msg_type: 消息类型(如 ``text`` / ``post`` / ``image``)。
|
||||
content: 消息内容字典。
|
||||
"""
|
||||
|
||||
msg_type: str
|
||||
content: dict[str, Any]
|
||||
@ -71,6 +71,11 @@ WHITELIST_CACHE_KEY_PREFIX = "instagram:whitelist:"
|
||||
# token_refresh_lead_days.default=7)
|
||||
DEFAULT_TOKEN_REFRESH_LEAD_DAYS = 7
|
||||
|
||||
# OAuth 授权端点基础 URL(Instagram Login 路径,与 wizard_adapter 共享)
|
||||
# Step 1 短期 token 交换必须使用 api.instagram.com,graph.instagram.com 仅用于
|
||||
# Step 2 长期 token 交换(Meta 官方约束)
|
||||
OAUTH_API_BASE = "https://api.instagram.com"
|
||||
|
||||
# Long-lived Token 有效期(秒),Instagram User Access Token 60 天
|
||||
LONG_LIVED_TOKEN_TTL_SECONDS = 60 * 24 * 60 * 60
|
||||
|
||||
@ -144,6 +149,7 @@ __all__ = [
|
||||
"TOKEN_CACHE_KEY_PREFIX",
|
||||
"WHITELIST_CACHE_KEY_PREFIX",
|
||||
"DEFAULT_TOKEN_REFRESH_LEAD_DAYS",
|
||||
"OAUTH_API_BASE",
|
||||
"LONG_LIVED_TOKEN_TTL_SECONDS",
|
||||
"MAX_MESSAGE_LENGTH",
|
||||
"MAX_QUICK_REPLY_OPTIONS",
|
||||
|
||||
@ -214,7 +214,12 @@ class InstagramDoctorAdapter:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _check_token_validity(self, account_id: str) -> DiagnosticResult:
|
||||
"""检查 Token 有效性。"""
|
||||
"""检查 Token 有效性。
|
||||
|
||||
过期阈值通过 ``InstagramClient.get_token_refresh_lead_days`` 读取
|
||||
账户级 ``token_refresh_lead_days``,未配置时回退默认值。
|
||||
"""
|
||||
lead_days = await self._client.get_token_refresh_lead_days(account_id)
|
||||
try:
|
||||
resp = await self._client.get_debug_token(account_id)
|
||||
except Error as exc:
|
||||
@ -263,7 +268,7 @@ class InstagramDoctorAdapter:
|
||||
if expires_at and isinstance(expires_at, (int, float)):
|
||||
now = datetime.now(UTC).timestamp()
|
||||
remaining_days = (expires_at - now) / 86400
|
||||
if remaining_days < 7:
|
||||
if remaining_days < lead_days:
|
||||
return DiagnosticResult(
|
||||
check_id=_CHECK_TOKEN_VALIDITY,
|
||||
passed=False,
|
||||
|
||||
@ -33,6 +33,7 @@ from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
from .._constants import (
|
||||
CHANNEL_TARGET,
|
||||
MESSAGING_WINDOW_SECONDS,
|
||||
MSG_ACCOUNT_CACHE_KEY_PREFIX,
|
||||
WINDOW_CACHE_KEY_PREFIX,
|
||||
)
|
||||
from ..instagram_client import InstagramClient
|
||||
@ -133,6 +134,16 @@ class InstagramInboundAdapter:
|
||||
"message_type": message_type,
|
||||
}
|
||||
|
||||
# location 消息补充坐标到 metadata(_parse_message 仅标记 message_type,
|
||||
# 此处从原始 attachments 提取 coordinates 供下游使用)
|
||||
if message_type == "location":
|
||||
for attach in message.get("attachments", []):
|
||||
if isinstance(attach, dict) and attach.get("type") == "location":
|
||||
coords = attach.get("payload", {}).get("coordinates")
|
||||
if isinstance(coords, dict):
|
||||
metadata["coordinates"] = coords
|
||||
break
|
||||
|
||||
# 引用回复信息
|
||||
reply_to = message.get("reply_to")
|
||||
if isinstance(reply_to, dict):
|
||||
@ -155,6 +166,22 @@ class InstagramInboundAdapter:
|
||||
ttl_seconds=MESSAGING_WINDOW_SECONDS,
|
||||
)
|
||||
|
||||
# 写入 channel_msg_id → {account_id, recipient_igsid} 反向映射,
|
||||
# 供 MessageOpsAdapter(如 Reactions)解析入站消息的账户与接收方上下文。
|
||||
# 双键策略:legacy 键(无前缀)用于 sendReaction 反查;带 account_id
|
||||
# 前缀键用于 beforeAccountDelete 按 pattern 清理。
|
||||
# 仅当 mid 存在时写入(部分事件如纯 referral 无 mid)。
|
||||
if mid:
|
||||
legacy_value = {"account_id": account_id, "recipient_igsid": sender_igsid}
|
||||
await self._cache.set(
|
||||
f"{MSG_ACCOUNT_CACHE_KEY_PREFIX}{mid}",
|
||||
legacy_value,
|
||||
)
|
||||
await self._cache.set(
|
||||
f"{MSG_ACCOUNT_CACHE_KEY_PREFIX}{account_id}:{mid}",
|
||||
legacy_value,
|
||||
)
|
||||
|
||||
return MessageContent(
|
||||
text=text,
|
||||
format=MessageFormat.TEXT,
|
||||
@ -379,17 +406,10 @@ def _parse_message(
|
||||
message_type = "story_reply"
|
||||
continue
|
||||
|
||||
# location 附件构造为 file 类型(契约 Attachment 仅支持 image/file/audio/video)
|
||||
# location 附件无可下载 URL,跳过构造 Attachment(避免 downloadAttachment
|
||||
# 尝试 HTTP GET 失败)。仅标记 message_type,坐标由调用方从原始
|
||||
# attachments 提取到 metadata.coordinates。
|
||||
if attach_type == "location":
|
||||
lat = attach_payload.get("coordinates", {}).get("lat", 0)
|
||||
long = attach_payload.get("coordinates", {}).get("long", 0)
|
||||
# location 无可下载 URL,使用占位 URL 编码坐标信息
|
||||
url = f"instagram://location?lat={lat}&long={long}"
|
||||
attachment = Attachment(
|
||||
type="file",
|
||||
url=url,
|
||||
)
|
||||
attachments.append(attachment)
|
||||
message_type = "location"
|
||||
continue
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ from yuxi.channels.contract.dtos.channel import (
|
||||
ChannelAccount,
|
||||
RotateCredentialsResult,
|
||||
)
|
||||
from yuxi.channels.contract.dtos.config import ConfigScope
|
||||
from yuxi.channels.contract.errors import (
|
||||
DependencyError,
|
||||
Error,
|
||||
@ -40,11 +41,7 @@ from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||||
from yuxi.channels.contract.ports.driven.config_port import ConfigPort
|
||||
from yuxi.channels.contract.ports.driven.logger_port import LoggerPort
|
||||
|
||||
from .._constants import (
|
||||
DEFAULT_API_VERSION,
|
||||
DEFAULT_TOKEN_REFRESH_LEAD_DAYS,
|
||||
LONG_LIVED_TOKEN_TTL_SECONDS,
|
||||
)
|
||||
from .._constants import DEFAULT_API_VERSION
|
||||
from ..instagram_client import InstagramClient
|
||||
|
||||
# 必填配置字段
|
||||
@ -57,9 +54,10 @@ _FIELD_API_VERSION = "api_version"
|
||||
_FIELD_WEBHOOK_VERIFY_TOKEN = "webhook_verify_token"
|
||||
|
||||
# CachePort 清理键模式
|
||||
_CACHE_KEY_PREFIX_TOKEN = "instagram:token:"
|
||||
_CACHE_KEY_PREFIX_WHITELIST = "instagram:whitelist:"
|
||||
_CACHE_KEY_PREFIX_WINDOW = "instagram:window:"
|
||||
_CACHE_KEY_PREFIX_MSG = "instagram:msg:"
|
||||
_CACHE_KEY_PREFIX_REACTION = "instagram:reaction:"
|
||||
|
||||
# IGID 纯数字校验
|
||||
_IGID_PATTERN = re.compile(r"^\d+$")
|
||||
@ -138,9 +136,14 @@ class InstagramLifecycleAdapter:
|
||||
# 补全默认值
|
||||
if _FIELD_API_VERSION not in normalized or not normalized[_FIELD_API_VERSION]:
|
||||
normalized[_FIELD_API_VERSION] = DEFAULT_API_VERSION
|
||||
# 计算并补全 token_expires_at(若缺失则按 60 天计算)
|
||||
# token_expires_at 必须由调用方(OAuth 流程)传入,缺失则视为配置错误。
|
||||
# OAuth 响应包含 expires_in(秒),调用方应基于此计算绝对过期时间。
|
||||
# 不在此处硬编码补全,避免掩盖调用方未传 expires_in 的设计缺陷。
|
||||
if _FIELD_TOKEN_EXPIRES_AT not in normalized or not normalized[_FIELD_TOKEN_EXPIRES_AT]:
|
||||
normalized[_FIELD_TOKEN_EXPIRES_AT] = time.time() + LONG_LIVED_TOKEN_TTL_SECONDS
|
||||
raise ValidationError(
|
||||
field=_FIELD_TOKEN_EXPIRES_AT,
|
||||
message="token_expires_at_required_oauth_must_provide_expires_in",
|
||||
)
|
||||
return normalized
|
||||
|
||||
async def afterAccountConfigWritten(self, account: ChannelAccount) -> None:
|
||||
@ -160,7 +163,7 @@ class InstagramLifecycleAdapter:
|
||||
)
|
||||
return
|
||||
|
||||
lead_days = await self._get_token_refresh_lead_days()
|
||||
lead_days = await self._client.get_token_refresh_lead_days(account.account_id)
|
||||
now = time.time()
|
||||
remaining_days = (expires_at - now) / 86400
|
||||
if remaining_days < lead_days:
|
||||
@ -183,22 +186,32 @@ class InstagramLifecycleAdapter:
|
||||
"""删除前回调:清空 CachePort 中该账户的所有缓存。
|
||||
|
||||
清理键模式:
|
||||
- ``instagram:token:{account_id}``
|
||||
- ``instagram:whitelist:{account_id}:*``
|
||||
- ``instagram:window:{account_id}:*``
|
||||
- ``instagram:msg:{account_id}:*``(出站消息反向映射)
|
||||
- ``instagram:reaction:{account_id}:*``(reaction emoji 缓存)
|
||||
|
||||
注:``instagram:msg:{mid}`` legacy 键(无 account_id 前缀)无法按账户
|
||||
清理,依赖 TTL 自然过期。新数据写入时已同时写入带前缀键,此处可
|
||||
按账户清理带前缀键。
|
||||
|
||||
CachePort 异常 **不抛**(仅日志告警,避免阻塞删除流程)。
|
||||
|
||||
幂等:重复调用安全。
|
||||
"""
|
||||
try:
|
||||
await self._cache.delete(f"{_CACHE_KEY_PREFIX_TOKEN}{account.account_id}")
|
||||
await self._cache.invalidate(
|
||||
f"{_CACHE_KEY_PREFIX_WHITELIST}{account.account_id}:*",
|
||||
)
|
||||
await self._cache.invalidate(
|
||||
f"{_CACHE_KEY_PREFIX_WINDOW}{account.account_id}:*",
|
||||
)
|
||||
await self._cache.invalidate(
|
||||
f"{_CACHE_KEY_PREFIX_MSG}{account.account_id}:*",
|
||||
)
|
||||
await self._cache.invalidate(
|
||||
f"{_CACHE_KEY_PREFIX_REACTION}{account.account_id}:*",
|
||||
)
|
||||
await self._logger.info(
|
||||
"Instagram account delete: cache cleared",
|
||||
account_id=account.account_id,
|
||||
@ -258,17 +271,27 @@ class InstagramLifecycleAdapter:
|
||||
self,
|
||||
account: ChannelAccount,
|
||||
) -> RotateCredentialsResult:
|
||||
"""轮换账户凭据:调用 ``refreshToken`` 获取新 Token。
|
||||
"""轮换账户凭据:刷新 Token 并持久化到 ConfigPort。
|
||||
|
||||
@failure 刷新失败抛 ``DependencyError``;Token 无效抛 ``AuthError``。
|
||||
直接调用 ``client.refresh_token``(保留错误传播),成功后调
|
||||
``_persist_refreshed_token`` 写回 ConfigPort,确保轮换后后续
|
||||
请求使用新 token。
|
||||
|
||||
@failure 刷新失败抛 ``DependencyError``。
|
||||
"""
|
||||
try:
|
||||
await self._client.refresh_token(account.account_id)
|
||||
resp = await self._client.refresh_token(account.account_id)
|
||||
except Error:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DependencyError(dep="instagram_api", cause=exc) from exc
|
||||
|
||||
if not await self._persist_refreshed_token(account.account_id, resp):
|
||||
raise DependencyError(
|
||||
dep="instagram_api",
|
||||
cause=ValueError("token refresh response invalid or persist failed"),
|
||||
)
|
||||
|
||||
return RotateCredentialsResult(
|
||||
account_id=account.account_id,
|
||||
rotated_at=datetime.now(UTC),
|
||||
@ -278,15 +301,17 @@ class InstagramLifecycleAdapter:
|
||||
async def validateCredentials(self, config: dict[str, Any]) -> bool:
|
||||
"""验证凭据有效性。
|
||||
|
||||
调用 ``InstagramClient.get_debug_token`` 验证 ``access_token``
|
||||
有效性。有效返回 ``True``,无效返回 ``False``。API 异常 **不抛**,
|
||||
返回 ``False``。
|
||||
直接使用 ``config["access_token"]`` 调用
|
||||
``InstagramClient.get_debug_token_for_token`` 验证,适用于凭据
|
||||
轮换场景(新 token 尚未落库)。
|
||||
|
||||
@return 有效返回 ``True``,无效或 API 异常返回 ``False``。
|
||||
"""
|
||||
ig_id = config.get(_FIELD_IG_ID)
|
||||
if not ig_id:
|
||||
access_token = config.get(_FIELD_ACCESS_TOKEN)
|
||||
if not access_token:
|
||||
return False
|
||||
try:
|
||||
resp = await self._client.get_debug_token(ig_id)
|
||||
resp = await self._client.get_debug_token_for_token(access_token)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@ -308,7 +333,11 @@ class InstagramLifecycleAdapter:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _refresh_token(self, account_id: str) -> None:
|
||||
"""刷新 Token 并记录日志。"""
|
||||
"""刷新 Token 并更新 ConfigPort(容错:异常仅日志告警不抛)。
|
||||
|
||||
供 ``afterAccountConfigWritten`` 调用——刷新失败不应阻塞配置写入
|
||||
后的回调流程。
|
||||
"""
|
||||
try:
|
||||
resp = await self._client.refresh_token(account_id)
|
||||
except Error as exc:
|
||||
@ -326,26 +355,53 @@ class InstagramLifecycleAdapter:
|
||||
)
|
||||
return
|
||||
|
||||
expires_in = resp.get("expires_in", 0)
|
||||
await self._persist_refreshed_token(account_id, resp)
|
||||
|
||||
async def _persist_refreshed_token(self, account_id: str, resp: dict) -> bool:
|
||||
"""将刷新响应中的新 Token 持久化到 ConfigPort。
|
||||
|
||||
@return 成功返回 ``True``,响应无效或 ConfigPort 写入失败返回
|
||||
``False``(仅日志告警,不抛异常)。
|
||||
"""
|
||||
new_token = resp.get("access_token")
|
||||
expires_in = int(resp.get("expires_in", 0))
|
||||
if not new_token or not expires_in:
|
||||
await self._logger.warn(
|
||||
"Instagram token refresh response missing access_token or expires_in",
|
||||
account_id=account_id,
|
||||
)
|
||||
return False
|
||||
|
||||
# 更新 ConfigPort 中的 access_token 与 token_expires_at,
|
||||
# 确保后续请求使用新 token,且刷新调度基于新的过期时间
|
||||
new_expires_at = time.time() + expires_in
|
||||
try:
|
||||
await self._config.set(
|
||||
"access_token",
|
||||
new_token,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
target=account_id,
|
||||
)
|
||||
await self._config.set(
|
||||
"token_expires_at",
|
||||
new_expires_at,
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
target=account_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
"Instagram token refresh: failed to update ConfigPort",
|
||||
account_id=account_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return False
|
||||
|
||||
await self._logger.info(
|
||||
"Instagram token refreshed",
|
||||
"Instagram token refreshed and persisted",
|
||||
account_id=account_id,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
async def _get_token_refresh_lead_days(self) -> int:
|
||||
"""从 ConfigPort 读取 ``token_refresh_lead_days``。
|
||||
|
||||
ConfigPort 未命中时回退到 ``DEFAULT_TOKEN_REFRESH_LEAD_DAYS``。
|
||||
"""
|
||||
try:
|
||||
cv = await self._config.get("token_refresh_lead_days")
|
||||
value = cv.value if hasattr(cv, "value") else None
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_TOKEN_REFRESH_LEAD_DAYS
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["InstagramLifecycleAdapter"]
|
||||
|
||||
@ -183,7 +183,7 @@ class InstagramMessageOpsAdapter:
|
||||
|
||||
# 缓存 reaction emoji 供 deleteReaction 反查
|
||||
await self._cache.set(
|
||||
_reaction_cache_key(channel_msg_id),
|
||||
_reaction_cache_key(account_id, channel_msg_id),
|
||||
emoji,
|
||||
)
|
||||
return True
|
||||
@ -191,7 +191,8 @@ class InstagramMessageOpsAdapter:
|
||||
async def deleteReaction(self, channel_msg_id: str, emoji: str) -> bool:
|
||||
"""删除表情反应。
|
||||
|
||||
Instagram Send API 通过发送空 emoji 移除反应。
|
||||
Instagram Send API 通过发送空 emoji 移除反应。``emoji`` 参数保留
|
||||
仅为契约兼容,实际不使用(Instagram 不支持按 emoji 选择性删除)。
|
||||
"""
|
||||
account_id, recipient_igsid = await self._resolve_msg_context(channel_msg_id)
|
||||
try:
|
||||
@ -206,7 +207,7 @@ class InstagramMessageOpsAdapter:
|
||||
except Exception as exc:
|
||||
raise DependencyError(dep=INSTAGRAM_API_DEP, cause=exc) from exc
|
||||
|
||||
await self._cache.delete(_reaction_cache_key(channel_msg_id))
|
||||
await self._cache.delete(_reaction_cache_key(account_id, channel_msg_id))
|
||||
return True
|
||||
|
||||
async def pinMessage(self, channel_msg_id: str) -> bool:
|
||||
@ -276,16 +277,22 @@ class InstagramMessageOpsAdapter:
|
||||
async def _resolve_msg_context(self, channel_msg_id: str) -> tuple[str, str]:
|
||||
"""通过 CachePort 反向映射解析 account_id 与 recipient_igsid。
|
||||
|
||||
出站适配器在发送消息时写入 ``instagram:msg:{channel_msg_id}`` →
|
||||
``{"account_id": ..., "recipient_igsid": ...}`` 复合值,供 Reactions
|
||||
等后续操作反查账户与接收方上下文。
|
||||
出站适配器与入站适配器在消息处理时写入双键:
|
||||
- legacy 键 ``instagram:msg:{channel_msg_id}``(无 account_id
|
||||
前缀,供本方法反查)
|
||||
- 带前缀键 ``instagram:msg:{account_id}:{channel_msg_id}``
|
||||
(供 beforeAccountDelete 按 pattern 清理)
|
||||
|
||||
本方法仅查询 legacy 键(因契约 ``sendReaction`` 仅接收
|
||||
``channel_msg_id``,无法预知 account_id)。多账户下 channel_msg_id
|
||||
冲突时返回首个匹配(Instagram message_id 全局唯一,实际不冲突)。
|
||||
|
||||
@return ``(account_id, recipient_igsid)`` 元组。
|
||||
@failure 无缓存记录抛 ``NotFoundError``。
|
||||
"""
|
||||
from yuxi.channels.contract.errors import NotFoundError
|
||||
|
||||
opt = await self._cache.get(_msg_account_cache_key(channel_msg_id))
|
||||
opt = await self._cache.get(_msg_account_cache_key_legacy(channel_msg_id))
|
||||
if opt.is_nothing():
|
||||
raise NotFoundError(
|
||||
resource="channel_msg_id",
|
||||
@ -304,11 +311,18 @@ class InstagramMessageOpsAdapter:
|
||||
return account_id, recipient_igsid
|
||||
|
||||
|
||||
def _reaction_cache_key(channel_msg_id: str) -> str:
|
||||
return f"{REACTION_CACHE_KEY_PREFIX}{channel_msg_id}"
|
||||
def _reaction_cache_key(account_id: str, channel_msg_id: str) -> str:
|
||||
"""构造 reaction 缓存键(含 account_id 前缀,便于按账户清理)。"""
|
||||
return f"{REACTION_CACHE_KEY_PREFIX}{account_id}:{channel_msg_id}"
|
||||
|
||||
|
||||
def _msg_account_cache_key(channel_msg_id: str) -> str:
|
||||
def _msg_account_cache_key_legacy(channel_msg_id: str) -> str:
|
||||
"""构造旧格式反向映射缓存键(无 account_id 前缀)。
|
||||
|
||||
保留用于兼容查询:由于 ``_resolve_msg_context`` 仅接收 channel_msg_id,
|
||||
无法预知 account_id,需通过旧格式键回退查询。新数据写入使用
|
||||
outbound_adapter 中的带 account_id 前缀键。
|
||||
"""
|
||||
return f"{MSG_ACCOUNT_CACHE_KEY_PREFIX}{channel_msg_id}"
|
||||
|
||||
|
||||
|
||||
@ -48,8 +48,20 @@ _MARKDOWN_PATTERNS = (
|
||||
)
|
||||
|
||||
|
||||
def _msg_account_cache_key(channel_msg_id: str) -> str:
|
||||
"""构造 channel_msg_id → account_id 反向映射缓存键。"""
|
||||
def _msg_account_cache_key(account_id: str, channel_msg_id: str) -> str:
|
||||
"""构造 channel_msg_id → account_id 反向映射缓存键。
|
||||
|
||||
缓存键含 account_id 前缀,确保 beforeAccountDelete 可按账户清理。
|
||||
"""
|
||||
return f"{MSG_ACCOUNT_CACHE_KEY_PREFIX}{account_id}:{channel_msg_id}"
|
||||
|
||||
|
||||
def _msg_account_cache_key_legacy(channel_msg_id: str) -> str:
|
||||
"""构造 legacy 反向映射缓存键(无 account_id 前缀)。
|
||||
|
||||
供 MessageOpsAdapter._resolve_msg_context 反查(仅接收 channel_msg_id,
|
||||
无法预知 account_id)。与 ``_msg_account_cache_key`` 双写保证兼容。
|
||||
"""
|
||||
return f"{MSG_ACCOUNT_CACHE_KEY_PREFIX}{channel_msg_id}"
|
||||
|
||||
|
||||
@ -58,6 +70,11 @@ def _window_cache_key(account_id: str, peer_id: str) -> str:
|
||||
return f"{WINDOW_CACHE_KEY_PREFIX}{account_id}:{peer_id}"
|
||||
|
||||
|
||||
# 窗口关闭后的合理 backoff(秒)。窗口不会自然重开(需用户再次交互),
|
||||
# 此值供 outbox 重试机制参考,避免立即重试必然再次失败
|
||||
_WINDOW_CLOSED_BACKOFF_SECONDS = 3600
|
||||
|
||||
|
||||
class InstagramOutboundAdapter:
|
||||
"""Instagram 出站适配器。
|
||||
|
||||
@ -174,9 +191,15 @@ class InstagramOutboundAdapter:
|
||||
cause=ValueError("send_message_missing_message_id"),
|
||||
)
|
||||
# 写入 channel_msg_id → {account_id, recipient_igsid} 反向映射,
|
||||
# 供 MessageOpsAdapter 解析账户与接收方上下文
|
||||
# 供 MessageOpsAdapter 解析账户与接收方上下文。
|
||||
# 双键策略:legacy 键(无前缀)用于 sendReaction 反查;带 account_id
|
||||
# 前缀键用于 beforeAccountDelete 按 pattern 清理。
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(message_id),
|
||||
_msg_account_cache_key_legacy(message_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(account_id, message_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
return message_id
|
||||
@ -234,10 +257,16 @@ class InstagramOutboundAdapter:
|
||||
)
|
||||
if msg_id:
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(msg_id),
|
||||
_msg_account_cache_key_legacy(msg_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
except Error as exc:
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(account_id, msg_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
except Error:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DependencyError(
|
||||
dep=INSTAGRAM_API_DEP,
|
||||
cause=exc,
|
||||
@ -270,10 +299,16 @@ class InstagramOutboundAdapter:
|
||||
)
|
||||
if msg_id:
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(msg_id),
|
||||
_msg_account_cache_key_legacy(msg_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
except Error as exc:
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(account_id, msg_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
except Error:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise DependencyError(
|
||||
dep=INSTAGRAM_API_DEP,
|
||||
cause=exc,
|
||||
@ -333,6 +368,16 @@ class InstagramOutboundAdapter:
|
||||
dep=INSTAGRAM_API_DEP,
|
||||
cause=ValueError("send_thread_message_missing_message_id"),
|
||||
)
|
||||
# 写入双键反向映射(与 sendMessage 一致),供 MessageOpsAdapter
|
||||
# 解析账户与接收方上下文(线程回复消息也支持 reaction)
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key_legacy(message_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
await self._cache.set(
|
||||
_msg_account_cache_key(account_id, message_id),
|
||||
{"account_id": account_id, "recipient_igsid": peer_id},
|
||||
)
|
||||
return message_id
|
||||
|
||||
async def batchSendMessages(
|
||||
@ -367,6 +412,9 @@ class InstagramOutboundAdapter:
|
||||
从 CachePort 读取该 peer_id 的最后用户交互时间戳,若距当前时间
|
||||
> 24h,抛 ``RateLimitError`` 由 outbox 重试机制在窗口重开后自动
|
||||
投递。
|
||||
|
||||
窗口不会自然重开(需用户再次交互触发),retry_after 设为合理 backoff
|
||||
(1 小时),避免 outbox 立即重试必然再次失败。
|
||||
"""
|
||||
cache_key = self._window_cache_key(account_id, peer_id)
|
||||
opt = await self._cache.get(cache_key)
|
||||
@ -379,12 +427,11 @@ class InstagramOutboundAdapter:
|
||||
|
||||
elapsed = time.time() - float(last_interaction_ts)
|
||||
if elapsed > MESSAGING_WINDOW_SECONDS:
|
||||
retry_after = MESSAGING_WINDOW_SECONDS - int(elapsed)
|
||||
if retry_after < 0:
|
||||
retry_after = 0
|
||||
# 窗口已关闭,retry_after 设为合理 backoff(窗口不会自然重开,
|
||||
# 需用户再次交互;outbox 应按指数退避,此处仅给最小建议值)
|
||||
raise RateLimitError(
|
||||
resource=INSTAGRAM_API_DEP,
|
||||
retry_after_ms=retry_after * 1000,
|
||||
retry_after_ms=_WINDOW_CLOSED_BACKOFF_SECONDS * 1000,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@ -148,7 +148,12 @@ class InstagramProbeableAdapter:
|
||||
self,
|
||||
account_id: str,
|
||||
) -> tuple[bool, str | None]:
|
||||
"""深度探测 Token 有效性。"""
|
||||
"""深度探测 Token 有效性。
|
||||
|
||||
过期阈值通过 ``InstagramClient.get_token_refresh_lead_days`` 读取
|
||||
账户级 ``token_refresh_lead_days``,未配置时回退默认值。
|
||||
"""
|
||||
lead_days = await self._client.get_token_refresh_lead_days(account_id)
|
||||
try:
|
||||
resp = await self._client.get_debug_token(account_id)
|
||||
except Error as exc:
|
||||
@ -170,7 +175,7 @@ class InstagramProbeableAdapter:
|
||||
remaining = expires_at - now
|
||||
if remaining < 0:
|
||||
return False, "token_expired"
|
||||
if remaining < 7 * 86400:
|
||||
if remaining < lead_days * 86400:
|
||||
return False, f"token_expiring_soon: {int(remaining / 86400)} days"
|
||||
return True, "token_valid"
|
||||
|
||||
|
||||
@ -15,17 +15,6 @@ from yuxi.channels.contract.dtos.common import RawEvent
|
||||
from yuxi.channels.contract.dtos.status import EventType, StatusPayload
|
||||
from yuxi.channels.contract.errors import ValidationError
|
||||
|
||||
# Webhook 事件字段名 → EventType 映射
|
||||
_FIELD_EVENT_MAP = (
|
||||
("message", EventType.MESSAGE),
|
||||
("reaction", EventType.UNKNOWN), # REACTION 不在 EventType 枚举中,使用 UNKNOWN
|
||||
("postback", EventType.UNKNOWN), # POSTBACK 不在 EventType 枚举中,使用 UNKNOWN
|
||||
("referral", EventType.UNKNOWN), # REFERRAL 不在 EventType 枚举中,使用 UNKNOWN
|
||||
("delivery", EventType.DELIVERED),
|
||||
("read", EventType.READ),
|
||||
("deleted_message", EventType.RECALLED), # MESSAGE_DELETE 映射为 RECALLED
|
||||
)
|
||||
|
||||
# 反应事件专用 EventType(存入 StatusPayload.event_type)
|
||||
_REACTION_EVENT_TYPE = EventType.UNKNOWN
|
||||
_POSTBACK_EVENT_TYPE = EventType.MESSAGE # Postback 触发对话,按 MESSAGE 处理
|
||||
@ -123,8 +112,16 @@ class InstagramStatusAdapter:
|
||||
|
||||
# 提取关联消息 ID(不同事件类型字段不同)
|
||||
ref_msg_id = _extract_ref_msg_id(payload, event_type)
|
||||
|
||||
# referral 事件(ig.me 链接入口)无 message.mid,使用 sender.id 作为
|
||||
# 关联 ID 兜底,避免 referral 事件被 ValidationError 误拒
|
||||
if not ref_msg_id and "referral" in payload:
|
||||
sender = payload.get("sender", {})
|
||||
if isinstance(sender, dict):
|
||||
ref_msg_id = sender.get("id")
|
||||
|
||||
if not ref_msg_id:
|
||||
# 状态事件必须携带关联消息 ID
|
||||
# 状态事件必须携带关联消息 ID(referral 已兜底,仍无则视为异常)
|
||||
raise ValidationError(
|
||||
field="ref_channel_msg_id",
|
||||
message="ref_msg_id_required",
|
||||
|
||||
@ -21,7 +21,6 @@ from yuxi.channels.contract.dtos.whitelist import (
|
||||
)
|
||||
from yuxi.channels.contract.errors import (
|
||||
DependencyError,
|
||||
Error,
|
||||
ValidationError,
|
||||
)
|
||||
from yuxi.channels.contract.ports.driven.cache_port import CachePort
|
||||
@ -233,8 +232,6 @@ class InstagramWhitelistAdapter:
|
||||
continue
|
||||
try:
|
||||
entries.append(whitelistEntryFromDict(item))
|
||||
except Error:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
return entries
|
||||
@ -250,8 +247,6 @@ class InstagramWhitelistAdapter:
|
||||
raw = [whitelistEntryToDict(e) for e in entries]
|
||||
try:
|
||||
await self._cache.set(cache_key, raw)
|
||||
except Error as exc:
|
||||
raise DependencyError(dep="cache", cause=exc) from exc
|
||||
except Exception as exc:
|
||||
raise DependencyError(dep="cache", cause=exc) from exc
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
@ -260,12 +261,23 @@ class InstagramWizardAdapter:
|
||||
message="OAuth callback did not return access_token or user_id",
|
||||
)
|
||||
|
||||
# 基于 OAuth 响应的 expires_in 计算绝对过期时间戳(秒),
|
||||
# 供 applyAccountConfig 校验(M-3)与 token 刷新调度使用
|
||||
expires_in = data.get("expires_in", 0)
|
||||
if not expires_in:
|
||||
raise ValidationError(
|
||||
field="expires_in",
|
||||
message="OAuth callback did not return expires_in",
|
||||
)
|
||||
token_expires_at = time.time() + int(expires_in)
|
||||
|
||||
return WizardConfigPatch(
|
||||
step_id=_STEP_OAUTH_CALLBACK,
|
||||
values={
|
||||
"access_token": access_token,
|
||||
"ig_id": str(user_id),
|
||||
"oauth_state": state,
|
||||
"token_expires_at": token_expires_at,
|
||||
},
|
||||
)
|
||||
|
||||
@ -386,6 +398,7 @@ class InstagramWizardAdapter:
|
||||
|
||||
access_token = data.get("access_token")
|
||||
user_id = data.get("user_id")
|
||||
expires_in = data.get("expires_in", 0)
|
||||
if not access_token or not user_id:
|
||||
return WizardStepResult(
|
||||
step_id=_STEP_OAUTH_CALLBACK,
|
||||
@ -394,6 +407,18 @@ class InstagramWizardAdapter:
|
||||
config_patch=None,
|
||||
)
|
||||
|
||||
# 基于 OAuth 响应的 expires_in 计算绝对过期时间戳(秒),
|
||||
# 供 applyAccountConfig 校验(M-3:缺失 token_expires_at 抛
|
||||
# ValidationError)与 token 刷新调度使用
|
||||
token_expires_at = time.time() + int(expires_in) if expires_in else 0
|
||||
if not token_expires_at:
|
||||
return WizardStepResult(
|
||||
step_id=_STEP_OAUTH_CALLBACK,
|
||||
valid=False,
|
||||
errors=("token_exchange_failed: missing expires_in",),
|
||||
config_patch=None,
|
||||
)
|
||||
|
||||
return WizardStepResult(
|
||||
step_id=_STEP_OAUTH_CALLBACK,
|
||||
valid=True,
|
||||
@ -403,6 +428,7 @@ class InstagramWizardAdapter:
|
||||
values={
|
||||
"access_token": access_token,
|
||||
"ig_id": str(user_id),
|
||||
"token_expires_at": token_expires_at,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@ -200,8 +200,8 @@ def _classify_http_status_error(
|
||||
error_subcode=error_subcode,
|
||||
)
|
||||
if translated is not None:
|
||||
# 保留精确的 Error 子类,将原始异常挂在 cause 链上
|
||||
translated.__cause__ = exc
|
||||
# 原始异常通过 raise ... from exc 链由调用方挂载(参见
|
||||
# instagram_client._execute_http),此处无需手动设置 __cause__。
|
||||
return translated
|
||||
|
||||
if status_code == 401:
|
||||
|
||||
@ -31,10 +31,11 @@ from ._constants import (
|
||||
CHANNEL_TARGET,
|
||||
DEFAULT_API_VERSION,
|
||||
INSTAGRAM_API_DEP,
|
||||
OAUTH_API_BASE,
|
||||
)
|
||||
|
||||
# Instagram Graph API 端点路径常量
|
||||
_OAUTH_AUTHORIZE_PATH = "https://api.instagram.com/oauth/authorize"
|
||||
# Step 1 短期 token 交换端点(必须使用 api.instagram.com 域名)
|
||||
_OAUTH_TOKEN_PATH = "/oauth/access_token"
|
||||
_IG_TOKEN_EXCHANGE_PATH = "/access_token" # graph.instagram.com 路径
|
||||
_IG_REFRESH_TOKEN_PATH = "/refresh_access_token"
|
||||
@ -147,6 +148,39 @@ class InstagramClient:
|
||||
return DEFAULT_API_VERSION
|
||||
return value
|
||||
|
||||
async def _get_api_version_for_token_check(self) -> str:
|
||||
"""获取凭据轮换场景使用的 Graph API 版本。
|
||||
|
||||
``api_version`` 在 config_schema 中声明为 account 作用域,凭据轮换
|
||||
场景(新 token 尚未落库,无 account_id 上下文)无法从账户级读取。
|
||||
/debug_token 端点兼容各 API 版本,直接使用默认版本即可。
|
||||
"""
|
||||
return DEFAULT_API_VERSION
|
||||
|
||||
async def get_token_refresh_lead_days(self, account_id: str) -> int:
|
||||
"""读取账户级 ``token_refresh_lead_days``,未配置回退默认值。
|
||||
|
||||
供 doctor / probeable 适配器共享,避免每个适配器独立注入 ConfigPort。
|
||||
"""
|
||||
from ._constants import DEFAULT_TOKEN_REFRESH_LEAD_DAYS
|
||||
|
||||
try:
|
||||
cv = await self._config.get(
|
||||
"token_refresh_lead_days",
|
||||
scope=ConfigScope.ACCOUNT,
|
||||
target=account_id,
|
||||
)
|
||||
value = cv.value if hasattr(cv, "value") else None
|
||||
if isinstance(value, int) and value > 0:
|
||||
return value
|
||||
except Exception as exc:
|
||||
await self._logger.warn(
|
||||
"Failed to read token_refresh_lead_days, fallback to default",
|
||||
account_id=account_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return DEFAULT_TOKEN_REFRESH_LEAD_DAYS
|
||||
|
||||
async def _get_access_token(self, account_id: str) -> str:
|
||||
"""读取账户 access_token(直接从 ConfigPort 读取,Instagram User
|
||||
Access Token 是 OAuth 颁发的长期 token,不通过 CachePort 缓存)。
|
||||
@ -315,7 +349,9 @@ class InstagramClient:
|
||||
"""用 OAuth 授权码交换 Long-lived Token。
|
||||
|
||||
两步流程(Instagram Login 路径):
|
||||
1. POST ``graph.instagram.com/oauth/access_token`` 短期 token 交换
|
||||
1. POST ``api.instagram.com/oauth/access_token`` 短期 token 交换
|
||||
(Step1 必须使用 api.instagram.com 域名,graph.instagram.com
|
||||
不支持此端点)
|
||||
2. GET ``graph.instagram.com/access_token?grant_type=ig_exchange_token``
|
||||
短期 token 换长期 token(60 天)
|
||||
|
||||
@ -323,9 +359,9 @@ class InstagramClient:
|
||||
|
||||
@return Meta 响应字典,含 ``access_token`` / ``user_id`` / ``expires_in``。
|
||||
"""
|
||||
ig_api_base_url = await self._get_channel_config("ig_api_base_url")
|
||||
# Step 1: 短期 token 交换
|
||||
step1_url = f"{ig_api_base_url}{_OAUTH_TOKEN_PATH}"
|
||||
timeout = await self._get_request_timeout_seconds()
|
||||
# Step 1: 短期 token 交换(必须使用 api.instagram.com)
|
||||
step1_url = f"{OAUTH_API_BASE}{_OAUTH_TOKEN_PATH}"
|
||||
step1_body = {
|
||||
"client_id": app_id,
|
||||
"client_secret": app_secret,
|
||||
@ -334,9 +370,7 @@ class InstagramClient:
|
||||
"code": code,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=await self._get_request_timeout_seconds(),
|
||||
) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp1 = await client.post(step1_url, data=step1_body)
|
||||
resp1.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
@ -346,7 +380,7 @@ class InstagramClient:
|
||||
)
|
||||
raise error_translator.wrap_http_exception(
|
||||
exc,
|
||||
timeout_ms=int((await self._get_request_timeout_seconds()) * 1000),
|
||||
timeout_ms=int(timeout * 1000),
|
||||
) from exc
|
||||
|
||||
data1 = self._parse_json_response(resp1)
|
||||
@ -357,7 +391,8 @@ class InstagramClient:
|
||||
cause=ValueError("oauth step1 missing access_token"),
|
||||
)
|
||||
|
||||
# Step 2: 短期 token 换长期 token
|
||||
# Step 2: 短期 token 换长期 token(graph.instagram.com)
|
||||
ig_api_base_url = await self._get_channel_config("ig_api_base_url")
|
||||
step2_url = f"{ig_api_base_url}{_IG_TOKEN_EXCHANGE_PATH}"
|
||||
step2_params = {
|
||||
"grant_type": "ig_exchange_token",
|
||||
@ -365,9 +400,7 @@ class InstagramClient:
|
||||
"access_token": short_token,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=await self._get_request_timeout_seconds(),
|
||||
) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp2 = await client.get(step2_url, params=step2_params)
|
||||
resp2.raise_for_status()
|
||||
except httpx.HTTPError as exc:
|
||||
@ -377,7 +410,7 @@ class InstagramClient:
|
||||
)
|
||||
raise error_translator.wrap_http_exception(
|
||||
exc,
|
||||
timeout_ms=int((await self._get_request_timeout_seconds()) * 1000),
|
||||
timeout_ms=int(timeout * 1000),
|
||||
) from exc
|
||||
|
||||
data2 = self._parse_json_response(resp2)
|
||||
@ -419,6 +452,28 @@ class InstagramClient:
|
||||
params={"input_token": token},
|
||||
)
|
||||
|
||||
async def get_debug_token_for_token(self, access_token: str) -> dict:
|
||||
"""调试指定 access_token(用于凭据轮换场景验证新 token)。
|
||||
|
||||
与 ``get_debug_token`` 不同,本方法不依赖 ConfigPort 读取 token,
|
||||
直接用传入的 ``access_token`` 调用 debug_token API。用于凭据轮换
|
||||
新 token 尚未落库时的有效性验证。
|
||||
|
||||
@param access_token: 待验证的 access_token。
|
||||
@return Meta 响应字典,含 ``data`` 字段。
|
||||
"""
|
||||
api_base_url = await self._get_channel_config("api_base_url")
|
||||
api_version = await self._get_api_version_for_token_check()
|
||||
url = f"{api_base_url}/{api_version}{_DEBUG_TOKEN_PATH}"
|
||||
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
|
||||
resp = await self._execute_http(
|
||||
"GET",
|
||||
url,
|
||||
headers,
|
||||
params={"input_token": access_token},
|
||||
)
|
||||
return self._parse_json_response(resp)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 账户信息
|
||||
# ------------------------------------------------------------------
|
||||
@ -499,8 +554,6 @@ class InstagramClient:
|
||||
"""
|
||||
ig_id = await self._get_account_value(account_id, "ig_id")
|
||||
payload: dict[str, Any] = {"url": media_url}
|
||||
if media_type == "file":
|
||||
payload["is_reusable"] = False
|
||||
body = {
|
||||
"recipient": {"id": recipient_igsid},
|
||||
"message": {
|
||||
|
||||
@ -19,8 +19,8 @@
|
||||
"credential_strategy": {
|
||||
"type": "dynamic",
|
||||
"acquire_method": "oauth",
|
||||
"required_fields": ["access_token", "ig_id", "token_expires_at"],
|
||||
"optional_fields": ["app_id", "app_secret", "page_id"],
|
||||
"required_fields": ["access_token", "ig_id", "token_expires_at", "app_id", "app_secret"],
|
||||
"optional_fields": [],
|
||||
"supports_rotation": true,
|
||||
"supports_revocation": true
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user