cross-poster-sdk 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 takao-dev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,205 @@
1
- # Temporary Holding Version
1
+ # cross-poster-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/codeql.yml/badge.svg)](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/codeql.yml)
5
+ [![npm version](https://img.shields.io/npm/v/cross-poster-sdk.svg)](https://www.npmjs.com/package/cross-poster-sdk)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ > **Zero-dependency, multi-SNS cross-posting SDK for X (Twitter), Mastodon, Bluesky (AT Protocol), Misskey, and Meta Threads, engineered with DNS-rebinding-safe SSRF defense and AES-256-GCM credential envelopes.**
9
+
10
+ Cross-posting across federated (Mastodon, Misskey) and centralized (X, Bluesky, Threads) networks introduces severe security hazards: users supply arbitrary instance domains that can target internal cloud metadata endpoints (`169.254.169.254`) or trigger DNS rebinding attacks. `cross-poster-sdk` provides an all-in-one unified cross-posting pipeline with rock-solid security defaults and **zero external npm dependencies**.
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - **Unified 5-Network Adapter**:
17
+ Post and delete across **X (v2 API)**, **Mastodon (v1 API)**, **Bluesky (AT Protocol / XRPC)**, **Misskey (Notes API)**, and **Threads (2-step container API)** through a single clean interface.
18
+ - **DNS-Rebinding-Proof SSRF Protection**:
19
+ Validates destination addresses against private/internal IPv4 and IPv6 CIDR blocks (RFC1918, CGNAT, Loopback, Link-Local, Cloud Metadata, 6to4, IPv4-mapped IPv6). Resolves and **pins the IP address** directly into the Node.js socket TLS handshake (`lookup` hook), completely eliminating Time-Of-Check to Time-Of-Use (TOCTOU) DNS rebinding attacks.
20
+ - **AES-256-GCM Credential Envelopes**:
21
+ Built-in `v1.<iv>.<tag>.<ciphertext>` authenticated encryption helpers for securely storing OAuth tokens and app passwords at rest in your database.
22
+ - **Dry-Run Simulation Mode**:
23
+ Test your cross-posting workflows locally without touching live APIs or burning rate limits.
24
+ - **Zero Runtime Dependencies**:
25
+ Built exclusively on Node.js core modules (`node:https`, `node:dns/promises`, `node:net`, `node:crypto`).
26
+
27
+ ---
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ npm install cross-poster-sdk
33
+ ```
34
+
35
+ Requirements:
36
+ - Node.js `>= 18.0.0`
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ### 1. Unified Cross-Posting
43
+
44
+ ```typescript
45
+ import { postToService, deleteFromService } from 'cross-poster-sdk';
46
+
47
+ // 1. Post to Bluesky
48
+ const bskyResult = await postToService(
49
+ {
50
+ service: 'bluesky',
51
+ credentials: {
52
+ username: 'handle.bsky.social',
53
+ appPassword: 'xxxx-xxxx-xxxx-xxxx',
54
+ },
55
+ },
56
+ 'Hello from cross-poster-sdk! 🚀'
57
+ );
58
+ console.log('Bluesky Post URI:', bskyResult.remoteId);
59
+ // -> "at://did:plc:.../app.bsky.feed.post/3k..."
60
+
61
+ // 2. Post to a custom Mastodon or Misskey instance (SSRF-protected)
62
+ const fediverseResult = await postToService(
63
+ {
64
+ service: 'mastodon',
65
+ instanceUrl: 'https://mastodon.social',
66
+ credentials: { accessToken: 'YOUR_MASTODON_ACCESS_TOKEN' },
67
+ },
68
+ 'Federated status update'
69
+ );
70
+ console.log('Mastodon Status ID:', fediverseResult.remoteId);
71
+
72
+ // 3. Delete a post
73
+ await deleteFromService(
74
+ {
75
+ service: 'bluesky',
76
+ credentials: {
77
+ username: 'handle.bsky.social',
78
+ appPassword: 'xxxx-xxxx-xxxx-xxxx',
79
+ },
80
+ },
81
+ bskyResult.remoteId!
82
+ );
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Supported Services & Credentials
88
+
89
+ | Service | `service` identifier | Required Credentials | Notes |
90
+ | :--- | :--- | :--- | :--- |
91
+ | **Bluesky** | `'bluesky'` | `{ username, appPassword }` | Authenticates via ATProto XRPC `createSession` and writes to `app.bsky.feed.post` |
92
+ | **Mastodon** | `'mastodon'` | `{ accessToken }`, `instanceUrl` | Posts via `/api/v1/statuses` with strict instance URL validation |
93
+ | **Misskey** | `'misskey'` | `{ accessToken }`, `instanceUrl` | Posts via `/api/notes/create` with strict instance URL validation |
94
+ | **Threads** | `'threads'` | `{ accessToken }` | Handles 2-step container creation and immediate publishing |
95
+ | **X (Twitter)** | `'x'` | `{ accessToken }` | Uses Twitter API v2 `/2/tweets` |
96
+
97
+ ---
98
+
99
+ ## Security Architecture
100
+
101
+ ### Why standard `fetch()` is dangerous for Fediverse instances
102
+
103
+ When accepting federated instance URLs from users (e.g. `https://my-community.net`), attackers can provide URLs that resolve to internal network addresses or use DNS rebinding (returning a public IP during DNS check, and `169.254.169.254` during HTTP connection):
104
+
105
+ ```mermaid
106
+ sequenceDiagram
107
+ participant App as cross-poster-sdk
108
+ participant DNS as DNS Resolver
109
+ participant Target as Target / Internal Service
110
+
111
+ App->>DNS: Resolve "user-domain.com"
112
+ DNS-->>App: 93.184.216.34 (Public IP)
113
+ Note over App: Validates against private IP blocks (OK)
114
+ Note over App: Pins IP into TLS socket lookup hook!
115
+ App->>Target: Connect directly to pinned 93.184.216.34 (SNI: user-domain.com)
116
+ Note over App: Attacker's DNS TTL expiration has NO effect!
117
+ ```
118
+
119
+ `cross-poster-sdk` blocks:
120
+ - `127.0.0.0/8` (Loopback)
121
+ - `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` (Private RFC1918)
122
+ - `169.254.0.0/16` (Link-local & AWS/GCP/Azure Cloud Metadata)
123
+ - `100.64.0.0/10` (Carrier-Grade NAT)
124
+ - `::1` (IPv6 loopback), `fe80::/10` (IPv6 Link-Local), `fc00::/7` (IPv6 Unique Local)
125
+ - `::ffff:0:0/96` (IPv4-mapped IPv6)
126
+ - Cloud internal hostnames (`metadata.google.internal`, `instance-data.ec2.internal`, `*.localhost`, `*.internal`)
127
+
128
+ ---
129
+
130
+ ### Credential Encryption at Rest
131
+
132
+ Secure sensitive API tokens with authenticated AES-256-GCM before persisting them in your database:
133
+
134
+ ```typescript
135
+ import { encryptCredentials, decryptCredentials } from 'cross-poster-sdk';
136
+ import { randomBytes } from 'node:crypto';
137
+
138
+ // 32-byte secret key (e.g. from process.env.ENCRYPTION_KEY)
139
+ const secretKey = Buffer.from(process.env.APP_SECRET_KEY!, 'hex');
140
+
141
+ // Encrypt into safe string envelope: "v1.<iv>.<tag>.<ciphertext>"
142
+ const envelope = encryptCredentials({ accessToken: 'ghp_secretToken...' }, secretKey);
143
+
144
+ // Decrypt safely (throws if tampered or counterfeit)
145
+ const creds = decryptCredentials(envelope, secretKey);
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Dry-Run Testing
151
+
152
+ Simulate posts without invoking external network requests:
153
+
154
+ ```typescript
155
+ const result = await postToService(
156
+ { service: 'x', credentials: {} },
157
+ 'Testing post',
158
+ { dryRun: true }
159
+ );
160
+
161
+ console.log(result);
162
+ // { remoteId: 'dry_x_04a8b792-...', simulated: true }
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 🧵 Thread Splitting & Sequential Posting
168
+
169
+ Different networks enforce different character counting rules (X counts CJK as 2 and URLs as 23; Bluesky counts Grapheme Clusters; Mastodon counts code points). `cross-poster-sdk` provides built-in calculators, natural text splitters with numbering indicators `(1/N)`, and sequential chained thread posting:
170
+
171
+ ```typescript
172
+ import {
173
+ countServiceCharacters,
174
+ splitPostIntoThreads,
175
+ postThreadToService
176
+ } from 'cross-poster-sdk';
177
+
178
+ const myLongArticle = `Very long update text...`;
179
+
180
+ // 1. Measure character weight for specific platform
181
+ const xLength = countServiceCharacters('x', myLongArticle);
182
+
183
+ // 2. Automatically split into numbered threads safe for the platform limits
184
+ const xThreads = splitPostIntoThreads('x', myLongArticle, { numbering: true });
185
+ // -> ['Paragraph 1... (1/3)', 'Paragraph 2... (2/3)', 'Paragraph 3... (3/3)']
186
+
187
+ // 3. Post sequentially as linked reply thread
188
+ const threadResults = await postThreadToService(
189
+ { service: 'x', credentials: { accessToken: 'token' } },
190
+ xThreads
191
+ );
192
+ console.log('Thread published! Root ID:', threadResults[0].remoteId);
193
+ ```
194
+
195
+ ---
196
+
197
+ ## TypeScript Support
198
+
199
+ Complete TypeScript declarations are bundled in `./src/index.d.ts`. Types for `CrossPosterAccount`, `SupportedService`, `PostOptions`, and `PostResult` are exported automatically.
200
+
201
+ ---
202
+
203
+ ## License
204
+
205
+ MIT © [takao-dev](https://github.com/takao-dev)
package/README_JA.md ADDED
@@ -0,0 +1,185 @@
1
+ # cross-poster-sdk
2
+
3
+ [![CI](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/takao-dev/cross-poster-sdk/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/cross-poster-sdk.svg)](https://www.npmjs.com/package/cross-poster-sdk)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
+
7
+ > **X(Twitter)、Mastodon、Bluesky(AT Protocol)、Misskey、Meta Threads に対応したゼロ依存マルチSNSクロスポストSDK。DNSリバインディング完全耐性を持つSSRF防御と、AES-256-GCM 資格情報暗号化エンベロープを内蔵。**
8
+
9
+ [English README is available here](./README.md)
10
+
11
+ ---
12
+
13
+ ## 💡 なぜ `cross-poster-sdk` なのか?
14
+
15
+ 分散型SNS(Mastodon, Misskey)と中央集権型SNS(X, Bluesky, Threads)を横断してクロスポストするシステムを構築する場合、深刻なセキュリティリスクと複雑さに直面します:
16
+
17
+ 1. **悪意あるインスタンスURLによる SSRF & クラウド認証情報窃取**:
18
+ ユーザーが自由に入力できるインスタンスドメイン(`https://my-instance.com`)が悪意を持って内部ネットワークやクラウドのメタデータエンドポイント(`169.254.169.254`)に向けられていた場合、サーバーの機密情報や IAM 認証情報が即座に奪われます。
19
+ 2. **DNS リバインディング攻撃(TOCTOU)の脅威**:
20
+ URL検証時に正規のパブリックIPを返し、HTTP接続確立時のミリ秒で内部IP(`127.0.0.1` や `169.254.169.254`)を返すDNSリバインディング攻撃に対し、一般的な `fetch()` や `axios` では防御が極めて困難です。
21
+ 3. **API仕様と文字数計算の断片化**:
22
+ X (全角2/半角1/URL23), Bluesky (Grapheme Clusters), Mastodon (Code points) など、各社で異なる文字数制限やスレッド構造の処理。
23
+
24
+ `cross-poster-sdk` はこれらをゼロ依存で完全に解決します:
25
+
26
+ - **DNSリバインディング完全耐性を持つ SSRF 防御**:
27
+ プライベートIP、リンクローカル、CGNAT、ループバック、クラウドメタデータ、IPv4射影IPv6、6to4を完全遮断。DNS解決した安全なIPアドレスを **Node.js の TLS ソケットの `lookup` コールバックに直接ピン留め** するため、DNSのTTL改ざんによるすり替えが物理的に不可能です。
28
+ - **統一 5-Network アダプター**:
29
+ `postToService` / `deleteFromService` の単一インターフェースで、5大SNSへの投稿・削除を網羅。
30
+ - **各社仕様に忠実な文字数カウンター & 自動スレッド分割**:
31
+ 各SNS固有のカウントロジックに基づき、長文を最適な文末境界で分割して `(1/3)`, `(2/3)` などのナンバリングを付与し、連鎖スレッド投稿(`postThreadToService`)を実行。
32
+ - **AES-256-GCM 資格情報エンベロープ**:
33
+ データベースに保存する OAuth トークンや App パスワードを `v1.<iv>.<tag>.<ciphertext>` 形式で安全に認証暗号化。
34
+ - **外部依存ゼロ (Zero Dependencies)**:
35
+ Node.js 標準組み込みモジュール(`node:https`, `node:dns`, `node:net`, `node:crypto`)のみで完結。
36
+
37
+ ---
38
+
39
+ ## 📦 インストール
40
+
41
+ ```bash
42
+ npm install cross-poster-sdk
43
+ ```
44
+
45
+ 要件: Node.js `>= 18.0.0`
46
+
47
+ ---
48
+
49
+ ## 🚀 クイックスタート
50
+
51
+ ### 1. 単一投稿と削除
52
+
53
+ ```typescript
54
+ import { postToService, deleteFromService } from 'cross-poster-sdk';
55
+
56
+ // 1. Bluesky へ投稿
57
+ const bskyResult = await postToService(
58
+ {
59
+ service: 'bluesky',
60
+ credentials: {
61
+ username: 'handle.bsky.social',
62
+ appPassword: 'xxxx-xxxx-xxxx-xxxx',
63
+ },
64
+ },
65
+ 'cross-poster-sdk からこんにちは! 🚀'
66
+ );
67
+ console.log('Bluesky Post URI:', bskyResult.remoteId);
68
+
69
+ // 2. カスタム Mastodon または Misskey インスタンスへ安全に投稿(SSRF保護済み)
70
+ const mastodonResult = await postToService(
71
+ {
72
+ service: 'mastodon',
73
+ instanceUrl: 'https://mastodon.social',
74
+ credentials: { accessToken: 'YOUR_MASTODON_TOKEN' },
75
+ },
76
+ 'Mastodonへのステータス投稿'
77
+ );
78
+
79
+ // 3. 投稿の削除
80
+ await deleteFromService(
81
+ {
82
+ service: 'bluesky',
83
+ credentials: { username: 'handle.bsky.social', appPassword: 'xxxx-xxxx-xxxx-xxxx' },
84
+ },
85
+ bskyResult.remoteId!
86
+ );
87
+ ```
88
+
89
+ ---
90
+
91
+ ### 2. スマート長文スレッド分割 & 連鎖投稿
92
+
93
+ 各SNSの文字数上限に合わせて自動分割し、直前の投稿にリプライする形でスレッドを連続投稿します:
94
+
95
+ ```typescript
96
+ import {
97
+ countServiceCharacters,
98
+ splitPostIntoThreads,
99
+ postThreadToService,
100
+ } from 'cross-poster-sdk';
101
+
102
+ const longText = `長文のアナウンス記事です...(非常に長い本文)...`;
103
+
104
+ // 1. 各SNS固有の文字数重みを正確に測定
105
+ const weight = countServiceCharacters('x', longText);
106
+
107
+ // 2. ナンバリングインジケータ付きで安全な長さに分割
108
+ const threads = splitPostIntoThreads('x', longText, { numbering: true });
109
+ // -> ['段落1... (1/3)', '段落2... (2/3)', '段落3... (3/3)']
110
+
111
+ // 3. 前の投稿へ自動リプライしながら一括連鎖投稿
112
+ const results = await postThreadToService(
113
+ { service: 'x', credentials: { accessToken: 'token' } },
114
+ threads
115
+ );
116
+ console.log('スレッド投稿完了!親ツイートID:', results[0].remoteId);
117
+ ```
118
+
119
+ ---
120
+
121
+ ### 3. トークン暗号化エンベロープ
122
+
123
+ データベースにアクセストークンを保存する際、平文ではなく AES-256-GCM 認証暗号化エンベロープとして保護します:
124
+
125
+ ```typescript
126
+ import { encryptCredentials, decryptCredentials } from 'cross-poster-sdk';
127
+
128
+ const secretKey = Buffer.from(process.env.APP_SECRET_KEY!, 'hex'); // 32バイト
129
+
130
+ // 暗号化エンベロープを作成: "v1.<iv>.<tag>.<ciphertext>"
131
+ const envelope = encryptCredentials({ accessToken: 'user-secret-oauth-token' }, secretKey);
132
+
133
+ // 復号(改ざんや破損時は安全に例外スロー)
134
+ const creds = decryptCredentials(envelope, secretKey);
135
+ ```
136
+
137
+ ---
138
+
139
+ ### 4. ドライラン(シミュレーション)モード
140
+
141
+ API のレートリミットを消費せずに、ローカルテストやデバッグを実行できます:
142
+
143
+ ```typescript
144
+ const dryResult = await postToService(
145
+ { service: 'x', credentials: {} },
146
+ 'テストメッセージ',
147
+ { dryRun: true }
148
+ );
149
+
150
+ console.log(dryResult);
151
+ // -> { remoteId: 'dry_x_04a8b792-...', simulated: true }
152
+ ```
153
+
154
+ ---
155
+
156
+ ## 🛡️ SSRF 防御の仕組み
157
+
158
+ ```
159
+ ユーザー指定URL (例: https://custom-community.net)
160
+ │
161
+ ▼
162
+ [URL形式検証] ── HTTPS 必須 / Basic認証情報の混入を禁止
163
+ │
164
+ [DNS名前解決] ── 全IPアドレスを取得
165
+ │
166
+ [IP範囲検証] ── 以下の危険なIPブロックを完全遮断:
167
+ • 127.0.0.0/8 (ループバック)
168
+ • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 (プライベートIP)
169
+ • 169.254.0.0/16 (リンクローカル / クラウドメタデータ)
170
+ • 100.64.0.0/10 (CGNAT)
171
+ • ::1, fe80::/10, fc00::/7 (IPv6 ループバック/ローカル)
172
+ • ::ffff:0:0/96 (IPv4射影IPv6)
173
+ • 内部ホスト名 (*.internal, metadata.google.internal 等)
174
+ │
175
+ ▼
176
+ [IPピン留め] ── 検証を通過した IP を TLS ソケットの lookup フックに直接固定
177
+ 接続確立時の DNS 再問い合わせを発生させないため
178
+ DNSリバインディング攻撃は成立しません。
179
+ ```
180
+
181
+ ---
182
+
183
+ ## 📄 ライセンス
184
+
185
+ MIT License © 2026 takao-dev
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "cross-poster-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency multi-SNS cross-posting SDK (X, Mastodon, Bluesky, Misskey, Threads) with DNS-rebinding-safe SSRF protection for federated instances and AES-256-GCM credential envelopes.",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "LICENSE",
17
+ "README.md",
18
+ "README_JA.md"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
23
+ "scripts": {
24
+ "test": "node --test",
25
+ "test:coverage": "node -e \"require('node:fs').mkdirSync('coverage', {recursive: true})\" && node --test --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=coverage/lcov.info"
26
+ },
27
+ "sideEffects": false,
28
+ "keywords": [
29
+ "cross-post",
30
+ "social-media",
31
+ "x",
32
+ "twitter",
33
+ "mastodon",
34
+ "bluesky",
35
+ "atproto",
36
+ "misskey",
37
+ "threads",
38
+ "fediverse",
39
+ "ssrf-protection",
40
+ "dns-rebinding",
41
+ "zero-dependencies"
42
+ ],
43
+ "author": "takao-dev",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/takao-dev/cross-poster-sdk.git"
48
+ }
49
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,149 @@
1
+ /// <reference types="node" />
2
+
3
+ import type { LookupAddress } from 'node:dns';
4
+ import type { URL } from 'node:url';
5
+
6
+ export type SupportedService = 'x' | 'mastodon' | 'bluesky' | 'misskey' | 'threads';
7
+
8
+ export declare const SUPPORTED_SERVICES: ReadonlySet<SupportedService>;
9
+
10
+ export declare const SERVICE_LABELS: Record<SupportedService, string>;
11
+
12
+ export declare const SERVICE_LIMITS: Record<SupportedService, number>;
13
+
14
+ export interface SplitPostOptions {
15
+ numbering?: boolean;
16
+ }
17
+
18
+ export declare function countServiceCharacters(
19
+ service: SupportedService,
20
+ text: string
21
+ ): number;
22
+
23
+ export declare function splitPostIntoThreads(
24
+ service: SupportedService,
25
+ text: string,
26
+ options?: SplitPostOptions
27
+ ): string[];
28
+
29
+ export interface XCredentials {
30
+ accessToken: string;
31
+ }
32
+
33
+ export interface MastodonCredentials {
34
+ accessToken: string;
35
+ }
36
+
37
+ export interface MisskeyCredentials {
38
+ accessToken: string;
39
+ }
40
+
41
+ export interface BlueskyCredentials {
42
+ username: string;
43
+ appPassword: string;
44
+ }
45
+
46
+ export interface ThreadsCredentials {
47
+ accessToken: string;
48
+ }
49
+
50
+ export type ServiceCredentials =
51
+ | XCredentials
52
+ | MastodonCredentials
53
+ | MisskeyCredentials
54
+ | BlueskyCredentials
55
+ | ThreadsCredentials;
56
+
57
+ export interface CrossPosterAccount {
58
+ service: SupportedService;
59
+ instanceUrl?: string | null;
60
+ credentials: Record<string, any>;
61
+ }
62
+
63
+ export interface PostOptions {
64
+ dryRun?: boolean;
65
+ replyToId?: string | null;
66
+ replyRoot?: string | null;
67
+ replyRootCid?: string | null;
68
+ replyParentCid?: string | null;
69
+ requestImpl?: (
70
+ url: string,
71
+ options: {
72
+ method?: string;
73
+ headers?: Record<string, string>;
74
+ body?: string | URLSearchParams;
75
+ signal?: AbortSignal;
76
+ }
77
+ ) => Promise<{
78
+ status: number;
79
+ ok: boolean;
80
+ text: () => Promise<string>;
81
+ }>;
82
+ }
83
+
84
+ export interface PostResult {
85
+ remoteId: string | null;
86
+ simulated?: boolean;
87
+ }
88
+
89
+ export interface DeleteResult {
90
+ simulated?: boolean;
91
+ }
92
+
93
+ export declare function normalizeInstanceUrl(value: unknown): string;
94
+
95
+ export declare function isUnsafeIpv4(address: string): boolean;
96
+
97
+ export declare function parseIpv6(address: string): number[] | null;
98
+
99
+ export declare function isUnsafeAddress(address: string): boolean;
100
+
101
+ export declare function createPinnedAddressLookup(
102
+ address: string
103
+ ): (
104
+ hostname: string,
105
+ options: { all?: boolean },
106
+ callback: (err: Error | null, addressOrList: string | LookupAddress[], family?: number) => void
107
+ ) => void;
108
+
109
+ export declare function resolveSafeExternalUrl(
110
+ rawUrl: string | URL
111
+ ): Promise<{ parsed: URL; address: string }>;
112
+
113
+ export declare function assertSafeExternalUrl(rawUrl: string | URL): Promise<URL>;
114
+
115
+ export declare function encryptCredentials(value: unknown, key: Buffer): string;
116
+
117
+ export declare function decryptCredentials<T = unknown>(value: string, key: Buffer): T;
118
+
119
+ export declare function requestExternal(
120
+ rawUrl: string | URL,
121
+ options?: {
122
+ method?: string;
123
+ headers?: Record<string, string>;
124
+ body?: string | URLSearchParams | Buffer;
125
+ signal?: AbortSignal;
126
+ }
127
+ ): Promise<{
128
+ status: number;
129
+ ok: boolean;
130
+ text: () => Promise<string>;
131
+ }>;
132
+
133
+ export declare function postToService(
134
+ account: CrossPosterAccount,
135
+ text: string,
136
+ options?: PostOptions
137
+ ): Promise<PostResult>;
138
+
139
+ export declare function deleteFromService(
140
+ account: CrossPosterAccount,
141
+ remoteId: string,
142
+ options?: PostOptions
143
+ ): Promise<DeleteResult>;
144
+
145
+ export declare function postThreadToService(
146
+ account: CrossPosterAccount,
147
+ posts: string[],
148
+ options?: PostOptions
149
+ ): Promise<PostResult[]>;
package/src/index.mjs ADDED
@@ -0,0 +1,479 @@
1
+ import http from 'node:http';
2
+ import https from 'node:https';
3
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
4
+ import dns from 'node:dns/promises';
5
+ import net from 'node:net';
6
+ import { URL } from 'node:url';
7
+
8
+ export const SUPPORTED_SERVICES = new Set(['x', 'mastodon', 'bluesky', 'misskey', 'threads']);
9
+
10
+ export const SERVICE_LABELS = {
11
+ x: 'X',
12
+ mastodon: 'Mastodon',
13
+ bluesky: 'Bluesky',
14
+ misskey: 'Misskey',
15
+ threads: 'Threads',
16
+ };
17
+
18
+ export const SERVICE_LIMITS = {
19
+ x: 280,
20
+ mastodon: 500,
21
+ bluesky: 300,
22
+ threads: 500,
23
+ misskey: 3000,
24
+ };
25
+
26
+ export function countServiceCharacters(service, text) {
27
+ const str = String(text || '');
28
+ if (service === 'x') {
29
+ // X Twitter: CJK / fullwidth = 2, ASCII = 1, URLs = 23
30
+ let count = 0;
31
+ const urlRegex = /https?:\/\/[^\s]+/g;
32
+ const cleanText = str.replace(urlRegex, () => {
33
+ count += 23;
34
+ return '';
35
+ });
36
+ for (const char of cleanText) {
37
+ const code = char.codePointAt(0);
38
+ if (
39
+ (code >= 0x1100 && code <= 0x115f) ||
40
+ (code >= 0x2e80 && code <= 0xa4cf) ||
41
+ (code >= 0xac00 && code <= 0xd7a3) ||
42
+ (code >= 0xf900 && code <= 0xfaff) ||
43
+ (code >= 0xfe10 && code <= 0xfe19) ||
44
+ (code >= 0xfe30 && code <= 0xfe6f) ||
45
+ (code >= 0xff00 && code <= 0xff60) ||
46
+ (code >= 0xffe0 && code <= 0xffe6) ||
47
+ (code >= 0x20000 && code <= 0x2fffd) ||
48
+ (code >= 0x30000 && code <= 0x3fffd)
49
+ ) {
50
+ count += 2;
51
+ } else {
52
+ count += 1;
53
+ }
54
+ }
55
+ return count;
56
+ }
57
+
58
+ if (service === 'bluesky') {
59
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
60
+ const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
61
+ return Array.from(segmenter.segment(str)).length;
62
+ }
63
+ return Array.from(str).length;
64
+ }
65
+
66
+ // Mastodon, Threads, Misskey: Unicode code point count
67
+ return Array.from(str).length;
68
+ }
69
+
70
+ export function splitPostIntoThreads(service, text, options = {}) {
71
+ const limit = SERVICE_LIMITS[service] || 300;
72
+ const numbering = options.numbering !== false;
73
+ const raw = String(text || '').trim();
74
+ if (!raw) return [];
75
+
76
+ if (countServiceCharacters(service, raw) <= limit) {
77
+ return [raw];
78
+ }
79
+
80
+ const paragraphs = raw.split(/\n\n+/);
81
+ const chunks = [];
82
+ let current = '';
83
+
84
+ for (const para of paragraphs) {
85
+ const candidate = current ? `${current}\n\n${para}` : para;
86
+ if (countServiceCharacters(service, candidate) <= limit - (numbering ? 8 : 0)) {
87
+ current = candidate;
88
+ } else {
89
+ if (current) chunks.push(current);
90
+ if (countServiceCharacters(service, para) > limit - (numbering ? 8 : 0)) {
91
+ const sentences = para.split(/(?<=[。..!?!?\n])\s*/);
92
+ let sub = '';
93
+ for (const s of sentences) {
94
+ const subCand = sub ? `${sub} ${s}` : s;
95
+ if (countServiceCharacters(service, subCand) <= limit - (numbering ? 8 : 0)) {
96
+ sub = subCand;
97
+ } else {
98
+ if (sub) chunks.push(sub);
99
+ if (countServiceCharacters(service, s) > limit - (numbering ? 8 : 0)) {
100
+ let remaining = s;
101
+ while (remaining) {
102
+ let sliceLen = limit - (numbering ? 10 : 0);
103
+ while (sliceLen > 0 && countServiceCharacters(service, remaining.slice(0, sliceLen)) > limit - (numbering ? 10 : 0)) {
104
+ sliceLen--;
105
+ }
106
+ if (sliceLen <= 0) sliceLen = 1;
107
+ chunks.push(remaining.slice(0, sliceLen));
108
+ remaining = remaining.slice(sliceLen);
109
+ }
110
+ sub = '';
111
+ } else {
112
+ sub = s;
113
+ }
114
+ }
115
+ }
116
+ if (sub) chunks.push(sub);
117
+ current = '';
118
+ } else {
119
+ current = para;
120
+ }
121
+ }
122
+ }
123
+ if (current) chunks.push(current);
124
+
125
+ if (!numbering || chunks.length <= 1) {
126
+ return chunks;
127
+ }
128
+
129
+ const total = chunks.length;
130
+ return chunks.map((chunk, idx) => `${chunk} (${idx + 1}/${total})`);
131
+ }
132
+
133
+ export function normalizeInstanceUrl(value) {
134
+ const raw = String(value || '').trim();
135
+ if (!raw) return '';
136
+ let parsed;
137
+ try {
138
+ parsed = new URL(raw);
139
+ } catch {
140
+ throw Object.assign(new Error('インスタンスURLはHTTPS形式で入力してください。'), { statusCode: 400 });
141
+ }
142
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
143
+ throw Object.assign(new Error('インスタンスURLはHTTPS形式で、認証情報を含めないでください。'), { statusCode: 400 });
144
+ }
145
+ return parsed.origin;
146
+ }
147
+
148
+ export function isUnsafeIpv4(address) {
149
+ const octets = String(address).split('.').map((part) => Number(part));
150
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
151
+ const value = (((octets[0] * 256) + octets[1]) * 256 + octets[2]) * 256 + octets[3];
152
+ const ranges = [
153
+ [0, 0xff000000], // 0.0.0.0/8
154
+ [0x0a000000, 0xff000000], // 10.0.0.0/8
155
+ [0x64400000, 0xffc00000], // 100.64.0.0/10
156
+ [0x7f000000, 0xff000000], // 127.0.0.0/8
157
+ [0xa9fe0000, 0xffff0000], // 169.254.0.0/16
158
+ [0xac100000, 0xfff00000], // 172.16.0.0/12
159
+ [0xc0000000, 0xffffff00], // 192.0.0.0/24
160
+ [0xc0000200, 0xffffff00], // 192.0.2.0/24
161
+ [0xc0a80000, 0xffff0000], // 192.168.0.0/16
162
+ [0xc6120000, 0xfffe0000], // 198.18.0.0/15
163
+ [0xc6336400, 0xffffff00], // 198.51.100.0/24
164
+ [0xcb007100, 0xffffff00], // 203.0.113.0/24
165
+ [0xe0000000, 0xf0000000], // multicast
166
+ [0xf0000000, 0xf0000000], // reserved
167
+ ];
168
+ return ranges.some(([network, mask]) => ((value & mask) >>> 0) === (network >>> 0));
169
+ }
170
+
171
+ export function parseIpv6(address) {
172
+ let value = String(address).toLowerCase().split('%')[0];
173
+ if (value.includes('.')) {
174
+ const lastColon = value.lastIndexOf(':');
175
+ if (lastColon < 0) return null;
176
+ const octets = value.slice(lastColon + 1).split('.').map(Number);
177
+ if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null;
178
+ const ipv4Hex = `${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`;
179
+ value = `${value.slice(0, lastColon)}:${ipv4Hex}`;
180
+ }
181
+ const halves = value.split('::');
182
+ if (halves.length > 2) return null;
183
+ const left = halves[0] ? halves[0].split(':') : [];
184
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
185
+ const missing = 8 - left.length - right.length;
186
+ if (missing < (halves.length === 2 ? 1 : 0)) return null;
187
+ const groups = [...left, ...Array(missing).fill('0'), ...right];
188
+ if (groups.length !== 8 || groups.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) return null;
189
+ return groups.map((part) => Number.parseInt(part, 16));
190
+ }
191
+
192
+ export function isUnsafeAddress(address) {
193
+ const normalized = String(address || '').toLowerCase().replace(/^\[|\]$/g, '').split('%')[0];
194
+ if (net.isIP(normalized) === 4) return isUnsafeIpv4(normalized);
195
+ if (net.isIP(normalized) !== 6) return true;
196
+ const groups = parseIpv6(normalized);
197
+ if (!groups) return true;
198
+ const first = groups[0];
199
+ const bytes = groups.flatMap((group) => [(group >> 8) & 0xff, group & 0xff]);
200
+ const mappedIpv4 = bytes.slice(0, 10).every((byte) => byte === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
201
+ if (mappedIpv4) return true;
202
+ const isGlobalUnicast = (first & 0xe000) === 0x2000;
203
+ const isProtocolAssignment = groups[0] === 0x2001 && (groups[1] & 0xfe00) === 0;
204
+ const isSixToFour = groups[0] === 0x2002;
205
+ const isDocumentation = (groups[0] === 0x2001 && groups[1] === 0x0db8)
206
+ || (groups[0] === 0x3fff && (groups[1] & 0xf000) === 0);
207
+ return !isGlobalUnicast
208
+ || isProtocolAssignment
209
+ || isSixToFour
210
+ || isDocumentation
211
+ || (first & 0xfe00) === 0xfc00
212
+ || (first & 0xffc0) === 0xfe80;
213
+ }
214
+
215
+ export const createPinnedAddressLookup = (address) => (_hostname, options, callback) =>
216
+ options?.all
217
+ ? callback(null, [{ address, family: net.isIP(address) }])
218
+ : callback(null, address, net.isIP(address));
219
+
220
+ export async function resolveSafeExternalUrl(rawUrl) {
221
+ let parsed;
222
+ try {
223
+ parsed = new URL(String(rawUrl));
224
+ } catch {
225
+ throw Object.assign(new Error('外部URLが不正です。'), { statusCode: 400 });
226
+ }
227
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password) {
228
+ throw Object.assign(new Error('外部URLはHTTPS形式で、認証情報を含めないでください。'), { statusCode: 400 });
229
+ }
230
+ const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
231
+ if (
232
+ !hostname ||
233
+ hostname === 'localhost' ||
234
+ hostname.endsWith('.localhost') ||
235
+ hostname.endsWith('.local') ||
236
+ hostname.endsWith('.internal') ||
237
+ hostname === 'metadata.google.internal' ||
238
+ hostname === 'instance-data.ec2.internal' ||
239
+ hostname === 'host.docker.internal'
240
+ ) {
241
+ throw Object.assign(new Error('内部ネットワーク向けのURLは利用できません。'), { statusCode: 400 });
242
+ }
243
+ let addresses;
244
+ try {
245
+ addresses = net.isIP(hostname) ? [{ address: hostname }] : await dns.lookup(hostname, { all: true, verbatim: true });
246
+ } catch {
247
+ throw Object.assign(new Error('外部URLのホストを解決できません。'), { statusCode: 400 });
248
+ }
249
+ if (!addresses.length || addresses.some(({ address }) => isUnsafeAddress(address))) {
250
+ throw Object.assign(new Error('内部ネットワーク向けのURLは利用できません。'), { statusCode: 400 });
251
+ }
252
+ return { parsed, address: addresses[0].address };
253
+ }
254
+
255
+ export async function assertSafeExternalUrl(rawUrl) {
256
+ const { parsed } = await resolveSafeExternalUrl(rawUrl);
257
+ return parsed;
258
+ }
259
+
260
+ export function encryptCredentials(value, key) {
261
+ const iv = randomBytes(12);
262
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
263
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
264
+ return `v1.${iv.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}.${encrypted.toString('base64url')}`;
265
+ }
266
+
267
+ export function decryptCredentials(value, key) {
268
+ const [version, ivEncoded, tagEncoded, payloadEncoded] = String(value || '').split('.');
269
+ if (version !== 'v1' || !ivEncoded || !tagEncoded || !payloadEncoded) throw new Error('暗号化データが不正です。');
270
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivEncoded, 'base64url'));
271
+ decipher.setAuthTag(Buffer.from(tagEncoded, 'base64url'));
272
+ const payload = Buffer.concat([decipher.update(Buffer.from(payloadEncoded, 'base64url')), decipher.final()]);
273
+ return JSON.parse(payload.toString('utf8'));
274
+ }
275
+
276
+ export function requestExternal(rawUrl, options = {}) {
277
+ return resolveSafeExternalUrl(rawUrl).then(({ parsed, address }) => new Promise((resolve, reject) => {
278
+ const transport = parsed.protocol === 'https:' ? https : http;
279
+ const headers = { ...(options.headers || {}) };
280
+ const body = options.body instanceof URLSearchParams ? options.body.toString() : options.body;
281
+ if (body && !Object.keys(headers).some((key) => key.toLowerCase() === 'content-length')) {
282
+ headers['Content-Length'] = Buffer.byteLength(body);
283
+ }
284
+ const request = transport.request({
285
+ protocol: parsed.protocol,
286
+ hostname: parsed.hostname.replace(/^\[|\]$/g, ''),
287
+ port: parsed.port || undefined,
288
+ path: `${parsed.pathname}${parsed.search}`,
289
+ method: options.method || 'GET',
290
+ headers,
291
+ lookup: createPinnedAddressLookup(address),
292
+ servername: net.isIP(parsed.hostname.replace(/^\[|\]$/g, '')) ? undefined : parsed.hostname,
293
+ signal: options.signal,
294
+ }, (response) => {
295
+ const chunks = [];
296
+ let total = 0;
297
+ response.on('data', (chunk) => {
298
+ total += chunk.length;
299
+ if (total > 2 * 1024 * 1024) {
300
+ request.destroy(new Error('外部APIレスポンスが大きすぎます。'));
301
+ return;
302
+ }
303
+ chunks.push(chunk);
304
+ });
305
+ response.on('end', () => {
306
+ const raw = Buffer.concat(chunks).toString('utf8');
307
+ resolve({ status: response.statusCode || 0, ok: (response.statusCode || 0) >= 200 && (response.statusCode || 0) < 300, text: async () => raw });
308
+ });
309
+ response.on('error', reject);
310
+ });
311
+ request.on('error', reject);
312
+ if (body) request.write(body);
313
+ request.end();
314
+ }));
315
+ }
316
+
317
+ async function fetchJson(url, options = {}) {
318
+ const requester = options.requestImpl || requestExternal;
319
+ const response = await requester(url, { ...options, signal: AbortSignal.timeout(30000) });
320
+ const raw = await response.text();
321
+ let body = null;
322
+ try { body = raw ? JSON.parse(raw) : {}; } catch { body = { raw: raw.slice(0, 500) }; }
323
+ if (!response.ok) {
324
+ const message = body?.error?.message || body?.error || body?.message || `外部APIエラー (${response.status})`;
325
+ throw new Error(String(message).slice(0, 300));
326
+ }
327
+ return body;
328
+ }
329
+
330
+ async function formRequest(url, params, options = {}) {
331
+ return fetchJson(url, {
332
+ ...options,
333
+ method: options.method || 'POST',
334
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(options.headers || {}) },
335
+ body: new URLSearchParams(Object.entries(params).map(([key, value]) => [key, String(value)])),
336
+ });
337
+ }
338
+
339
+ export async function postToService(account, text, options = {}) {
340
+ if (options.dryRun) {
341
+ return { remoteId: `dry_${account.service}_${randomUUID()}`, simulated: true };
342
+ }
343
+ const credentials = account.credentials;
344
+ const reqOpts = options.requestImpl ? { requestImpl: options.requestImpl } : {};
345
+ switch (account.service) {
346
+ case 'x': {
347
+ const payload = { text };
348
+ if (options.replyToId) {
349
+ payload.reply = { in_reply_to_tweet_id: options.replyToId };
350
+ }
351
+ const body = await fetchJson('https://api.x.com/2/tweets', {
352
+ ...reqOpts,
353
+ method: 'POST',
354
+ headers: { Authorization: `Bearer ${credentials.accessToken}`, 'Content-Type': 'application/json' },
355
+ body: JSON.stringify(payload),
356
+ });
357
+ return { remoteId: body.data?.id || null };
358
+ }
359
+ case 'mastodon': {
360
+ const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
361
+ const form = { status: text };
362
+ if (options.replyToId) {
363
+ form.in_reply_to_id = options.replyToId;
364
+ }
365
+ const body = await formRequest(`${instanceUrl}/api/v1/statuses`, form, { ...reqOpts, headers: { Authorization: `Bearer ${credentials.accessToken}` } });
366
+ return { remoteId: body.id || null };
367
+ }
368
+ case 'misskey': {
369
+ const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
370
+ const payload = { i: credentials.accessToken, text };
371
+ if (options.replyToId) {
372
+ payload.replyId = options.replyToId;
373
+ }
374
+ const body = await fetchJson(`${instanceUrl}/api/notes/create`, {
375
+ ...reqOpts,
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify(payload),
379
+ });
380
+ return { remoteId: body.createdNote?.id || null };
381
+ }
382
+ case 'bluesky': {
383
+ const session = await fetchJson('https://bsky.social/xrpc/com.atproto.server.createSession', {
384
+ ...reqOpts,
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json' },
387
+ body: JSON.stringify({ identifier: credentials.username, password: credentials.appPassword }),
388
+ });
389
+ const record = {
390
+ '$type': 'app.bsky.feed.post',
391
+ text,
392
+ createdAt: new Date().toISOString(),
393
+ };
394
+ if (options.replyToId) {
395
+ record.reply = {
396
+ root: { uri: options.replyRoot || options.replyToId, cid: options.replyRootCid || 'bafyreie546fudph4' },
397
+ parent: { uri: options.replyToId, cid: options.replyParentCid || 'bafyreie546fudph4' },
398
+ };
399
+ }
400
+ const postRecord = await fetchJson('https://bsky.social/xrpc/com.atproto.repo.createRecord', {
401
+ ...reqOpts,
402
+ method: 'POST',
403
+ headers: { Authorization: `Bearer ${session.accessJwt}`, 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({
405
+ repo: session.did || credentials.username,
406
+ collection: 'app.bsky.feed.post',
407
+ record,
408
+ }),
409
+ });
410
+ return { remoteId: postRecord.uri || null };
411
+ }
412
+ case 'threads': {
413
+ const form = { media_type: 'TEXT', text, access_token: credentials.accessToken };
414
+ if (options.replyToId) {
415
+ form.reply_to_id = options.replyToId;
416
+ }
417
+ const container = await formRequest('https://graph.threads.com/v1.0/me/threads', form, reqOpts);
418
+ const published = await formRequest('https://graph.threads.com/v1.0/me/threads_publish', { creation_id: container.id, access_token: credentials.accessToken }, reqOpts);
419
+ return { remoteId: published.id || container.id || null };
420
+ }
421
+ default:
422
+ throw new Error('投稿サービスが不正です。');
423
+ }
424
+ }
425
+
426
+ export async function postThreadToService(account, posts, options = {}) {
427
+ const results = [];
428
+ let lastRemoteId = null;
429
+ let blueskyRootUri = null;
430
+
431
+ for (let i = 0; i < posts.length; i++) {
432
+ const text = posts[i];
433
+ const postOptions = {
434
+ ...options,
435
+ ...(lastRemoteId ? { replyToId: lastRemoteId } : {}),
436
+ ...(blueskyRootUri ? { replyRoot: blueskyRootUri } : {}),
437
+ };
438
+ const res = await postToService(account, text, postOptions);
439
+ results.push(res);
440
+ lastRemoteId = res.remoteId;
441
+ if (account.service === 'bluesky' && i === 0 && res.remoteId) {
442
+ blueskyRootUri = res.remoteId;
443
+ }
444
+ }
445
+ return results;
446
+ }
447
+
448
+ export async function deleteFromService(account, remoteId, options = {}) {
449
+ if (options.dryRun || String(remoteId).startsWith('dry_')) return { simulated: true };
450
+ const credentials = account.credentials;
451
+ const reqOpts = options.requestImpl ? { requestImpl: options.requestImpl } : {};
452
+ switch (account.service) {
453
+ case 'x':
454
+ await fetchJson(`https://api.x.com/2/tweets/${encodeURIComponent(remoteId)}`, { ...reqOpts, method: 'DELETE', headers: { Authorization: `Bearer ${credentials.accessToken}` } });
455
+ return {};
456
+ case 'mastodon': {
457
+ const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
458
+ await fetchJson(`${instanceUrl}/api/v1/statuses/${encodeURIComponent(remoteId)}`, { ...reqOpts, method: 'DELETE', headers: { Authorization: `Bearer ${credentials.accessToken}` } });
459
+ return {};
460
+ }
461
+ case 'misskey': {
462
+ const instanceUrl = normalizeInstanceUrl(account.instanceUrl);
463
+ await fetchJson(`${instanceUrl}/api/notes/delete`, { ...reqOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ i: credentials.accessToken, noteId: remoteId }) });
464
+ return {};
465
+ }
466
+ case 'bluesky': {
467
+ const [repo, collection, rkey] = String(remoteId).replace(/^at:\/\//, '').split('/');
468
+ if (!repo || !rkey) throw new Error('Bluesky投稿IDを解釈できません。');
469
+ const session = await fetchJson('https://bsky.social/xrpc/com.atproto.server.createSession', { ...reqOpts, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: credentials.username, password: credentials.appPassword }) });
470
+ await fetchJson('https://bsky.social/xrpc/com.atproto.repo.deleteRecord', { ...reqOpts, method: 'POST', headers: { Authorization: `Bearer ${session.accessJwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo, collection: collection || 'app.bsky.feed.post', rkey }) });
471
+ return {};
472
+ }
473
+ case 'threads':
474
+ await fetchJson(`https://graph.threads.com/v1.0/${encodeURIComponent(remoteId)}?access_token=${encodeURIComponent(credentials.accessToken)}`, { ...reqOpts, method: 'DELETE' });
475
+ return {};
476
+ default:
477
+ throw new Error('削除サービスが不正です。');
478
+ }
479
+ }