diff --git a/docs/PLAN_SENSE_SOCIAL_CONNECTORS_v1.0.md b/docs/PLAN_SENSE_SOCIAL_CONNECTORS_v1.0.md index a3874a8..cd74dca 100644 --- a/docs/PLAN_SENSE_SOCIAL_CONNECTORS_v1.0.md +++ b/docs/PLAN_SENSE_SOCIAL_CONNECTORS_v1.0.md @@ -841,3 +841,49 @@ Sense 的核心是默认关闭、最小采集。把公共发布放在同一品 ## 17. 一句话 > 让 LISA 帮用户表达,而不是替用户擅自发声:Connector 管连接,Skill 管方法,Host 管不可绕过的最终确认。 + +--- + +## 18. 实施推进记录(2026-07-26) + +### 18.1 已完成 + +- PR #324:connector manifest、canonical draft、revision、snapshot digest 与 MCP annotations 保真。 +- PR #325:模型可见的 `social_compose`、可信 UI/session 审批、Sense 完整快照、cancel;不存在 HTTP publish route。 +- PR #326:Bluesky 与 Mastodon 首方 connector plugin + Skill;CLI 安装/连接;隐藏 runner;文字、链接、图片、视频、幂等、逐目标 receipt、结构化 audit、pause kill switch。 +- 媒体进入 host store 前做 magic-byte MIME sniff、SHA-256、JPEG/PNG 隐私元数据剥离;不把文件路径或媒体 bytes 放进草稿。 +- `social_publish` 与 disconnect operation 根据受信 manifest 绑定,从模型、remote channel、autonomous run 和 task tool closure 中排除。 + +### 18.2 商业平台 adapter contract 已完成 + +`commercial.ts` 登记 Threads、Instagram、LinkedIn、X、TikTok、YouTube、Facebook Pages 的: + +- OAuth scope、账号类型、平台审核/业务验证/付费门槛。 +- 版本固定策略,禁止 silent floating。 +- 媒体交付类型:公网拉取、直接上传、分片/可恢复上传。 +- 平台专有必填字段与 fail-closed validation。 +- 从上传初始化到 publish/status reconciliation 的请求步骤。 +- `draft-only`、`private-test-only`、`ready` 三态 readiness;没有凭据或审核时绝不显示 ready。 + +可用 `lisa sense social readiness` 查看门槛。该命令不读取或打印 secret。 + +### 18.3 不能由代码仓单方面完成的外部事项 + +| 平台 | 外部门槛 | 未满足时的产品行为 | +|---|---|---| +| Threads | Meta app review;图片/视频必须由 Meta 可拉取的公网 URL | 仅草稿 | +| Instagram | Meta review/Business Verification;专业账号;公网 media URL | 仅草稿 | +| LinkedIn | product access;member/org scope;组织角色;月版本轮换 | 仅草稿 | +| X | 付费 project、write scope 与运行配额 | 仅草稿,并显示费用提示 | +| TikTok | Content Posting 产品批准;Direct Post audit;creator-info UX;URL prefix verification | 未 audit 只允许 private test | +| YouTube | Google OAuth verification;YouTube API audit | 未 audit 上传强制 private | +| Facebook | Meta review/Business Verification;Page token/role;不能发个人 profile | 仅草稿 | + +因此,“代码已实现 adapter contract”不等于“平台已授权生产直发”。要把某一平台从 draft-only 提升为 ready,必须由平台 app owner 提供 client 配置、完成 review/audit,并用真实测试账号通过 connector contract suite。任何绕过这些门槛的浏览器自动化或 Cookie 注入仍明确不采用。 + +### 18.4 本轮实证 + +- 全量测试:1466 passed、1 skipped。 +- 包含真实 MCP stdio initialize/list-tools/close,而非只测 mock。 +- failure injection 覆盖 stale digest、approval expiry、one-shot claim、pause-before-claim、partial outcome、MIME mismatch、媒体 hash 与远端审批拒绝。 +- live platform smoke test 需要用户账号与 app 审核;本轮没有使用或伪造任何生产凭据,也没有向真实社交账号发布测试内容。 diff --git a/src/cli/sense.ts b/src/cli/sense.ts index 4e69dcd..97cabc2 100644 --- a/src/cli/sense.ts +++ b/src/cli/sense.ts @@ -13,6 +13,10 @@ import { connectBlueskyAccount } from "../sense/social/connectors/bluesky.js"; import { connectMastodonAccount } from "../sense/social/connectors/mastodon.js"; import { publicAccount } from "../sense/social/connectors/accounts.js"; import { stageSocialMedia } from "../sense/social/media.js"; +import { + COMMERCIAL_ADAPTER_PROFILES, + evaluateCommercialReadiness, +} from "../sense/social/connectors/commercial.js"; import fs from "node:fs/promises"; import readline from "node:readline"; @@ -103,6 +107,26 @@ export async function runSenseCommand(subargs: string[]): Promise { console.log(JSON.stringify(ref, null, 2)); return 0; } + if (action === "readiness") { + console.log("Sense · Commercial connector readiness\n"); + for (const platform of Object.keys(COMMERCIAL_ADAPTER_PROFILES) as Array< + keyof typeof COMMERCIAL_ADAPTER_PROFILES + >) { + const readiness = evaluateCommercialReadiness(platform, { + oauthClientConfigured: false, + redirectOriginConfigured: false, + platformReviewApproved: false, + }); + const profile = COMMERCIAL_ADAPTER_PROFILES[platform]; + console.log(`${profile.displayName.padEnd(16)} ${readiness.state}`); + for (const gate of profile.externalGates) console.log(` external: ${gate}`); + if (profile.pricingNotice) console.log(` notice: ${profile.pricingNotice}`); + } + console.log( + "\nThese adapters remain draft-only until a connector owns OAuth, passes platform review, and satisfies media delivery gates.", + ); + return 0; + } const [connectors, drafts] = await Promise.all([ discoverSocialConnectors(), listSocialDrafts(), diff --git a/src/sense/social/connectors/commercial.test.ts b/src/sense/social/connectors/commercial.test.ts new file mode 100644 index 0000000..1f4b74a --- /dev/null +++ b/src/sense/social/connectors/commercial.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + COMMERCIAL_ADAPTER_PROFILES, + commercialPublishPlan, + evaluateCommercialReadiness, + validateCommercialDraft, +} from "./commercial.js"; + +test("all seven commercial platforms have official-source and version gates", () => { + assert.deepEqual(Object.keys(COMMERCIAL_ADAPTER_PROFILES).sort(), [ + "facebook", "instagram", "linkedin", "threads", "tiktok", "x", "youtube", + ]); + for (const profile of Object.values(COMMERCIAL_ADAPTER_PROFILES)) { + assert.ok(profile.oauthScopes.length); + assert.ok(profile.externalGates.length); + assert.ok(profile.officialDocs.every((url) => url.startsWith("https://"))); + assert.ok(profile.versionPolicy.length > 20); + } +}); + +test("readiness never calls an unaudited or unconfigured platform ready", () => { + const blocked = evaluateCommercialReadiness("instagram", { + oauthClientConfigured: false, + redirectOriginConfigured: false, + platformReviewApproved: false, + }); + assert.equal(blocked.state, "draft-only"); + assert.ok(blocked.blockers.length >= 4); + const youtube = evaluateCommercialReadiness("youtube", { + oauthClientConfigured: true, + redirectOriginConfigured: true, + platformReviewApproved: true, + youtubeAuditApproved: false, + }); + assert.equal(youtube.state, "private-test-only"); +}); + +test("platform-specific required fields fail closed", () => { + const youtube = validateCommercialDraft( + "youtube", + { connectorId: "youtube-official", accountId: "channel", platform: "youtube" }, + { title: "video", media: [] }, + ); + assert.equal(youtube.ok, false); + assert.match(youtube.errors.join(" "), /exactly one video/); + assert.match(youtube.errors.join(" "), /privacyStatus/); + + const tiktok = validateCommercialDraft( + "tiktok", + { connectorId: "tiktok-official", accountId: "creator", platform: "tiktok" }, + { text: "caption", media: [{ id: "1", kind: "video", mimeType: "video/mp4", bytes: 1, sha256: "x" }] }, + ); + assert.equal(tiktok.ok, false); + assert.match(tiktok.errors.join(" "), /privacyLevel/); +}); + +test("request plans preserve each official multi-step protocol", () => { + assert.deepEqual( + commercialPublishPlan("threads", true).map((step) => step.operation), + ["create-container", "publish-container"], + ); + assert.deepEqual( + commercialPublishPlan("youtube", true).map((step) => step.operation), + ["start-resumable", "upload-video", "poll-processing"], + ); + assert.equal( + commercialPublishPlan("tiktok", true).some((step) => step.operation === "creator-info"), + true, + ); +}); diff --git a/src/sense/social/connectors/commercial.ts b/src/sense/social/connectors/commercial.ts new file mode 100644 index 0000000..704c917 --- /dev/null +++ b/src/sense/social/connectors/commercial.ts @@ -0,0 +1,322 @@ +import type { + SocialDraftContent, + SocialPlatformVariant, + SocialTarget, +} from "../types.js"; +import type { ConnectorValidation } from "./types.js"; + +export type CommercialPlatform = + | "threads" + | "instagram" + | "linkedin" + | "x" + | "tiktok" + | "youtube" + | "facebook"; + +export interface CommercialAdapterProfile { + platform: CommercialPlatform; + connectorId: string; + displayName: string; + accountKinds: string[]; + oauthScopes: string[]; + externalGates: string[]; + mediaTransfer: "public-url" | "direct-upload" | "resumable-upload" | "mixed"; + versionPolicy: string; + pricingNotice?: string; + officialDocs: string[]; +} + +export interface CommercialReadinessInput { + oauthClientConfigured: boolean; + redirectOriginConfigured: boolean; + platformReviewApproved: boolean; + businessVerificationComplete?: boolean; + publicMediaStagingConfigured?: boolean; + youtubeAuditApproved?: boolean; + tiktokAuditApproved?: boolean; + xPaidAccessConfirmed?: boolean; +} + +export interface CommercialReadiness { + platform: CommercialPlatform; + state: "draft-only" | "private-test-only" | "ready"; + blockers: string[]; + notices: string[]; +} + +export interface CommercialPublishStep { + operation: string; + method: "GET" | "POST" | "PUT"; + endpoint: string; + purpose: string; + carriesMediaBytes?: boolean; +} + +const META_VERSION_POLICY = + "Resolve a supported Graph API version at connector release time; never silently float versions."; + +export const COMMERCIAL_ADAPTER_PROFILES: Record< + CommercialPlatform, + CommercialAdapterProfile +> = { + threads: { + platform: "threads", + connectorId: "threads-official", + displayName: "Threads", + accountKinds: ["Threads user"], + oauthScopes: ["threads_basic", "threads_content_publish"], + externalGates: ["Meta app review"], + mediaTransfer: "public-url", + versionPolicy: META_VERSION_POLICY, + officialDocs: [ + "https://developers.facebook.com/docs/threads/posts", + "https://developers.facebook.com/docs/threads/get-started", + ], + }, + instagram: { + platform: "instagram", + connectorId: "instagram-official", + displayName: "Instagram", + accountKinds: ["Instagram Business", "Instagram Creator"], + oauthScopes: ["instagram_basic", "instagram_content_publish"], + externalGates: ["Meta app review", "Professional account eligibility"], + mediaTransfer: "public-url", + versionPolicy: META_VERSION_POLICY, + officialDocs: [ + "https://developers.facebook.com/docs/instagram-platform/content-publishing", + ], + }, + linkedin: { + platform: "linkedin", + connectorId: "linkedin-official", + displayName: "LinkedIn", + accountKinds: ["Member", "Organization"], + oauthScopes: ["w_member_social", "w_organization_social"], + externalGates: ["LinkedIn product access", "Organization role check"], + mediaTransfer: "direct-upload", + versionPolicy: + "Pin a supported YYYYMM Linkedin-Version and rotate before its sunset; always send X-Restli-Protocol-Version: 2.0.0.", + officialDocs: [ + "https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api", + ], + }, + x: { + platform: "x", + connectorId: "x-official", + displayName: "X", + accountKinds: ["X user"], + oauthScopes: ["tweet.read", "tweet.write", "users.read", "offline.access"], + externalGates: ["Paid API tier/quota"], + mediaTransfer: "mixed", + versionPolicy: "Pin X API v2 endpoints and capability-test the paid project at runtime.", + pricingNotice: "Publishing and media requests may consume paid X API quota.", + officialDocs: [ + "https://docs.x.com/x-api/posts/create-post", + "https://docs.x.com/x-api/media/upload-media", + ], + }, + tiktok: { + platform: "tiktok", + connectorId: "tiktok-official", + displayName: "TikTok", + accountKinds: ["TikTok creator"], + oauthScopes: ["user.info.basic", "video.publish", "video.upload"], + externalGates: [ + "Content Posting API product approval", + "Direct Post audit for non-private visibility", + "Verified media URL prefix for PULL_FROM_URL", + ], + mediaTransfer: "mixed", + versionPolicy: "Capability-query creator_info immediately before every confirmation.", + officialDocs: [ + "https://developers.tiktok.com/doc/content-posting-api-reference-direct-post", + "https://developers.tiktok.com/doc/content-posting-api-media-transfer-guide", + ], + }, + youtube: { + platform: "youtube", + connectorId: "youtube-official", + displayName: "YouTube", + accountKinds: ["YouTube channel"], + oauthScopes: ["https://www.googleapis.com/auth/youtube.upload"], + externalGates: ["Google OAuth verification", "YouTube API audit for public uploads"], + mediaTransfer: "resumable-upload", + versionPolicy: "Use YouTube Data API v3 and resumable sessions with status reconciliation.", + officialDocs: [ + "https://developers.google.com/youtube/v3/docs/videos/insert", + "https://developers.google.com/youtube/v3/guides/using_resumable_upload_protocol", + ], + }, + facebook: { + platform: "facebook", + connectorId: "facebook-pages-official", + displayName: "Facebook Pages", + accountKinds: ["Facebook Page"], + oauthScopes: ["pages_show_list", "pages_read_engagement", "pages_manage_posts"], + externalGates: ["Meta app review", "Business verification", "Page role check"], + mediaTransfer: "mixed", + versionPolicy: META_VERSION_POLICY, + officialDocs: [ + "https://developers.facebook.com/docs/pages-api/posts", + "https://developers.facebook.com/docs/pages-api/getting-started", + ], + }, +}; + +export function evaluateCommercialReadiness( + platform: CommercialPlatform, + input: CommercialReadinessInput, +): CommercialReadiness { + const profile = COMMERCIAL_ADAPTER_PROFILES[platform]; + const blockers: string[] = []; + const notices: string[] = []; + if (!input.oauthClientConfigured) blockers.push("OAuth client is not configured"); + if (!input.redirectOriginConfigured) blockers.push("canonical HTTPS OAuth redirect is not configured"); + if (!input.platformReviewApproved) blockers.push("platform product/app review is not approved"); + if ( + ["threads", "instagram", "facebook"].includes(platform) && + !input.businessVerificationComplete + ) { + blockers.push("Meta business verification is incomplete"); + } + if ( + profile.mediaTransfer === "public-url" && + !input.publicMediaStagingConfigured + ) { + blockers.push("privacy-scoped public media staging is not configured"); + } + if (platform === "x" && !input.xPaidAccessConfirmed) { + blockers.push("X paid API access/quota is not confirmed"); + } + if (platform === "youtube" && !input.youtubeAuditApproved) { + notices.push("unverified YouTube API projects can upload only private videos"); + } + if (platform === "tiktok" && !input.tiktokAuditApproved) { + notices.push("unaudited TikTok clients can publish only private content"); + } + if (blockers.length) return { platform, state: "draft-only", blockers, notices }; + if (notices.length) return { platform, state: "private-test-only", blockers, notices }; + return { platform, state: "ready", blockers, notices }; +} + +function textFor(content: SocialDraftContent, variant?: SocialPlatformVariant): string { + return variant?.text ?? content.text ?? ""; +} + +function mediaFor(content: SocialDraftContent, variant?: SocialPlatformVariant) { + return variant?.mediaIds + ? content.media.filter((item) => variant.mediaIds!.includes(item.id)) + : content.media; +} + +function option( + target: SocialTarget, + variant: SocialPlatformVariant | undefined, + key: string, +): unknown { + return variant?.options?.[key] ?? target.options?.[key]; +} + +export function validateCommercialDraft( + platform: CommercialPlatform, + target: SocialTarget, + content: SocialDraftContent, + variant?: SocialPlatformVariant, +): ConnectorValidation { + const errors: string[] = []; + const warnings: string[] = []; + const text = textFor(content, variant); + const media = mediaFor(content, variant); + if (platform === "threads" && [...text].length > 500) { + errors.push("Threads text exceeds 500 characters"); + } + if (platform === "instagram") { + if (media.length === 0) errors.push("Instagram publishing requires image or video media"); + if (media.length > 10) errors.push("Instagram carousel exceeds 10 items"); + if ([...text].length > 2200) errors.push("Instagram caption exceeds 2200 characters"); + if (content.link) warnings.push("Instagram feed captions do not provide a clickable link attachment"); + } + if (platform === "linkedin" && [...text].length > 3000) { + errors.push("LinkedIn commentary exceeds the conservative 3000-character limit"); + } + if (platform === "x") { + if ([...text].length > 280) errors.push("X post exceeds the standard 280-character limit"); + if (media.length > 4) errors.push("X post exceeds 4 media items"); + warnings.push("X API publishing may consume paid quota"); + } + if (platform === "tiktok") { + if (media.length === 0) errors.push("TikTok requires video or photo media"); + for (const key of ["privacyLevel", "disableComment", "disableDuet", "disableStitch"]) { + if (option(target, variant, key) == null) errors.push(`TikTok requires ${key} from creator-aware confirmation UI`); + } + } + if (platform === "youtube") { + if (media.length !== 1 || media[0]?.kind !== "video") { + errors.push("YouTube requires exactly one video"); + } + const title = variant?.title ?? content.title ?? ""; + if (!title) errors.push("YouTube title is required"); + if ([...title].length > 100) errors.push("YouTube title exceeds 100 characters"); + if ([...(variant?.description ?? content.description ?? "")].length > 5000) { + errors.push("YouTube description exceeds 5000 characters"); + } + for (const key of ["privacyStatus", "categoryId", "madeForKids"]) { + if (option(target, variant, key) == null) errors.push(`YouTube requires ${key}`); + } + } + if (platform === "facebook" && !target.accountId) { + errors.push("Facebook publishing requires a Page account, not a personal profile"); + } + if (!text && !content.link && !content.title && media.length === 0) { + errors.push(`${COMMERCIAL_ADAPTER_PROFILES[platform].displayName} post is empty`); + } + return { ok: errors.length === 0, errors, warnings }; +} + +export function commercialPublishPlan( + platform: CommercialPlatform, + hasMedia: boolean, +): CommercialPublishStep[] { + switch (platform) { + case "threads": + return [ + { operation: "create-container", method: "POST", endpoint: "/{threads-user-id}/threads", purpose: hasMedia ? "Create IMAGE/VIDEO/CAROUSEL container from staged public URL" : "Create TEXT container" }, + { operation: "publish-container", method: "POST", endpoint: "/{threads-user-id}/threads_publish", purpose: "Publish creation_id" }, + ]; + case "instagram": + return [ + { operation: "create-container", method: "POST", endpoint: "/{ig-user-id}/media", purpose: "Create image, reel, or carousel container from staged public URL" }, + { operation: "poll-container", method: "GET", endpoint: "/{creation-id}?fields=status_code", purpose: "Wait for media processing" }, + { operation: "publish-container", method: "POST", endpoint: "/{ig-user-id}/media_publish", purpose: "Publish creation_id" }, + ]; + case "linkedin": + return [ + ...(hasMedia ? [{ operation: "initialize-upload", method: "POST" as const, endpoint: "/rest/images?action=initializeUpload or /rest/videos?action=initializeUpload", purpose: "Obtain upload URL and media URN" }] : []), + ...(hasMedia ? [{ operation: "upload-media", method: "PUT" as const, endpoint: "{uploadUrl}", purpose: "Upload bytes", carriesMediaBytes: true }] : []), + { operation: "create-post", method: "POST", endpoint: "/rest/posts", purpose: "Create versioned Post resource" }, + ]; + case "x": + return [ + ...(hasMedia ? [{ operation: "upload-media", method: "POST" as const, endpoint: "/2/media/upload", purpose: "Upload media and poll processing", carriesMediaBytes: true }] : []), + { operation: "create-post", method: "POST", endpoint: "/2/tweets", purpose: "Create post with text and media_ids" }, + ]; + case "tiktok": + return [ + { operation: "creator-info", method: "POST", endpoint: "/v2/post/publish/creator_info/query/", purpose: "Refresh allowed privacy and interaction controls" }, + { operation: "initialize-post", method: "POST", endpoint: "/v2/post/publish/video/init/ or /v2/post/publish/content/init/", purpose: "Initialize direct post" }, + { operation: "upload-media", method: "PUT", endpoint: "{upload_url}", purpose: "Transfer media bytes or use verified PULL_FROM_URL", carriesMediaBytes: true }, + { operation: "poll-status", method: "POST", endpoint: "/v2/post/publish/status/fetch/", purpose: "Reconcile publish_id" }, + ]; + case "youtube": + return [ + { operation: "start-resumable", method: "POST", endpoint: "/upload/youtube/v3/videos?uploadType=resumable&part=snippet,status", purpose: "Create resumable session with metadata" }, + { operation: "upload-video", method: "PUT", endpoint: "{Location}", purpose: "Upload/resume video bytes", carriesMediaBytes: true }, + { operation: "poll-processing", method: "GET", endpoint: "/youtube/v3/videos?part=status,processingDetails&id={id}", purpose: "Reconcile processing and privacy" }, + ]; + case "facebook": + return hasMedia + ? [{ operation: "upload-page-media", method: "POST", endpoint: "/{page-id}/photos or /{page-id}/videos", purpose: "Upload and publish Page media", carriesMediaBytes: true }] + : [{ operation: "create-page-post", method: "POST", endpoint: "/{page-id}/feed", purpose: "Publish Page text/link post" }]; + } +} diff --git a/src/sense/social/drafts.test.ts b/src/sense/social/drafts.test.ts index 0ec832b..03c40de 100644 --- a/src/sense/social/drafts.test.ts +++ b/src/sense/social/drafts.test.ts @@ -137,4 +137,33 @@ describe("social drafts", () => { assert.equal(cancelled.state, "cancelled"); assert.equal(cancelled.approval, undefined); }); + + test("mixed target receipts finish as partial", async () => { + const raw = input(); + raw.targets.push({ + connectorId: "mastodon-official", + accountId: "alice@example.social", + platform: "mastodon", + }); + const draft = await createSocialDraft(raw, 1000); + const { digest } = await requestSocialDraftApproval(draft.id, 1, 2000); + await approveSocialDraft(draft.id, digest, 3000); + await claimApprovedSocialDraft(draft.id, digest, 4000); + const finished = await completeSocialDraftPublish(draft.id, [ + { + targetKey: "bluesky-official:did:plc:alice", + ok: true, + attempts: 1, + completedAt: new Date(5000).toISOString(), + }, + { + targetKey: "mastodon-official:alice@example.social", + ok: false, + error: "rate limited", + attempts: 1, + completedAt: new Date(5000).toISOString(), + }, + ], 5000); + assert.equal(finished.state, "partial"); + }); });