diff --git a/.changes/+twitter-archive.added.md b/.changes/+twitter-archive.added.md
new file mode 100644
index 00000000..92c08f8d
--- /dev/null
+++ b/.changes/+twitter-archive.added.md
@@ -0,0 +1 @@
+新增可配置的 Peer JWT 签发默认期限并移除 admission 的强制最大寿命;Host SDK 0.3.1 支持扩展在既有 Graph 事务中批量定位原生身份并串行合并。
diff --git a/.env.example b/.env.example
index 3180f295..cc8c8c63 100644
--- a/.env.example
+++ b/.env.example
@@ -6,6 +6,9 @@ PEER_NAME=core-py-development
# JWT Authentication (Required)
JWT_SECRET=replace-with-at-least-32-random-bytes
+# Signing defaults in seconds; neither value limits admission of a valid JWT.
+JWT_DEFAULT_LIFETIME_SECONDS=60
+TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS=604800
# Database lifecycle authority. Compose uses the PostgreSQL owner only in its
# one-shot init service and gives the application a separate runtime URL.
diff --git a/app/database_contract/constants.py b/app/database_contract/constants.py
index 3514854d..984ed84b 100644
--- a/app/database_contract/constants.py
+++ b/app/database_contract/constants.py
@@ -4,7 +4,7 @@
CONTRACT_FORMAT = 1
-CONTRACT_REVISION = "extension-peer-participation-v2"
+CONTRACT_REVISION = "peer-jwt-signing-defaults-v1"
PROTOCOL_SCHEMA = "inkcre"
INTERNAL_SCHEMA = "inkcre_internal"
@@ -18,6 +18,7 @@
JWT_ROLE = AUTHENTICATED_ROLE
JWT_ISSUER = "inkcre-peer"
JWT_AUDIENCE = "inkcre-api"
+# Frozen input for admitted historical migrations; live admission has no lifetime cap.
JWT_MAX_LIFETIME_SECONDS = 24 * 60 * 60
JWT_MINIMUM_SECRET_BYTES = 32
diff --git a/app/database_contract/lifecycle.py b/app/database_contract/lifecycle.py
index f4e4e88b..d0fc976f 100644
--- a/app/database_contract/lifecycle.py
+++ b/app/database_contract/lifecycle.py
@@ -14,7 +14,6 @@
JWT_ALGORITHM,
JWT_AUDIENCE,
JWT_ISSUER,
- JWT_MAX_LIFETIME_SECONDS,
JWT_ROLE,
RESET_CONFIRMATION,
)
@@ -79,7 +78,6 @@ def contract_document() -> dict[str, object]:
"issuer": JWT_ISSUER,
"audience": JWT_AUDIENCE,
"required_claims": ["role", "iss", "aud", "iat", "exp"],
- "maximum_lifetime_seconds": JWT_MAX_LIFETIME_SECONDS,
},
"profiles": ["runtime", "development"],
"protocol": protocol_document(),
diff --git a/app/middleware.py b/app/middleware.py
index c9405e96..7a16662e 100644
--- a/app/middleware.py
+++ b/app/middleware.py
@@ -1,5 +1,6 @@
"""Middleware for logging and request tracking."""
+import math
import time
from typing import Callable
import uuid
@@ -13,7 +14,6 @@
JWT_ALGORITHM,
JWT_AUDIENCE,
JWT_ISSUER,
- JWT_MAX_LIFETIME_SECONDS,
JWT_ROLE,
)
from app.settings import settings
@@ -63,15 +63,19 @@ def decode_peer_jwt(
)
if not valid_numeric_dates:
raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch")
- issued_at_number = float(issued_at)
- expires_at_number = float(expires_at)
+ try:
+ issued_at_number = float(issued_at)
+ expires_at_number = float(expires_at)
+ except OverflowError as error:
+ raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch") from error
current_epoch = time.time() if now is None else now
if (
claims["role"] != JWT_ROLE
+ or not math.isfinite(issued_at_number)
+ or not math.isfinite(expires_at_number)
or issued_at_number > current_epoch + 60
or expires_at_number <= current_epoch
or expires_at_number <= issued_at_number
- or expires_at_number - issued_at_number > JWT_MAX_LIFETIME_SECONDS
):
raise jwt.exceptions.InvalidTokenError("JWT claim contract mismatch")
return claims
@@ -81,11 +85,13 @@ def create_peer_jwt(
secret: str,
*,
now: float | None = None,
- lifetime_seconds: int = 60,
+ lifetime_seconds: int | None = None,
) -> str:
- """Create one short-lived token under the canonical Peer HTTP contract."""
- if lifetime_seconds <= 0 or lifetime_seconds > JWT_MAX_LIFETIME_SECONDS:
- raise ValueError("Peer JWT lifetime is outside the contract bound")
+ """Sign a Peer JWT using the configured default when no lifetime is supplied."""
+ if lifetime_seconds is None:
+ lifetime_seconds = settings.jwt_default_lifetime_seconds
+ if lifetime_seconds <= 0:
+ raise ValueError("Peer JWT lifetime must be positive")
issued_at = int(time.time() if now is None else now)
return jwt.encode(
{
diff --git a/app/persistence/info_base/repository.py b/app/persistence/info_base/repository.py
index cefa2366..f87b6c03 100644
--- a/app/persistence/info_base/repository.py
+++ b/app/persistence/info_base/repository.py
@@ -172,6 +172,41 @@ async def find_json_field(
statement = statement.where(sqlmodel.col(BlockModel.id).in_(tuple(block_ids)))
return tuple(await self._session.scalars(statement))
+ async def find_json_field_many(
+ self,
+ resolvers: typing.Collection[str],
+ field: str,
+ values: typing.Collection[str],
+ ) -> tuple[BlockModel, ...]:
+ """Find inline JSON records in a bounded native-identity set."""
+ from sqlalchemy.dialects.postgresql import JSONB
+
+ if not resolvers or not values:
+ return ()
+ return tuple(
+ await self._session.scalars(
+ sqlmodel.select(BlockModel)
+ .where(
+ sqlmodel.col(BlockModel.resolver).in_(tuple(resolvers)),
+ sqlalchemy.case(
+ (
+ sqlalchemy.func.pg_input_is_valid(BlockModel.content, "jsonb"),
+ sqlalchemy.cast(BlockModel.content, JSONB)[field].astext,
+ )
+ ).in_(tuple(values)),
+ sqlmodel.col(BlockModel.storage).is_(None),
+ )
+ .order_by(sqlmodel.col(BlockModel.id))
+ )
+ )
+
+ async def acquire_transaction_lock(self, namespace: str) -> None:
+ """Serialize an owning use case until its surrounding transaction ends."""
+ await self._session.execute(
+ sqlalchemy.text("SELECT pg_advisory_xact_lock(hashtextextended(:name, 0))"),
+ {"name": namespace},
+ )
+
async def get_related(
self, block_id: BlockID, *, content: str, outgoing: bool = True
) -> BlockModel | None:
diff --git a/app/settings.py b/app/settings.py
index 8c392cf2..88d3ff2a 100644
--- a/app/settings.py
+++ b/app/settings.py
@@ -52,6 +52,16 @@ class Settings(BaseSettings):
min_length=JWT_MINIMUM_SECRET_BYTES,
description="Secret key for JWT token signing and verification",
)
+ jwt_default_lifetime_seconds: int = Field(
+ default=60,
+ gt=0,
+ description="Default Peer JWT signing lifetime; not an admission limit",
+ )
+ twitter_x_monkey_jwt_lifetime_seconds: int = Field(
+ default=7 * 24 * 60 * 60,
+ gt=0,
+ description="Signing lifetime for the Twitter x-monkey session and its renewal",
+ )
# Observability settings
obsrv: ObsrvSetting = Field(default_factory=ObsrvSetting)
diff --git a/app/version.py b/app/version.py
index 1ca57f6a..15b5af52 100644
--- a/app/version.py
+++ b/app/version.py
@@ -3,7 +3,7 @@
from pathlib import Path
import tomllib
-CORE_VERSION = "0.3.0"
+CORE_VERSION = "0.3.1"
APPLICATION_VERSION = tomllib.loads(
(Path(__file__).parents[1] / "pyproject.toml").read_text(encoding="utf-8")
)["project"]["version"]
diff --git a/docker-compose.yml b/docker-compose.yml
index cd949363..165953d8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -56,6 +56,8 @@ services:
DATABASE_URL: "postgresql+psycopg://inkcre_core:${CORE_DATABASE_PASSWORD:-local-core-database-password-at-least-32-bytes}@postgres:5432/${POSTGRES_DB:-inkcre}"
INKCRE_ENV_FILE: ""
JWT_SECRET: ${JWT_SECRET:-local-development-jwt-secret-at-least-32-bytes}
+ JWT_DEFAULT_LIFETIME_SECONDS: ${JWT_DEFAULT_LIFETIME_SECONDS:-60}
+ TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS: ${TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS:-604800}
OBSRV__LOGGING_BACKEND: ${OBSRV__LOGGING_BACKEND:-postgresql}
OBSRV__TELEMETRY_ENABLED: ${OBSRV__TELEMETRY_ENABLED:-false}
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}
diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md
index 0dcb2f42..8a5ef9c5 100644
--- a/docs/30-unit-tdd/README.md
+++ b/docs/30-unit-tdd/README.md
@@ -13,6 +13,7 @@ This directory owns expensive internal design truth for logical units delivered
| [mail-extension.md](mail-extension.md) | Mail identity, MIME materialization, collection, graph, and failure boundaries |
| [memos-extension.md](memos-extension.md) | Memos adapter identity, graph grammar, persistence, and failure boundaries |
| [rss-extension.md](rss-extension.md) | RSS adapter identity, collection lifecycle, reconciliation, and materialization |
+| [twitter-extension.md](twitter-extension.md) | Twitter 共同身份、Bookmark 过渡、事务合并和按需媒体读取 |
| [security-model.md](security-model.md) | Core-py realization of the shared security boundary model |
Do not add a document for a directory inventory, a fact cheaply recovered from code, a one-off decision, or a cross-unit contract already owned by `../_shared/20-product-tdd/`. Put repeated physical-subtree hazards in the nearest `AGENTS.md`; put runtime, rollout, recovery, and environment truth in `../40-deployment/`.
diff --git a/docs/30-unit-tdd/twitter-extension.md b/docs/30-unit-tdd/twitter-extension.md
new file mode 100644
index 00000000..358ec2fe
--- /dev/null
+++ b/docs/30-unit-tdd/twitter-extension.md
@@ -0,0 +1,37 @@
+# Python Twitter 采集与归档
+
+Python Twitter distribution 把 x-monkey 提交和 Bookmark 采集写入同一组普通 Blocks。共同字段与跨分发关系由 `inkcre/twitter` 的 [Twitter 归档契约](../_shared/20-product-tdd/twitter-archive-contract.md) 拥有,`model.py` 实现独立的 User/Post/Media、身份根与捕获集合。现有 JSON 字段、Resolver ID 和 Block ID 保持不变,数据库没有另一套归档表。
+
+`archive_schema.py` 只保存 x-monkey 0.2.0 的固定协议 DTO;`x_monkey.py` 把已校验的提交转成共同模型,并按固定 DTO 字段集递归投影 query 数据、构造协议状态与汇总。自由的 Article JSON 按值保留。共同模型未来新增的字段不会自动进入旧协议,无法用该协议表达的已知结构返回错误,不改写持久事实。Official/Twikit 适配器在 `api.py` 中直接产出共同模型,`bookmark.py` 与 `resolver.py` 不依赖协议 DTO。当前适配器保留它们明确映射的事实,不宣称保存完整 provider 原始响应或所有未知字段。
+
+`reconcile.py` 拥有本实现的身份选择、事务合并和关系维护,只返回变化集合、Block IDs 及共同读取结果,不包含 HTTP 状态或 wire 响应类型。`TwitterBatch` 没有继承远端请求的条数限制;每类 1000 条的准入边界留在 x-monkey DTO。
+
+一次 submit 使用一个 `graph_uow()`。Bookmark 先完成 provider 请求,再进入 `source_uow()`,锁定 Source,使用其 `graph` 合并新主帖及实际捕获的上下文,并在同一事务内推进 cursor。主帖顺序与展开上下文分开,因此引用帖不会成为下一次书签采集的 watermark;旧的数值 cursor 在比较边界转换为十进制字符串,不重置历史状态。
+
+两条写入路径都先取得 `extensions.twitter.reconcile` 全局事务 advisory 锁,再批量重读原生身份。根内容和受管关系一起提交;异常让整批回滚,调用方可安全重放。该锁仅约束正常 Twitter 写入入口,不能替代所有 info-base 原始写入的约束。provider 和媒体网络访问都不能放进此事务。
+
+原生身份、Blocks 和引用边使用批量查询与创建。不要在每个媒体项内单独查询、创建和 flush:Core 与数据库跨网络部署时,这会把一次正常批次放大为数百次串行往返,并在持锁期间耗尽客户端的同步请求期限。
+
+匹配先选新版 resolver,再按 Block ID 稳定选择旧 `tweet.v1`。旧根重新采集后原地更新,保留现有 Block ID、引用和旧附件关系;未重新采集的旧根仍由 v1 decoder 读取。旧加工正文不足以重建 native RichText,不能用它伪造新记录。共同合并提供 `replace` 与 `supplement` 正文策略:正常捕获合并已提交的正文结构,补充捕获在已有正文时保留它并增加其他事实。Bookmark 使用补充策略,避免较少详情的正文覆盖完整捕获;该选择不作为字段写进持久记录。
+
+身份引用严格保存为 `{id}`,不补作者、时间或正文。query 对完整 native 记录返回 `found`,身份引用返回 `missing`,能按 ID 定位但结构损坏的记录返回 `failed`。批量身份查询使用有效 JSON 的条件表达式隔离普通文本和损坏 JSON;无法解析且无法定位 ID 的内容不会命中某个原生 ID,不会导致其他正常记录的查询失败。
+
+共同合并选择缺省保留、对象递归合并和数组整体替换,保留有序媒体引用。原生观察时间保存输入字符串,不在序列化时改写精度。Twitter 以毫秒分辨率逐计数比较观察时间;较旧观察不覆盖新值,相同值的新时间也更新持久记录,无时间的新值删除该计数的旧时间标签。这些是共同语义的规则,x-monkey 兼容基线用于验证其适配结果。协议 Adapter 将无持久变化映射为 `unchanged`,仅观察时间更新仍为 `accepted`。
+
+新版引用边只维护约定的 reference schema,旧 `attachment:*`、`entities:url` 和用户关系保留。媒体引用顺序是 `post.mediaIds`、文章封面、`article.mediaIds` 依次合并去重,首次出现保留。Media 根只保存原生 JSON;写入不创建 `core.image.v1`、`core.video.v1` 或 Media→`content` 关系。较早版本产生的内容块和边保留,须先核对其他引用及来源,再由明确的数据清理处理,正常重放不会删除它们。
+
+媒体原生 URL、variants 和 alt 按捕获值保留,Python 不筛选或规范化这些字段。保存、query、文本投影和索引只消费元数据;用户显式展开媒体时,消费端 Twitter Distribution 才选择图片地址或最高 bitrate MP4,并交给现有 HTTP Storage。HTTP(S) 地址、重定向、超时与体积限制由通用 Storage 合同处理,不增加 Twitter 专属 CDN、HTTPS/443 或禁用重定向策略。HLS 继续保留为元数据。本版本不保存媒体本体,也不创建下载 Job,外部来源失效后仍可读取文字与元数据。
+
+此边界通过减少持久块实现。Core 的普通图片和视频 resolver 在 lexical 索引中仍可读取字节并生成 OCR/STT;如果 Twitter 提前建立这类内容块,独立的索引扫描会触发读取,即使 Post/Media 自身的投影不展开媒体。Twitter 原生记录因此不预建内容块,其他来源的通用 lexical/OCR/STT 行为保持原有合同。
+
+Python 的 v2 Post 文本投影包含原帖 ID、链接、已捕获作者、正文和长文;User/Media 投影使用自身已经捕获的文字。label 保持 Block-local,不为投影或标签下载媒体。检索沿用现有独立索引维护流程,不在提交中隐式维护索引。
+
+Python Twitter 在 `/twitter/x-monkey/account` 自托管连接页面。用户只在自己信任的 HTTPS 部署输入已有 `JWT_SECRET`,页面使用 WebCrypto HMAC SHA-256 本地签发 60 秒普通 Peer JWT,再以同 Origin Bearer 和 JSON `{}` 请求 `POST /twitter/x-monkey/session`。该路由复用 `require_peer_jwt`,拒绝非 Core Origin、非 JSON 和带 `session_use` 的凭据,返回 `SessionResponse` JSON 并建立归档 Cookie。页面随后 GET `api/v1/session`,确认 Cookie 的 `authenticated=true` 后才显示 `Connected`、Peer 和到期时间。
+
+账户页以随机 nonce CSP 限定脚本,`connect-src 'self'` 限定连接目标。JWT 不进入 URL,密钥不持久化或发送给服务器。页面不加载第三方脚本,不接受任意回跳;浏览器内签名不使恶意或被篡改页面安全。
+
+连接不要求 Web 或 MF 分发。`setup.x_monkey_base_url` 继续供用户复制,Web Setup 只提供通向该 Core 账户页的普通链接,不持有此连接流程的密钥、JWT 签发或凭据传输逻辑。账户页显示 Peer 与到期时间,脚本通过会话检测确认连接。七天 Cookie 默认期限与协议续签仍由 Python Twitter 拥有。
+
+候选验证脚本与 fixture 放在 `scripts/tests/twitter`,默认 pytest 与 CI 不收集。离线合同与 HTTP 边界验证入口为 `pdm run python -m pytest -q scripts/tests/twitter/test_archive_contract.py scripts/tests/twitter/test_archive_http.py`;显式运行整个目录可用 `pdm run python -m pytest -q scripts/tests/twitter`。
+
+真实数据库场景要求显式提供 `INKCRE_TEST_DATABASE_URL`,使用已迁移的可丢弃 PostgreSQL runtime;测试只清理自己创建的精确 IDs,不重置开发库。原生 roundtrip 与真实 Twikit Bookmark 验证执行 `LexicalRetrievalManager.maintain` 的实际扫描、投影和检索链,在 HTTP Storage 字节边界断言零调用;同时检查归档不额外创建内容块、blob 或 Job。固定 fixture 同时用于原生 roundtrip 和独立上游合同验证,真实 userscript、HTTP 会话及 Web 消费仍需要各自的纵向验收。
diff --git a/docs/40-deployment/database-contract.md b/docs/40-deployment/database-contract.md
index b46e032a..7d52dc4e 100644
--- a/docs/40-deployment/database-contract.md
+++ b/docs/40-deployment/database-contract.md
@@ -122,13 +122,17 @@ role: authenticated
issuer: inkcre-peer
audience: inkcre-api
required: role, iss, aud, iat, exp
-maximum lifetime: 24 hours
future iat tolerance: 60 seconds
```
PostgREST exposes only `inkcre`,connects as `authenticator`,uses denied `anonymous` fallback,and invokes
`inkcre_internal.check_jwt` before every admitted request。FastAPI peer middleware validates the same vectors。
+JWT 没有强制最大寿命。`JWT_DEFAULT_LIFETIME_SECONDS` 是 Core 签发器的默认值(60 秒),
+`TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS` 是 Twitter x-monkey 会话初次签发及续签的默认值(604800 秒,7 天);
+二者必须为正整数,不参与 token admission。调整默认值影响后续签发,不会撤销已签发的有效 token。
+普通 Web Peer 仍默认签发 24 小时 token。有效期、签名、必需 claims 和未来 iat 容差仍参与验证。
+
## Canonical Production Profile
[`deploy/profiles/production.json`](../../deploy/profiles/production.json) 是 checked-in non-secret discovery surface。
@@ -140,7 +144,7 @@ implementation 或 migration verification 不授权修改 production profile 或
## Versioning
-`db contract --json` reports `peer-database-runtime-v3` plus the image source revision.
+`db contract --json` reports the executable contract revision plus the image source revision.
Every production candidate also contains `/app/database-contract/`:
- `database-roles.sql` recreates the password-free global principals required by policies;
diff --git a/docs/40-deployment/first-party-extension-distribution.md b/docs/40-deployment/first-party-extension-distribution.md
index fdd69928..3f707496 100644
--- a/docs/40-deployment/first-party-extension-distribution.md
+++ b/docs/40-deployment/first-party-extension-distribution.md
@@ -147,7 +147,10 @@ Core Host SDK 0.3 adds `app.http.get_public_http_base_url()` without changing th
or persistence API. Its authority is `app/version.py`, independently of the Core service package
version in `pyproject.toml`; preparing a service Release PR does not change the Host SDK version.
-GitHub, Learn English, Mail, RSS, Telegram, and Twitter retain their SDK 0.2 lower bound and support
+Core Host SDK 0.3.1 adds batched JSON identity lookup and transaction advisory locking to the
+existing Graph repository. Twitter archive reconciliation requires `>=0.3.1 <0.4.0`.
+
+GitHub, Learn English, Mail, RSS, and Telegram retain their SDK 0.2 lower bound and support
SDK 0.3 through `>=0.2.0 <0.4.0`. Memos uses the new HTTP address API and requires
`>=0.3.0 <0.4.0`. Compatibility metadata changes require new immutable Extension
releases; an old wheel's range must not be widened. Before upgrading a deployment, disable affected
diff --git a/docs/_shared b/docs/_shared
index 8fc1ec69..7449135f 160000
--- a/docs/_shared
+++ b/docs/_shared
@@ -1 +1 @@
-Subproject commit 8fc1ec69124e5b4ea86e7343a64c38a2e148e95d
+Subproject commit 7449135fefdd6056fecba387e59e3005aed5eafd
diff --git a/extensions/twitter/.changes/+x-monkey.added.md b/extensions/twitter/.changes/+x-monkey.added.md
new file mode 100644
index 00000000..16287f1d
--- /dev/null
+++ b/extensions/twitter/.changes/+x-monkey.added.md
@@ -0,0 +1 @@
+支持以现有 JWT 连接 x-monkey,保存与合并用户、帖子和媒体元数据,与 Bookmark 共用原生身份;媒体保持按需读取,会话默认七天并自动续签。
diff --git a/extensions/twitter/__init__.py b/extensions/twitter/__init__.py
index 68fd37e1..d0764ef5 100644
--- a/extensions/twitter/__init__.py
+++ b/extensions/twitter/__init__.py
@@ -33,9 +33,17 @@ class Extension(
):
@classmethod
def _init_resolvers(cls):
- from .resolver import TweetResolver
+ from .resolver import (
+ ArchiveMediaResolver,
+ ArchivePostResolver,
+ ArchiveUserResolver,
+ TweetResolver,
+ )
ResolverManager.register_resolver(TweetResolver)
+ ResolverManager.register_resolver(ArchivePostResolver)
+ ResolverManager.register_resolver(ArchiveUserResolver)
+ ResolverManager.register_resolver(ArchiveMediaResolver)
@classmethod
def _init_sources(cls):
@@ -71,8 +79,10 @@ async def on_start_async(cls, app):
@classmethod
def _register_apis(cls, router: APIRouter):
from .setup_flow import register_setup_routes
+ from .archive_http import router as archive_http_router
register_setup_routes(router)
+ router.include_router(archive_http_router, prefix="/x-monkey")
@classmethod
def api_dependencies(cls) -> list[typing.Any]:
@@ -87,7 +97,15 @@ def peer_inbounds(cls) -> tuple[typing.Any, ...]:
@classmethod
def public_http_routes(cls) -> tuple[PublicHTTPRoute, ...]:
- return (PublicHTTPRoute(method="GET", path="/auth/callback"),)
+ return (
+ PublicHTTPRoute(method="GET", path="/auth/callback"),
+ PublicHTTPRoute(method="POST", path="/x-monkey/session"),
+ PublicHTTPRoute(method="GET", path="/x-monkey/account"),
+ PublicHTTPRoute(method="POST", path="/x-monkey/disconnect"),
+ PublicHTTPRoute(method="GET", path="/x-monkey/api/v1/session"),
+ PublicHTTPRoute(method="POST", path="/x-monkey/api/v1/archive/query"),
+ PublicHTTPRoute(method="POST", path="/x-monkey/api/v1/archive/submit"),
+ )
@classmethod
async def update_config_async(
diff --git a/extensions/twitter/api.py b/extensions/twitter/api.py
index 90ca3978..324f3fc3 100644
--- a/extensions/twitter/api.py
+++ b/extensions/twitter/api.py
@@ -3,6 +3,8 @@
import abc
import asyncio
import datetime
+from email.utils import parsedate_to_datetime
+import html
from pathlib import Path
import re
import typing
@@ -14,34 +16,274 @@
import twikit
import twikit.media
from typing import Optional as Opt
-from dd import dd
from utils.datetime_ import get_timestamp
-from .schema import TweetPhoto, TweetVideo, VideoVariant
+from .model import TwitterBatch
from . import Extension
-class Tweet(sqlmodel.SQLModel):
- id: int
- user_id: str | None = None
- """推文用户ID"""
- conversation_id: Opt[int] = None
- text: str
- """推文文本内容
-
- - 移除回复提及
- - 移除媒体、网页链接(用 `[photo]`、`[video]`、`[link]` 占位)
- """
- quote: Opt[int] = None
- """引用推文ID"""
- photos: tuple[TweetPhoto, ...] = ()
- videos: tuple[TweetVideo, ...] = ()
- urls: tuple[str, ...] = ()
-
-
class TwitterAPIResult(sqlmodel.SQLModel):
next_page: Opt[str] = None
previous_page: Opt[str] = None
- tweets: tuple[Tweet, ...] = ()
+ batch: TwitterBatch = pydantic.Field(default_factory=TwitterBatch)
+ primary_post_ids: tuple[str, ...] = ()
+
+
+def _instant(value: str | datetime.datetime) -> str:
+ if isinstance(value, str):
+ try:
+ value = datetime.datetime.fromisoformat(value)
+ except ValueError:
+ value = parsedate_to_datetime(value)
+ if value.tzinfo is None:
+ raise ValueError("Twitter provider returned a timestamp without a timezone")
+ return (
+ value.astimezone(datetime.timezone.utc)
+ .isoformat(timespec="seconds")
+ .replace("+00:00", "Z")
+ )
+
+
+def _observed_at() -> str:
+ return (
+ datetime.datetime.now(datetime.timezone.utc)
+ .isoformat(timespec="milliseconds")
+ .replace("+00:00", "Z")
+ )
+
+
+def _media_id(media: dict) -> str | None:
+ if media.get("id"):
+ return str(media["id"])
+ match = re.fullmatch(r"(?:3|7|13)_([1-9][0-9]*)", media.get("media_key", ""))
+ return match[1] if match else None
+
+
+def _provider_counts(raw: dict, names: dict[str, str], observed_at: str) -> dict:
+ values = {
+ name: raw[source] for source, name in names.items() if raw.get(source) is not None
+ }
+ return (
+ {"counts": values, "countsObservedAt": {name: observed_at for name in values}}
+ if values
+ else {}
+ )
+
+
+def _official_archive(
+ raw_tweets: list[dict], includes: dict, observed_at: str
+) -> TwitterBatch:
+ media = []
+ media_ids = {}
+ for raw in includes.get("media", ()):
+ media_id = _media_id(raw)
+ if media_id is None:
+ continue
+ media_ids[raw["media_key"]] = media_id
+ kind = {"photo": "photo", "video": "video", "animated_gif": "gif"}.get(raw["type"])
+ url = raw.get("url") if kind == "photo" else raw.get("preview_image_url")
+ if kind is None or not url:
+ continue
+ item = {"id": media_id, "type": kind, "url": url}
+ item.update(
+ {
+ target: raw[source]
+ for source, target in {
+ "width": "width",
+ "height": "height",
+ "alt_text": "alt",
+ }.items()
+ if raw.get(source) is not None
+ }
+ )
+ if raw.get("variants"):
+ item["video"] = {
+ "variants": [
+ {
+ "url": variant["url"],
+ "type": variant["content_type"],
+ **({"bitrate": variant["bit_rate"]} if "bit_rate" in variant else {}),
+ }
+ for variant in raw["variants"]
+ ]
+ }
+ if raw.get("duration_ms") is not None:
+ item["video"]["durationMs"] = raw["duration_ms"]
+ media.append(item)
+ users = []
+ for raw in includes.get("users", ()):
+ user = {"id": raw["id"], "handle": raw["username"], "name": raw["name"]}
+ user.update(
+ {
+ target: raw[source]
+ for source, target in {
+ "profile_image_url": "avatarUrl",
+ "location": "location",
+ "url": "website",
+ "protected": "protected",
+ }.items()
+ if raw.get(source) is not None
+ }
+ )
+ if "description" in raw:
+ user["bio"] = {"text": html.unescape(raw["description"])}
+ if "created_at" in raw:
+ user["createdAt"] = _instant(raw["created_at"])
+ user.update(
+ _provider_counts(
+ raw.get("public_metrics", {}),
+ {
+ "followers_count": "followers",
+ "following_count": "following",
+ "tweet_count": "posts",
+ },
+ observed_at,
+ )
+ )
+ users.append(user)
+ posts = []
+ for raw in [*raw_tweets, *includes.get("tweets", ())]:
+ post = {
+ "id": raw["id"],
+ "authorId": raw["author_id"],
+ "createdAt": _instant(raw["created_at"]),
+ }
+ references = {
+ reference["type"]: reference["id"] for reference in raw.get("referenced_tweets", ())
+ }
+ if "retweeted" in references:
+ post["repostOf"] = references["retweeted"]
+ else:
+ post["text"] = {
+ "text": html.unescape(raw.get("note_tweet", {}).get("text", raw["text"]))
+ }
+ if "replied_to" in references:
+ post["replyTo"] = {"postId": references["replied_to"]}
+ if "quoted" in references:
+ post["quote"] = {"postId": references["quoted"]}
+ if raw.get("conversation_id") and raw["conversation_id"] != raw["id"]:
+ post["conversationId"] = raw["conversation_id"]
+ post.update(
+ {
+ target: raw[source]
+ for source, target in {"lang": "lang", "possibly_sensitive": "sensitive"}.items()
+ if raw.get(source) is not None
+ }
+ )
+ attached = [
+ media_ids[key]
+ for key in raw.get("attachments", {}).get("media_keys", ())
+ if key in media_ids
+ ]
+ if attached:
+ post["mediaIds"] = attached
+ post.update(
+ _provider_counts(
+ raw.get("public_metrics", {}),
+ {
+ "reply_count": "replies",
+ "retweet_count": "reposts",
+ "quote_count": "quotes",
+ "like_count": "likes",
+ "bookmark_count": "bookmarks",
+ "impression_count": "views",
+ },
+ observed_at,
+ )
+ )
+ posts.append(post)
+ return TwitterBatch.model_validate({"users": users, "posts": posts, "media": media})
+
+
+def _twikit_archive(
+ tweets: typing.Iterable[twikit.Tweet], observed_at: str
+) -> TwitterBatch:
+ posts: dict[str, dict] = {}
+ users: dict[str, dict] = {}
+ media: dict[str, dict] = {}
+
+ def collect(tweet: twikit.Tweet) -> None:
+ if tweet.id in posts:
+ return
+ user = tweet.user
+ post: dict[str, typing.Any] = {
+ "id": tweet.id,
+ "authorId": user.id,
+ "createdAt": _instant(tweet.created_at_datetime),
+ }
+ posts[tweet.id] = post
+ users[user.id] = {"id": user.id, "handle": user.screen_name, "name": user.name}
+ if tweet.retweeted_tweet is not None:
+ post["repostOf"] = tweet.retweeted_tweet.id
+ collect(tweet.retweeted_tweet)
+ return
+ post["text"] = {"text": html.unescape(tweet.full_text)}
+ if tweet.in_reply_to:
+ post["replyTo"] = {"postId": tweet.in_reply_to}
+ conversation_id = tweet._legacy.get("conversation_id_str")
+ if conversation_id and conversation_id != tweet.id:
+ post["conversationId"] = conversation_id
+ if tweet.quote is not None:
+ post["quote"] = {"postId": tweet.quote.id}
+ collect(tweet.quote)
+ if tweet.lang:
+ post["lang"] = tweet.lang
+ post.update(
+ _provider_counts(
+ tweet._legacy,
+ {
+ "reply_count": "replies",
+ "retweet_count": "reposts",
+ "quote_count": "quotes",
+ "favorite_count": "likes",
+ "bookmark_count": "bookmarks",
+ },
+ observed_at,
+ )
+ )
+ attached = []
+ for item in tweet.media:
+ kind = {"photo": "photo", "video": "video", "animated_gif": "gif"}.get(item.type)
+ if kind is None or not item.id or not item.media_url:
+ continue
+ value: dict[str, typing.Any] = {"id": item.id, "type": kind, "url": item.media_url}
+ original = item._data.get("original_info") or {}
+ value.update(
+ {
+ name: original[name]
+ for name in ("width", "height")
+ if original.get(name) is not None
+ }
+ )
+ if item._data.get("ext_alt_text") is not None:
+ value["alt"] = item._data["ext_alt_text"]
+ info = item._data.get("video_info") or {}
+ if info.get("variants"):
+ value["video"] = {
+ "variants": [
+ {
+ "url": variant["url"],
+ "type": variant["content_type"],
+ **({"bitrate": variant["bitrate"]} if "bitrate" in variant else {}),
+ }
+ for variant in info["variants"]
+ ]
+ }
+ if info.get("duration_millis") is not None:
+ value["video"]["durationMs"] = info["duration_millis"]
+ media[item.id] = value
+ attached.append(item.id)
+ if attached:
+ post["mediaIds"] = attached
+
+ for tweet in tweets:
+ collect(tweet)
+ return TwitterBatch.model_validate(
+ {
+ "users": list(users.values()),
+ "posts": list(posts.values()),
+ "media": list(media.values()),
+ }
+ )
class TwitterAPI(abc.ABC):
@@ -359,74 +601,8 @@ async def get_user(self) -> tuple[str, str]:
def _resolve_tweets(
self, raw_tweets: list[dict], includes: dict[str, list[dict]]
- ) -> list[Tweet]:
- include_medias = includes.get("media", ())
-
- tweets: list[Tweet] = []
- for tweet in raw_tweets:
- tweet = dd(tweet)
- tweet_id = tweet.id()
-
- # resolve medias
- media_keys = tweet._.attachments.media_keys() or ()
- photos: list[TweetPhoto] = []
- videos: list[TweetVideo] = []
- for media_key in media_keys:
- for include_media in include_medias:
- include_media = dd(include_media)
- if include_media._.media_key() == media_key:
- media_type = include_media._.type()
- if media_type == "video":
- videos.append(
- TweetVideo(
- id=media_key,
- variants=tuple(
- VideoVariant(**variant)
- for variant in (include_media._.variants() or ())
- ),
- )
- )
- elif media_type == "photo":
- photos.append(
- TweetPhoto(
- id=media_key,
- url=include_media.url(lambda x: x or ""),
- )
- )
- else:
- # TODO log warning for unsupported media type
- pass
- break
-
- # resolve conversation ID
- conversation_id = tweet._.conversation_id()
- if conversation_id == tweet_id:
- conversation_id = None
-
- # resolve url entities
- urls: list[str] = []
- for entity in tweet._.entities.urls() or []:
- url = entity.get("expanded_url")
- if url:
- urls.append(url)
-
- # resolve text
- tweet_text = tweet.text()
- tweet_text = re.sub(r"^(?:@\w+\s*)+", "", tweet_text)
-
- tweets.append(
- Tweet(
- id=tweet_id,
- user_id=tweet._.author_id(),
- text=tweet_text,
- conversation_id=conversation_id,
- photos=tuple(photos),
- videos=tuple(videos),
- urls=tuple(urls),
- )
- )
-
- return tweets
+ ) -> TwitterBatch:
+ return _official_archive(raw_tweets, includes, _observed_at())
async def get_bookmarks(
self, max_results: int = 20, page: str | None = None
@@ -440,9 +616,21 @@ async def get_bookmarks(
"""
bookmarks_query = {
"max_results": max_results,
- "tweet.fields": "attachments,entities,lang,conversation_id",
- "media.fields": "alt_text,media_key,url,type",
- "expansions": "attachments.media_keys,attachments.media_source_tweet",
+ "tweet.fields": (
+ "author_id,created_at,attachments,entities,lang,conversation_id,"
+ "referenced_tweets,public_metrics,possibly_sensitive,note_tweet"
+ ),
+ "media.fields": (
+ "alt_text,media_key,url,type,preview_image_url,width,height,duration_ms,variants"
+ ),
+ "user.fields": (
+ "id,name,username,description,profile_image_url,url,"
+ "protected,location,public_metrics,created_at"
+ ),
+ "expansions": (
+ "author_id,attachments.media_keys,referenced_tweets.id,"
+ "referenced_tweets.id.author_id"
+ ),
}
if page:
bookmarks_query["pagination_token"] = page
@@ -452,11 +640,12 @@ async def get_bookmarks(
path_params={"id": self.__user_id},
query=bookmarks_query,
)
- tweets = self._resolve_tweets(res.get("data", []), res.get("includes", {}))
+ batch = self._resolve_tweets(res.get("data", []), res.get("includes", {}))
return TwitterAPIResult(
next_page=res.get("meta", {}).get("next_token"),
previous_page=res.get("meta", {}).get("previous_token"),
- tweets=tuple(tweets),
+ batch=batch,
+ primary_post_ids=tuple(post["id"] for post in res.get("data", ())),
)
async def get_tweets(
@@ -468,16 +657,29 @@ async def get_tweets(
query={
"query": query,
"max_results": max_results,
- "tweet.fields": "attachments,entities,lang,conversation_id",
- "media.fields": "alt_text,media_key,url,type",
- "expansions": "attachments.media_keys,attachments.media_source_tweet",
+ "tweet.fields": (
+ "author_id,created_at,attachments,entities,lang,conversation_id,"
+ "referenced_tweets,public_metrics,possibly_sensitive,note_tweet"
+ ),
+ "media.fields": (
+ "alt_text,media_key,url,type,preview_image_url,width,height,duration_ms,variants"
+ ),
+ "user.fields": (
+ "id,name,username,description,profile_image_url,url,"
+ "protected,location,public_metrics,created_at"
+ ),
+ "expansions": (
+ "author_id,attachments.media_keys,referenced_tweets.id,"
+ "referenced_tweets.id.author_id"
+ ),
},
)
- tweets = self._resolve_tweets(res.get("data", []), res.get("includes", {}))
+ batch = self._resolve_tweets(res.get("data", []), res.get("includes", {}))
return TwitterAPIResult(
next_page=res.get("meta", {}).get("next_token"),
previous_page=res.get("meta", {}).get("previous_token"),
- tweets=tuple(tweets),
+ batch=batch,
+ primary_post_ids=tuple(post["id"] for post in res.get("data", ())),
)
async def get_replies(
@@ -550,59 +752,8 @@ async def _login(self):
)
@staticmethod
- def _resolve_tweet(tweet: twikit.Tweet) -> Tweet:
- # resolve text
- # remove reply mentions
- tweet_text = re.sub(r"^(?:@\w+\s*)+", "", tweet.text)
-
- # resolve medias
- photos: list[TweetPhoto] = []
- videos: list[TweetVideo] = []
- for media in tweet.media:
- media_url = media.url
- if isinstance(media, twikit.media.Photo):
- photos.append(TweetPhoto(id=media.id, url=media.media_url))
- tweet_text = tweet_text.replace(media_url, "[photo]")
- elif isinstance(media, twikit.media.Video):
- videos.append(
- TweetVideo(
- id=media.id,
- variants=tuple(
- VideoVariant(
- bitrate=variant.bitrate,
- content_type=variant.content_type,
- url=variant.url or "",
- )
- for variant in media.streams
- ),
- )
- )
- tweet_text = tweet_text.replace(media_url, "[video]")
-
- # resolve urls
- urls: list[str] = []
- for i in typing.cast(dict, tweet.urls):
- i = dd(i)
- ex_url = i._.expanded_url()
- url = i._.url()
- if ex_url:
- urls.append(ex_url)
- if url:
- tweet_text = tweet_text.replace(url, "[link]")
-
- return Tweet(
- id=int(tweet.id),
- conversation_id=int(tweet.in_reply_to) if tweet.in_reply_to else None,
- user_id=tweet.user.screen_name,
- text=tweet_text,
- photos=tuple(photos),
- videos=tuple(videos),
- urls=tuple(urls),
- )
-
- @classmethod
- def _resolve_tweets(cls, result: twikit.utils.Result[twikit.Tweet]) -> tuple[Tweet, ...]:
- return tuple(cls._resolve_tweet(tweet) for tweet in result)
+ def _resolve_tweets(result: typing.Iterable[twikit.Tweet]) -> TwitterBatch:
+ return _twikit_archive(result, _observed_at())
async def get_bookmarks(
self, max_results: int = 20, page: str | None = None
@@ -613,7 +764,8 @@ async def get_bookmarks(
return TwitterAPIResult(
next_page=res.next_cursor,
previous_page=res.previous_cursor,
- tweets=tuple(self._resolve_tweets(res)),
+ batch=self._resolve_tweets(res),
+ primary_post_ids=tuple(tweet.id for tweet in res),
)
async def get_tweets(
@@ -635,17 +787,18 @@ async def get_tweets(
return TwitterAPIResult(
next_page=res.next_cursor,
previous_page=res.previous_cursor,
- tweets=tuple(self._resolve_tweets(res)),
+ batch=self._resolve_tweets(res),
+ primary_post_ids=tuple(tweet.id for tweet in res),
)
async def _get_a_reply_of(
self, from_: str, replies: twikit.utils.Result[twikit.Tweet]
- ) -> Tweet | None:
+ ) -> twikit.Tweet | None:
if len(replies) == 0:
return None
for reply in replies:
if reply.user.screen_name == from_:
- return self._resolve_tweet(reply)
+ return reply
else:
await asyncio.sleep(5) # avoid rate limit
replies = await replies.next()
@@ -654,7 +807,7 @@ async def _get_a_reply_of(
async def get_replies(
self, *conversation_ids: str, from_: str | None = None, max_results: int = 20
) -> TwitterAPIResult:
- res_tweets: list[Tweet] = []
+ res_tweets: list[twikit.Tweet] = []
for cid in conversation_ids:
await asyncio.sleep(3) # avoid rate limit
try:
@@ -672,6 +825,9 @@ async def get_replies(
if the_reply:
res_tweets.append(the_reply)
else:
- res_tweets.extend(self._resolve_tweets(replies))
+ res_tweets.extend(replies)
- return TwitterAPIResult(tweets=tuple(res_tweets))
+ return TwitterAPIResult(
+ batch=self._resolve_tweets(res_tweets),
+ primary_post_ids=tuple(tweet.id for tweet in res_tweets),
+ )
diff --git a/extensions/twitter/archive_http.py b/extensions/twitter/archive_http.py
new file mode 100644
index 00000000..34a67998
--- /dev/null
+++ b/extensions/twitter/archive_http.py
@@ -0,0 +1,79 @@
+"""HTTP endpoints implementing the x-monkey remote archive contract."""
+
+from __future__ import annotations
+
+import fastapi
+from fastapi import Request, Response
+
+from .archive_schema import ArchiveBatch, ArchiveQuery, QueryResponse, SubmitResponse
+from .archive_session import (
+ SESSION_COOKIE_NAME,
+ SESSION_USE_CLAIM,
+ SESSION_USE_TWITTER_ARCHIVE,
+ _decode_peer_token,
+ renew_session_if_needed,
+ validate_archive_request_source,
+ x_monkey_base_url,
+)
+from .x_monkey import query_archive, submit_archive
+
+router = fastapi.APIRouter()
+CLIENT_HEADER = "x-tweet-db-client"
+CLIENT_NAME = "x-monkey"
+
+
+def _protocol_error(status_code: int, message: str) -> fastapi.responses.JSONResponse:
+ return fastapi.responses.JSONResponse(
+ {"error": message},
+ status_code=status_code,
+ headers={"Cache-Control": "no-store", "X-Content-Type-Options": "nosniff"},
+ )
+
+
+async def _authorize_archive_request(request: Request, response: Response) -> int | None:
+ if not await validate_archive_request_source(request):
+ return 403
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+ if content_type != "application/json":
+ return 415
+ if request.headers.get(CLIENT_HEADER) != CLIENT_NAME:
+ return 400
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ claims = _decode_peer_token(token) if token else None
+ if claims is None or claims.get(SESSION_USE_CLAIM) != SESSION_USE_TWITTER_ARCHIVE:
+ return 401
+ base_url = await x_monkey_base_url()
+ if base_url:
+ renew_session_if_needed(response, claims, base_url=base_url)
+ return None
+
+
+@router.post(
+ "/api/v1/archive/query",
+ response_model=QueryResponse,
+ response_model_exclude_unset=True,
+)
+async def archive_query(body: ArchiveQuery, request: Request, response: Response):
+ failure = await _authorize_archive_request(request, response)
+ if failure is not None:
+ return _protocol_error(failure, "archive session or request headers are invalid")
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return await query_archive(body)
+
+
+@router.post(
+ "/api/v1/archive/submit",
+ response_model=SubmitResponse,
+ response_model_exclude_unset=True,
+)
+async def archive_submit(body: ArchiveBatch, request: Request, response: Response):
+ failure = await _authorize_archive_request(request, response)
+ if failure is not None:
+ return _protocol_error(failure, "archive session or request headers are invalid")
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return await submit_archive(body)
+
+
+__all__ = ["CLIENT_HEADER", "CLIENT_NAME", "router"]
diff --git a/extensions/twitter/archive_merge.py b/extensions/twitter/archive_merge.py
new file mode 100644
index 00000000..ae6fddfb
--- /dev/null
+++ b/extensions/twitter/archive_merge.py
@@ -0,0 +1,79 @@
+"""Twitter-owned sparse fact merge, including per-counter observation times."""
+
+from __future__ import annotations
+
+import copy
+import datetime
+
+from pydantic import JsonValue
+
+
+def _merge_content(target: dict[str, JsonValue], incoming: dict[str, JsonValue]) -> None:
+ for key, value in incoming.items():
+ current = target.get(key)
+ if isinstance(value, dict) and isinstance(current, dict):
+ _merge_content(current, value)
+ else:
+ target[key] = copy.deepcopy(value)
+
+
+def _observation_time(value: str) -> datetime.datetime:
+ parsed = datetime.datetime.fromisoformat(value)
+ # Twitter compares observations at millisecond resolution; retain finer source precision.
+ return parsed.replace(microsecond=parsed.microsecond // 1000 * 1000)
+
+
+def merge_archive_record(
+ existing: dict[str, JsonValue], incoming: dict[str, JsonValue]
+) -> dict[str, JsonValue]:
+ """Return a detached merge; delayed counter observations cannot replace newer ones."""
+ result = copy.deepcopy(existing)
+ _merge_content(
+ result,
+ {
+ key: value
+ for key, value in incoming.items()
+ if key not in {"counts", "countsObservedAt"}
+ },
+ )
+ counts = incoming.get("counts")
+ if not isinstance(counts, dict):
+ return result
+
+ current_counts = result.get("counts")
+ if not isinstance(current_counts, dict):
+ current_counts = {}
+ current_times = result.get("countsObservedAt")
+ if not isinstance(current_times, dict):
+ current_times = {}
+ incoming_times = incoming.get("countsObservedAt")
+ if not isinstance(incoming_times, dict):
+ incoming_times = {}
+
+ for key, value in counts.items():
+ observed_at = incoming_times.get(key)
+ current_time = current_times.get(key)
+ if (
+ isinstance(observed_at, str)
+ and isinstance(current_time, str)
+ and _observation_time(observed_at) < _observation_time(current_time)
+ ):
+ continue
+ count_changed = key not in current_counts or current_counts[key] != value
+ if count_changed:
+ current_counts[key] = copy.deepcopy(value)
+ if isinstance(observed_at, str):
+ current_times[key] = observed_at
+ elif count_changed:
+ current_times.pop(key, None)
+
+ if current_counts:
+ result["counts"] = current_counts
+ if current_times:
+ result["countsObservedAt"] = current_times
+ else:
+ result.pop("countsObservedAt", None)
+ return result
+
+
+__all__ = ["merge_archive_record"]
diff --git a/extensions/twitter/archive_schema.py b/extensions/twitter/archive_schema.py
new file mode 100644
index 00000000..5fd88d65
--- /dev/null
+++ b/extensions/twitter/archive_schema.py
@@ -0,0 +1,436 @@
+"""Fixed x-monkey 0.2.0 DTOs; these do not define Twitter persisted semantics."""
+
+from __future__ import annotations
+
+import datetime
+import re
+from typing import Annotated, Generic, Literal, TypeVar
+
+import pydantic
+
+
+ArchiveID = Annotated[str, pydantic.Field(strict=True, pattern=r"^[1-9][0-9]*$")]
+Count = Annotated[int, pydantic.Field(strict=True, ge=0)]
+Number = Annotated[int | float, pydantic.Field(allow_inf_nan=False)]
+
+
+def _validate_instant(value: str) -> str:
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", value):
+ raise ValueError("must be a UTC timestamp with second precision")
+ datetime.datetime.fromisoformat(value)
+ return value
+
+
+def _validate_observed_at(value: str) -> str:
+ if not re.fullmatch(
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z", value
+ ):
+ raise ValueError("must be a UTC ISO-8601 timestamp including seconds")
+ datetime.datetime.fromisoformat(value)
+ return value
+
+
+Instant = Annotated[str, pydantic.AfterValidator(_validate_instant)]
+ObservedAt = Annotated[str, pydantic.AfterValidator(_validate_observed_at)]
+
+
+def _validate_range(value: tuple[int, int]) -> tuple[int, int]:
+ if value[1] < value[0]:
+ raise ValueError("range end before start")
+ return value
+
+
+Range = Annotated[
+ tuple[Count, Count],
+ pydantic.Field(strict=False),
+ pydantic.AfterValidator(_validate_range),
+]
+Rect = Annotated[tuple[Count, Count, Count, Count], pydantic.Field(strict=False)]
+
+
+class _WireModel(pydantic.BaseModel):
+ model_config = pydantic.ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
+
+ @pydantic.field_validator("*")
+ @classmethod
+ def reject_explicit_null(cls, value: object) -> object:
+ if value is None:
+ raise ValueError("omit unknown fields instead of sending null")
+ return value
+
+
+class UrlEntity(_WireModel):
+ type: Literal["url"]
+ range: Range
+ expanded: str
+
+
+class MentionEntity(_WireModel):
+ type: Literal["mention"]
+ range: Range
+ userId: ArchiveID
+
+
+class HashtagEntity(_WireModel):
+ type: Literal["hashtag"]
+ range: Range
+
+
+class CashtagEntity(_WireModel):
+ type: Literal["cashtag"]
+ range: Range
+ name: str | None = None
+ ticker: str | None = None
+
+
+class TimestampEntity(_WireModel):
+ type: Literal["timestamp"]
+ range: Range
+
+
+Entity = Annotated[
+ UrlEntity | MentionEntity | HashtagEntity | CashtagEntity | TimestampEntity,
+ pydantic.Field(discriminator="type"),
+]
+
+
+class TextStyle(_WireModel):
+ range: Range
+ styles: list[str] = pydantic.Field(min_length=1)
+
+
+class RichText(_WireModel):
+ text: str
+ visible: Range | None = None
+ entities: list[Entity] | None = pydantic.Field(default=None, min_length=1)
+ styles: list[TextStyle] | None = pydantic.Field(default=None, min_length=1)
+
+
+class UserCounts(_WireModel):
+ followers: Count | None = None
+ following: Count | None = None
+ posts: Count | None = None
+ mediaPosts: Count | None = None
+ likes: Count | None = None
+
+
+class UserCountsObservedAt(_WireModel):
+ followers: ObservedAt | None = None
+ following: ObservedAt | None = None
+ posts: ObservedAt | None = None
+ mediaPosts: ObservedAt | None = None
+ likes: ObservedAt | None = None
+
+
+class Affiliation(_WireModel):
+ name: str
+ url: str | None = None
+ badgeUrl: str | None = None
+ kind: str | None = None
+ ownerId: ArchiveID | None = None
+
+
+class Verification(_WireModel):
+ blue: bool | None = None
+ type: str | None = None
+
+
+class Professional(_WireModel):
+ type: str | None = None
+ categories: list[str] | None = pydantic.Field(default=None, min_length=1)
+
+
+class ArchiveUser(_WireModel):
+ id: ArchiveID
+ handle: str
+ name: str
+ createdAt: Instant | None = None
+ avatarUrl: str | None = None
+ avatarShape: str | None = None
+ bannerUrl: str | None = None
+ bio: RichText | None = None
+ location: str | None = None
+ website: str | None = None
+ pinnedPostIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1)
+ protected: bool | None = None
+ sensitive: bool | None = None
+ interstitial: str | None = None
+ withheldIn: list[str] | None = pydantic.Field(default=None, min_length=1)
+ verified: Verification | None = None
+ affiliation: Affiliation | None = None
+ parody: str | None = None
+ professional: Professional | None = None
+ counts: UserCounts | None = None
+ countsObservedAt: UserCountsObservedAt | None = None
+
+
+class VideoVariant(_WireModel):
+ url: str
+ type: str
+ bitrate: Count | None = None
+
+
+class MediaTag(_WireModel):
+ userId: ArchiveID
+ handle: str | None = None
+
+
+class Video(_WireModel):
+ durationMs: Count | None = None
+ variants: list[VideoVariant] = pydantic.Field(min_length=1)
+
+
+class ArchiveMedia(_WireModel):
+ id: ArchiveID
+ type: Literal["photo", "video", "gif"]
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+ alt: str | None = None
+ crops: list[Rect] | None = pydantic.Field(default=None, min_length=1)
+ faces: list[Rect] | None = pydantic.Field(default=None, min_length=1)
+ tags: list[MediaTag] | None = pydantic.Field(default=None, min_length=1)
+ video: Video | None = None
+ sourcePostId: ArchiveID | None = None
+ sourceUserId: ArchiveID | None = None
+ sensitive: list[str] | None = pydantic.Field(default=None, min_length=1)
+ unavailable: str | None = None
+ title: str | None = None
+ description: str | None = None
+ link: str | None = None
+
+
+class PollChoice(_WireModel):
+ label: str
+ count: Count | None = None
+
+
+class Poll(_WireModel):
+ choices: list[PollChoice] = pydantic.Field(min_length=2)
+ endsAt: Instant | None = None
+ final: bool | None = None
+ updatedAt: Instant | None = None
+
+
+class CardImage(_WireModel):
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+ alt: str | None = None
+
+
+class CardPlayer(_WireModel):
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+
+
+class Card(_WireModel):
+ type: str
+ url: str
+ title: str | None = None
+ description: str | None = None
+ domain: str | None = None
+ image: CardImage | None = None
+ siteUserId: ArchiveID | None = None
+ creatorUserId: ArchiveID | None = None
+ player: CardPlayer | None = None
+ poll: Poll | None = None
+ extra: dict[str, str | bool] | None = None
+
+
+class ArticleBlock(_WireModel):
+ type: str
+ text: str
+ styles: list[Annotated[tuple[Count, Count, str], pydantic.Field(strict=False)]] | None = (
+ pydantic.Field(default=None, min_length=1)
+ )
+ entities: (
+ list[Annotated[tuple[Count, Count, Count], pydantic.Field(strict=False)]] | None
+ ) = pydantic.Field(default=None, min_length=1)
+ data: dict[str, pydantic.JsonValue] | None = None
+
+
+class ArticleEntity(_WireModel):
+ type: str
+ data: dict[str, pydantic.JsonValue]
+
+
+class Article(_WireModel):
+ id: ArchiveID
+ title: str
+ preview: str | None = None
+ coverMediaId: ArchiveID | None = None
+ publishedAt: Instant | None = None
+ modifiedAt: Instant | None = None
+ blocks: list[ArticleBlock] | None = pydantic.Field(default=None, min_length=1)
+ entities: list[ArticleEntity] | None = pydantic.Field(default=None, min_length=1)
+ mediaIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1)
+
+
+class Place(_WireModel):
+ id: str
+ name: str | None = None
+ fullName: str | None = None
+ country: str | None = None
+ countryCode: str | None = None
+ type: str | None = None
+ bbox: (
+ Annotated[tuple[Number, Number, Number, Number], pydantic.Field(strict=False)] | None
+ ) = None
+
+
+class PostCounts(_WireModel):
+ replies: Count | None = None
+ reposts: Count | None = None
+ quotes: Count | None = None
+ likes: Count | None = None
+ bookmarks: Count | None = None
+ views: Count | None = None
+
+
+class PostCountsObservedAt(_WireModel):
+ replies: ObservedAt | None = None
+ reposts: ObservedAt | None = None
+ quotes: ObservedAt | None = None
+ likes: ObservedAt | None = None
+ bookmarks: ObservedAt | None = None
+ views: ObservedAt | None = None
+
+
+class ReplyTo(_WireModel):
+ postId: ArchiveID
+ userId: ArchiveID | None = None
+ handle: str | None = None
+ tombstone: str | None = pydantic.Field(default=None, min_length=1)
+
+
+class Quote(_WireModel):
+ postId: ArchiveID
+ url: str | None = None
+ tombstone: str | None = pydantic.Field(default=None, min_length=1)
+
+
+class CommunityNote(_WireModel):
+ id: ArchiveID | None = None
+ title: str | None = None
+ text: RichText
+ lang: str | None = None
+ url: str | None = None
+
+
+class ContentWarning(_WireModel):
+ title: str
+ text: str | None = None
+
+
+class Community(_WireModel):
+ id: ArchiveID
+ name: str | None = None
+
+
+class AIGenerated(_WireModel):
+ media: bool
+ source: str
+
+
+class ArchivePost(_WireModel):
+ id: ArchiveID
+ authorId: ArchiveID
+ createdAt: Instant
+ repostOf: ArchiveID | None = None
+ conversationId: ArchiveID | None = None
+ replyTo: ReplyTo | None = None
+ quote: Quote | None = None
+ text: RichText | None = None
+ lang: str | None = None
+ source: str | None = None
+ mediaIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=1)
+ card: Card | None = None
+ article: Article | None = None
+ place: Place | None = None
+ counts: PostCounts | None = None
+ countsObservedAt: PostCountsObservedAt | None = None
+ editIds: list[ArchiveID] | None = pydantic.Field(default=None, min_length=2)
+ communityNote: CommunityNote | None = None
+ sensitive: bool | None = None
+ contentWarning: ContentWarning | None = None
+ community: Community | None = None
+ replyPolicy: str | None = None
+ paidPromotion: bool | None = None
+ aiGenerated: AIGenerated | None = None
+
+
+class ArchiveBatch(_WireModel):
+ users: list[ArchiveUser] = pydantic.Field(default_factory=list, max_length=1000)
+ posts: list[ArchivePost] = pydantic.Field(default_factory=list, max_length=1000)
+ media: list[ArchiveMedia] = pydantic.Field(default_factory=list, max_length=1000)
+
+
+class ArchiveQuery(_WireModel):
+ users: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000)
+ posts: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000)
+ media: list[ArchiveID] = pydantic.Field(default_factory=list, max_length=1000)
+
+
+T = TypeVar("T")
+
+
+class QueryResult(_WireModel, Generic[T]):
+ id: ArchiveID
+ status: Literal["found", "missing", "failed"]
+ data: T | None = None
+ error: str | None = None
+
+
+class QueryResponse(_WireModel):
+ users: list[QueryResult[ArchiveUser]]
+ posts: list[QueryResult[ArchivePost]]
+ media: list[QueryResult[ArchiveMedia]]
+
+
+class SubmitResult(_WireModel):
+ id: ArchiveID
+ status: Literal["accepted", "unchanged", "partial", "failed"]
+ reason: str | None = None
+
+
+class SubmitSummary(_WireModel):
+ accepted: Count
+ unchanged: Count
+ partial: Count
+ failed: Count
+ total: Count
+
+
+class SubmitResponse(_WireModel):
+ summary: SubmitSummary
+ users: list[SubmitResult]
+ posts: list[SubmitResult]
+ media: list[SubmitResult]
+
+
+class SessionResponse(pydantic.BaseModel):
+ model_config = pydantic.ConfigDict(extra="forbid", strict=True)
+
+ authenticated: bool
+ registered: bool
+ username: str | None
+ expires_at: str | None = None
+ account_url: str | None = None
+
+
+__all__ = [
+ "ArchiveBatch",
+ "ArchiveID",
+ "ArchiveMedia",
+ "ArchivePost",
+ "ArchiveQuery",
+ "ArchiveUser",
+ "QueryResponse",
+ "QueryResult",
+ "SessionResponse",
+ "SubmitResponse",
+ "SubmitResult",
+ "SubmitSummary",
+]
diff --git a/extensions/twitter/archive_session.py b/extensions/twitter/archive_session.py
new file mode 100644
index 00000000..0c24c0d8
--- /dev/null
+++ b/extensions/twitter/archive_session.py
@@ -0,0 +1,450 @@
+"""Browser session boundary for the x-monkey archive protocol."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+import html
+import secrets
+import time
+import re
+from urllib.parse import urlsplit
+
+import fastapi
+import jwt
+from fastapi import Depends, Request, Response
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
+
+from app.business.peer import PeerManager
+from app.database_contract.constants import (
+ JWT_ALGORITHM,
+ JWT_AUDIENCE,
+ JWT_ISSUER,
+ JWT_ROLE,
+)
+from app.middleware import decode_peer_jwt, require_peer_jwt
+from app.settings import settings
+from .archive_schema import SessionResponse
+
+SESSION_USE_CLAIM = "session_use"
+SESSION_USE_TWITTER_ARCHIVE = "twitter_archive"
+SESSION_COOKIE_NAME = "inkcre_twitter_archive"
+SESSION_COOKIE_PATH_SUFFIX = "/twitter/x-monkey/"
+SESSION_RENEWAL_REMAINING_FRACTION = 0.5
+CONNECT_PEER_TOKEN_LIFETIME_SECONDS = 60
+
+router = fastapi.APIRouter()
+
+
+async def x_monkey_base_url() -> str | None:
+ """Return the Core's configured public protocol base, preserving its path prefix."""
+ try:
+ base_url = (await PeerManager.get_current_config_async()).http_public_base_url
+ except (RuntimeError, ValueError):
+ return None
+ return f"{base_url.rstrip('/')}/twitter/x-monkey" if base_url else None
+
+
+def _session_cookie_path(base_url: str) -> str:
+ return f"{urlsplit(base_url).path.rstrip('/')}/"
+
+
+def _format_expiry(epoch_seconds: int) -> str:
+ return datetime.fromtimestamp(epoch_seconds, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def _decode_peer_token(token: str) -> dict | None:
+ try:
+ return decode_peer_jwt(token, settings.jwt_secret)
+ except jwt.InvalidTokenError:
+ return None
+
+
+def _decode_session_cookie(request: Request) -> dict | None:
+ token = request.cookies.get(SESSION_COOKIE_NAME)
+ if not token:
+ return None
+ claims = _decode_peer_token(token)
+ if claims is None or claims.get(SESSION_USE_CLAIM) != SESSION_USE_TWITTER_ARCHIVE:
+ return None
+ return claims
+
+
+def _issue_session_token() -> tuple[str, int]:
+ issued_at = int(time.time())
+ lifetime = settings.twitter_x_monkey_jwt_lifetime_seconds
+ expires_at = issued_at + lifetime
+ token = jwt.encode(
+ {
+ "role": JWT_ROLE,
+ "iss": JWT_ISSUER,
+ "aud": JWT_AUDIENCE,
+ "iat": issued_at,
+ "exp": expires_at,
+ SESSION_USE_CLAIM: SESSION_USE_TWITTER_ARCHIVE,
+ },
+ settings.jwt_secret,
+ algorithm=JWT_ALGORITHM,
+ )
+ return token, expires_at
+
+
+def _set_session_cookie(
+ response: Response,
+ *,
+ token: str,
+ expires_at: int,
+ base_url: str,
+) -> None:
+ response.set_cookie(
+ SESSION_COOKIE_NAME,
+ token,
+ max_age=max(0, expires_at - int(time.time())),
+ path=_session_cookie_path(base_url),
+ secure=True,
+ httponly=True,
+ samesite="none",
+ )
+
+
+def _clear_session_cookie(response: Response, base_url: str | None) -> None:
+ path = _session_cookie_path(base_url) if base_url else SESSION_COOKIE_PATH_SUFFIX
+ response.delete_cookie(
+ SESSION_COOKIE_NAME,
+ path=path,
+ secure=True,
+ httponly=True,
+ samesite="none",
+ )
+
+
+def renew_session_if_needed(
+ response: Response,
+ claims: dict,
+ *,
+ base_url: str,
+) -> int:
+ """Renew an eligible session JWT at half of its configured lifetime."""
+ expires_at = int(claims["exp"])
+ lifetime = settings.twitter_x_monkey_jwt_lifetime_seconds
+ if expires_at - int(time.time()) <= lifetime * SESSION_RENEWAL_REMAINING_FRACTION:
+ token, expires_at = _issue_session_token()
+ _set_session_cookie(response, token=token, expires_at=expires_at, base_url=base_url)
+ return expires_at
+
+
+def _html_page(
+ body: str, *, status_code: int = 200, script_nonce: str | None = None
+) -> HTMLResponse:
+ script_policy = f"script-src 'nonce-{script_nonce}'; " if script_nonce else ""
+ return HTMLResponse(
+ f"""
+
+
+
+x-monkey 连接
+InKCre x-monkey 连接 {body} """,
+ status_code=status_code,
+ headers={
+ "Cache-Control": "no-store",
+ "Content-Security-Policy": (
+ f"default-src 'none'; {script_policy}connect-src 'self'; form-action 'self'; "
+ "base-uri 'none'; frame-ancestors 'none'"
+ ),
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ },
+ )
+
+
+def _request_origin(request: Request) -> str | None:
+ value = request.headers.get("origin")
+ if value is None:
+ return None
+ try:
+ parts = urlsplit(value)
+ # Accessing .port also rejects malformed authority syntax.
+ _ = parts.port
+ except ValueError:
+ return None
+ if (
+ parts.scheme not in {"http", "https", "chrome-extension", "moz-extension"}
+ or not parts.netloc
+ or parts.username is not None
+ or parts.password is not None
+ or parts.path not in {"", "/"}
+ or parts.query
+ or parts.fragment
+ ):
+ return None
+ if parts.scheme in {"chrome-extension", "moz-extension"}:
+ if not re.fullmatch(r"[a-zA-Z0-9_-]+", parts.netloc):
+ return None
+ elif parts.scheme == "https" and parts.netloc.lower() == "x.com":
+ return "https://x.com"
+ return f"{parts.scheme}://{parts.netloc}"
+
+
+async def _core_origin() -> str | None:
+ try:
+ base_url = (await PeerManager.get_current_config_async()).http_public_base_url
+ except (RuntimeError, ValueError):
+ return None
+ if not base_url:
+ return None
+ parts = urlsplit(base_url)
+ return f"{parts.scheme}://{parts.netloc}"
+
+
+async def validate_archive_request_source(request: Request) -> bool:
+ """Apply the protocol's source policy; Origin is a filter, never a credential."""
+ origin = _request_origin(request)
+ if origin is None:
+ # A missing Origin is expected for some extension-origin GM requests.
+ return "origin" not in request.headers
+ if origin in {"https://x.com"} or origin.startswith(
+ ("chrome-extension://", "moz-extension://")
+ ):
+ return True
+ return origin == await _core_origin()
+
+
+def _is_top_level_navigation(request: Request) -> bool:
+ return (
+ request.headers.get("sec-fetch-mode") == "navigate"
+ and request.headers.get("sec-fetch-dest") == "document"
+ )
+
+
+@router.post("/session", response_model=SessionResponse)
+async def connect_session(
+ request: Request,
+ response: Response,
+ claims: dict = Depends(require_peer_jwt),
+):
+ if _request_origin(request) != await _core_origin():
+ return JSONResponse({"error": "same-origin request required"}, status_code=403)
+ if SESSION_USE_CLAIM in claims:
+ return JSONResponse({"error": "ordinary Peer credential required"}, status_code=401)
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
+ if content_type != "application/json":
+ return JSONResponse({"error": "application/json required"}, status_code=415)
+
+ base_url = await x_monkey_base_url()
+ if base_url is None:
+ return JSONResponse({"error": "public Core URL is not configured"}, status_code=503)
+
+ token, expires_at = _issue_session_token()
+ _set_session_cookie(response, token=token, expires_at=expires_at, base_url=base_url)
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ return SessionResponse(
+ authenticated=True,
+ registered=True,
+ username=settings.peer_name,
+ expires_at=_format_expiry(expires_at),
+ account_url=f"{base_url}/account",
+ )
+
+
+@router.get("/account", response_class=HTMLResponse)
+async def account_page(request: Request):
+ claims = _decode_session_cookie(request)
+ base_url = await x_monkey_base_url()
+ nonce = secrets.token_urlsafe(18)
+ connected_state = (
+ 'Connected
'
+ f'InKCre Peer: {html.escape(settings.peer_name)}
'
+ if claims is not None
+ else '尚未连接。
'
+ '
'
+ )
+ expiry = (
+ f'会话到期时间:'
+ f"{html.escape(_format_expiry(int(claims['exp'])))}
"
+ if claims is not None
+ else (
+ '会话到期时间:'
+ '
'
+ )
+ )
+ disconnect = (
+ f''
+ if base_url
+ else ""
+ )
+ script = r"""
+"""
+ for placeholder, value in (
+ ("__NONCE__", nonce),
+ ("__JWT_ROLE__", JWT_ROLE),
+ ("__JWT_ISSUER__", JWT_ISSUER),
+ ("__JWT_AUDIENCE__", JWT_AUDIENCE),
+ ("__JWT_LIFETIME__", str(CONNECT_PEER_TOKEN_LIFETIME_SECONDS)),
+ ):
+ script = script.replace(placeholder, value)
+ return _html_page(
+ connected_state
+ + expiry
+ + "连接 x-monkey "
+ + 'JWT_SECRET '
+ + ' '
+ + 'Connect '
+ + "密钥只在此页面内存中用于签发短时 Peer JWT,不会保存。
"
+ + disconnect
+ + "断开只清除此浏览器中的会话,不会删除已保存的信息。
"
+ + script,
+ script_nonce=nonce,
+ )
+
+
+@router.post("/disconnect")
+async def disconnect_session(request: Request):
+ if _request_origin(request) != await _core_origin() or not _is_top_level_navigation(
+ request
+ ):
+ return _html_page("断开请求来源无效。
", status_code=403)
+
+ base_url = await x_monkey_base_url()
+ if base_url is None:
+ return _html_page("Core 尚未配置公共 HTTPS 地址。
", status_code=503)
+ response = RedirectResponse(f"{base_url}/account", status_code=303)
+ _clear_session_cookie(response, base_url)
+ return response
+
+
+@router.get("/api/v1/session")
+async def get_session(request: Request, response: Response):
+ from .archive_schema import SessionResponse
+
+ base_url = await x_monkey_base_url()
+ response.headers["Cache-Control"] = "no-store"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ account_url = f"{base_url}/account" if base_url else None
+ fetch_mode = request.headers.get("sec-fetch-mode")
+ if fetch_mode == "no-cors" or not await validate_archive_request_source(request):
+ return fastapi.responses.JSONResponse(
+ {"error": "request source is not allowed"},
+ status_code=403,
+ headers={"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"},
+ )
+ claims = _decode_session_cookie(request)
+ if claims is None:
+ if request.cookies.get(SESSION_COOKIE_NAME):
+ _clear_session_cookie(response, base_url)
+ return SessionResponse(
+ authenticated=False,
+ registered=False,
+ username=None,
+ account_url=account_url,
+ )
+
+ if base_url is None:
+ return SessionResponse(
+ authenticated=False,
+ registered=False,
+ username=None,
+ account_url=None,
+ )
+ expires_at = renew_session_if_needed(response, claims, base_url=base_url)
+ return SessionResponse(
+ authenticated=True,
+ registered=True,
+ username=settings.peer_name,
+ expires_at=_format_expiry(expires_at),
+ account_url=account_url,
+ )
+
+
+__all__ = [
+ "SESSION_COOKIE_NAME",
+ "SESSION_RENEWAL_REMAINING_FRACTION",
+ "SESSION_USE_CLAIM",
+ "SESSION_USE_TWITTER_ARCHIVE",
+ "connect_session",
+ "disconnect_session",
+ "get_session",
+ "renew_session_if_needed",
+ "router",
+ "validate_archive_request_source",
+ "x_monkey_base_url",
+]
diff --git a/extensions/twitter/bookmark.py b/extensions/twitter/bookmark.py
index eb75248d..bf6aa057 100644
--- a/extensions/twitter/bookmark.py
+++ b/extensions/twitter/bookmark.py
@@ -3,16 +3,12 @@
import pydantic
import sqlmodel
import typing
-from app.business.info_base.commands import persist_stars
-from app.business.info_base.resolver import ImageResolver, VideoResolver, HTMLResolver
from app.business.source import SourceBase
from app.persistence.source.uow import source_uow
-from app.schemas.info_base.main import OutArcForm, StarsGraphForm
-from app.schemas.info_base.relation import RelationForm
from app.schemas.job import JobModel
from .api import TwitterAPI
-from .resolver import TweetResolver
-from .schema import Tweet
+from .model import TwitterBatch
+from .reconcile import persist_twitter
class SourceConfig(sqlmodel.SQLModel):
@@ -28,51 +24,33 @@ class CollectConfig(pydantic.BaseModel):
result_limit: int = pydantic.Field(default=40, ge=5, le=100)
-def _video_url(video) -> str | None:
- supported = tuple(
- variant
- for variant in video.variants
- if variant.url and variant.content_type in {None, "video/mp4"}
- )
- selected = max(supported, key=lambda variant: variant.bitrate or -1, default=None)
- return selected.url if selected is not None else None
-
-
-def tweet_to_graph(tweet) -> StarsGraphForm:
- """Map one Twitter API DTO into the persisted root and relation-owned links."""
- canonical = Tweet(
- id=tweet.id,
- user_id=tweet.user_id,
- conversation_id=tweet.conversation_id,
- quote=tweet.quote,
- text=tweet.text,
- )
- videos = tuple(
- (video, url) for video in tweet.videos if (url := _video_url(video)) is not None
- )
- return StarsGraphForm(
- block=TweetResolver.create_block(canonical),
- out_arcs=tuple(
- OutArcForm(
- relation=RelationForm(content=f"attachment:photo:{photo.id}"),
- to_graph=ImageResolver.create_graph(url=photo.url, alt_text=photo.alt_text),
- )
- for photo in tweet.photos
- )
- + tuple(
- OutArcForm(
- relation=RelationForm(content=f"attachment:video:{video.id}"),
- to_graph=VideoResolver.create_graph(url=url),
- )
- for video, url in videos
- )
- + tuple(
- OutArcForm(
- relation=RelationForm(content="entities:url"),
- to_graph=HTMLResolver.create_graph(url=url),
- )
- for url in tweet.urls
- ),
+def _selected_batch(batch: TwitterBatch, primary_ids: tuple[str, ...]) -> TwitterBatch:
+ posts = {post.id: post for post in batch.posts}
+ selected = set(primary_ids)
+ pending = list(primary_ids)
+ while pending:
+ post = posts[pending.pop()]
+ references = [post.repostOf]
+ if post.quote is not None:
+ references.append(post.quote.postId)
+ if post.replyTo is not None:
+ references.append(post.replyTo.postId)
+ for native_id in references:
+ if native_id is not None and native_id in posts and native_id not in selected:
+ selected.add(native_id)
+ pending.append(native_id)
+ chosen = [post for post in batch.posts if post.id in selected]
+ authors = {post.authorId for post in chosen}
+ media_ids = {native_id for post in chosen for native_id in post.mediaIds or ()}
+ for post in chosen:
+ if post.article is not None:
+ media_ids.update(post.article.mediaIds or ())
+ if post.article.coverMediaId is not None:
+ media_ids.add(post.article.coverMediaId)
+ return TwitterBatch(
+ posts=chosen,
+ users=[user for user in batch.users if user.id in authors],
+ media=[media for media in batch.media if media.id in media_ids],
)
@@ -107,35 +85,26 @@ async def collect(self, job: JobModel, config: pydantic.BaseModel) -> None:
api_client = await TwitterAPI.new()
bookmarks_res = await api_client.get_bookmarks(page=page, max_results=result_limit)
- # find new tweets start point
- old_start_at = len(bookmarks_res.tweets)
- if not full:
- state = await self.get_state()
- latest_tweet_id = state.get("latest_tweet_id")
- if latest_tweet_id:
- old_start_at = next(
- (
- i for i, tweet in enumerate(bookmarks_res.tweets) if tweet.id == latest_tweet_id
- ),
- len(bookmarks_res.tweets),
- )
-
- collected = []
- for tweet in (
- bookmarks_res.tweets if full else reversed(bookmarks_res.tweets[:old_start_at])
- ):
- collected.append(tweet_to_graph(tweet))
-
async with source_uow() as uow:
source = await uow.sources.get(self._id, lock=True)
if source is None:
raise ValueError("Twitter Source no longer exists")
- for graph in reversed(collected) if full else collected:
- await persist_stars(graph, uow.graph)
- if not full and bookmarks_res.tweets:
+ primary_ids = bookmarks_res.primary_post_ids
+ selected = primary_ids
+ if not full:
+ latest_id = str((source.state or {}).get("latest_tweet_id", ""))
+ if latest_id in primary_ids:
+ selected = primary_ids[: primary_ids.index(latest_id)]
+ if selected:
+ await persist_twitter(
+ _selected_batch(bookmarks_res.batch, selected),
+ uow.graph,
+ text_policy="supplement",
+ )
+ if not full and primary_ids:
source.state = {
**(source.state or {}),
- "latest_tweet_id": bookmarks_res.tweets[0].id,
+ "latest_tweet_id": primary_ids[0],
}
await uow.sources.save(source)
diff --git a/extensions/twitter/docs/global/.vitepress/config.mts b/extensions/twitter/docs/global/.vitepress/config.mts
new file mode 100644
index 00000000..fe74a532
--- /dev/null
+++ b/extensions/twitter/docs/global/.vitepress/config.mts
@@ -0,0 +1,17 @@
+import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts'
+
+export default {
+ ...extensionDocs({
+ name: 'inkcre/twitter',
+ title: 'Twitter / X 扩展',
+ scope: 'global',
+ description: '通过 x-monkey 保存帖子,或采集自己的 X 书签,并在 InKCre 中检索和阅读。',
+ sidebar: [
+ { text: '扩展概览', link: '/' },
+ { text: '用 x-monkey 保存帖子', link: '/x-monkey' },
+ { text: '授权与采集 Bookmark', link: '/bookmarks' },
+ { text: '检索、阅读与媒体', link: '/reading' },
+ ],
+ }),
+ lang: 'zh-CN',
+}
diff --git a/extensions/twitter/docs/global/.vitepress/theme/index.ts b/extensions/twitter/docs/global/.vitepress/theme/index.ts
new file mode 100644
index 00000000..e23b0685
--- /dev/null
+++ b/extensions/twitter/docs/global/.vitepress/theme/index.ts
@@ -0,0 +1 @@
+export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index'
diff --git a/extensions/twitter/docs/global/bookmarks.md b/extensions/twitter/docs/global/bookmarks.md
new file mode 100644
index 00000000..6ed5b3e6
--- /dev/null
+++ b/extensions/twitter/docs/global/bookmarks.md
@@ -0,0 +1,181 @@
+---
+description: 授权自己的 X 账号,创建 Bookmark Source 并观察一次采集。
+outline: false
+---
+
+# 授权与采集 Bookmark
+
+Bookmark 采集使用 X 官方 API 读取自己账号的书签,与
+[x-monkey 连接](./x-monkey.md)独立。你需要可访问相关 API 的 X 开发者应用,以及一条能辨认正文的近期书签。开始前检查应用的访问权限和计费;具体条件以
+[X Bookmarks 文档](https://docs.x.com/x-api/posts/bookmarks/introduction)为准。
+
+这条路径使用 OAuth 2.0 用户授权。应用级 app-only bearer token 不能代替账号授权;不要把 InKCre 的
+`JWT_SECRET` 填入 X。
+
+
+
+
+## 在 Web 中连接 X 账号
+
+1. 在所选 Core 和 **This browser** 安装、启用兼容的 `inkcre/twitter` 分发。在 **Extensions**
+ 打开 Twitter 的 **Setup**,选择在线 Core;仅启用浏览器分发不能执行采集。
+2. 复制 Setup 显示的 callback URL。在 X 开发者应用的 OAuth 2.0 设置中选择提供 Client ID 和 Client
+ Secret 的 confidential web-app 客户端,并完整登记该回调地址。
+3. 在 Setup 输入 OAuth **Client ID** 和 **Client Secret**,不是 API Key/Secret。点击 **Save OAuth
+ App**;部分界面显示 **Save application**。
+4. 点击 **Create authorization link**,再打开 **Open X
+ authorization**。批准要采集的账号,返回向导等待状态更新,并核对连接的 handle。一次授权需在十分钟内完成。
+
+扩展请求 `tweet.read`、`users.read`、`bookmark.read` 和
+`offline.access`;后者用于刷新 X 的账号授权。这些 X 凭据与 x-monkey 的 Peer
+JWT 会话是两套用途不同的授权。X 的具体设置见
+[OAuth 2.0 PKCE 说明](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code)。
+
+所有 Twitter Bookmark
+Sources 共用扩展当前连接的一个 X 账号。不要通过切换账号来创建另一个人的 Source;那会改变既有 Sources 使用的账号。替换 OAuth 应用可能断开当前账号,先阅读重置确认。
+
+## 设置并观察采集
+
+1. 选择已有 **Bookmark Source**,或输入昵称后点击 **Create Bookmark
+ Source**。已有目标 Source 时不要再创建一份。
+2. 设置 **Collect bookmarks daily at**,再点击
+ **Continue**。此时间交给 Core 的调度器解释,部署时区应由运行人员确认,不应仅按浏览器时区理解。
+3. 复核账号、Source 与时间,明确点击 **Start collecting
+ bookmarks**。它同时启用计划并提交一次立即采集 Job。
+4. 打开该 Source 的 Jobs,等待这次 Job 的实际结果,再按[检索与阅读](./reading.md)维护索引、搜索那条已知书签。点击启动并不表示采集已完成;不要再为同一 Source 添加第二份计划。
+
+
+
+
+## 通过 CLI 授权和采集
+
+这条路径不需要安装 Web Module Federation 分发。先将
+[CLI 连接到目标 Core](https://inkcre.dev/guide/connect-cli),确认该 Core 上的 Twitter
+Python 分发已启用,并在 `inkcre-cli source types` 中看到 `extensions.twitter.bookmark.Source`。
+
+### 1. 授权自己的 X 账号
+
+CLI 当前没有专用 OAuth 设置命令,下面的本地脚本直接调用 Core 已有的 setup
+API。使用“连接 CLI”步骤中的 Python 环境;`inkcre-cli` 的依赖已经提供 `httpx` 和
+`PyJWT`,脚本不需要 Web 分发。授权属于整个扩展,所有 Bookmark
+Sources 共用当前账号,不要把 OAuth 配置写进 Source。
+
+保存为 `connect-twitter.py`,在该 Python 环境中运行
+`python connect-twitter.py`。脚本先显示确切的回调地址;保持脚本运行,在 X 开发者应用中启用 OAuth
+2.0,选择提供 Client ID/Secret 的 confidential web-app 客户端,完整登记该地址,再继续。使用 OAuth
+Client ID/Secret,不是 API Key/Secret;设置说明见
+[X OAuth 2.0 文档](https://docs.x.com/fundamentals/authentication/oauth-2-0/authorization-code)。
+
+```python
+import getpass
+import time
+
+import httpx
+import jwt
+
+core_url = input("Core HTTPS URL: ").strip().rstrip("/")
+if not core_url.startswith("https://"):
+ raise ValueError("Use your trusted Core HTTPS URL, not PostgREST")
+secret = getpass.getpass("InKCre JWT_SECRET: ")
+if not secret:
+ raise ValueError("JWT_SECRET is required")
+
+with httpx.Client(timeout=30) as client:
+ def request(method, path, **kwargs):
+ issued = int(time.time()) - 5
+ token = jwt.encode(
+ {"role": "authenticated", "iss": "inkcre-peer", "aud": "inkcre-api",
+ "iat": issued, "exp": issued + 900},
+ secret, algorithm="HS256",
+ )
+ response = client.request(
+ method, core_url + path,
+ headers={"Authorization": f"Bearer {token}"}, **kwargs,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ status = request("GET", "/twitter/setup")
+ print("Register this callback URL in X:", status["callback_url"])
+ if not status["connected"]:
+ client_id = getpass.getpass("X OAuth Client ID: ").strip()
+ client_secret = getpass.getpass("X OAuth Client Secret: ")
+ if not client_id or not client_secret:
+ raise ValueError("Both OAuth app credentials are required")
+ request("PUT", "/twitter/setup/oauth-app", json={
+ "client_id": client_id, "client_secret": client_secret,
+ })
+ transaction = request("POST", "/twitter/setup/oauth-transactions")
+ print("Open this private authorization URL:", transaction["authorize_url"])
+ input("Authorize in X; after the callback says connected, press Enter: ")
+ result = request("POST", "/twitter/setup/oauth-transaction", json={
+ "transaction_id": transaction["id"],
+ })
+ if result["status"] != "succeeded":
+ raise RuntimeError(f"Authorization did not complete: {result['status']}")
+ status = request("GET", "/twitter/setup")
+ if not status["connected"]:
+ raise RuntimeError("Account is not connected; inspect setup status")
+ print("Connected account:", status["handle"])
+```
+
+在 X 批准要采集的账号,十分钟内完成浏览器授权;脚本会检查事务结果和最终连接的 handle。`offline.access`
+让扩展能够刷新 X 账号授权。凭据和脚本显示的授权链接都应保持私密,不要分享终端输出,也不要将
+`JWT_SECRET` 输入 X 页面。账号 token 保存在部署中,可由该部署已准入的 Peers 使用。
+
+回调地址缺失或不正确时,先修正 Core Peer 的 `http_public_base_url`,让它指向可公开访问的 Core
+HTTPS 基址并产生
+`/twitter/auth/callback`。新建授权会替代此前未完成的尝试。替换已配置的 OAuth 应用可能返回 HTTP
+409;不要盲目添加 `confirm_account_reset` 重试,因为替换会断开现有账号并影响全部 Bookmark Sources。
+
+### 2. 创建并采集 Source
+
+保存 `twitter.json`:
+
+```json
+{
+ "nickname": "我的 X 书签",
+ "config": {}
+}
+```
+
+```sh
+inkcre-cli source create --type extensions.twitter.bookmark.Source --input twitter.json
+```
+
+记录返回的 Source ID,替换下面的 `42`:
+
+```sh
+inkcre-cli source collect 42 --input-json '{"result_limit":40}'
+```
+
+记录返回的 Job
+ID,按既有[采集观察流程](https://inkcre.dev/guide/collect)确认完成,再[维护索引并阅读](./reading.md)。首次成功后,再按部署要求添加一个[采集计划](https://inkcre.dev/guide/schedules)。
+
+
+
+
+## 采集范围与停止方式
+
+一次普通采集只读取一页,`result_limit`
+为 5–100,默认 40;命中已记录的上一条主书签时,只保存它之前的新主书签,未命中则保存返回的本页。书签顺序变化或两次采集之间新增过多内容时,不能据此保证全部覆盖。
+
+`full=true` 只取消本页的旧书签截断,并在 Job
+state 记录下一页位置;当前实现不会自动提交下一页 Job 或排空历史,也没有独立的 Source
+backfill 实现。不要把它当作完整回填、文件夹同步、取消书签同步或全部回复采集。
+
+停止自动采集时先禁用对应 Cron,再在向导账号步骤选择
+**Disconnect**。也可以在 X 撤销应用。它们都不删除已经保存的信息;断开 x-monkey 会话则不会代替停止 Bookmark 计划。
+
+## 授权或采集失败时
+
+- **setup 404**:检查 Core 地址及 Twitter 在该 Core 上的运行状态。
+- **setup 401**:检查当前 InKCre Peer 连接;它与 X 的账号授权不同。
+- **X 授权失效 / 要求重新连接**:重新授权,核对账号和应用是否被替换或撤销。
+- **X 返回 402/403**:检查应用访问权限、额度和账号授权;**429** 表示应等待供应商限流恢复。
+- **Job 完成但没有新信息**:核对连接的 handle,新增一条可辨认的书签后再运行,不重置已有 Source 游标来试错。
+
+另有 `twikit` 账号登录后端;它不是上述 OAuth 向导流程。运行人员可从 Python
+scope 查看实际配置和本地会话文件要求。
+
+返回[扩展概览](./index.md)。
diff --git a/extensions/twitter/docs/global/images/x-monkey-read.jpg b/extensions/twitter/docs/global/images/x-monkey-read.jpg
new file mode 100644
index 00000000..9aff8b29
Binary files /dev/null and b/extensions/twitter/docs/global/images/x-monkey-read.jpg differ
diff --git a/extensions/twitter/docs/global/images/x-monkey-sync.jpg b/extensions/twitter/docs/global/images/x-monkey-sync.jpg
new file mode 100644
index 00000000..be3fb7f4
Binary files /dev/null and b/extensions/twitter/docs/global/images/x-monkey-sync.jpg differ
diff --git a/extensions/twitter/docs/global/index.md b/extensions/twitter/docs/global/index.md
new file mode 100644
index 00000000..6f07fd33
--- /dev/null
+++ b/extensions/twitter/docs/global/index.md
@@ -0,0 +1,38 @@
+---
+description: 通过 x-monkey 保存帖子,或采集自己的 X 书签,并在 InKCre 中检索和阅读。
+---
+
+# Twitter / X 扩展
+
+`inkcre/twitter`
+把 X 上的信息保存到共享 info-base,提供两种独立入口。保存后的帖子、作者和媒体元数据属于信息库,不依赖采集脚本或某个 Peer 持续运行。
+
+| 你要做什么 | 使用路径 | 需要的授权 |
+| -------------------------- | ------------------------------------- | --------------------------------------------------------------------- |
+| 保存浏览时已捕获的帖子 | [连接 x-monkey](./x-monkey.md) | 在自己的 Core 账户页使用现有 `JWT_SECRET` 连接,不需要 X OAuth 应用。 |
+| 定期采集自己的书签 | [授权与采集 Bookmark](./bookmarks.md) | X OAuth 应用、自己的 X 账号授权,以及 Source 和采集 Job。 |
+| 保存后找回、阅读和查看媒体 | [检索、阅读与媒体](./reading.md) | 已连接同一 info-base 的 InKCre 界面,以及兼容的 Twitter 阅读分发。 |
+
+两种入口按原生帖子 ID 补充同一份信息。已捕获的正文、长文、作者和上下文可以继续使用;缺少的内容不会被编造,也不会在阅读时补抓完整线程。
+
+## 先准备扩展
+
+在实际执行能力的 Peer 上安装、启用兼容的 Distribution。Core 运行 Python 分发,负责协议接入、x-monkey 账户页和 Bookmark 采集;Web 的 Module
+Federation 分发提供 Setup 和内容视图。x-monkey 可以直接从 Core 账户页连接,不要求安装 Web 分发;需要在 Web 中配置或阅读时,先[连接 InKCre](https://inkcre.dev/guide/connect)。各分发独立选择版本,不要求版本数字相同。
+
+Python 分发要求 Core Host SDK `>=0.3.1 <0.4.0`,Web 分发要求 `@inkcre/core` Host SDK
+`>=0.4.0 <0.5.0`。仅安装扩展并不表示它已经在所选 Peer 上运行。x-monkey 和 OAuth 回调需要浏览器可访问的 Core 公共 HTTPS 地址;服务地址保留部署路径前缀,Web
+Setup 也可以提供地址供复制。
+
+运行人员可从同一 Release 的 Registry 文档入口选择 **Python** scope,查看运行配置、采集参数和 setup
+API;本组 **global** 文档说明用户工作流。不同 scope 是独立文档站,不通过拼接 URL 切换。
+
+## 已保存的内容与限制
+
+本版本保存原生帖子、作者、媒体元数据和已捕获引用,不自动转存图片或视频文件。只有用户明确加载媒体时才读取源站字节;源站失效后,文字和元数据仍保留。具体行为见[媒体边界](./reading.md#媒体按需读取)。
+
+保存内容采用 Twitter 扩展自己的共同模型。各入口只写入当前适配器已支持的源事实,不保存官方 API 或 Twikit 的完整原始响应;未知且未映射的字段不能当作已经归档的信息。
+
+x-monkey 接入不提供远端历史浏览、搜索恢复或未捕获线程补抓。Bookmark 的一次 Job 读取一页,不保证全量回填或同步删除。采集完成与检索索引维护是两个动作,新增内容需要按既有流程维护索引后再搜索。
+
+操作截图来自隔离预览,连接时使用自己的 Core 服务地址,不照抄截图中的预览域名。
diff --git a/extensions/twitter/docs/global/reading.md b/extensions/twitter/docs/global/reading.md
new file mode 100644
index 00000000..a176d638
--- /dev/null
+++ b/extensions/twitter/docs/global/reading.md
@@ -0,0 +1,40 @@
+---
+description: 找回已保存的帖子,阅读正文和上下文,并显式加载外部媒体。
+---
+
+# 检索、阅读与媒体
+
+[x-monkey](./x-monkey.md) 与 [Bookmark](./bookmarks.md)
+保存的信息都可从 InKCre 独立使用。关闭脚本、断开提交会话或停止采集计划,不会删除已经写入 info-base 的记录。Web 需要能够解释当前 Twitter 记录的兼容分发。
+
+## 保存后找回同一帖子
+
+1. 先确认脚本的逐项同步结果,或 Bookmark 采集 Job 的实际完成状态。
+2. 按既有[索引与搜索流程](https://inkcre.dev/guide/search)维护词法索引,等待维护完成。提交和采集不会隐式维护索引,不需要为了这条检索路径配置 AI 凭据。
+3. 用原帖 ID、原始链接、作者或正文线索搜索,在 Info Base 打开结果。检查正文、长文和 **Original
+ post** 出处;已捕获的作者及引用、回复、转帖上下文可以继续导航。
+
+只知道原生 ID 的引用会显示为未捕获的身份,不伪造正文或作者资料。纯转帖也不补出一段假正文。旧 v1 帖子仍保留读取能力;重新采集到足够原生事实后可沿用原 Block
+ID 升级,既有用户引用继续有效。历史上已经存在的重复不会在本版本全量清理。
+
+## 媒体按需读取
+
+图片、视频或 GIF 初始只显示已经捕获的元数据。需要查看时点击 **Load
+media**;Twitter 阅读分发才从已捕获的媒体 URL 经 HTTP
+Storage 读取字节。保存、query、列表、正文投影与索引不会提前下载这些原生媒体。
+
+
+
+这是隔离预览中真实保存的同一帖子,照片在用户明确加载后显示。本版本不预建持久媒体内容 Block,不转存媒体文件,也不创建媒体下载 Job;**Load
+media** 不会将字节保存在信息库。
+
+图片使用已捕获的 URL,视频和 GIF 从已捕获的可用 MP4 variants 中选择最高码率;随后由现有 HTTP
+Storage 按通用 HTTP(S)、超时、体积与重定向规则读取。HLS 不作为视频本体加载,URL 和其他元数据仍保留,即使它们不能用于当前读取。
+
+## 来源失效或内容不完整时
+
+源站 URL 失效、超时、体积超限或没有可用地址时,界面会反馈媒体不可用;文字和元数据仍可读取。重新打开内容不代表媒体已经长期保全,不应把“已同步”理解为文件备份成功。
+
+阅读只解释已经保存的内容,不额外抓取未捕获的完整线程、引用正文或历史。x-monkey 与 Bookmark 的源信息密度不同,后来的稀疏信息不会抹掉未提交的已知内容;计数按各项实际观察时间合并,较旧观察不会覆盖较新值。
+
+返回[扩展概览](./index.md)。
diff --git a/extensions/twitter/docs/global/x-monkey.md b/extensions/twitter/docs/global/x-monkey.md
new file mode 100644
index 00000000..3bd080a1
--- /dev/null
+++ b/extensions/twitter/docs/global/x-monkey.md
@@ -0,0 +1,61 @@
+---
+description: 将原版 x-monkey 连接到自己的 InKCre,保存已捕获的帖子。
+---
+
+# 用 x-monkey 保存帖子
+
+此入口接收原版 [x-monkey](https://github.com/steven-vvv/x-monkey)
+捕获的帖子、作者和媒体元数据,不需要 X OAuth 应用、Bookmark Source 或采集计划。兼容基线为 x-monkey
+`v0.2.0`。
+
+## 连接自己的 InKCre
+
+准备可从浏览器访问的 Core 公共 HTTPS 地址、该部署现有的
+`JWT_SECRET`,以及正在使用的原版 x-monkey 脚本。在 Core 安装并启用兼容的 Twitter
+Python 分发;建立此连接不要求安装 InKCre Web 或 Twitter Web 分发。
+
+1. 在 x-monkey 打开 **设置 → 远端数据库**,启用远端数据库,将 Core 公共基址加 `/twitter/x-monkey`
+ 填入 **服务地址**,保存并点击 **检测**。地址必须保留部署路径前缀。
+2. 显示“需要登录”时,点击“打开账户页”,进入同一服务地址下的
+ `/account`。先确认地址栏是自己信任的 Core HTTPS 部署。
+3. 在该账户页输入这个部署已有的 `JWT_SECRET`,点击 **Connect**。页面在浏览器内签发短期 Peer
+ JWT,Core 验证后建立归档会话;不要把密钥填进脚本或 X 页面。
+4. 页面检测到浏览器中的实际会话后,显示 **Connected**、已连接的 Peer 和会话到期时间。返回脚本重新
+ **检测**,确认已连接再开始同步。
+
+如果使用 InKCre Web,也可以在 Twitter **Setup** 中选择 Core,复制 **x-monkey service address**
+并通过普通链接打开账户页,完成相同步骤。该链接不携带凭据。
+
+密钥输入只用于当前页面的本地签名,页面不持久化它,也不把它发送给服务器;服务器接收的是签发出的 Peer
+JWT。只在自己信任的部署页面输入密钥:浏览器内签名不能保护被篡改或恶意的页面,它们仍可能读取输入。页面不加载第三方脚本,不接受任意回跳地址。
+
+## 同步已捕获的帖子
+
+在 X 打开帖子,让 x-monkey 捕获它,再在脚本中打开该帖详情。找到 **远端归档**,点击
+**同步此帖**,检查帖子、作者及媒体的逐项结果。**同步全部**也可能包含已经捕获的上下文;它不会让 InKCre 补抓尚未捕获的完整线程或历史。
+
+
+
+图中是预览环境的真实同步结果,展示的是连接后的同步操作;使用自己配置的服务地址,记录数量取决于脚本捕获的内容。“媒体已同步”表示媒体元数据已保存,不表示图片或视频文件已经转存。
+
+再次同步同一原生帖子会补充同一个信息根,保留未提交的已知字段,并按计数观察时间合并更新。Bookmark 采集到同一帖子时也补充这份信息。较旧捕获可能继续显示计数差异;不能仅凭差异判断保存失败。
+
+保存后,按[检索与阅读](./reading.md)找回同一帖子。关闭脚本或结束提交会话后,已保存的信息仍在。
+
+## 续签、过期与断开
+
+此会话具有现有 Peer
+JWT 的完整信息库访问权限,默认有效期为 7 天。有效的会话检测、查询和提交请求在剩余期限不超过当前配置寿命的一半时自动续签。部署者可以调整签发期限;普通 Core 请求不会续签该会话。
+
+过期后需回到同一 Core 账户页重新连接,不需要额外的 refresh
+token。活跃会话没有从首次连接开始计算的绝对续签上限。账户页的断开操作只清除此浏览器的会话,不删除已保存信息,也不撤销已经复制的 JWT。
+
+## 检测或同步失败时
+
+- **需要登录 /
+ 401**:打开自己 Core 的账户页重新连接,然后返回脚本检测。不要把 Core 的 JWT_SECRET 填入脚本来代替连接流程。
+- **账户页或协议返回 404**:检查服务地址、部署路径前缀,以及 Twitter 是否正在所选 Core 上运行。
+- **结果失败或请求超时**:保留捕获内容,检查实际请求结果再重试。成功保存后重复提交仍补充同一帖子,不必创建第二个存储后端。
+- **媒体读取失败**:按[媒体边界](./reading.md#媒体按需读取)处理;元数据同步成功不保证外部媒体 URL 长期可用。
+
+返回[扩展概览](./index.md),或继续配置独立的 [Bookmark 采集](./bookmarks.md)。
diff --git a/extensions/twitter/docs/python/.vitepress/config.mts b/extensions/twitter/docs/python/.vitepress/config.mts
new file mode 100644
index 00000000..5aebf784
--- /dev/null
+++ b/extensions/twitter/docs/python/.vitepress/config.mts
@@ -0,0 +1,12 @@
+import { extensionDocs } from '../../../../../docs/_shared/website/extension-docs/preset.mts'
+
+export default {
+ ...extensionDocs({
+ name: 'inkcre/twitter',
+ title: 'Twitter Core 运行与配置',
+ scope: 'python',
+ description: '在 Core 上运行 Twitter 的协议接入和 Bookmark 采集,配置授权及运行参数。',
+ sidebar: [{ text: '运行与配置', link: '/' }],
+ }),
+ lang: 'zh-CN',
+}
diff --git a/extensions/twitter/docs/python/.vitepress/theme/index.ts b/extensions/twitter/docs/python/.vitepress/theme/index.ts
new file mode 100644
index 00000000..e23b0685
--- /dev/null
+++ b/extensions/twitter/docs/python/.vitepress/theme/index.ts
@@ -0,0 +1 @@
+export { default } from '../../../../../../docs/_shared/website/.vitepress/theme/index'
diff --git a/extensions/twitter/docs/python/index.md b/extensions/twitter/docs/python/index.md
new file mode 100644
index 00000000..b4e3e866
--- /dev/null
+++ b/extensions/twitter/docs/python/index.md
@@ -0,0 +1,110 @@
+---
+description: Twitter Python 分发的宿主要求、公共地址、授权配置与采集参数。
+---
+
+# Core 运行与配置
+
+Python 分发在所选 Core 上提供 x-monkey 归档协议与自托管账户页、Bookmark Source 及 Twitter
+Resolver。连接 x-monkey 不要求 Web 分发;需要 Web Setup 或阅读界面时,再选择兼容的 Module
+Federation 分发。用户工作流由本扩展的 **global** 文档说明。
+
+Python 分发要求 Python `>=3.12,<3.13` 和 Core Host SDK
+`>=0.3.1 <0.4.0`。选择精确分发、启用意图和实际运行属于相应 Peer;扩展配置与账号状态由部署共享。
+
+## 公共地址与生命周期
+
+在执行该能力的 Core Peer 配置
+`http_public_base_url`,使用浏览器或脚本实际可访问的 HTTPS 基址。Setup 由此产生
+`/twitter/auth/callback` 和 `/twitter/x-monkey` 地址,x-monkey 账户页位于后者的
+`/account`;所有地址保留部署路径前缀。不要使用 PostgREST 地址代替 Core 地址,也不要把预览环境地址作为其他部署的默认值。
+
+启用 Python 分发后才会注册 Source、Resolver 和 HTTP 路由;停用撤回入口,不删除已保存的图信息。Web 独立启用不会让离线 Core 执行采集,安装状态也不等于实际运行状态。多 Peer 可以具备采集能力,但同一 Source 不应配置重复的采集计划。
+
+## 扩展配置
+
+下表是 `TwitterExtensionConfig`
+的实际字段,通过既有扩展配置管理接口修改。OAuth 向导会同时维护相应账号状态,优先使用向导配置官方后端。
+
+| 字段 | 默认值 | 用途 |
+| ------------------------------- | ---------- | --------------------------------------------------------------- |
+| `backend` | `official` | `official` 使用 X OAuth 用户授权;`twikit` 使用账号登录。 |
+| `client_id`、`client_secret` | 空字符串 | 官方 OAuth 应用的 Client ID/Secret,不是 API Key/Secret。 |
+| `email`、`username`、`password` | 空字符串 | `twikit` 登录所需账号信息。 |
+| `totp_secret` | `null` | `twikit` 可选 TOTP 秘密。 |
+| `api_language` | `en-US` | 传给 `twikit` 客户端的请求语言。 |
+| `proxy` | `null` | 传给 `twikit` 客户端的代理;当前官方 OAuth 客户端不读取此字段。 |
+
+保存 OAuth 应用会把 `backend` 设为 `official`。如果应用变更会影响已有账号或待完成授权,setup
+API 要求显式
+`confirm_account_reset`;配置指纹改变会清除不再匹配的账号和授权事务。不要盲目确认重置或直接编辑持久 state 来绕过它。
+
+官方后端把账号 token、user
+ID、handle 和续期状态保存在扩展 state;账号授权属于整个扩展,不属于某个 Source。官方客户端使用当前授权刷新 X
+token,供应商拒绝授权时标记需要重新连接。凭据及授权链接不应进入日志或截图。
+
+`twikit` 在实际采集时登录,使用 `data/extensions/twitter/twikit_cookies.json`
+作为运行实例的 cookie 文件。该路径需要可写并保护其中的会话数据;它不是共享 info-base 的账号状态,也不会自动在多个 Core 之间同步。OAuth 向导不能代替这套账号登录配置。改变后端或账号后应在正常生命周期中停用并重新启用,以结束旧的本地客户端。
+
+## setup API 与认证边界
+
+运行人员或 Agent 可通过既有 Peer HTTP 方式调用下列 Core 路由。setup API 要求 Peer JWT
+Bearer;CLI 当前没有专用 OAuth 设置命令。
+
+| 方法与路径 | 作用 |
+| ---------------------------------------- | ----------------------------------------------------------------------- |
+| `GET /twitter/setup` | 读取 callback URL、可用 x-monkey 基址和账号连接状态。 |
+| `PUT /twitter/setup/oauth-app` | 提交 `client_id`、`client_secret`,必要时明确 `confirm_account_reset`。 |
+| `POST /twitter/setup/oauth-transactions` | 创建十分钟有效的授权事务,返回供用户打开的私有授权链接。 |
+| `POST /twitter/setup/oauth-transaction` | 以 `transaction_id` 读取该事务结果。 |
+| `DELETE /twitter/setup/account` | 断开扩展当前连接的 X 账号,不删除已采集信息。 |
+
+用户仍需在 X 页面批准正确账号,浏览器回调为
+`/twitter/auth/callback`。新建授权会替代此前未完成的尝试;替换应用或账号会影响所有 Bookmark
+Sources。
+
+x-monkey 的
+`/twitter/x-monkey/api/v1/session`、`/twitter/x-monkey/api/v1/archive/query`、`/twitter/x-monkey/api/v1/archive/submit`
+使用协议专用 Cookie,与以上 Bearer 路由分开。首次连接从 Core 自托管的
+`GET /twitter/x-monkey/account` 开始:用户在可信部署页面输入现有
+`JWT_SECRET`,页面用 WebCrypto 在浏览器内签发 60 秒的普通 Peer JWT,并以同 Origin
+`Authorization: Bearer` 和 JSON `{}` 请求 `POST /twitter/x-monkey/session`。该路由复用
+`require_peer_jwt`,要求 Origin 精确匹配 Core 且 JWT 不带
+`session_use`。Core 为相同完整 Peer 权限签发带 `session_use=twitter_archive`
+的会话 JWT,Cookie 路径不构成降权。Cookie 为 Secure、HttpOnly、SameSite=None,要求正式链路使用 HTTPS;普通 Core 保护路由不读取它。
+
+连接路由返回 `200 SessionResponse` JSON 和 Cookie。页面随后请求
+`GET /twitter/x-monkey/api/v1/session`,确认 `authenticated=true` 后才显示
+**Connected**、Peer 与到期时间;不能只凭 POST 成功认为浏览器已接受 Cookie。
+
+账户页用随机 nonce 限定脚本,CSP 的 `connect-src 'self'`
+只允许同源请求。它不持久化或经网络发送密钥,不加载第三方脚本,不接受任意回跳;密钥输入与签名由该部署提供的页面拥有,只应信任自己确认的 HTTPS 页面。页面被篡改时,本地签名不能保护输入。用户完成后返回脚本再次检测。可选的 Web
+Setup 仅展示 `x_monkey_base_url` 和账户页普通链接,不参与签名或凭据传递。Web 分发的 Host SDK 范围为
+`@inkcre/core >=0.4.0 <0.5.0`,Python Host 范围保持不变。
+
+## x-monkey 寿命配置
+
+`TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS` 是 Core 环境配置,不是扩展 JSON 配置字段。默认 `604800`
+秒,即 7 天,只接受正整数。首次连接与续签使用同一配置;有效协议请求在剩余期限不超过当前配置寿命的一半时续签。
+
+此值是以后签发 token 的默认期限,不是 JWT 准入上限;降低它不会撤销仍有效的既有 token。它与普通 Core 签发使用的
+`JWT_DEFAULT_LIFETIME_SECONDS` 分开。没有额外 PAT、refresh
+token、会话表或从首次授权计算的绝对上限。断开只清理当前浏览器 Cookie,不能撤销复制的 JWT;部署更换原有 JWT_SECRET 后按既有机制失效。
+
+## Bookmark Source 与采集参数
+
+Source 类型为 `extensions.twitter.bookmark.Source`,Source
+config 当前为空对象。普通采集通过既有 Source Job 执行,参数如下:
+
+| 参数 | 默认值 | 实际行为 |
+| -------------- | ------- | -------------------------------------------------------------------------------------------------------------- |
+| `result_limit` | `40` | 整数,5–100,作为本次一页请求的结果上限。 |
+| `full` | `false` | `false` 时按 `latest_tweet_id` 截断本页旧书签并更新主书签游标;`true` 时不截断,并将下一页位置记到 Job state。 |
+
+每次 Job 只请求一页,不自动提交后续 Job;`full`
+不等于完整回填。此 Source 没有独立 backfill 实现。分页位置和 Source 游标是运行状态,不应当成账号配置手工重置来排查授权故障。外部请求失败与数据库写入失败不能被报告为成功采集。
+
+媒体只保存原生元数据,Python 写入不创建持久图片/视频内容 Block、媒体 blob 或下载 Job。只有消费端明确展开时才通过现有 HTTP
+Storage 读取源站字节。词法索引维护独立执行,不在 submit 或 collect 中隐式完成。
+
+持久内容由 Twitter 自有共同模型拥有,x-monkey 固定 DTO 在适配边界转换。官方 API 和 Twikit 只投影当前支持的字段,不保存全部原始响应。消费端选择图片或最高码率的可用 MP4 后,由通用 HTTP
+Storage 负责 HTTP(S)、超时、体积与重定向;Twitter 不另设 CDN 或固定端口限制。
diff --git a/extensions/twitter/model.py b/extensions/twitter/model.py
new file mode 100644
index 00000000..22775342
--- /dev/null
+++ b/extensions/twitter/model.py
@@ -0,0 +1,391 @@
+"""Twitter-owned persisted facts, independent of capture-provider wire contracts."""
+
+from __future__ import annotations
+
+import datetime
+import re
+from typing import Annotated, Literal
+
+import pydantic
+
+
+TwitterID = Annotated[str, pydantic.Field(strict=True, pattern=r"^[1-9][0-9]*$")]
+Count = Annotated[int, pydantic.Field(strict=True, ge=0)]
+Number = Annotated[int | float, pydantic.Field(allow_inf_nan=False)]
+
+
+def _validate_instant(value: str) -> str:
+ if not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", value):
+ raise ValueError("must be a UTC timestamp with second precision")
+ datetime.datetime.fromisoformat(value)
+ return value
+
+
+def _validate_observed_at(value: str) -> str:
+ if not re.fullmatch(
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z", value
+ ):
+ raise ValueError("must be a UTC ISO-8601 timestamp including seconds")
+ datetime.datetime.fromisoformat(value)
+ return value
+
+
+Instant = Annotated[str, pydantic.AfterValidator(_validate_instant)]
+ObservedAt = Annotated[str, pydantic.AfterValidator(_validate_observed_at)]
+
+
+def _validate_range(value: tuple[int, int]) -> tuple[int, int]:
+ if value[1] < value[0]:
+ raise ValueError("range end before start")
+ return value
+
+
+Range = Annotated[
+ tuple[Count, Count],
+ pydantic.Field(strict=False),
+ pydantic.AfterValidator(_validate_range),
+]
+Rect = Annotated[tuple[Count, Count, Count, Count], pydantic.Field(strict=False)]
+
+
+class _TwitterModel(pydantic.BaseModel):
+ model_config = pydantic.ConfigDict(extra="forbid", strict=True, allow_inf_nan=False)
+
+ @pydantic.field_validator("*")
+ @classmethod
+ def reject_explicit_null(cls, value: object) -> object:
+ if value is None:
+ raise ValueError("omit unknown fields instead of sending null")
+ return value
+
+
+class UrlEntity(_TwitterModel):
+ type: Literal["url"]
+ range: Range
+ expanded: str
+
+
+class MentionEntity(_TwitterModel):
+ type: Literal["mention"]
+ range: Range
+ userId: TwitterID
+
+
+class HashtagEntity(_TwitterModel):
+ type: Literal["hashtag"]
+ range: Range
+
+
+class CashtagEntity(_TwitterModel):
+ type: Literal["cashtag"]
+ range: Range
+ name: str | None = None
+ ticker: str | None = None
+
+
+class TimestampEntity(_TwitterModel):
+ type: Literal["timestamp"]
+ range: Range
+
+
+Entity = Annotated[
+ UrlEntity | MentionEntity | HashtagEntity | CashtagEntity | TimestampEntity,
+ pydantic.Field(discriminator="type"),
+]
+
+
+class TextStyle(_TwitterModel):
+ range: Range
+ styles: list[str] = pydantic.Field(min_length=1)
+
+
+class RichText(_TwitterModel):
+ text: str
+ visible: Range | None = None
+ entities: list[Entity] | None = pydantic.Field(default=None, min_length=1)
+ styles: list[TextStyle] | None = pydantic.Field(default=None, min_length=1)
+
+
+class UserCounts(_TwitterModel):
+ followers: Count | None = None
+ following: Count | None = None
+ posts: Count | None = None
+ mediaPosts: Count | None = None
+ likes: Count | None = None
+
+
+class UserCountsObservedAt(_TwitterModel):
+ followers: ObservedAt | None = None
+ following: ObservedAt | None = None
+ posts: ObservedAt | None = None
+ mediaPosts: ObservedAt | None = None
+ likes: ObservedAt | None = None
+
+
+class Affiliation(_TwitterModel):
+ name: str
+ url: str | None = None
+ badgeUrl: str | None = None
+ kind: str | None = None
+ ownerId: TwitterID | None = None
+
+
+class Verification(_TwitterModel):
+ blue: bool | None = None
+ type: str | None = None
+
+
+class Professional(_TwitterModel):
+ type: str | None = None
+ categories: list[str] | None = pydantic.Field(default=None, min_length=1)
+
+
+class TwitterUser(_TwitterModel):
+ id: TwitterID
+ handle: str
+ name: str
+ createdAt: Instant | None = None
+ avatarUrl: str | None = None
+ avatarShape: str | None = None
+ bannerUrl: str | None = None
+ bio: RichText | None = None
+ location: str | None = None
+ website: str | None = None
+ pinnedPostIds: list[TwitterID] | None = pydantic.Field(default=None, min_length=1)
+ protected: bool | None = None
+ sensitive: bool | None = None
+ interstitial: str | None = None
+ withheldIn: list[str] | None = pydantic.Field(default=None, min_length=1)
+ verified: Verification | None = None
+ affiliation: Affiliation | None = None
+ parody: str | None = None
+ professional: Professional | None = None
+ counts: UserCounts | None = None
+ countsObservedAt: UserCountsObservedAt | None = None
+
+
+class VideoVariant(_TwitterModel):
+ url: str
+ type: str
+ bitrate: Count | None = None
+
+
+class MediaTag(_TwitterModel):
+ userId: TwitterID
+ handle: str | None = None
+
+
+class Video(_TwitterModel):
+ durationMs: Count | None = None
+ variants: list[VideoVariant] = pydantic.Field(min_length=1)
+
+
+class TwitterMedia(_TwitterModel):
+ id: TwitterID
+ type: Literal["photo", "video", "gif"]
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+ alt: str | None = None
+ crops: list[Rect] | None = pydantic.Field(default=None, min_length=1)
+ faces: list[Rect] | None = pydantic.Field(default=None, min_length=1)
+ tags: list[MediaTag] | None = pydantic.Field(default=None, min_length=1)
+ video: Video | None = None
+ sourcePostId: TwitterID | None = None
+ sourceUserId: TwitterID | None = None
+ sensitive: list[str] | None = pydantic.Field(default=None, min_length=1)
+ unavailable: str | None = None
+ title: str | None = None
+ description: str | None = None
+ link: str | None = None
+
+
+class PollChoice(_TwitterModel):
+ label: str
+ count: Count | None = None
+
+
+class Poll(_TwitterModel):
+ choices: list[PollChoice] = pydantic.Field(min_length=2)
+ endsAt: Instant | None = None
+ final: bool | None = None
+ updatedAt: Instant | None = None
+
+
+class CardImage(_TwitterModel):
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+ alt: str | None = None
+
+
+class CardPlayer(_TwitterModel):
+ url: str
+ width: Count | None = None
+ height: Count | None = None
+
+
+class Card(_TwitterModel):
+ type: str
+ url: str
+ title: str | None = None
+ description: str | None = None
+ domain: str | None = None
+ image: CardImage | None = None
+ siteUserId: TwitterID | None = None
+ creatorUserId: TwitterID | None = None
+ player: CardPlayer | None = None
+ poll: Poll | None = None
+ extra: dict[str, str | bool] | None = None
+
+
+class ArticleBlock(_TwitterModel):
+ type: str
+ text: str
+ styles: list[Annotated[tuple[Count, Count, str], pydantic.Field(strict=False)]] | None = (
+ pydantic.Field(default=None, min_length=1)
+ )
+ entities: (
+ list[Annotated[tuple[Count, Count, Count], pydantic.Field(strict=False)]] | None
+ ) = pydantic.Field(default=None, min_length=1)
+ data: dict[str, pydantic.JsonValue] | None = None
+
+
+class ArticleEntity(_TwitterModel):
+ type: str
+ data: dict[str, pydantic.JsonValue]
+
+
+class Article(_TwitterModel):
+ id: TwitterID
+ title: str
+ preview: str | None = None
+ coverMediaId: TwitterID | None = None
+ publishedAt: Instant | None = None
+ modifiedAt: Instant | None = None
+ blocks: list[ArticleBlock] | None = pydantic.Field(default=None, min_length=1)
+ entities: list[ArticleEntity] | None = pydantic.Field(default=None, min_length=1)
+ mediaIds: list[TwitterID] | None = pydantic.Field(default=None, min_length=1)
+
+
+class Place(_TwitterModel):
+ id: str
+ name: str | None = None
+ fullName: str | None = None
+ country: str | None = None
+ countryCode: str | None = None
+ type: str | None = None
+ bbox: (
+ Annotated[tuple[Number, Number, Number, Number], pydantic.Field(strict=False)] | None
+ ) = None
+
+
+class PostCounts(_TwitterModel):
+ replies: Count | None = None
+ reposts: Count | None = None
+ quotes: Count | None = None
+ likes: Count | None = None
+ bookmarks: Count | None = None
+ views: Count | None = None
+
+
+class PostCountsObservedAt(_TwitterModel):
+ replies: ObservedAt | None = None
+ reposts: ObservedAt | None = None
+ quotes: ObservedAt | None = None
+ likes: ObservedAt | None = None
+ bookmarks: ObservedAt | None = None
+ views: ObservedAt | None = None
+
+
+class ReplyTo(_TwitterModel):
+ postId: TwitterID
+ userId: TwitterID | None = None
+ handle: str | None = None
+ tombstone: str | None = pydantic.Field(default=None, min_length=1)
+
+
+class Quote(_TwitterModel):
+ postId: TwitterID
+ url: str | None = None
+ tombstone: str | None = pydantic.Field(default=None, min_length=1)
+
+
+class CommunityNote(_TwitterModel):
+ id: TwitterID | None = None
+ title: str | None = None
+ text: RichText
+ lang: str | None = None
+ url: str | None = None
+
+
+class ContentWarning(_TwitterModel):
+ title: str
+ text: str | None = None
+
+
+class Community(_TwitterModel):
+ id: TwitterID
+ name: str | None = None
+
+
+class AIGenerated(_TwitterModel):
+ media: bool
+ source: str
+
+
+class TwitterPost(_TwitterModel):
+ id: TwitterID
+ authorId: TwitterID
+ createdAt: Instant
+ repostOf: TwitterID | None = None
+ conversationId: TwitterID | None = None
+ replyTo: ReplyTo | None = None
+ quote: Quote | None = None
+ text: RichText | None = None
+ lang: str | None = None
+ source: str | None = None
+ mediaIds: list[TwitterID] | None = pydantic.Field(default=None, min_length=1)
+ card: Card | None = None
+ article: Article | None = None
+ place: Place | None = None
+ counts: PostCounts | None = None
+ countsObservedAt: PostCountsObservedAt | None = None
+ editIds: list[TwitterID] | None = pydantic.Field(default=None, min_length=2)
+ communityNote: CommunityNote | None = None
+ sensitive: bool | None = None
+ contentWarning: ContentWarning | None = None
+ community: Community | None = None
+ replyPolicy: str | None = None
+ paidPromotion: bool | None = None
+ aiGenerated: AIGenerated | None = None
+
+
+class TwitterIdentity(_TwitterModel):
+ """A known native identity without captured entity content."""
+
+ id: TwitterID
+
+
+class TwitterBatch(_TwitterModel):
+ """Facts captured for one common reconcile operation; transport limits stay outside."""
+
+ users: list[TwitterUser] = pydantic.Field(default_factory=list)
+ posts: list[TwitterPost] = pydantic.Field(default_factory=list)
+ media: list[TwitterMedia] = pydantic.Field(default_factory=list)
+
+
+TwitterRecord = TwitterUser | TwitterPost | TwitterMedia
+TextPolicy = Literal["replace", "supplement"]
+
+
+__all__ = [
+ "TextPolicy",
+ "TwitterBatch",
+ "TwitterID",
+ "TwitterIdentity",
+ "TwitterMedia",
+ "TwitterPost",
+ "TwitterRecord",
+ "TwitterUser",
+]
diff --git a/extensions/twitter/pdm.lock b/extensions/twitter/pdm.lock
index f3c8dd6f..9b6aa030 100644
--- a/extensions/twitter/pdm.lock
+++ b/extensions/twitter/pdm.lock
@@ -5,11 +5,99 @@
groups = ["default"]
strategy = ["inherit_metadata"]
lock_version = "4.5.0"
-content_hash = "sha256:b28244fe9332fd52d2f6c6a5ad59826451c72e3844da288d912be315ba38ce46"
+content_hash = "sha256:8f48b4fabbeb47e095370059f4fcbfa900ec72fd4c677199be1518dba61edb9e"
[[metadata.targets]]
requires_python = "==3.12.*"
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+requires_python = ">=3.10"
+summary = "Happy Eyeballs for asyncio"
+groups = ["default"]
+files = [
+ {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"},
+ {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"},
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.14.5"
+requires_python = ">=3.10"
+summary = "Async http client/server framework (asyncio)"
+groups = ["default"]
+dependencies = [
+ "aiohappyeyeballs>=2.5.0",
+ "aiosignal>=1.4.0",
+ "async-timeout<6.0,>=4.0; python_version < \"3.11\"",
+ "attrs>=17.3.0",
+ "frozenlist>=1.1.1",
+ "multidict<8.0,>=4.5",
+ "propcache>=0.2.0",
+ "typing-extensions>=4.4; python_version < \"3.13\"",
+ "yarl<2.0,>=1.25.1",
+]
+files = [
+ {file = "aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450"},
+ {file = "aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8"},
+ {file = "aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584"},
+ {file = "aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d"},
+ {file = "aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748"},
+ {file = "aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1"},
+ {file = "aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111"},
+ {file = "aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac"},
+ {file = "aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b"},
+ {file = "aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178"},
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+requires_python = ">=3.9"
+summary = "aiosignal: a list of registered asynchronous callbacks"
+groups = ["default"]
+dependencies = [
+ "frozenlist>=1.1.0",
+ "typing-extensions>=4.2; python_version < \"3.13\"",
+]
+files = [
+ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"},
+ {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"},
+]
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.5"
+requires_python = ">=3.9"
+summary = "Document parameters, class attributes, return types, and variables inline, with Annotated."
+groups = ["default"]
+files = [
+ {file = "annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101"},
+ {file = "annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb"},
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.8.0"
+requires_python = ">=3.10"
+summary = "Reusable constraint types to use with typing.Annotated"
+groups = ["default"]
+files = [
+ {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"},
+ {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"},
+]
+
[[package]]
name = "anyio"
version = "4.10.0"
@@ -27,6 +115,32 @@ files = [
{file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"},
]
+[[package]]
+name = "attrs"
+version = "26.1.0"
+requires_python = ">=3.9"
+summary = "Classes Without Boilerplate"
+groups = ["default"]
+files = [
+ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
+ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
+]
+
+[[package]]
+name = "authlib"
+version = "1.9.1"
+requires_python = ">=3.10"
+summary = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
+groups = ["default"]
+dependencies = [
+ "cryptography>=45.0.1",
+ "joserfc>=1.6.8",
+]
+files = [
+ {file = "authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b"},
+ {file = "authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974"},
+]
+
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
@@ -53,6 +167,101 @@ files = [
{file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
]
+[[package]]
+name = "cffi"
+version = "2.1.1"
+requires_python = ">=3.10"
+summary = "Foreign Function Interface for Python calling C code."
+groups = ["default"]
+marker = "platform_python_implementation != \"PyPy\""
+dependencies = [
+ "pycparser; implementation_name != \"PyPy\"",
+]
+files = [
+ {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"},
+ {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"},
+ {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"},
+ {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"},
+ {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"},
+ {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"},
+ {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"},
+ {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"},
+ {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"},
+ {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"},
+ {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"},
+ {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"},
+ {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"},
+]
+
+[[package]]
+name = "cryptography"
+version = "50.0.2"
+requires_python = "!=3.9.0,!=3.9.1,>=3.9"
+summary = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+groups = ["default"]
+dependencies = [
+ "cffi>=2.0.0; platform_python_implementation != \"PyPy\"",
+ "typing-extensions>=4.13.2; python_full_version < \"3.11\"",
+]
+files = [
+ {file = "cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc"},
+ {file = "cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079"},
+ {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51"},
+ {file = "cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93"},
+ {file = "cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c"},
+ {file = "cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227"},
+ {file = "cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c"},
+ {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e"},
+ {file = "cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94"},
+ {file = "cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de"},
+ {file = "cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5"},
+]
+
+[[package]]
+name = "datadot"
+version = "1.0.0"
+requires_python = ">=3.8"
+summary = "A functional data navigation tool for Python"
+groups = ["default"]
+files = [
+ {file = "datadot-1.0.0-py3-none-any.whl", hash = "sha256:65cda58f57d2b9c4172d193f6c9e7952bfea033b0fb0237985133496a08c980f"},
+ {file = "datadot-1.0.0.tar.gz", hash = "sha256:10d44a4277813d1f8ce7fd49dacf05c61bfc61ea8f50066279bfe2977ff0b208"},
+]
+
+[[package]]
+name = "fastapi"
+version = "0.139.2"
+requires_python = ">=3.10"
+summary = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
+groups = ["default"]
+dependencies = [
+ "annotated-doc>=0.0.2",
+ "pydantic>=2.9.0",
+ "starlette>=0.46.0",
+ "typing-extensions>=4.8.0",
+ "typing-inspection>=0.4.2",
+]
+files = [
+ {file = "fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c"},
+ {file = "fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e"},
+]
+
[[package]]
name = "filetype"
version = "1.2.0"
@@ -63,6 +272,54 @@ files = [
{file = "filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb"},
]
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+requires_python = ">=3.9"
+summary = "A list-like structure which implements collections.abc.MutableSequence"
+groups = ["default"]
+files = [
+ {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1"},
+ {file = "frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b"},
+ {file = "frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4"},
+ {file = "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383"},
+ {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4"},
+ {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8"},
+ {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b"},
+ {file = "frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52"},
+ {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29"},
+ {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3"},
+ {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143"},
+ {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608"},
+ {file = "frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa"},
+ {file = "frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf"},
+ {file = "frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746"},
+ {file = "frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd"},
+ {file = "frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d"},
+ {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
+]
+
+[[package]]
+name = "greenlet"
+version = "3.5.6"
+requires_python = ">=3.10"
+summary = "Lightweight in-process concurrent programming"
+groups = ["default"]
+marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""
+files = [
+ {file = "greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422"},
+ {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f"},
+ {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8"},
+ {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188"},
+ {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1"},
+ {file = "greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc"},
+ {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44"},
+ {file = "greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7"},
+ {file = "greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395"},
+ {file = "greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0"},
+ {file = "greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575"},
+]
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -133,6 +390,20 @@ files = [
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
+[[package]]
+name = "joserfc"
+version = "1.7.7"
+requires_python = ">=3.10"
+summary = "The ultimate Python library for JOSE RFCs, including JWS, JWE, JWK, JWA, JWT"
+groups = ["default"]
+dependencies = [
+ "cryptography>=45.0.1",
+]
+files = [
+ {file = "joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c"},
+ {file = "joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510"},
+]
+
[[package]]
name = "js2py-3-13"
version = "0.74.1"
@@ -188,6 +459,125 @@ files = [
{file = "m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd"},
]
+[[package]]
+name = "multidict"
+version = "7.1.0"
+requires_python = ">=3.10"
+summary = "multidict implementation"
+groups = ["default"]
+dependencies = [
+ "typing-extensions>=4.1.0; python_version < \"3.11\"",
+]
+files = [
+ {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21"},
+ {file = "multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4"},
+ {file = "multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01"},
+ {file = "multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf"},
+ {file = "multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de"},
+ {file = "multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb"},
+ {file = "multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085"},
+ {file = "multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d"},
+ {file = "multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0"},
+ {file = "multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec"},
+]
+
+[[package]]
+name = "propcache"
+version = "0.5.4"
+requires_python = ">=3.10"
+summary = "Accelerated property cache"
+groups = ["default"]
+files = [
+ {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429"},
+ {file = "propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8"},
+ {file = "propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21"},
+ {file = "propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef"},
+ {file = "propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f"},
+ {file = "propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546"},
+ {file = "propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b"},
+ {file = "propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031"},
+ {file = "propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161"},
+ {file = "propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8"},
+ {file = "propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb"},
+ {file = "propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737"},
+ {file = "propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468"},
+ {file = "propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558"},
+]
+
+[[package]]
+name = "pycparser"
+version = "3.11"
+requires_python = ">=3.10"
+summary = "C parser in Python"
+groups = ["default"]
+marker = "implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\""
+files = [
+ {file = "pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80"},
+ {file = "pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc"},
+]
+
+[[package]]
+name = "pydantic"
+version = "2.14.1"
+requires_python = ">=3.10"
+summary = "Data validation using Python type hints"
+groups = ["default"]
+dependencies = [
+ "annotated-types>=0.6.0",
+ "pydantic-core==2.50.1",
+ "typing-extensions>=4.16.0",
+ "typing-inspection>=0.4.4",
+]
+files = [
+ {file = "pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454"},
+ {file = "pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26"},
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.50.1"
+requires_python = ">=3.10"
+summary = "Core functionality for Pydantic validation and serialization"
+groups = ["default"]
+dependencies = [
+ "typing-extensions>=4.16.0",
+]
+files = [
+ {file = "pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498"},
+ {file = "pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1"},
+ {file = "pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09"},
+]
+
[[package]]
name = "pyjsparser"
version = "2.7.1"
@@ -197,6 +587,20 @@ files = [
{file = "pyjsparser-2.7.1.tar.gz", hash = "sha256:be60da6b778cc5a5296a69d8e7d614f1f870faf94e1b1b6ac591f2ad5d729579"},
]
+[[package]]
+name = "pyjwt"
+version = "2.15.1"
+requires_python = ">=3.9"
+summary = "JSON Web Token implementation in Python"
+groups = ["default"]
+dependencies = [
+ "typing-extensions>=4.0; python_version < \"3.11\"",
+]
+files = [
+ {file = "pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193"},
+ {file = "pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8"},
+]
+
[[package]]
name = "pyotp"
version = "2.9.0"
@@ -252,6 +656,59 @@ files = [
{file = "soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a"},
]
+[[package]]
+name = "sqlalchemy"
+version = "2.0.55"
+requires_python = ">=3.7"
+summary = "Database Abstraction Library"
+groups = ["default"]
+dependencies = [
+ "greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"",
+ "importlib-metadata; python_version < \"3.8\"",
+ "typing-extensions>=4.6.0",
+]
+files = [
+ {file = "sqlalchemy-2.0.55-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2c37c286877ca04b577fb02779ed77dbf884539dcb4712507e25ca4d8d48a6ec"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6058240996c16cccc19dbd948aade826de3854171f45e045219dc9e9ea75ef"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7694e736d63b476d81a0eae7b304d9b747a3f0a1b7dc10134655c4cccf5eb7"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:11d7e28319efa0a52d4601ce4498df7e9c4014a740d9f0eae1d8dd6432a4ace2"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cfa3afdaccf4ddb91de5dcffdc9298395b4a3c740fbae478b4491e96085051d2"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-win32.whl", hash = "sha256:4ff08c8c2f701d2bcbe731833bfbb96195e3ce7a0e400e8b699f8e4a0020545f"},
+ {file = "sqlalchemy-2.0.55-cp312-cp312-win_amd64.whl", hash = "sha256:a13820f47eda53d603f56feaadeab9787d0110b7fcbe6ab2ff2415bd3ea31c79"},
+ {file = "sqlalchemy-2.0.55-py3-none-any.whl", hash = "sha256:d2954796165c08e2f70082e4577a4c3ce6f141c77e2e4b378b3b528653b54311"},
+ {file = "sqlalchemy-2.0.55.tar.gz", hash = "sha256:ae40c563bc10a8d56f1fd1f39f069aeb5eb85ca26913b2314dd8d190139258fa"},
+]
+
+[[package]]
+name = "sqlmodel"
+version = "0.0.24"
+requires_python = ">=3.7"
+summary = "SQLModel, SQL databases in Python, designed for simplicity, compatibility, and robustness."
+groups = ["default"]
+dependencies = [
+ "SQLAlchemy<2.1.0,>=2.0.14",
+ "pydantic<3.0.0,>=1.10.13",
+]
+files = [
+ {file = "sqlmodel-0.0.24-py3-none-any.whl", hash = "sha256:6778852f09370908985b667d6a3ab92910d0d5ec88adcaf23dbc242715ff7193"},
+ {file = "sqlmodel-0.0.24.tar.gz", hash = "sha256:cc5c7613c1a5533c9c7867e1aab2fd489a76c9e8a061984da11b4e613c182423"},
+]
+
+[[package]]
+name = "starlette"
+version = "1.9.0"
+requires_python = ">=3.11"
+summary = "The little ASGI library that shines."
+groups = ["default"]
+dependencies = [
+ "anyio<5,>=4.0.0",
+ "typing-extensions>=4.10.0; python_version < \"3.13\"",
+]
+files = [
+ {file = "starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc"},
+ {file = "starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318"},
+]
+
[[package]]
name = "twikit"
version = "2.3.3"
@@ -275,13 +732,27 @@ files = [
[[package]]
name = "typing-extensions"
-version = "4.14.1"
+version = "4.16.0"
requires_python = ">=3.9"
summary = "Backported and Experimental Type Hints for Python 3.9+"
groups = ["default"]
files = [
- {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"},
- {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"},
+ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
+ {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.4"
+requires_python = ">=3.10"
+summary = "Runtime typing introspection tools"
+groups = ["default"]
+dependencies = [
+ "typing-extensions>=4.15.0",
+]
+files = [
+ {file = "typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147"},
+ {file = "typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47"},
]
[[package]]
@@ -320,3 +791,36 @@ files = [
{file = "webvtt-py-0.5.1.tar.gz", hash = "sha256:2040dd325277ddadc1e0c6cc66cbc4a1d9b6b49b24c57a0c3364374c3e8a3dc1"},
{file = "webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7"},
]
+
+[[package]]
+name = "yarl"
+version = "1.25.1"
+requires_python = ">=3.10"
+summary = "Yet another URL library"
+groups = ["default"]
+dependencies = [
+ "idna>=2.0",
+ "multidict>=4.0",
+ "propcache>=0.2.1",
+]
+files = [
+ {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4"},
+ {file = "yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d"},
+ {file = "yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d"},
+ {file = "yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68"},
+ {file = "yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707"},
+ {file = "yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03"},
+ {file = "yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85"},
+ {file = "yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3"},
+ {file = "yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7"},
+]
diff --git a/extensions/twitter/pyproject.toml b/extensions/twitter/pyproject.toml
index f26febaa..94777605 100644
--- a/extensions/twitter/pyproject.toml
+++ b/extensions/twitter/pyproject.toml
@@ -13,6 +13,8 @@ dependencies = [
"datadot>=1.0.0,<2.0.0",
"fastapi>=0.139.2,<0.140.0",
"httpx>=0.28.1,<0.29.0",
+ "pydantic>=2.12.0,<3.0.0",
+ "PyJWT>=2.10.1,<3.0.0",
"sqlmodel>=0.0.24,<0.0.25",
"twikit>=2.3.3,<3.0.0",
]
@@ -26,7 +28,7 @@ twitter = "extensions.twitter:Extension"
name = "inkcre/twitter"
nickname = "Twitter"
host-sdk = "core-py"
-host-sdk-version = ">=0.2.0 <0.4.0"
+host-sdk-version = ">=0.3.1 <0.4.0"
[tool.setuptools]
packages = ["extensions.twitter"]
diff --git a/extensions/twitter/reconcile.py b/extensions/twitter/reconcile.py
new file mode 100644
index 00000000..0374e3a2
--- /dev/null
+++ b/extensions/twitter/reconcile.py
@@ -0,0 +1,263 @@
+"""One transactional native Twitter archive shared by HTTP and Bookmark sources."""
+
+from __future__ import annotations
+
+from collections.abc import Collection
+from dataclasses import dataclass
+import json
+import typing
+
+import pydantic
+
+from app.persistence.info_base.uow import GraphUnitOfWork, graph_uow
+from app.schemas.info_base.block import BlockForm, BlockModel
+from app.schemas.info_base.relation import RelationCreateForm
+
+from .archive_merge import merge_archive_record
+from .model import (
+ TextPolicy,
+ TwitterBatch,
+ TwitterMedia,
+ TwitterPost,
+ TwitterRecord,
+ TwitterUser,
+)
+
+
+POST_RESOLVER = "extensions.twitter.tweet.v2"
+USER_RESOLVER = "extensions.twitter.user.v1"
+MEDIA_RESOLVER = "extensions.twitter.media.v1"
+LEGACY_POST_RESOLVER = "extensions.twitter.tweet.v1"
+REFERENCE_SCHEMA = "extensions.twitter.reference.v1"
+TWITTER_WRITE_LOCK = "extensions.twitter.reconcile"
+KINDS = ("users", "posts", "media")
+RESOLVERS = {"users": USER_RESOLVER, "posts": POST_RESOLVER, "media": MEDIA_RESOLVER}
+MODELS = {"users": TwitterUser, "posts": TwitterPost, "media": TwitterMedia}
+Record = dict[str, typing.Any]
+Identity = tuple[str, str]
+
+
+@dataclass(frozen=True)
+class TwitterWriteResult:
+ changed: frozenset[Identity]
+ block_ids: dict[str, dict[str, int]]
+
+
+def _json(value: object) -> str:
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
+
+
+def _block_id(block: BlockModel) -> int:
+ if block.id is None:
+ raise RuntimeError("Twitter Block has no persisted identity")
+ return block.id
+
+
+def _references(post: Record) -> tuple[tuple[str, Identity, int | None], ...]:
+ refs: list[tuple[str, Identity, int | None]] = []
+ if "authorId" in post:
+ refs.append(("author", ("users", post["authorId"]), None))
+ article = post.get("article", {})
+ media_ids = list(post.get("mediaIds", ()))
+ if "coverMediaId" in article:
+ media_ids.append(article["coverMediaId"])
+ media_ids.extend(article.get("mediaIds", ()))
+ for position, media_id in enumerate(dict.fromkeys(media_ids)):
+ refs.append(("media", ("media", media_id), position))
+ for field, kind in (("replyTo", "reply"), ("quote", "quote")):
+ if field in post:
+ refs.append((kind, ("posts", post[field]["postId"]), None))
+ if "repostOf" in post:
+ refs.append(("repost", ("posts", post["repostOf"]), None))
+ return tuple(refs)
+
+
+def _reference_content(kind: str, position: int | None) -> str:
+ content: Record = {"schema": REFERENCE_SCHEMA, "type": kind}
+ if position is not None:
+ content["position"] = position
+ return _json(content)
+
+
+def _is_reference(content: str) -> bool:
+ try:
+ value = json.loads(content)
+ except ValueError:
+ return False
+ if not isinstance(value, dict) or value.get("schema") != REFERENCE_SCHEMA:
+ return False
+ if value.get("type") == "media":
+ position = value.get("position")
+ return (
+ set(value) == {"schema", "type", "position"}
+ and type(position) is int
+ and position >= 0
+ )
+ return set(value) == {"schema", "type"} and value.get("type") in {
+ "author",
+ "reply",
+ "quote",
+ "repost",
+ }
+
+
+async def _load_roots(
+ identities: Collection[Identity],
+ uow: GraphUnitOfWork,
+ *,
+ include_legacy: bool = False,
+) -> dict[Identity, BlockModel]:
+ result: dict[Identity, BlockModel] = {}
+ for kind in KINDS:
+ ids = {native_id for candidate_kind, native_id in identities if candidate_kind == kind}
+ resolvers = (RESOLVERS[kind],)
+ if include_legacy and kind == "posts":
+ resolvers += (LEGACY_POST_RESOLVER,)
+ candidates = await uow.blocks.find_json_field_many(resolvers, "id", ids)
+ for block in sorted(
+ candidates,
+ key=lambda block: (block.resolver == LEGACY_POST_RESOLVER, _block_id(block)),
+ ):
+ native_id = str(json.loads(block.content)["id"])
+ result.setdefault((kind, native_id), block)
+ return result
+
+
+async def persist_twitter(
+ batch: TwitterBatch,
+ uow: GraphUnitOfWork,
+ *,
+ text_policy: TextPolicy = "replace",
+) -> TwitterWriteResult:
+ """Merge a complete batch without committing the caller's graph transaction.
+
+ Supplement captures retain an already known body while adding other facts.
+ Capture adapters choose that policy; persistence does not identify providers.
+ """
+ await uow.blocks.acquire_transaction_lock(TWITTER_WRITE_LOCK)
+ incoming: dict[Identity, Record] = {}
+ for kind in KINDS:
+ for record in getattr(batch, kind):
+ key = (kind, record.id)
+ incoming[key] = merge_archive_record(
+ incoming.get(key, {}), record.model_dump(mode="json", exclude_unset=True)
+ )
+ identities = set(incoming)
+ for (kind, _), record in incoming.items():
+ if kind == "posts":
+ identities.update(target for _, target, _ in _references(record))
+ roots = await _load_roots(identities, uow, include_legacy=True)
+ changed: set[Identity] = set()
+ records: dict[Identity, Record] = {}
+ new: list[tuple[Identity, BlockForm]] = []
+ updated: list[BlockModel] = []
+ for key in sorted(identities):
+ root = roots.get(key)
+ existing: Record = (
+ json.loads(root.content)
+ if root is not None and root.resolver != LEGACY_POST_RESOLVER
+ else {"id": key[1]}
+ )
+ value: Record = incoming.get(key, {"id": key[1]})
+ if text_policy == "supplement" and "text" in existing and "text" in value:
+ value = {field: item for field, item in value.items() if field != "text"}
+ merged = merge_archive_record(existing, value)
+ if key in incoming:
+ MODELS[key[0]].model_validate(merged)
+ records[key] = merged
+ if root is None:
+ new.append((key, BlockForm(resolver=RESOLVERS[key[0]], content=_json(merged))))
+ changed.add(key)
+ elif key in incoming and (
+ _json(merged) != _json(existing) or root.resolver != RESOLVERS[key[0]]
+ ):
+ root.content = _json(merged)
+ root.resolver = RESOLVERS[key[0]]
+ root.storage = None
+ updated.append(root)
+ changed.add(key)
+ for (key, _), root in zip(
+ new, await uow.blocks.create_many(form for _, form in new), strict=True
+ ):
+ roots[key] = root
+ await uow.blocks.save_many(updated)
+
+ # Merged, retained references may be absent from this sparse input.
+ retained = {
+ target
+ for (kind, _), record in records.items()
+ if kind == "posts"
+ for _, target, _ in _references(record)
+ } - roots.keys()
+ if retained:
+ roots.update(await _load_roots(retained, uow, include_legacy=True))
+ absent = sorted(retained - roots.keys())
+ placeholders = await uow.blocks.create_many(
+ BlockForm(resolver=RESOLVERS[key[0]], content=_json({"id": key[1]})) for key in absent
+ )
+ roots.update(zip(absent, placeholders, strict=True))
+
+ desired: set[tuple[int, int, str]] = set()
+ post_ids: set[int] = set()
+ for key in incoming:
+ root_id = _block_id(roots[key])
+ record = records[key]
+ if key[0] == "posts":
+ post_ids.add(root_id)
+ desired.update(
+ (root_id, _block_id(roots[target]), _reference_content(kind, position))
+ for kind, target, position in _references(record)
+ )
+ stale: list[int] = []
+ for relation in await uow.relations.get_outgoing_many(post_ids):
+ managed = _is_reference(relation.content)
+ if not managed:
+ continue
+ identity = (relation.from_, relation.to_, relation.content)
+ if identity in desired:
+ desired.remove(identity)
+ elif relation.id is not None:
+ stale.append(relation.id)
+ await uow.relations.delete_many(stale)
+ await uow.relations.create_many(
+ RelationCreateForm(from_=source, to_=target, content=content)
+ for source, target, content in sorted(desired)
+ )
+ return TwitterWriteResult(
+ changed=frozenset(changed),
+ block_ids={
+ kind: {
+ native_id: _block_id(root)
+ for (candidate_kind, native_id), root in roots.items()
+ if candidate_kind == kind
+ }
+ for kind in KINDS
+ },
+ )
+
+
+async def write_twitter(batch: TwitterBatch) -> TwitterWriteResult:
+ async with graph_uow() as uow:
+ return await persist_twitter(batch, uow)
+
+
+@dataclass(frozen=True)
+class TwitterReadResult:
+ records: dict[Identity, TwitterRecord]
+ invalid: frozenset[Identity]
+
+
+async def read_twitter(identities: Collection[Identity]) -> TwitterReadResult:
+ async with graph_uow() as uow:
+ roots = await _load_roots(identities, uow)
+ records: dict[Identity, TwitterRecord] = {}
+ invalid: set[Identity] = set()
+ for key in identities:
+ root = roots.get(key)
+ if root is None or json.loads(root.content) == {"id": key[1]}:
+ continue
+ try:
+ records[key] = MODELS[key[0]].model_validate_json(root.content)
+ except pydantic.ValidationError:
+ invalid.add(key)
+ return TwitterReadResult(records=records, invalid=frozenset(invalid))
diff --git a/extensions/twitter/resolver.py b/extensions/twitter/resolver.py
index 7c83273c..429e0429 100644
--- a/extensions/twitter/resolver.py
+++ b/extensions/twitter/resolver.py
@@ -1,12 +1,17 @@
import json
+import typing
from typing import Optional as Opt
+import pydantic
+
from app.business.info_base.resolver import Resolver, ResolverManager, TextProjectionContext
from app.business.info_base.resolver.label import format_label
from app.business.info_base.services import BlockService
from app.schemas.info_base.block import BlockForm
from app.schemas.info_base.main import StarsGraphForm
from .schema import Tweet
+from .model import TwitterIdentity, TwitterMedia, TwitterPost, TwitterUser
+from .reconcile import MEDIA_RESOLVER, POST_RESOLVER, REFERENCE_SCHEMA, USER_RESOLVER
class TweetResolver(Resolver[Tweet, str], rso_type="extensions.twitter.tweet.v1"):
@@ -101,3 +106,144 @@ def create_graph(cls, content: Tweet) -> StarsGraphForm:
block=block,
out_arcs=(),
)
+
+
+Native = typing.TypeVar("Native", bound=pydantic.BaseModel)
+
+
+async def _native_content(
+ resolver: Resolver, model: type[Native], *, refresh: bool
+) -> Native | TwitterIdentity:
+ raw = await resolver.get_raw_content(refresh=refresh)
+ value = json.loads(raw)
+ if isinstance(value, dict) and set(value) == {"id"}:
+ return TwitterIdentity.model_validate(value)
+ return model.model_validate(value)
+
+
+class ArchivePostResolver(
+ Resolver[TwitterPost | TwitterIdentity, str], rso_type=POST_RESOLVER
+):
+ async def _get_solved_content(
+ self, *, refresh: bool = False, materialize_missing: bool = True
+ ) -> TwitterPost | TwitterIdentity:
+ del materialize_missing
+ return await _native_content(self, TwitterPost, refresh=refresh)
+
+ async def get_text(
+ self,
+ *,
+ context: TextProjectionContext = "default",
+ refresh: bool = False,
+ materialize_missing: bool = True,
+ ) -> str:
+ del context, materialize_missing
+ post = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ parts = [f"https://x.com/i/status/{post.id}", post.id]
+ if isinstance(post, TwitterIdentity):
+ return "\n".join(parts)
+ parts.append(f"author: {post.authorId}")
+ for relation in await self.get_relations(include_in=False, refresh=refresh):
+ try:
+ reference = json.loads(relation.content)
+ except ValueError:
+ continue
+ if reference != {"schema": REFERENCE_SCHEMA, "type": "author"}:
+ continue
+ author = await BlockService.get(relation.to_)
+ if author is not None and author.resolver == USER_RESOLVER:
+ value = json.loads(author.content)
+ if set(value) != {"id"}:
+ user = TwitterUser.model_validate(value)
+ parts.append(f"{user.name} @{user.handle}")
+ break
+ if post.text is not None:
+ parts.append(post.text.text)
+ if post.article is not None:
+ parts.append(post.article.title)
+ if post.article.blocks is not None:
+ parts.extend(block.text for block in post.article.blocks)
+ elif post.article.preview is not None:
+ parts.append(post.article.preview)
+ return "\n".join(parts)
+
+ async def get_label(self, *, refresh: bool = False) -> str:
+ post = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ text = (
+ post.text.text if isinstance(post, TwitterPost) and post.text is not None else post.id
+ )
+ return format_label("tweet", text or post.id, first_line=True)
+
+
+class ArchiveUserResolver(
+ Resolver[TwitterUser | TwitterIdentity, str], rso_type=USER_RESOLVER
+):
+ async def _get_solved_content(
+ self, *, refresh: bool = False, materialize_missing: bool = True
+ ) -> TwitterUser | TwitterIdentity:
+ del materialize_missing
+ return await _native_content(self, TwitterUser, refresh=refresh)
+
+ async def get_text(
+ self,
+ *,
+ context: TextProjectionContext = "default",
+ refresh: bool = False,
+ materialize_missing: bool = True,
+ ) -> str:
+ del context, materialize_missing
+ user = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ if isinstance(user, TwitterIdentity):
+ return user.id
+ return "\n".join(
+ value
+ for value in (
+ user.id,
+ f"{user.name} @{user.handle}",
+ f"https://x.com/{user.handle}",
+ user.bio.text if user.bio is not None else None,
+ user.location,
+ user.website,
+ )
+ if value is not None
+ )
+
+ async def get_label(self, *, refresh: bool = False) -> str:
+ user = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ return format_label(
+ "twitter user", f"@{user.handle}" if isinstance(user, TwitterUser) else user.id
+ )
+
+
+class ArchiveMediaResolver(
+ Resolver[TwitterMedia | TwitterIdentity, str], rso_type=MEDIA_RESOLVER
+):
+ async def _get_solved_content(
+ self, *, refresh: bool = False, materialize_missing: bool = True
+ ) -> TwitterMedia | TwitterIdentity:
+ del materialize_missing
+ return await _native_content(self, TwitterMedia, refresh=refresh)
+
+ async def get_text(
+ self,
+ *,
+ context: TextProjectionContext = "default",
+ refresh: bool = False,
+ materialize_missing: bool = True,
+ ) -> str:
+ del context, materialize_missing
+ media = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ if isinstance(media, TwitterIdentity):
+ return media.id
+ return "\n".join(
+ value
+ for value in (media.id, media.alt, media.title, media.description)
+ if value is not None
+ )
+
+ async def get_label(self, *, refresh: bool = False) -> str:
+ media = await self.get_solved_content(refresh=refresh, materialize_missing=False)
+ label = (
+ media.title or media.alt or media.id if isinstance(media, TwitterMedia) else media.id
+ )
+ return format_label("twitter media", label, first_line=True)
diff --git a/extensions/twitter/setup_flow.py b/extensions/twitter/setup_flow.py
index 59bf2193..6d6e340d 100644
--- a/extensions/twitter/setup_flow.py
+++ b/extensions/twitter/setup_flow.py
@@ -98,6 +98,7 @@ class OAuthTransactionView(pydantic.BaseModel):
class TwitterSetupStatus(pydantic.BaseModel):
callback_url: str
+ x_monkey_base_url: str | None = None
connected: bool
user_id: str | None = None
handle: str | None = None
@@ -251,6 +252,8 @@ def reconcile(config_model, state_model):
async def get_setup_status() -> TwitterSetupStatus:
+ from .archive_session import x_monkey_base_url
+
config = _config()
state = await _reconcile_oauth_state()
configured = bool(config.client_id and config.client_secret)
@@ -263,6 +266,7 @@ async def get_setup_status() -> TwitterSetupStatus:
)
return TwitterSetupStatus(
callback_url=await _redirect_uri(),
+ x_monkey_base_url=await x_monkey_base_url(),
connected=connected,
user_id=account.user_id if connected and account is not None else None,
handle=account.handle if connected and account is not None else None,
@@ -671,3 +675,6 @@ async def delete_account():
methods=["GET"],
response_class=HTMLResponse,
)
+ from .archive_session import router as archive_session_router
+
+ router.include_router(archive_session_router, prefix="/x-monkey")
diff --git a/extensions/twitter/x_monkey.py b/extensions/twitter/x_monkey.py
new file mode 100644
index 00000000..b10d8d85
--- /dev/null
+++ b/extensions/twitter/x_monkey.py
@@ -0,0 +1,93 @@
+"""Translate the fixed x-monkey protocol to Twitter-owned capture operations."""
+
+from __future__ import annotations
+
+import pydantic
+from pydantic import JsonValue
+
+from .archive_schema import (
+ ArchiveBatch,
+ ArchiveMedia,
+ ArchivePost,
+ ArchiveQuery,
+ ArchiveUser,
+ QueryResponse,
+ SubmitResponse,
+ SubmitResult,
+ SubmitSummary,
+)
+from .model import TwitterBatch, TwitterRecord
+from .reconcile import (
+ KINDS,
+ TwitterReadResult,
+ TwitterWriteResult,
+ read_twitter,
+ write_twitter,
+)
+
+
+WIRE_MODELS = {"users": ArchiveUser, "posts": ArchivePost, "media": ArchiveMedia}
+
+
+def decode_submission(batch: ArchiveBatch) -> TwitterBatch:
+ """Map accepted provider facts without manufacturing absent values."""
+ return TwitterBatch.model_validate(batch.model_dump(mode="json", exclude_unset=True))
+
+
+def export_record(kind: str, record: TwitterRecord) -> JsonValue:
+ """Project native facts into the supported wire version, then validate that DTO."""
+ model = WIRE_MODELS[kind]
+ projected = model.model_validate(
+ record.model_dump(mode="json", exclude_unset=True), extra="ignore"
+ )
+ return projected.model_dump(mode="json", exclude_unset=True)
+
+
+def encode_submission(batch: ArchiveBatch, result: TwitterWriteResult) -> SubmitResponse:
+ results = {
+ kind: [
+ SubmitResult(
+ id=record.id,
+ status="accepted" if (kind, record.id) in result.changed else "unchanged",
+ )
+ for record in getattr(batch, kind)
+ ]
+ for kind in KINDS
+ }
+ total = sum(len(items) for items in results.values())
+ accepted = sum(item.status == "accepted" for items in results.values() for item in items)
+ return SubmitResponse(
+ summary=SubmitSummary(
+ total=total, accepted=accepted, unchanged=total - accepted, partial=0, failed=0
+ ),
+ **results,
+ )
+
+
+def encode_query(query: ArchiveQuery, result: TwitterReadResult) -> QueryResponse:
+ results: dict[str, list[dict[str, JsonValue]]] = {kind: [] for kind in KINDS}
+ for kind in KINDS:
+ for native_id in getattr(query, kind):
+ key = (kind, native_id)
+ item: dict[str, JsonValue] = {"id": native_id, "status": "missing"}
+ record = result.records.get(key)
+ if key in result.invalid:
+ item.update(status="failed", error="Stored archive record is invalid")
+ elif record is not None:
+ try:
+ data = export_record(kind, record)
+ except pydantic.ValidationError:
+ item.update(status="failed", error="Stored record cannot use this archive format")
+ else:
+ item.update(status="found", data=data)
+ results[kind].append(item)
+ return QueryResponse.model_validate(results)
+
+
+async def submit_archive(batch: ArchiveBatch) -> SubmitResponse:
+ return encode_submission(batch, await write_twitter(decode_submission(batch)))
+
+
+async def query_archive(query: ArchiveQuery) -> QueryResponse:
+ identities = {(kind, native_id) for kind in KINDS for native_id in getattr(query, kind)}
+ return encode_query(query, await read_twitter(identities))
diff --git a/migrations/revision-integrity.json b/migrations/revision-integrity.json
index 9504fa8c..181e3f63 100644
--- a/migrations/revision-integrity.json
+++ b/migrations/revision-integrity.json
@@ -3,6 +3,7 @@
"baseline": "peer-database-runtime-v1",
"previous_manifest_sha256": "077705815948319b03286fa8550478710f8964263be19c35432d2a3462e76ed8",
"revisions": {
+ "09a921a95314_remove_peer_jwt_lifetime_cap.py": "c0b62fc9fbf7b2c6ec9c6c3c27c3e80dbbd6022b55e7ccdf5ba9910169dddec3",
"143c4f4adc85_add_sink_catalog_and_instances.py": "dc5151df7c73ec84077f7698cfb1e7fdd5596978270055de2812da539d02043d",
"1e4c7a9b2d5f_add_block_lexical_records.py": "20cd7c361b0f822811d52beb692a255078e3842fd64d74287ee0c13d0bc8df26",
"3d9593b0c855_add_job_submission_trace_context.py": "60d61b798da850ea24d301db3f97cb07e0d8d9695a6c5a9763b9ef4f990d0845",
diff --git a/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py b/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py
new file mode 100644
index 00000000..0b95200c
--- /dev/null
+++ b/migrations/versions/09a921a95314_remove_peer_jwt_lifetime_cap.py
@@ -0,0 +1,87 @@
+"""Make JWT signing defaults independent of admission.
+
+Revision ID: 09a921a95314
+Revises: e6a1b2c3d4f5
+Create Date: 2026-10-19
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "09a921a95314"
+down_revision: str | Sequence[str] | None = "e6a1b2c3d4f5"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def _replace_check_jwt(*, enforce_legacy_maximum: bool) -> None:
+ legacy_maximum = "OR expires_at - issued_at > 86400" if enforce_legacy_maximum else ""
+ op.execute(
+ f"""
+ CREATE OR REPLACE FUNCTION inkcre_internal.check_jwt()
+ RETURNS void
+ LANGUAGE plpgsql
+ SECURITY INVOKER
+ SET search_path = pg_catalog
+ AS $$
+ DECLARE
+ claims jsonb;
+ issued_at numeric;
+ expires_at numeric;
+ current_epoch numeric := extract(epoch FROM statement_timestamp());
+ BEGIN
+ claims := nullif(current_setting('request.jwt.claims', true), '')::jsonb;
+ IF claims IS NULL
+ OR jsonb_typeof(claims -> 'role') IS DISTINCT FROM 'string'
+ OR claims ->> 'role' IS DISTINCT FROM 'authenticated'
+ OR jsonb_typeof(claims -> 'iss') IS DISTINCT FROM 'string'
+ OR claims ->> 'iss' IS DISTINCT FROM 'inkcre-peer'
+ OR jsonb_typeof(claims -> 'aud') IS DISTINCT FROM 'string'
+ OR claims ->> 'aud' IS DISTINCT FROM 'inkcre-api'
+ OR jsonb_typeof(claims -> 'iat') IS DISTINCT FROM 'number'
+ OR jsonb_typeof(claims -> 'exp') IS DISTINCT FROM 'number'
+ THEN
+ RAISE insufficient_privilege USING MESSAGE = 'invalid jwt claims';
+ END IF;
+
+ issued_at := (claims ->> 'iat')::numeric;
+ expires_at := (claims ->> 'exp')::numeric;
+ IF issued_at > current_epoch + 60
+ OR expires_at <= current_epoch
+ OR expires_at <= issued_at
+ {legacy_maximum}
+ THEN
+ RAISE insufficient_privilege USING MESSAGE = 'invalid jwt lifetime';
+ END IF;
+ END
+ $$
+ """
+ )
+
+
+def _set_contract_revision(value: str) -> None:
+ op.execute(
+ sa.text(
+ """
+ UPDATE inkcre_internal.contract_state
+ SET contract_revision = :revision, updated_at = CURRENT_TIMESTAMP
+ WHERE singleton
+ """
+ ).bindparams(revision=value)
+ )
+ op.execute("NOTIFY pgrst, 'reload schema'")
+
+
+def upgrade() -> None:
+ """Keep claim and time validation without a maximum token lifetime."""
+ _replace_check_jwt(enforce_legacy_maximum=False)
+ _set_contract_revision("peer-jwt-signing-defaults-v1")
+
+
+def downgrade() -> None:
+ """Restore the previous admission limit; existing longer tokens are rejected."""
+ _replace_check_jwt(enforce_legacy_maximum=True)
+ _set_contract_revision("extension-peer-participation-v2")
diff --git a/scripts/dev_database.py b/scripts/dev_database.py
index 6b94826e..61535fd0 100644
--- a/scripts/dev_database.py
+++ b/scripts/dev_database.py
@@ -161,7 +161,6 @@ def _write_runtime_files(
"issuer": "inkcre-peer",
"audience": "inkcre-api",
"required_claims": ["role", "iss", "aud", "iat", "exp"],
- "maximum_lifetime_seconds": 86400,
},
}
published_ports = (
diff --git a/scripts/tests/twitter/conftest.py b/scripts/tests/twitter/conftest.py
new file mode 100644
index 00000000..3015882b
--- /dev/null
+++ b/scripts/tests/twitter/conftest.py
@@ -0,0 +1,3 @@
+"""Reuse the existing hermetic environment for explicitly selected Twitter checks."""
+
+from tests.conftest import async_runner as async_runner
diff --git a/scripts/tests/twitter/fixtures/native_archive.json b/scripts/tests/twitter/fixtures/native_archive.json
new file mode 100644
index 00000000..730ecee6
--- /dev/null
+++ b/scripts/tests/twitter/fixtures/native_archive.json
@@ -0,0 +1,406 @@
+{
+ "users": [
+ {
+ "id": "17",
+ "handle": "author",
+ "name": "作者🌏",
+ "createdAt": "2024-02-29T12:00:00Z",
+ "avatarUrl": "https://pbs.twimg.com/avatar.jpg",
+ "avatarShape": "Hexagon",
+ "bannerUrl": "https://pbs.twimg.com/banner.jpg",
+ "bio": {
+ "text": "正文🌏 @author #X $BTC 01:30 https://t.co/x",
+ "visible": [
+ 0,
+ 9
+ ],
+ "entities": [
+ {
+ "type": "mention",
+ "range": [
+ 4,
+ 11
+ ],
+ "userId": "17"
+ },
+ {
+ "type": "hashtag",
+ "range": [
+ 12,
+ 14
+ ]
+ },
+ {
+ "type": "cashtag",
+ "range": [
+ 15,
+ 19
+ ],
+ "name": "Bitcoin",
+ "ticker": "BTC"
+ },
+ {
+ "type": "timestamp",
+ "range": [
+ 20,
+ 25
+ ]
+ },
+ {
+ "type": "url",
+ "range": [
+ 26,
+ 40
+ ],
+ "expanded": "https://example.org/source"
+ }
+ ],
+ "styles": [
+ {
+ "range": [
+ 0,
+ 2
+ ],
+ "styles": [
+ "Bold",
+ "Italic"
+ ]
+ }
+ ]
+ },
+ "location": "上海",
+ "website": "https://example.org/",
+ "pinnedPostIds": [
+ "31"
+ ],
+ "protected": false,
+ "sensitive": true,
+ "interstitial": "sensitive_media",
+ "withheldIn": [
+ "SA",
+ "AE"
+ ],
+ "verified": {
+ "blue": false,
+ "type": "Business"
+ },
+ "affiliation": {
+ "name": "示例",
+ "url": "https://example.org/",
+ "badgeUrl": "https://pbs.twimg.com/badge.jpg",
+ "kind": "AutomatedLabel",
+ "ownerId": "18"
+ },
+ "parody": "Commentary",
+ "professional": {
+ "type": "Business",
+ "categories": [
+ "Technology"
+ ]
+ },
+ "counts": {
+ "followers": 0,
+ "following": 1,
+ "posts": 2,
+ "mediaPosts": 3,
+ "likes": 4
+ },
+ "countsObservedAt": {
+ "followers": "2026-10-19T01:02:03.1200Z",
+ "following": "2026-10-19T01:02:03Z",
+ "posts": "2026-10-19T01:02:00Z",
+ "mediaPosts": "2026-10-19T01:02:03.000001Z",
+ "likes": "2026-10-19T01:02:03.0000017Z"
+ }
+ }
+ ],
+ "posts": [
+ {
+ "id": "31",
+ "authorId": "17",
+ "createdAt": "2026-10-18T12:34:56Z",
+ "repostOf": "32",
+ "conversationId": "29",
+ "replyTo": {
+ "postId": "29",
+ "userId": "18",
+ "handle": "prior",
+ "tombstone": "这个帖子已被其作者删除。"
+ },
+ "quote": {
+ "postId": "30",
+ "url": "https://x.com/prior/status/30",
+ "tombstone": "这个账号的帖子受保护。"
+ },
+ "text": {
+ "text": "正文🌏 @author #X $BTC 01:30 https://t.co/x",
+ "visible": [
+ 0,
+ 9
+ ],
+ "entities": [
+ {
+ "type": "mention",
+ "range": [
+ 4,
+ 11
+ ],
+ "userId": "17"
+ },
+ {
+ "type": "hashtag",
+ "range": [
+ 12,
+ 14
+ ]
+ },
+ {
+ "type": "cashtag",
+ "range": [
+ 15,
+ 19
+ ],
+ "name": "Bitcoin",
+ "ticker": "BTC"
+ },
+ {
+ "type": "timestamp",
+ "range": [
+ 20,
+ 25
+ ]
+ },
+ {
+ "type": "url",
+ "range": [
+ 26,
+ 40
+ ],
+ "expanded": "https://example.org/source"
+ }
+ ],
+ "styles": [
+ {
+ "range": [
+ 0,
+ 2
+ ],
+ "styles": [
+ "Bold",
+ "Italic"
+ ]
+ }
+ ]
+ },
+ "lang": "zh",
+ "source": "X Web App",
+ "mediaIds": [
+ "41"
+ ],
+ "card": {
+ "type": "poll2choice_text_only",
+ "url": "card://31",
+ "title": "问题",
+ "description": "详细说明",
+ "domain": "example.org",
+ "image": {
+ "url": "https://example.org/image.png",
+ "width": 640,
+ "height": 480,
+ "alt": "示例图"
+ },
+ "siteUserId": "18",
+ "creatorUserId": "17",
+ "player": {
+ "url": "https://example.org/video",
+ "width": 640,
+ "height": 480
+ },
+ "poll": {
+ "choices": [
+ {
+ "label": "是",
+ "count": 0
+ },
+ {
+ "label": "否",
+ "count": 3
+ }
+ ],
+ "endsAt": "2026-10-20T00:00:00Z",
+ "final": false,
+ "updatedAt": "2026-10-19T01:02:03Z"
+ },
+ "extra": {
+ "app_name": "example",
+ "is_enabled": false
+ }
+ },
+ "article": {
+ "id": "51",
+ "title": "长文",
+ "preview": "摘要",
+ "coverMediaId": "41",
+ "publishedAt": "2026-10-18T12:34:56Z",
+ "modifiedAt": "2026-10-18T13:34:56Z",
+ "blocks": [
+ {
+ "type": "unstyled",
+ "text": "文章🌏",
+ "styles": [
+ [
+ 0,
+ 2,
+ "BOLD"
+ ]
+ ],
+ "entities": [
+ [
+ 2,
+ 2,
+ 0
+ ]
+ ],
+ "data": {
+ "future": {
+ "items": [
+ null,
+ true,
+ 1,
+ 2.5,
+ "字"
+ ]
+ }
+ }
+ }
+ ],
+ "entities": [
+ {
+ "type": "MEDIA",
+ "data": {
+ "mediaId": "41",
+ "unknown": null
+ }
+ }
+ ],
+ "mediaIds": [
+ "41"
+ ]
+ },
+ "place": {
+ "id": "place:shanghai",
+ "name": "上海",
+ "fullName": "上海,中国",
+ "country": "中国",
+ "countryCode": "CN",
+ "type": "city",
+ "bbox": [
+ 120,
+ 30.5,
+ 122.5,
+ 32
+ ]
+ },
+ "counts": {
+ "replies": 0,
+ "reposts": 1,
+ "quotes": 2,
+ "likes": 3,
+ "bookmarks": 4,
+ "views": 5
+ },
+ "countsObservedAt": {
+ "replies": "2026-10-19T01:02:03.100Z",
+ "reposts": "2026-10-19T01:02:03.200Z",
+ "quotes": "2026-10-19T01:02:03.300Z",
+ "likes": "2026-10-19T01:02:03.400Z",
+ "bookmarks": "2026-10-19T01:02:03.500Z",
+ "views": "2026-10-19T01:02:03.600Z"
+ },
+ "editIds": [
+ "30",
+ "31"
+ ],
+ "communityNote": {
+ "id": "61",
+ "title": "补充背景",
+ "text": {
+ "text": "来源已核实"
+ },
+ "lang": "zh",
+ "url": "https://example.org/note"
+ },
+ "sensitive": false,
+ "contentWarning": {
+ "title": "敏感内容",
+ "text": "作者已标注"
+ },
+ "community": {
+ "id": "71",
+ "name": "示例社群"
+ },
+ "replyPolicy": "ByInvitation",
+ "paidPromotion": true,
+ "aiGenerated": {
+ "media": true,
+ "source": "C2paClient"
+ }
+ }
+ ],
+ "media": [
+ {
+ "id": "41",
+ "type": "video",
+ "url": "https://pbs.twimg.com/cover.jpg",
+ "width": 1920,
+ "height": 1080,
+ "alt": "演示视频",
+ "crops": [
+ [
+ 0,
+ 0,
+ 640,
+ 480
+ ]
+ ],
+ "faces": [
+ [
+ 1,
+ 2,
+ 30,
+ 40
+ ]
+ ],
+ "tags": [
+ {
+ "userId": "18",
+ "handle": "prior"
+ }
+ ],
+ "video": {
+ "durationMs": 1234,
+ "variants": [
+ {
+ "url": "https://video.twimg.com/a.mp4",
+ "type": "video/mp4",
+ "bitrate": 256000
+ },
+ {
+ "url": "https://video.twimg.com/a.m3u8",
+ "type": "application/x-mpegURL"
+ }
+ ]
+ },
+ "sourcePostId": "31",
+ "sourceUserId": "17",
+ "sensitive": [
+ "adult_content",
+ "other"
+ ],
+ "unavailable": "Geoblocked",
+ "title": "标题",
+ "description": "说明",
+ "link": "https://example.org/video"
+ }
+ ]
+}
diff --git a/scripts/tests/twitter/integration/test_archive_graph.py b/scripts/tests/twitter/integration/test_archive_graph.py
new file mode 100644
index 00000000..739ee49c
--- /dev/null
+++ b/scripts/tests/twitter/integration/test_archive_graph.py
@@ -0,0 +1,685 @@
+"""Native Twitter facts survive sparse, concurrent and cross-source writes."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+import typing
+from types import SimpleNamespace
+from unittest import mock
+import uuid
+
+import pytest
+import sqlalchemy
+from sqlalchemy import event
+import sqlmodel
+import twikit
+
+from app.business.info_base.resolver import ResolverManager
+from app.business.info_base.storage import HTTPStorage
+from app.business.lexical_retrieval import LexicalRetrievalManager
+from app.business.source import SourceManager
+from app.persistence.info_base.repository import BlockRepository, RelationRepository
+from app.persistence.info_base.uow import graph_uow
+from app.schemas.info_base.block import BlockForm, BlockModel
+from app.schemas.info_base.relation import RelationModel
+from app.schemas.info_base.storage import StorageBlobModel
+from app.schemas.job import JobModel
+from app.schemas.lexical_retrieval import (
+ BlockLexicalRecordModel,
+ LexicalMaintenanceOptions,
+)
+from app.schemas.source import SourceModel
+from extensions.twitter.api import (
+ TwitterAPI,
+ TwitterAPIResult,
+ _official_archive,
+ _twikit_archive,
+)
+from extensions.twitter.archive_schema import ArchiveBatch, ArchiveQuery
+from extensions.twitter.model import TwitterBatch
+from extensions.twitter.x_monkey import (
+ decode_submission,
+ encode_submission,
+ query_archive,
+ submit_archive,
+)
+from extensions.twitter.bookmark import CollectConfig, Source
+from extensions.twitter.reconcile import (
+ MEDIA_RESOLVER,
+ POST_RESOLVER,
+ REFERENCE_SCHEMA,
+ USER_RESOLVER,
+ persist_twitter,
+)
+from extensions.twitter.resolver import TweetResolver
+from extensions.twitter.schema import Tweet
+from tests.database import TestSession, seed_block, seed_relation
+
+
+pytestmark = pytest.mark.skipif(
+ not os.getenv("INKCRE_TEST_DATABASE_URL"),
+ reason="requires an explicitly selected migrated disposable PostgreSQL runtime",
+)
+
+
+@pytest.fixture
+def native() -> dict[str, typing.Any]:
+ value = json.loads(
+ (Path(__file__).parents[1] / "fixtures" / "native_archive.json").read_text()
+ )
+ offset = int(uuid.uuid4().hex[:15], 16) + 10**20
+
+ def distinct(item):
+ if isinstance(item, str):
+ if item.isascii() and item.isdecimal():
+ return str(int(item) + offset)
+ if item.startswith(("https://pbs.twimg.com/", "https://video.twimg.com/")):
+ return f"{item}{'&' if '?' in item else '?'}twitter_test={offset}"
+ if isinstance(item, list):
+ return [distinct(child) for child in item]
+ if isinstance(item, dict):
+ return {key: distinct(child) for key, child in item.items()}
+ return item
+
+ return distinct(value)
+
+
+@pytest.fixture
+def tracked() -> typing.Iterator[set[int]]:
+ ids: set[int] = set()
+ yield ids
+ if ids:
+ with TestSession() as db:
+ db.connection().execute(
+ sqlalchemy.delete(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tuple(ids)))
+ )
+ db.commit()
+
+
+async def _write(batch: ArchiveBatch | TwitterBatch, tracked: set[int]):
+ async with graph_uow() as uow:
+ result = await persist_twitter(
+ decode_submission(batch) if isinstance(batch, ArchiveBatch) else batch, uow
+ )
+ tracked.update(block_id for ids in result.block_ids.values() for block_id in ids.values())
+ return SimpleNamespace(
+ block_ids=result.block_ids,
+ response=encode_submission(
+ batch
+ if isinstance(batch, ArchiveBatch)
+ else ArchiveBatch.model_validate(batch.model_dump(mode="json", exclude_unset=True)),
+ result,
+ ),
+ )
+
+
+def _minimal(post: dict) -> dict:
+ return {field: post[field] for field in ("id", "authorId", "createdAt")}
+
+
+def _record(native_id: str) -> BlockModel:
+ with TestSession() as db:
+ result = db.exec(
+ sqlmodel.select(BlockModel).where(
+ BlockModel.resolver == POST_RESOLVER,
+ sqlalchemy.cast(BlockModel.content, sqlalchemy.JSON)["id"].as_string() == native_id,
+ )
+ ).one()
+ return result
+
+
+def test_native_roundtrip_projection_and_media_are_lazy(async_runner, native, tracked):
+ # Importing the module registers all exact decoders, including legacy v1.
+ from extensions.twitter import resolver # noqa: F401
+
+ batch = ArchiveBatch.model_validate(native)
+ query = ArchiveQuery(
+ **{
+ kind: [record.id for record in getattr(batch, kind)]
+ for kind in ("users", "posts", "media")
+ }
+ )
+ with TestSession() as db:
+ block_count = db.exec(
+ sqlmodel.select(sqlmodel.func.count()).select_from(BlockModel)
+ ).one()
+ blob_count = db.exec(
+ sqlmodel.select(sqlmodel.func.count()).select_from(StorageBlobModel)
+ ).one()
+ job_count = db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(JobModel)).one()
+ with mock.patch.object(
+ HTTPStorage, "get_raw_content", new_callable=mock.AsyncMock
+ ) as fetch:
+ fetch.side_effect = AssertionError("metadata read downloaded external media")
+ result = async_runner.run(_write(batch, tracked))
+ assert result.response.summary.accepted == 3
+ response = async_runner.run(query_archive(query))
+ for kind in ("users", "posts", "media"):
+ assert [item.status for item in getattr(response, kind)] == ["found"]
+ assert [
+ item.data.model_dump(mode="json", exclude_unset=True)
+ for item in getattr(response, kind)
+ ] == native[kind]
+ post = _record(batch.posts[0].id)
+ text = async_runner.run(ResolverManager.get(post).get_text(materialize_missing=False))
+ assert native["posts"][0]["article"]["title"] in text
+ assert native["users"][0]["handle"] in text
+ assert native["posts"][0]["id"] in text
+ report = async_runner.run(
+ LexicalRetrievalManager.maintain(
+ LexicalMaintenanceOptions(max_records=1000, scan_page_size=50)
+ )
+ )
+ assert report.failed == 0, report.diagnostics
+ for query_text in (
+ batch.posts[0].id,
+ native["users"][0]["handle"],
+ native["posts"][0]["text"]["text"],
+ ):
+ matches = async_runner.run(LexicalRetrievalManager.retrieve_local(query_text)).matches
+ assert post.id in {match.block.id for match in matches}
+ with TestSession() as db:
+ indexed = db.exec(
+ sqlmodel.select(BlockLexicalRecordModel.block).where(
+ sqlmodel.col(BlockLexicalRecordModel.block).in_(tuple(tracked))
+ )
+ ).all()
+ assert set(indexed) == tracked
+ assert fetch.await_count == 0
+ replay = async_runner.run(_write(batch, tracked))
+ assert replay.response.summary.unchanged == 3
+ assert replay.block_ids == result.block_ids
+ pure_repost = {
+ **_minimal(native["posts"][0]),
+ "id": str(int(batch.posts[0].id) + 1000),
+ "repostOf": batch.posts[0].id,
+ }
+ async_runner.run(_write(ArchiveBatch.model_validate({"posts": [pure_repost]}), tracked))
+ repost_response = async_runner.run(query_archive(ArchiveQuery(posts=[pure_repost["id"]])))
+ assert repost_response.posts[0].data is not None
+ assert (
+ repost_response.posts[0].data.model_dump(mode="json", exclude_unset=True) == pure_repost
+ )
+ assert batch.posts[0].quote is not None
+ missing = async_runner.run(
+ query_archive(ArchiveQuery(posts=[batch.posts[0].quote.postId]))
+ )
+ assert missing.posts[0].status == "missing"
+ with TestSession() as db:
+ assert (
+ db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(StorageBlobModel)).one()
+ == blob_count
+ )
+ assert (
+ db.exec(sqlmodel.select(sqlmodel.func.count()).select_from(JobModel)).one()
+ == job_count
+ )
+ media_id = result.block_ids["media"][batch.media[0].id]
+ relations = db.exec(
+ sqlmodel.select(RelationModel).where(
+ RelationModel.from_ == media_id, RelationModel.content == "content"
+ )
+ ).all()
+ assert relations == []
+ stored = db.exec(
+ sqlmodel.select(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tuple(tracked)))
+ ).all()
+ assert len(stored) == len(tracked)
+ assert all(block.storage is None for block in stored)
+ assert db.exec(
+ sqlmodel.select(sqlmodel.func.count()).select_from(BlockModel)
+ ).one() == block_count + len(tracked)
+
+
+def test_sparse_counter_history_and_managed_edges(async_runner, native, tracked):
+ result = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked))
+ post = native["posts"][0]
+ baseline = _minimal(post)
+ updates = [
+ (
+ {"counts": {"likes": 100}, "countsObservedAt": {"likes": "2026-10-20T01:00:00Z"}},
+ 100,
+ "2026-10-20T01:00:00Z",
+ "accepted",
+ ),
+ (
+ {"counts": {"likes": 90}, "countsObservedAt": {"likes": "2026-10-19T01:00:00Z"}},
+ 100,
+ "2026-10-20T01:00:00Z",
+ "unchanged",
+ ),
+ (
+ {
+ "counts": {"likes": 100},
+ "countsObservedAt": {"likes": "2026-10-20T02:00:00.1200Z"},
+ },
+ 100,
+ "2026-10-20T02:00:00.1200Z",
+ "accepted",
+ ),
+ ({"counts": {"likes": 101}}, 101, None, "accepted"),
+ ]
+ for patch, expected, observed, status in updates:
+ response = async_runner.run(
+ submit_archive(ArchiveBatch.model_validate({"posts": [{**baseline, **patch}]}))
+ )
+ assert response.posts[0].status == status
+ stored = json.loads(_record(post["id"]).content)
+ assert stored["counts"]["likes"] == expected
+ assert stored.get("countsObservedAt", {}).get("likes") == observed
+ assert stored["text"] == post["text"]
+ assert stored["article"] == post["article"]
+ assert _record(post["id"]).id == result.block_ids["posts"][post["id"]]
+
+ # A changed media array replaces its edges without touching user-authored links.
+ root_id = result.block_ids["posts"][post["id"]]
+ user_link = seed_relation(
+ root_id, result.block_ids["users"][post["authorId"]], "my citation"
+ )
+ old_media_id = post["mediaIds"][0]
+ replacement = str(int(old_media_id) + 1000)
+ update = ArchiveBatch.model_validate({"posts": [{**baseline, "mediaIds": [replacement]}]})
+ async_runner.run(_write(update, tracked))
+ with TestSession() as db:
+ assert db.get(RelationModel, user_link.id) is not None
+ media_refs = []
+ for relation in db.exec(
+ sqlmodel.select(RelationModel).where(RelationModel.from_ == root_id)
+ ).all():
+ try:
+ grammar = json.loads(relation.content)
+ except ValueError:
+ continue
+ if grammar.get("schema") == REFERENCE_SCHEMA and grammar["type"] == "media":
+ target = db.get(BlockModel, relation.to_)
+ assert target is not None
+ media_refs.append((grammar["position"], json.loads(target.content)["id"]))
+ expected = list(
+ dict.fromkeys(
+ [replacement, post["article"]["coverMediaId"], *post["article"]["mediaIds"]]
+ )
+ )
+ assert sorted(media_refs) == list(enumerate(expected))
+
+
+def test_bookmark_joins_legacy_and_native_identity_without_overwriting_text(
+ async_runner, native, tracked
+):
+ post = native["posts"][0]
+ legacy = seed_block(
+ TweetResolver.create_block(Tweet(id=int(post["id"]), text="旧加工 [photo]"))
+ )
+ citation = seed_block(BlockForm(resolver="core.text.v1", content="user citation"))
+ assert legacy.id is not None and citation.id is not None
+ tracked.update((legacy.id, citation.id))
+ reference = seed_relation(citation.id, legacy.id, "reference")
+ old_attachment = seed_relation(legacy.id, citation.id, "attachment:photo:legacy")
+ result = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked))
+ assert result.block_ids["posts"][post["id"]] == legacy.id
+ with TestSession() as db:
+ persisted_reference = db.get(RelationModel, reference.id)
+ assert persisted_reference is not None and persisted_reference.to_ == legacy.id
+ assert db.get(RelationModel, old_attachment.id) is not None
+
+ source_type = f"{Source.__module__}.{Source.__qualname__}"
+ async_runner.run(SourceManager.sync_source_types_async({source_type: Source}))
+ sparse = {**_minimal(post), "text": {"text": "provider has less detail"}, "lang": "en"}
+ provider = mock.Mock()
+ provider.get_bookmarks = mock.AsyncMock(
+ return_value=TwitterAPIResult(
+ batch=TwitterBatch.model_validate({"posts": [sparse]}), primary_post_ids=(post["id"],)
+ )
+ )
+ source = async_runner.run(
+ SourceManager.create(source_type, nickname="test Twitter bookmarks")
+ )
+ try:
+ with mock.patch.object(
+ TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider
+ ):
+ async_runner.run(
+ Source(source.id).collect(
+ JobModel(type="core.source.collect.v1", parameters={}), CollectConfig()
+ )
+ )
+ stored = json.loads(_record(post["id"]).content)
+ assert stored["text"] == post["text"]
+ assert stored["lang"] == "en"
+ with TestSession() as db:
+ persisted_source = db.get(SourceModel, source.id)
+ assert persisted_source is not None and persisted_source.state is not None
+ assert persisted_source.state["latest_tweet_id"] == post["id"]
+ finally:
+ with TestSession() as db:
+ db.delete(db.get(SourceModel, source.id))
+ db.commit()
+
+
+def test_real_transaction_lock_preserves_concurrent_supplements_and_rollback(
+ async_runner, native, tracked
+):
+ baseline = _minimal(native["posts"][0])
+ original_lock = BlockRepository.acquire_transaction_lock
+
+ async def exercise():
+ locked, attempted, release = asyncio.Event(), asyncio.Event(), asyncio.Event()
+
+ async def coordinated(repository, namespace):
+ task = asyncio.current_task()
+ assert task is not None
+ if task.get_name() == "second":
+ attempted.set()
+ await original_lock(repository, namespace)
+ if task.get_name() == "first":
+ locked.set()
+ await release.wait()
+
+ with mock.patch.object(BlockRepository, "acquire_transaction_lock", coordinated):
+ first = asyncio.create_task(
+ submit_archive(
+ ArchiveBatch.model_validate({"posts": [{**baseline, "source": "client"}]})
+ ),
+ name="first",
+ )
+ await locked.wait()
+ second = asyncio.create_task(
+ submit_archive(
+ ArchiveBatch.model_validate({"posts": [{**baseline, "lang": "zh"}]})
+ ),
+ name="second",
+ )
+ await attempted.wait()
+ release.set()
+ await asyncio.gather(first, second)
+ async with graph_uow() as uow:
+ roots = await uow.blocks.find_json_field_many(
+ (POST_RESOLVER, USER_RESOLVER), "id", (baseline["id"], baseline["authorId"])
+ )
+ tracked.update(block.id for block in roots)
+ posts = [root for root in roots if root.resolver == POST_RESOLVER]
+ assert len(posts) == 1
+ stored = json.loads(posts[0].content)
+ assert stored["source"] == "client" and stored["lang"] == "zh"
+
+ failed: dict[str, typing.Any] = {
+ **baseline,
+ "id": str(int(baseline["id"]) + 1000),
+ "quote": {"postId": str(int(baseline["id"]) + 1001)},
+ }
+ batch = ArchiveBatch.model_validate({"posts": [failed]})
+ with mock.patch.object(
+ RelationRepository,
+ "create_many",
+ new_callable=mock.AsyncMock,
+ side_effect=RuntimeError("deterministic persistence failure"),
+ ):
+ with pytest.raises(RuntimeError, match="deterministic persistence failure"):
+ await submit_archive(batch)
+ absent = await query_archive(
+ ArchiveQuery(posts=[failed["id"], failed["quote"]["postId"]])
+ )
+ assert [item.status for item in absent.posts] == ["missing", "missing"]
+ await _write(batch, tracked)
+ assert (await query_archive(ArchiveQuery(posts=[failed["id"]]))).posts[
+ 0
+ ].status == "found"
+
+ async_runner.run(exercise())
+
+
+def test_batch_lookup_tolerates_non_json_and_reports_locatable_corruption(
+ async_runner, native, tracked
+):
+ batch = ArchiveBatch.model_validate(native)
+ async_runner.run(_write(batch, tracked))
+ unknown = str(int(batch.posts[0].id) + 1000)
+ broken = [
+ seed_block(BlockForm(resolver="core.text.v1", content="ordinary non-JSON text")),
+ seed_block(BlockForm(resolver=POST_RESOLVER, content="broken JSON {")),
+ seed_block(
+ BlockForm(
+ resolver=POST_RESOLVER,
+ content=json.dumps({"id": unknown, "authorId": batch.posts[0].authorId}),
+ )
+ ),
+ ]
+ tracked.update(block.id for block in broken)
+ response = async_runner.run(
+ query_archive(ArchiveQuery(posts=[batch.posts[0].id, unknown]))
+ )
+ assert [item.status for item in response.posts] == ["found", "failed"]
+
+
+def test_media_metadata_roundtrips_without_content_blocks(async_runner, native, tracked):
+ base = int(native["media"][0]["id"]) + 1000
+ records: list[dict[str, typing.Any]] = [
+ {"id": str(base), "type": "photo", "url": "https://example.org/image.jpg"},
+ {
+ "id": str(base + 1),
+ "type": "video",
+ "url": "https://pbs.twimg.com/poster.jpg",
+ "video": {
+ "variants": [
+ {"url": "https://video.twimg.com/list.m3u8", "type": "application/x-mpegURL"}
+ ]
+ },
+ },
+ {"id": str(base + 2), "type": "photo", "url": "https://pbs.twimg.com/\nimage.jpg"},
+ {
+ "id": str(base + 3),
+ "type": "video",
+ "url": "https://pbs.twimg.com/poster.jpg",
+ "video": {
+ "variants": [
+ {
+ "url": f"https://video.twimg.com/{base}-low.mp4",
+ "type": "video/mp4",
+ "bitrate": 1,
+ },
+ {"url": "https://example.org/foreign.mp4", "type": "video/mp4", "bitrate": 99},
+ {
+ "url": f"https://video.twimg.com/{base}-high.mp4",
+ "type": "video/mp4",
+ "bitrate": 2,
+ },
+ ]
+ },
+ },
+ ]
+ result = async_runner.run(
+ _write(ArchiveBatch.model_validate({"media": records}), tracked)
+ )
+ response = async_runner.run(
+ query_archive(ArchiveQuery(media=[record["id"] for record in records]))
+ )
+ assert [item.status for item in response.media] == ["found"] * 4
+ assert [
+ item.data.model_dump(mode="json", exclude_unset=True) for item in response.media
+ ] == records
+ with TestSession() as db:
+ content = db.exec(
+ sqlmodel.select(RelationModel).where(
+ sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())),
+ RelationModel.content == "content",
+ )
+ ).all()
+ assert content == []
+
+
+def test_representative_batch_keeps_database_round_trips_bounded(
+ async_runner, native, tracked
+):
+ from app.engine import ASYNC_DB_ENGINE
+
+ base = int(native["posts"][0]["id"])
+ raw: dict[str, list[dict[str, typing.Any]]] = {"users": [], "posts": [], "media": []}
+ for index in range(200):
+ user, post, media, quoted = (
+ str(base + offset + index) for offset in (0, 1000, 2000, 3000)
+ )
+ raw["users"].append({"id": user, "handle": f"author{index}", "name": f"Author {index}"})
+ raw["posts"].append(
+ {
+ "id": post,
+ "authorId": user,
+ "createdAt": "2026-10-19T01:00:00Z",
+ "text": {"text": "benchmark " + "正文 " * 40},
+ "mediaIds": [media],
+ "quote": {"postId": quoted},
+ }
+ )
+ raw["media"].append(
+ {
+ "id": media,
+ "type": "photo",
+ "url": f"https://pbs.twimg.com/batch-{base}-{index}.jpg",
+ }
+ )
+ calls: list[str] = []
+
+ def count_round_trip(_connection, _cursor, statement, _parameters, _context, _many):
+ calls.append(statement.split(None, 1)[0])
+
+ event.listen(ASYNC_DB_ENGINE.sync_engine, "before_cursor_execute", count_round_trip)
+ try:
+ result = async_runner.run(_write(ArchiveBatch.model_validate(raw), tracked))
+ finally:
+ event.remove(ASYNC_DB_ENGINE.sync_engine, "before_cursor_execute", count_round_trip)
+ assert result.response.summary.accepted == 600
+ # Keep the synchronous native batch bounded when Core and DB are remote.
+ assert len(calls) < 20, {kind: calls.count(kind) for kind in set(calls)}
+ assert len(result.block_ids["posts"]) == 400
+ with TestSession() as db:
+ contents = db.exec(
+ sqlmodel.select(RelationModel).where(
+ sqlmodel.col(RelationModel.from_).in_(tuple(result.block_ids["media"].values())),
+ RelationModel.content == "content",
+ )
+ ).all()
+ assert contents == []
+
+
+def test_official_provider_keeps_source_text_and_exact_id_semantics(
+ async_runner, native, tracked
+):
+ root = native["posts"][0]
+ batch = _official_archive(
+ [
+ {
+ "id": root["id"],
+ "author_id": root["authorId"],
+ "created_at": root["createdAt"],
+ "text": "@reply 原始🌏 & https://t.co/media",
+ "conversation_id": "1",
+ "referenced_tweets": [{"type": "replied_to", "id": "2"}],
+ }
+ ],
+ {},
+ "2026-10-19T01:00:00Z",
+ )
+ assert batch.posts[0].text is not None
+ assert batch.posts[0].text.text == "@reply 原始🌏 & https://t.co/media"
+ assert batch.posts[0].conversationId == "1"
+ assert batch.posts[0].replyTo is not None and batch.posts[0].replyTo.postId == "2"
+ result = async_runner.run(_write(batch, tracked))
+ assert result.response.posts[0].status == "accepted"
+
+
+def test_twikit_bookmark_first_then_native_enrichment_keeps_one_root(
+ async_runner, native, tracked
+):
+ post = native["posts"][0]
+ media_id = native["media"][0]["id"]
+ author = SimpleNamespace(id=post["authorId"], screen_name="source_handle", name="source")
+ raw = {
+ "rest_id": post["id"],
+ "legacy": {
+ "created_at": "Sun Oct 18 12:34:56 +0000 2026",
+ "full_text": "truncated legacy…",
+ "lang": "zh",
+ "conversation_id_str": str(int(post["id"]) - 1),
+ "in_reply_to_status_id_str": str(int(post["id"]) - 2),
+ "entities": {
+ "media": [
+ {
+ "id_str": media_id,
+ "type": "photo",
+ "media_url_https": native["media"][0]["url"],
+ "url": "https://t.co/source",
+ "original_info": {"width": 1920, "height": 1080},
+ }
+ ],
+ "urls": [],
+ },
+ },
+ "note_tweet": {
+ "note_tweet_results": {
+ "result": {
+ "text": "@mention 完整🌏 & https://t.co/source",
+ }
+ }
+ },
+ }
+ # Exercise the installed provider's real getters, including note-tweet selection.
+ tweet = twikit.Tweet(mock.Mock(), raw, typing.cast(twikit.User, author))
+ batch = _twikit_archive((tweet,), "2026-10-19T01:00:00Z")
+ assert batch.posts[0].authorId == author.id
+ assert batch.posts[0].replyTo is not None and batch.posts[0].text is not None
+ assert batch.posts[0].conversationId != batch.posts[0].replyTo.postId
+ assert batch.posts[0].text.text == "@mention 完整🌏 & https://t.co/source"
+ assert [media.id for media in batch.media] == [media_id]
+ source_type = f"{Source.__module__}.{Source.__qualname__}"
+ async_runner.run(SourceManager.sync_source_types_async({source_type: Source}))
+ source = async_runner.run(SourceManager.create(source_type, nickname="Twikit first"))
+ provider = mock.Mock()
+ provider.get_bookmarks = mock.AsyncMock(
+ return_value=TwitterAPIResult(batch=batch, primary_post_ids=(post["id"],))
+ )
+ try:
+ with (
+ mock.patch.object(
+ TwitterAPI, "new", new_callable=mock.AsyncMock, return_value=provider
+ ),
+ mock.patch.object(
+ HTTPStorage,
+ "get_raw_content",
+ new_callable=mock.AsyncMock,
+ side_effect=AssertionError("Bookmark indexing downloaded external media"),
+ ) as fetch,
+ ):
+ async_runner.run(
+ Source(source.id).collect(
+ JobModel(type="core.source.collect.v1", parameters={}), CollectConfig()
+ )
+ )
+ root = _record(post["id"])
+ report = async_runner.run(
+ LexicalRetrievalManager.maintain(
+ LexicalMaintenanceOptions(max_records=1000, scan_page_size=50)
+ )
+ )
+ assert report.failed == 0, report.diagnostics
+ matches = async_runner.run(LexicalRetrievalManager.retrieve_local(post["id"])).matches
+ assert root.id in {match.block.id for match in matches}
+ assert fetch.await_count == 0
+ finally:
+ # Track native identities as well as the root created inside Source's UoW.
+ async def remember():
+ async with graph_uow() as uow:
+ rows = await uow.blocks.find_json_field_many(
+ (POST_RESOLVER, USER_RESOLVER, MEDIA_RESOLVER),
+ "id",
+ (post["id"], author.id, raw["legacy"]["in_reply_to_status_id_str"], media_id),
+ )
+ tracked.update(row.id for row in rows)
+
+ async_runner.run(remember())
+ with TestSession() as db:
+ db.delete(db.get(SourceModel, source.id))
+ db.commit()
+ enriched = async_runner.run(_write(ArchiveBatch.model_validate(native), tracked))
+ assert enriched.block_ids["posts"][post["id"]] == root.id
+ assert json.loads(_record(post["id"]).content)["text"] == post["text"]
diff --git a/scripts/tests/twitter/integration/test_archive_http_roundtrip.py b/scripts/tests/twitter/integration/test_archive_http_roundtrip.py
new file mode 100644
index 00000000..ea6cb89a
--- /dev/null
+++ b/scripts/tests/twitter/integration/test_archive_http_roundtrip.py
@@ -0,0 +1,232 @@
+"""The published x-monkey HTTP surface round-trips native records in PostgreSQL."""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+from pathlib import Path
+from types import SimpleNamespace
+import uuid
+
+import pytest
+import sqlalchemy
+import sqlmodel
+
+from app.persistence.info_base.uow import graph_uow
+from app.business.peer import PeerManager
+from app.business.source import SourceManager
+from app.schemas.info_base.block import BlockModel
+from app.schemas.info_base.relation import RelationModel
+from extensions.twitter.archive_session import SESSION_COOKIE_NAME, _issue_session_token
+from extensions.twitter.reconcile import MEDIA_RESOLVER, POST_RESOLVER, USER_RESOLVER
+from tests.database import TestSession
+from tests.extensions.runtime_support import publish_extension
+
+pytestmark = pytest.mark.skipif(
+ not os.getenv("INKCRE_TEST_DATABASE_URL"),
+ reason="requires an explicitly selected migrated disposable PostgreSQL runtime",
+)
+
+
+def _distinct_fixture() -> dict:
+ path = Path(__file__).parents[1] / "fixtures" / "native_archive.json"
+ value = json.loads(path.read_text())
+ offset = int(uuid.uuid4().hex[:15], 16) + 10**20
+
+ def distinct(item):
+ if isinstance(item, str):
+ if item.isascii() and item.isdecimal():
+ return str(int(item) + offset)
+ if item.startswith(("https://pbs.twimg.com/", "https://video.twimg.com/")):
+ return f"{item}{'&' if '?' in item else '?'}twitter_http_test={offset}"
+ if isinstance(item, list):
+ return [distinct(child) for child in item]
+ if isinstance(item, dict):
+ return {key: distinct(child) for key, child in item.items()}
+ return item
+
+ return distinct(value)
+
+
+def _archive_owned_ids(
+ native: dict,
+ *,
+ preexisting_ids: dict[str, set[str]] | None = None,
+) -> dict[str, tuple[str, ...]]:
+ """Return submitted identities and only the absent references this batch creates."""
+ resolvers = {"users": USER_RESOLVER, "posts": POST_RESOLVER, "media": MEDIA_RESOLVER}
+ defined = {
+ kind: {record["id"] for record in native[kind]} for kind in ("users", "posts", "media")
+ }
+ referenced = {kind: set() for kind in ("users", "posts", "media")}
+ for post in native["posts"]:
+ if "authorId" in post:
+ referenced["users"].add(post["authorId"])
+ article = post.get("article") or {}
+ media_ids = list(post.get("mediaIds", ()))
+ if "coverMediaId" in article:
+ media_ids.append(article["coverMediaId"])
+ media_ids.extend(article.get("mediaIds", ()))
+ referenced["media"].update(media_ids)
+ for field in ("replyTo", "quote"):
+ if field in post:
+ referenced["posts"].add(post[field]["postId"])
+ if "repostOf" in post:
+ referenced["posts"].add(post["repostOf"])
+
+ existing = preexisting_ids or {}
+ return {
+ resolvers[kind]: tuple(
+ sorted((defined[kind] | referenced[kind]) - existing.get(resolvers[kind], set()))
+ )
+ for kind in ("users", "posts", "media")
+ }
+
+
+async def _cleanup_archive(owned_ids: dict[str, tuple[str, ...]]) -> None:
+ async with graph_uow() as uow:
+ roots = []
+ for resolver, native_ids in owned_ids.items():
+ if native_ids:
+ roots.extend(await uow.blocks.find_json_field_many((resolver,), "id", native_ids))
+ tracked_ids = {block.id for block in roots if block.id is not None}
+
+ if not tracked_ids:
+ return
+ with TestSession() as db:
+ for relation in db.exec(
+ sqlmodel.select(RelationModel).where(
+ sqlalchemy.or_(
+ sqlmodel.col(RelationModel.from_).in_(tracked_ids),
+ sqlmodel.col(RelationModel.to_).in_(tracked_ids),
+ )
+ )
+ ).all():
+ db.delete(relation)
+ for block in db.exec(
+ sqlmodel.select(BlockModel).where(sqlmodel.col(BlockModel.id).in_(tracked_ids))
+ ).all():
+ db.delete(block)
+ db.commit()
+ async with graph_uow() as uow:
+ remaining = []
+ for resolver, native_ids in owned_ids.items():
+ if native_ids:
+ remaining.extend(
+ await uow.blocks.find_json_field_many((resolver,), "id", native_ids)
+ )
+ if remaining:
+ raise AssertionError("cleanup left test-owned archive identities in PostgreSQL")
+
+
+def test_real_http_submit_query_preserves_native_wire_payload(monkeypatch):
+ from extensions.twitter import Extension
+
+ native = _distinct_fixture()
+ simple_post_id = str(int(uuid.uuid4().hex[:15], 16) + 10**30)
+ shared_author_id = str(int(simple_post_id) + 1)
+ owned_ids = _archive_owned_ids(
+ native,
+ preexisting_ids={USER_RESOLVER: {shared_author_id}},
+ )
+ owned_ids[POST_RESOLVER] = tuple(sorted((*owned_ids[POST_RESOLVER], simple_post_id)))
+ with TestSession() as db:
+ shared_author = BlockModel(
+ resolver=USER_RESOLVER,
+ content=json.dumps({"id": shared_author_id}, separators=(",", ":")),
+ )
+ db.add(shared_author)
+ db.commit()
+ db.refresh(shared_author)
+ shared_author_block_id = shared_author.id
+
+ async def sync_source_types():
+ return None
+
+ async def current_config():
+ return SimpleNamespace(http_public_base_url="https://core.example")
+
+ monkeypatch.setattr(SourceManager, "sync_source_types_async", sync_source_types)
+ monkeypatch.setattr(PeerManager, "get_current_config_async", current_config)
+ token, _ = _issue_session_token()
+ published = publish_extension(Extension)
+ shared_author_survived_cleanup = False
+ try:
+ with published.client as client:
+ client.cookies.set(SESSION_COOKIE_NAME, token)
+ headers = {"Origin": "https://x.com", "X-Tweet-DB-Client": "x-monkey"}
+ simple_post = {
+ "id": simple_post_id,
+ "authorId": shared_author_id,
+ "createdAt": "2026-10-19T12:00:00Z",
+ "text": {"text": "HTTP to PostgreSQL round trip"},
+ }
+ simple_submit = client.post(
+ "/twitter/x-monkey/api/v1/archive/submit",
+ json={"posts": [simple_post]},
+ headers=headers,
+ )
+ assert simple_submit.status_code == 200, simple_submit.text
+ with TestSession() as db:
+ simple_root = next(
+ block
+ for block in db.exec(
+ sqlmodel.select(BlockModel).where(BlockModel.resolver == POST_RESOLVER)
+ ).all()
+ if json.loads(block.content).get("id") == simple_post_id
+ )
+ assert simple_root.id is not None
+ assert (
+ db.exec(
+ sqlmodel.select(RelationModel).where(
+ sqlmodel.col(RelationModel.from_) == simple_root.id,
+ sqlmodel.col(RelationModel.to_) == shared_author_block_id,
+ )
+ ).first()
+ is not None
+ )
+ simple_query = client.post(
+ "/twitter/x-monkey/api/v1/archive/query",
+ json={"posts": [simple_post_id]},
+ headers=headers,
+ )
+ assert simple_query.status_code == 200, simple_query.text
+ assert simple_query.json()["posts"] == [
+ {"id": simple_post_id, "status": "found", "data": simple_post}
+ ]
+
+ submitted = client.post(
+ "/twitter/x-monkey/api/v1/archive/submit",
+ json=native,
+ headers=headers,
+ )
+ assert submitted.status_code == 200, submitted.text
+ assert submitted.json()["summary"]["total"] == sum(
+ len(native[kind]) for kind in ("users", "posts", "media")
+ )
+
+ query = {
+ kind: [record["id"] for record in native[kind]]
+ for kind in ("users", "posts", "media")
+ }
+ queried = client.post(
+ "/twitter/x-monkey/api/v1/archive/query",
+ json=query,
+ headers=headers,
+ )
+ assert queried.status_code == 200, queried.text
+ response = queried.json()
+ for kind in ("users", "posts", "media"):
+ assert [item["status"] for item in response[kind]] == ["found"] * len(native[kind])
+ assert [item["data"] for item in response[kind]] == native[kind]
+ finally:
+ published.unpublish()
+ asyncio.run(_cleanup_archive(owned_ids))
+ with TestSession() as db:
+ shared_author_after_cleanup = db.get(BlockModel, shared_author_block_id)
+ shared_author_survived_cleanup = shared_author_after_cleanup is not None
+ if shared_author_after_cleanup is not None:
+ db.delete(shared_author_after_cleanup)
+ db.commit()
+ assert shared_author_survived_cleanup, "cleanup removed a preexisting author identity"
diff --git a/scripts/tests/twitter/test_archive_contract.py b/scripts/tests/twitter/test_archive_contract.py
new file mode 100644
index 00000000..43e7d20b
--- /dev/null
+++ b/scripts/tests/twitter/test_archive_contract.py
@@ -0,0 +1,309 @@
+"""Observable native archive round trips and x-monkey capture merge semantics."""
+
+from __future__ import annotations
+
+import copy
+import json
+from pathlib import Path
+from typing import Any
+
+import pydantic
+import pytest
+
+from extensions.twitter.archive_merge import merge_archive_record
+from extensions.twitter.archive_schema import (
+ ArchiveBatch,
+ ArchivePost,
+ ArchiveQuery,
+ QueryResponse,
+ SessionResponse,
+ SubmitResponse,
+)
+from extensions.twitter.model import RichText, TwitterBatch, TwitterPost
+from extensions.twitter.reconcile import TwitterReadResult, TwitterWriteResult
+from extensions.twitter.x_monkey import (
+ decode_submission,
+ encode_query,
+ encode_submission,
+ export_record,
+)
+
+
+@pytest.fixture
+def full_archive() -> dict[str, Any]:
+ fixture_path = Path(__file__).parent / "fixtures" / "native_archive.json"
+ return json.loads(fixture_path.read_text(encoding="utf-8"))
+
+
+def test_native_batch_preserves_complete_source_and_field_presence(full_archive):
+ original = copy.deepcopy(full_archive)
+ batch = ArchiveBatch.model_validate(full_archive)
+ assert batch.model_dump(mode="json", exclude_unset=True) == original
+ assert json.loads(batch.model_dump_json(exclude_unset=True)) == original
+ assert full_archive == original
+ assert batch.posts[0].communityNote is not None
+ assert batch.posts[0].communityNote.text.model_dump(exclude_unset=True) == {
+ "text": "来源已核实"
+ }
+
+
+def test_missing_and_explicit_empty_batches_remain_distinct():
+ batch = ArchiveBatch.model_validate({})
+ query = ArchiveQuery.model_validate({})
+ assert batch.users == batch.posts == batch.media == []
+ assert query.users == query.posts == query.media == []
+ assert batch.model_dump(mode="json", exclude_unset=True) == {}
+ assert query.model_dump(mode="json", exclude_unset=True) == {}
+ assert ArchiveBatch(posts=[]).model_dump(mode="json", exclude_unset=True) == {"posts": []}
+
+
+def test_fixed_protocol_roundtrips_through_owned_facts_and_neutral_results(full_archive):
+ wire = ArchiveBatch.model_validate(full_archive)
+ native = decode_submission(wire)
+ assert type(native) is TwitterBatch
+ assert type(native.posts[0]) is TwitterPost
+ assert native.model_dump(mode="json", exclude_unset=True) == full_archive
+ records = {
+ (kind, record.id): record
+ for kind in ("users", "posts", "media")
+ for record in getattr(native, kind)
+ }
+ query = ArchiveQuery(users=["17"], posts=["31", "32", "33"], media=["41"])
+ response = encode_query(
+ query,
+ TwitterReadResult(records=records, invalid=frozenset({("posts", "33")})),
+ )
+ for kind in ("users", "posts", "media"):
+ assert (
+ getattr(response, kind)[0].data.model_dump(mode="json", exclude_unset=True)
+ == (full_archive[kind][0])
+ )
+ assert [item.status for item in response.posts] == ["found", "missing", "failed"]
+ submitted = encode_submission(
+ wire, TwitterWriteResult(changed=frozenset(records), block_ids={})
+ )
+ assert submitted.summary.accepted == submitted.summary.total == 3
+ replayed = encode_submission(wire, TwitterWriteResult(changed=frozenset(), block_ids={}))
+ assert replayed.summary.unchanged == 3
+
+
+def test_capture_batch_does_not_inherit_remote_request_limits(full_archive):
+ records = {"posts": [full_archive["posts"][0]] * 1001}
+ assert len(TwitterBatch.model_validate(records).posts) == 1001
+ with pytest.raises(pydantic.ValidationError):
+ ArchiveBatch.model_validate(records)
+
+
+def test_fixed_export_projects_additive_native_fields_and_retains_open_json(full_archive):
+ class ExtendedText(RichText):
+ readingHint: str
+
+ class ExtendedPost(TwitterPost):
+ text: pydantic.SerializeAsAny[RichText] | None = None
+ readingHint: str
+
+ post = copy.deepcopy(full_archive["posts"][0])
+ post["readingHint"] = "native-only root information"
+ post["text"]["readingHint"] = "native-only nested information"
+ native = ExtendedPost.model_validate(
+ {**post, "text": ExtendedText.model_validate(post["text"])}
+ )
+ exported = export_record("posts", native)
+ assert exported == full_archive["posts"][0]
+ assert native.model_dump(mode="json", exclude_unset=True) == post
+ with pytest.raises(pydantic.ValidationError):
+ ArchivePost.model_validate(post)
+
+
+@pytest.mark.parametrize(
+ "invalid_id", ["0", "01", "-1", "1.0", " 1", "1\n", "11", 1, True]
+)
+def test_ids_are_positive_decimal_strings(invalid_id):
+ with pytest.raises(pydantic.ValidationError):
+ ArchiveQuery.model_validate({"posts": [invalid_id]})
+
+
+@pytest.mark.parametrize(
+ ("path", "invalid"),
+ [
+ (("users", 0, "counts", "likes"), True),
+ (("users", 0, "counts", "likes"), -1),
+ (("users", 0, "counts", "likes"), 1.0),
+ (("users", 0, "counts", "likes"), "1"),
+ (("posts", 0, "createdAt"), "2026-02-29T00:00:00Z"),
+ (("posts", 0, "createdAt"), "2026-10-19T00:00:00.123Z"),
+ (("posts", 0, "createdAt"), "2026-10-19T00:00:00+00:00"),
+ (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T24:00:00Z"),
+ (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00:00+08:00"),
+ (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00:00"),
+ (("posts", 0, "countsObservedAt", "likes"), "2026-10-19T01:00Z"),
+ (("users", 0, "countsObservedAt", "posts"), "2026-10-19T01:02Z"),
+ (("posts", 0, "text", "visible"), [9, 2]),
+ (("posts", 0, "text", "visible"), [False, 2]),
+ (("posts", 0, "text", "entities"), []),
+ (("posts", 0, "mediaIds"), []),
+ (("posts", 0, "editIds"), ["31"]),
+ (("posts", 0, "card", "poll", "choices"), [{"label": "only one"}]),
+ (("posts", 0, "card", "extra"), {"unexpected": 1}),
+ (("posts", 0, "place", "bbox"), [0, True, 2, 3]),
+ (("posts", 0, "place", "bbox"), [0, float("inf"), 2, 3]),
+ (("posts", 0, "quote", "tombstone"), ""),
+ (("media", 0, "video", "variants"), []),
+ (("media", 0, "crops"), [[0, 0, -1, 10]]),
+ (("posts", 0, "text"), None),
+ (("users", 0, "verified", "blue"), None),
+ (("posts", 0, "article", "blocks", 0, "unknown"), True),
+ (("media", 0, "unknown"), "discarding this would lose information"),
+ ],
+)
+def test_invalid_native_records_fail_instead_of_coercing_or_discarding(
+ full_archive, path, invalid
+):
+ current = full_archive
+ for part in path[:-1]:
+ current = current[part]
+ current[path[-1]] = invalid
+ with pytest.raises(pydantic.ValidationError):
+ ArchiveBatch.model_validate(full_archive)
+
+
+@pytest.mark.parametrize("kind", ["users", "posts", "media"])
+def test_query_and_submission_have_per_kind_limits(full_archive, kind):
+ assert len(getattr(ArchiveQuery.model_validate({kind: ["1"] * 1000}), kind)) == 1000
+ with pytest.raises(pydantic.ValidationError):
+ ArchiveQuery.model_validate({kind: ["1"] * 1001})
+ record = full_archive[kind][0]
+ assert len(getattr(ArchiveBatch.model_validate({kind: [record] * 1000}), kind)) == 1000
+ with pytest.raises(pydantic.ValidationError):
+ ArchiveBatch.model_validate({kind: [record] * 1001})
+
+
+def test_query_submit_and_session_wire_roundtrip(full_archive):
+ query = {
+ "users": [{"id": "17", "status": "found", "data": full_archive["users"][0]}],
+ "posts": [{"id": "31", "status": "missing"}],
+ "media": [{"id": "41", "status": "failed", "error": "invalid stored archive"}],
+ }
+ assert (
+ QueryResponse.model_validate(query).model_dump(mode="json", exclude_unset=True) == query
+ )
+ submission = {
+ "summary": {"total": 4, "accepted": 1, "unchanged": 1, "partial": 1, "failed": 1},
+ "users": [{"id": "17", "status": "accepted"}],
+ "posts": [{"id": "31", "status": "unchanged"}, {"id": "32", "status": "partial"}],
+ "media": [{"id": "41", "status": "failed", "reason": "invalid media"}],
+ }
+ assert (
+ SubmitResponse.model_validate(submission).model_dump(mode="json", exclude_unset=True)
+ == submission
+ )
+ session = {
+ "authenticated": False,
+ "registered": False,
+ "username": None,
+ "expires_at": None,
+ "account_url": None,
+ }
+ assert SessionResponse.model_validate(session).model_dump(mode="json") == session
+ with pytest.raises(pydantic.ValidationError):
+ ArchivePost.model_validate({"id": "31", "authorId": "17", "createdAt": None})
+
+
+def test_merge_is_recursive_replaces_arrays_and_does_not_mutate_inputs():
+ existing: dict[str, Any] = {
+ "id": "31",
+ "text": {"text": "正文", "visible": [0, 2], "entities": [{"type": "hashtag"}]},
+ "article": {"title": "长文", "blocks": [{"text": "第一版"}]},
+ }
+ incoming: dict[str, Any] = {
+ "text": {"text": "修订正文"},
+ "article": {"blocks": [{"text": "完整新版"}]},
+ }
+ old_existing, old_incoming = copy.deepcopy(existing), copy.deepcopy(incoming)
+ merged: dict[str, Any] = merge_archive_record(existing, incoming)
+ assert merged == {
+ "id": "31",
+ "text": {"text": "修订正文", "visible": [0, 2], "entities": [{"type": "hashtag"}]},
+ "article": {"title": "长文", "blocks": [{"text": "完整新版"}]},
+ }
+ merged["article"]["blocks"][0]["text"] = "调用方修改"
+ merged["text"]["entities"][0]["type"] = "调用方修改"
+ assert existing == old_existing
+ assert incoming == old_incoming
+
+
+def test_counter_observations_merge_independently_and_compare_instants():
+ existing: dict[str, Any] = {
+ "counts": {"likes": 10, "views": 100, "quotes": 3},
+ "countsObservedAt": {
+ "likes": "2026-10-19T01:00:00.900Z",
+ "views": "2026-10-19T01:00:00Z",
+ "quotes": "2026-10-19T01:00:00Z",
+ },
+ }
+ merged = merge_archive_record(
+ existing,
+ {
+ "lang": "zh",
+ "counts": {"likes": 9, "views": 110, "quotes": 2, "bookmarks": 0},
+ "countsObservedAt": {
+ "likes": "2026-10-19T01:00:00Z",
+ "views": "2026-10-19T01:00:00.100Z",
+ "quotes": "2026-10-19T01:00:00Z",
+ },
+ },
+ )
+ assert merged == {
+ "lang": "zh",
+ "counts": {"likes": 10, "views": 110, "quotes": 2, "bookmarks": 0},
+ "countsObservedAt": {
+ "likes": "2026-10-19T01:00:00.900Z",
+ "views": "2026-10-19T01:00:00.100Z",
+ "quotes": "2026-10-19T01:00:00Z",
+ },
+ }
+
+
+def test_untimed_changes_remove_only_the_changed_counters_previous_time():
+ existing: dict[str, Any] = {
+ "counts": {"likes": 10, "views": 100},
+ "countsObservedAt": {"likes": "2026-10-19T01:00:00Z", "views": "2026-10-19T01:00:00Z"},
+ }
+ assert merge_archive_record(existing, {"counts": {"likes": 11, "views": 100}}) == {
+ "counts": {"likes": 11, "views": 100},
+ "countsObservedAt": {"views": "2026-10-19T01:00:00Z"},
+ }
+ assert merge_archive_record(existing, {"counts": {"likes": 11, "views": 101}}) == {
+ "counts": {"likes": 11, "views": 101}
+ }
+
+
+def test_equal_counts_keep_new_observation_metadata_and_replay_is_unchanged():
+ existing: dict[str, Any] = {
+ "counts": {"likes": 10},
+ "countsObservedAt": {"likes": "2026-10-19T01:00:00Z"},
+ }
+ incoming: dict[str, Any] = {
+ "counts": {"likes": 10},
+ "countsObservedAt": {"likes": "2026-10-19T01:00:00.001Z"},
+ }
+ merged = merge_archive_record(existing, incoming)
+ assert merged == incoming
+ assert merged != existing
+ assert merge_archive_record(merged, incoming) == merged
+ assert (
+ merge_archive_record(existing, {"countsObservedAt": incoming["countsObservedAt"]})
+ == existing
+ )
+
+
+def test_capture_time_comparison_matches_javascript_millisecond_precision():
+ existing: dict[str, Any] = {
+ "counts": {"likes": 10},
+ "countsObservedAt": {"likes": "2026-10-19T01:00:00.1009Z"},
+ }
+ incoming: dict[str, Any] = {
+ "counts": {"likes": 11},
+ "countsObservedAt": {"likes": "2026-10-19T01:00:00.1001Z"},
+ }
+ assert merge_archive_record(existing, incoming) == incoming
diff --git a/scripts/tests/twitter/test_archive_http.py b/scripts/tests/twitter/test_archive_http.py
new file mode 100644
index 00000000..71a846ce
--- /dev/null
+++ b/scripts/tests/twitter/test_archive_http.py
@@ -0,0 +1,328 @@
+"""HTTP boundary checks for the x-monkey-compatible remote archive API."""
+
+from __future__ import annotations
+
+import time
+from types import SimpleNamespace
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+import jwt
+import pytest
+
+from app.business.peer import PeerManager
+from app.database_contract.constants import JWT_ALGORITHM
+from app.middleware import create_peer_jwt
+from app.settings import Settings, settings
+from extensions.twitter import archive_http, archive_session
+from extensions.twitter.archive_schema import QueryResponse, SubmitResponse, SubmitSummary
+from tests.extensions.runtime_support import publish_extension
+
+
+@pytest.fixture
+def archive_client(monkeypatch):
+ async def current_config():
+ return SimpleNamespace(http_public_base_url="https://core.example/prefix")
+
+ monkeypatch.setattr(PeerManager, "get_current_config_async", current_config)
+ app = FastAPI()
+ app.include_router(archive_session.router, prefix="/twitter/x-monkey")
+ app.include_router(archive_http.router, prefix="/twitter/x-monkey")
+ return TestClient(app, base_url="https://core.example")
+
+
+def _session_cookie() -> str:
+ token, _ = archive_session._issue_session_token()
+ return token
+
+
+def test_session_lifetime_defaults_to_seven_days_and_supports_environment_override(
+ monkeypatch,
+):
+ required = {
+ "database_url": "postgresql+psycopg://unused:unused@127.0.0.1:1/unused",
+ "jwt_secret": "test-only-jwt-secret-at-least-32-bytes",
+ }
+ monkeypatch.delenv("TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS", raising=False)
+ default = Settings(_env_file=None, **required)
+ assert default.twitter_x_monkey_jwt_lifetime_seconds == 7 * 24 * 60 * 60
+
+ monkeypatch.setenv("TWITTER_X_MONKEY_JWT_LIFETIME_SECONDS", "3600")
+ overridden = Settings(_env_file=None, **required)
+ assert overridden.twitter_x_monkey_jwt_lifetime_seconds == 3600
+
+
+def test_connect_exchanges_same_origin_bearer_for_scoped_cookie(archive_client):
+ response = archive_client.post(
+ "/twitter/x-monkey/session",
+ json={},
+ headers={
+ "Origin": "https://core.example",
+ "Authorization": (
+ f"Bearer {create_peer_jwt(settings.jwt_secret, lifetime_seconds=60)}"
+ ),
+ },
+ )
+
+ assert response.status_code == 200
+ assert response.json()["authenticated"] is True
+ cookie = response.headers["set-cookie"]
+ assert "inkcre_twitter_archive=" in cookie
+ assert "HttpOnly" in cookie and "Secure" in cookie and "samesite=none" in cookie.lower()
+ assert "Path=/prefix/twitter/x-monkey/" in cookie
+ readback = archive_client.get(
+ "/twitter/x-monkey/api/v1/session",
+ headers={"Cookie": cookie.split(";", 1)[0]},
+ )
+ assert readback.status_code == 200
+ assert readback.json()["authenticated"] is True
+
+
+def test_session_exchange_rejects_non_peer_or_cross_origin_credentials(archive_client):
+ session_cookie = _session_cookie()
+ missing_bearer = archive_client.post(
+ "/twitter/x-monkey/session",
+ json={},
+ headers={
+ "Origin": "https://core.example",
+ "Cookie": f"{archive_session.SESSION_COOKIE_NAME}={session_cookie}",
+ },
+ )
+ assert missing_bearer.status_code == 401
+
+ session_as_bearer = archive_client.post(
+ "/twitter/x-monkey/session",
+ json={},
+ headers={
+ "Origin": "https://core.example",
+ "Authorization": f"Bearer {session_cookie}",
+ },
+ )
+ assert session_as_bearer.status_code == 401
+
+ foreign_origin = archive_client.post(
+ "/twitter/x-monkey/session",
+ json={},
+ headers={
+ "Origin": "https://attacker.example",
+ "Authorization": f"Bearer {create_peer_jwt(settings.jwt_secret)}",
+ },
+ )
+ assert foreign_origin.status_code == 403
+
+
+def test_account_page_uses_nonce_csp_and_only_same_origin_webcrypto(archive_client):
+ response = archive_client.get("/twitter/x-monkey/account")
+ assert response.status_code == 200
+ assert 'id="jwt-secret" type="password"' in response.text
+ assert "JWT_SECRET" in response.text
+ assert '尚未连接。
' in response.text
+ assert "status.textContent = 'Connected'" in response.text
+ assert "crypto.subtle" in response.text
+ assert "exp: now + 60" in response.text
+ assert "fetch('./session'" in response.text
+ assert "fetch('./api/v1/session'" in response.text
+ assert "localStorage" not in response.text and "sessionStorage" not in response.text
+ csp = response.headers["content-security-policy"]
+ nonce = response.text.split('