@yoonion/mimi-seed-mcp 0.9.4 → 0.10.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/README.md +3 -2
- package/dist/facebook/api.js +15 -6
- package/dist/facebook/config.js +5 -1
- package/dist/instagram/api.js +4 -1
- package/dist/instagram/config.js +5 -1
- package/dist/lib/meta-auth.d.ts +12 -0
- package/dist/lib/meta-auth.js +60 -0
- package/dist/registers/auth.js +24 -14
- package/dist/registers/facebook.js +3 -11
- package/dist/registers/instagram.js +2 -10
- package/dist/registers/threads.d.ts +2 -0
- package/dist/registers/threads.js +109 -0
- package/dist/server.js +2 -0
- package/dist/social/setup-cli.js +89 -6
- package/dist/threads/api.d.ts +23 -0
- package/dist/threads/api.js +147 -0
- package/dist/threads/config.d.ts +9 -0
- package/dist/threads/config.js +35 -0
- package/dist/threads/setup.d.ts +4 -0
- package/dist/threads/setup.js +93 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ npx -y @yoonion/mimi-seed-mcp mimi-seed-playstore-auth # Google Play 서비
|
|
|
57
57
|
npx -y @yoonion/mimi-seed-mcp mimi-seed-bigquery-auth # BigQuery
|
|
58
58
|
npx -y @yoonion/mimi-seed-mcp mimi-seed-jenkins-auth # Jenkins (저장 전 서버 프로브)
|
|
59
59
|
npx -y @yoonion/mimi-seed-mcp mimi-seed-googleads-auth # Google Ads (저장 전 실제 호출로 검증)
|
|
60
|
-
npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram
|
|
60
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram / Threads
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
각 자격증명을 **어디서 어떻게 발급받는지**는 [`docs/credentials.md`](../../docs/credentials.md) 참고.
|
|
@@ -74,7 +74,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
74
74
|
|
|
75
75
|
---
|
|
76
76
|
|
|
77
|
-
## 제공 도구 (150+ 개 ·
|
|
77
|
+
## 제공 도구 (150+ 개 · 18개 영역)
|
|
78
78
|
|
|
79
79
|
| 영역 | 도구 수 | 주요 도구 |
|
|
80
80
|
|------|---------|-----------|
|
|
@@ -90,6 +90,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
90
90
|
| Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
|
|
91
91
|
| Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
|
|
92
92
|
| BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
|
|
93
|
+
| Threads | 6 | `threads_post` / `threads_post_carousel` / `threads_refresh_token` |
|
|
93
94
|
| 점검 / 위험 | 4 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `screenshot_validate` / `release_status` |
|
|
94
95
|
| Instagram | 4 | `instagram_post_image` / `instagram_post_carousel` / `instagram_save_config` |
|
|
95
96
|
| Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
|
package/dist/facebook/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { metaApiError } from '../lib/meta-auth.js';
|
|
1
2
|
const BASE = 'https://graph.facebook.com/v21.0';
|
|
2
3
|
async function fbPost(pageAccessToken, endpoint, params) {
|
|
3
4
|
const body = new URLSearchParams({ ...params, access_token: pageAccessToken });
|
|
@@ -12,21 +13,28 @@ async function fbPost(pageAccessToken, endpoint, params) {
|
|
|
12
13
|
json = JSON.parse(text);
|
|
13
14
|
}
|
|
14
15
|
catch {
|
|
15
|
-
throw
|
|
16
|
+
throw metaApiError('facebook', res.status, text);
|
|
16
17
|
}
|
|
17
18
|
if (json.error) {
|
|
18
19
|
const err = json.error;
|
|
19
|
-
throw
|
|
20
|
+
throw metaApiError('facebook', res.status, `${err.message} (code ${err.code})`, err.code);
|
|
20
21
|
}
|
|
21
22
|
return json;
|
|
22
23
|
}
|
|
23
24
|
async function fbGet(pageAccessToken, endpoint, params = {}) {
|
|
24
25
|
const qs = new URLSearchParams({ ...params, access_token: pageAccessToken });
|
|
25
26
|
const res = await fetch(`${BASE}${endpoint}?${qs}`);
|
|
26
|
-
const
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
let json;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.parse(text);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw metaApiError('facebook', res.status, text);
|
|
34
|
+
}
|
|
27
35
|
if (json.error) {
|
|
28
36
|
const err = json.error;
|
|
29
|
-
throw
|
|
37
|
+
throw metaApiError('facebook', res.status, `${err.message} (code ${err.code})`, err.code);
|
|
30
38
|
}
|
|
31
39
|
return json;
|
|
32
40
|
}
|
|
@@ -92,7 +100,8 @@ export async function listAccessiblePages(userAccessToken) {
|
|
|
92
100
|
});
|
|
93
101
|
const res = await fetch(`${BASE}/me/accounts?${qs}`);
|
|
94
102
|
const json = await res.json();
|
|
95
|
-
if (json.error)
|
|
96
|
-
throw
|
|
103
|
+
if (json.error) {
|
|
104
|
+
throw metaApiError('facebook', res.status, `${json.error.message} (code ${json.error.code})`, json.error.code);
|
|
105
|
+
}
|
|
97
106
|
return json.data ?? [];
|
|
98
107
|
}
|
package/dist/facebook/config.js
CHANGED
|
@@ -4,7 +4,11 @@ import os from 'node:os';
|
|
|
4
4
|
const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'facebook.json');
|
|
5
5
|
export function loadFacebookConfig() {
|
|
6
6
|
try {
|
|
7
|
-
|
|
7
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
+
if (typeof cfg.pageAccessToken !== 'string' || !cfg.pageAccessToken ||
|
|
9
|
+
typeof cfg.pageId !== 'string' || !cfg.pageId)
|
|
10
|
+
return null;
|
|
11
|
+
return cfg;
|
|
8
12
|
}
|
|
9
13
|
catch {
|
|
10
14
|
return null;
|
package/dist/instagram/api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { metaApiError } from '../lib/meta-auth.js';
|
|
1
2
|
// 두 가지 Meta API:
|
|
2
3
|
// IGAA... = Instagram API with Instagram Login (2024 신규) — graph.instagram.com
|
|
3
4
|
// EAA... = Instagram Graph API via Facebook Login — graph.facebook.com
|
|
@@ -24,14 +25,16 @@ async function igFetch(token, endpoint, params, method = 'GET') {
|
|
|
24
25
|
const text = await res.text();
|
|
25
26
|
if (!res.ok) {
|
|
26
27
|
let msg = text;
|
|
28
|
+
let code;
|
|
27
29
|
try {
|
|
28
30
|
const parsed = JSON.parse(text);
|
|
29
31
|
if (parsed.error) {
|
|
32
|
+
code = parsed.error.code;
|
|
30
33
|
msg = `${parsed.error.message} (code ${parsed.error.code}${parsed.error.error_subcode ? `/${parsed.error.error_subcode}` : ''})`;
|
|
31
34
|
}
|
|
32
35
|
}
|
|
33
36
|
catch { /* fall through with raw text */ }
|
|
34
|
-
throw
|
|
37
|
+
throw metaApiError('instagram', res.status, msg, code);
|
|
35
38
|
}
|
|
36
39
|
return JSON.parse(text);
|
|
37
40
|
}
|
package/dist/instagram/config.js
CHANGED
|
@@ -4,7 +4,11 @@ import os from 'node:os';
|
|
|
4
4
|
const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'instagram.json');
|
|
5
5
|
export function loadInstagramConfig() {
|
|
6
6
|
try {
|
|
7
|
-
|
|
7
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
+
if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
|
|
9
|
+
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
10
|
+
return null;
|
|
11
|
+
return cfg;
|
|
8
12
|
}
|
|
9
13
|
catch {
|
|
10
14
|
return null;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type MetaPlatform = 'facebook' | 'instagram' | 'threads';
|
|
2
|
+
export interface MetaTokenFreshness {
|
|
3
|
+
state: 'unknown' | 'fresh' | 'expiring' | 'expired';
|
|
4
|
+
daysRemaining?: number;
|
|
5
|
+
}
|
|
6
|
+
/** 저장된 만료 시각만 보는 빠른 로컬 판정. 실제 유효성은 provider API가 최종 판정한다. */
|
|
7
|
+
export declare function metaTokenFreshness(expiresAt: string | undefined, now?: number): MetaTokenFreshness;
|
|
8
|
+
export declare function metaExpiryMessage(expiresAt: string | undefined, fix: string): string;
|
|
9
|
+
/** Provider 원문은 유지하되 access token 모양은 절대 오류 메시지에 남기지 않는다. */
|
|
10
|
+
export declare function redactMetaSecrets(message: string): string;
|
|
11
|
+
/** Meta OAuth 만료/철회를 사용자가 바로 복구할 수 있는 명령으로 번역한다. */
|
|
12
|
+
export declare function metaApiError(platform: MetaPlatform, status: number, message: string, code?: number): Error;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const LABEL = {
|
|
2
|
+
facebook: 'Facebook',
|
|
3
|
+
instagram: 'Instagram',
|
|
4
|
+
threads: 'Threads',
|
|
5
|
+
};
|
|
6
|
+
const FIX = {
|
|
7
|
+
facebook: 'mimi-seed auth facebook',
|
|
8
|
+
instagram: 'mimi-seed auth instagram',
|
|
9
|
+
threads: 'mimi-seed auth threads',
|
|
10
|
+
};
|
|
11
|
+
const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
|
+
/** 저장된 만료 시각만 보는 빠른 로컬 판정. 실제 유효성은 provider API가 최종 판정한다. */
|
|
13
|
+
export function metaTokenFreshness(expiresAt, now = Date.now()) {
|
|
14
|
+
if (!expiresAt)
|
|
15
|
+
return { state: 'unknown' };
|
|
16
|
+
const expiresMs = Date.parse(expiresAt);
|
|
17
|
+
if (!Number.isFinite(expiresMs))
|
|
18
|
+
return { state: 'unknown' };
|
|
19
|
+
const remainingMs = expiresMs - now;
|
|
20
|
+
if (remainingMs <= 0)
|
|
21
|
+
return { state: 'expired', daysRemaining: 0 };
|
|
22
|
+
const daysRemaining = Math.max(1, Math.ceil(remainingMs / 86_400_000));
|
|
23
|
+
if (remainingMs <= EXPIRING_SOON_MS)
|
|
24
|
+
return { state: 'expiring', daysRemaining };
|
|
25
|
+
return { state: 'fresh', daysRemaining };
|
|
26
|
+
}
|
|
27
|
+
export function metaExpiryMessage(expiresAt, fix) {
|
|
28
|
+
const freshness = metaTokenFreshness(expiresAt);
|
|
29
|
+
if (freshness.state === 'expired')
|
|
30
|
+
return `❌ 토큰 만료 — ${fix}`;
|
|
31
|
+
if (freshness.state === 'expiring') {
|
|
32
|
+
return `⚠️ 토큰 ${freshness.daysRemaining}일 남음 — 지금 갱신 권장: ${fix}`;
|
|
33
|
+
}
|
|
34
|
+
if (freshness.state === 'fresh')
|
|
35
|
+
return `토큰: ${freshness.daysRemaining}일 남음`;
|
|
36
|
+
return `⚠️ 토큰 만료일 미상 — 계정 조회로 검증하고 필요하면 ${fix}`;
|
|
37
|
+
}
|
|
38
|
+
/** Provider 원문은 유지하되 access token 모양은 절대 오류 메시지에 남기지 않는다. */
|
|
39
|
+
export function redactMetaSecrets(message) {
|
|
40
|
+
return message
|
|
41
|
+
.replace(/(["']?access_token["']?\s*[:=]\s*["']?)([^"'&\s,}]+)/gi, '$1[REDACTED]')
|
|
42
|
+
.replace(/(\bBearer\s+)[A-Za-z0-9._|~-]+/gi, '$1[REDACTED]')
|
|
43
|
+
.replace(/\b(?:EAA|IGAA)[A-Za-z0-9_-]+\b/g, '[REDACTED]')
|
|
44
|
+
.replace(/\bTH[A-Za-z0-9_|-]{8,}\b/g, '[REDACTED]');
|
|
45
|
+
}
|
|
46
|
+
/** Meta OAuth 만료/철회를 사용자가 바로 복구할 수 있는 명령으로 번역한다. */
|
|
47
|
+
export function metaApiError(platform, status, message, code) {
|
|
48
|
+
const safeMessage = redactMetaSecrets(message);
|
|
49
|
+
const needsReconnect = code === 190 ||
|
|
50
|
+
status === 401 ||
|
|
51
|
+
/(?:session|access token|token).*(?:expired|invalid|revoked)|error validating access token/i.test(safeMessage);
|
|
52
|
+
if (needsReconnect) {
|
|
53
|
+
return new Error([
|
|
54
|
+
`❌ ${LABEL[platform]} 연결이 만료되었거나 취소되었습니다.`,
|
|
55
|
+
`복구: ${FIX[platform]}`,
|
|
56
|
+
`Meta 응답: ${safeMessage}`,
|
|
57
|
+
].join('\n'));
|
|
58
|
+
}
|
|
59
|
+
return new Error(`${LABEL[platform]} API ${status}: ${safeMessage}`);
|
|
60
|
+
}
|
package/dist/registers/auth.js
CHANGED
|
@@ -10,6 +10,8 @@ import { loadCiConfig } from '../ci/config.js';
|
|
|
10
10
|
import { loadConfig as loadGoogleAdsConfig } from '../googleads/config.js';
|
|
11
11
|
import { loadFacebookConfig } from '../facebook/config.js';
|
|
12
12
|
import { loadInstagramConfig } from '../instagram/config.js';
|
|
13
|
+
import { loadThreadsConfig } from '../threads/config.js';
|
|
14
|
+
import { metaTokenFreshness } from '../lib/meta-auth.js';
|
|
13
15
|
import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
|
|
14
16
|
import { syncRemoteCredentials } from '../remote-sync.js';
|
|
15
17
|
import { findProjectManifest, manifestServiceEntries, } from '../lib/project-manifest.js';
|
|
@@ -39,6 +41,21 @@ function formatLastRefreshHint(lastMs) {
|
|
|
39
41
|
}
|
|
40
42
|
return { label };
|
|
41
43
|
}
|
|
44
|
+
function renderMetaConnection(label, config, detail, fix) {
|
|
45
|
+
const padded = label.padEnd(18);
|
|
46
|
+
if (!config)
|
|
47
|
+
return `❌ ${padded} — 미설정 → ${fix} (선택)`;
|
|
48
|
+
const freshness = metaTokenFreshness(config.expiresAt);
|
|
49
|
+
if (freshness.state === 'expired')
|
|
50
|
+
return `❌ ${padded} — 토큰 만료 → ${fix}`;
|
|
51
|
+
if (freshness.state === 'expiring') {
|
|
52
|
+
return `⚠️ ${padded} — ${freshness.daysRemaining}일 후 만료 (${detail}) → ${fix}`;
|
|
53
|
+
}
|
|
54
|
+
if (freshness.state === 'unknown') {
|
|
55
|
+
return `⚠️ ${padded} — 설정됨, 만료일 미상 (${detail}) → 계정 조회 도구로 검증`;
|
|
56
|
+
}
|
|
57
|
+
return `✅ ${padded} — 연결됨 (${detail}, ${freshness.daysRemaining}일 남음)`;
|
|
58
|
+
}
|
|
42
59
|
// 매니페스트 서비스별 셋업 명령. status/doctor 가 "정확한 다음 명령"으로 안내한다.
|
|
43
60
|
const MANIFEST_FIX = {
|
|
44
61
|
oauth: () => 'mimi_seed_auth_start (Google 로그인 — Firebase/AdMob/Play/GSC/GA4)',
|
|
@@ -94,7 +111,7 @@ export function registerAuthTools(server) {
|
|
|
94
111
|
// ── 전체 연결 상태 진단 ────────────────────────────────────────────────────
|
|
95
112
|
server.tool('mimi_seed_status', [
|
|
96
113
|
'⭐ 새 세션을 시작하거나 "뭐가 연결됐지?" 라는 질문엔 이 도구를 먼저 호출하세요.',
|
|
97
|
-
'
|
|
114
|
+
'10개 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / BigQuery)',
|
|
98
115
|
'설정 상태를 한 번에 스캔해 ✅ / ❌ 트래픽 라이트 리포트와 번호 매긴 다음 단계를 반환합니다.',
|
|
99
116
|
'미설정 서비스마다 어떤 도구를 호출하면 되는지 구체적으로 알려줍니다.',
|
|
100
117
|
].join(' '), {}, async () => {
|
|
@@ -161,21 +178,14 @@ export function registerAuthTools(server) {
|
|
|
161
178
|
}
|
|
162
179
|
// 7. Facebook
|
|
163
180
|
const fb = loadFacebookConfig();
|
|
164
|
-
|
|
165
|
-
lines.push(`✅ Facebook — 연결됨 (pageId: ${fb.pageId})`);
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
lines.push('❌ Facebook — 미설정 → facebook_save_config (선택)');
|
|
169
|
-
}
|
|
181
|
+
lines.push(renderMetaConnection('Facebook', fb, `pageId: ${fb?.pageId ?? ''}`, 'mimi-seed auth facebook'));
|
|
170
182
|
// 8. Instagram
|
|
171
183
|
const ig = loadInstagramConfig();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
// 9. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
|
|
184
|
+
lines.push(renderMetaConnection('Instagram', ig, `userId: ${ig?.userId ?? ''}`, 'mimi-seed auth instagram'));
|
|
185
|
+
// 9. Threads
|
|
186
|
+
const threads = loadThreadsConfig();
|
|
187
|
+
lines.push(renderMetaConnection('Threads', threads, `userId: ${threads?.userId ?? ''}`, 'mimi-seed auth threads'));
|
|
188
|
+
// 10. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
|
|
179
189
|
// 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
|
|
180
190
|
const bqAuth = resolveBigQueryAuth();
|
|
181
191
|
if (bqAuth?.source === 'service-account') {
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { loadFacebookConfig, requireFacebookConfig } from '../facebook/config.js';
|
|
3
3
|
import { connectFacebook } from '../facebook/setup.js';
|
|
4
4
|
import * as api from '../facebook/api.js';
|
|
5
|
+
import { metaExpiryMessage } from '../lib/meta-auth.js';
|
|
5
6
|
export function registerFacebookTools(server) {
|
|
6
7
|
server.tool('facebook_save_config', [
|
|
7
8
|
'Facebook 페이지 액세스 토큰을 ~/.mimi-seed/facebook.json (mode 0600)에 저장합니다.',
|
|
@@ -41,9 +42,6 @@ export function registerFacebookTools(server) {
|
|
|
41
42
|
server.tool('facebook_get_page', 'Facebook 페이지 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
42
43
|
const cfg = requireFacebookConfig();
|
|
43
44
|
const page = await api.getPage(cfg);
|
|
44
|
-
const remainingDays = cfg.expiresAt
|
|
45
|
-
? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
|
46
|
-
: null;
|
|
47
45
|
return {
|
|
48
46
|
content: [{
|
|
49
47
|
type: 'text',
|
|
@@ -52,13 +50,7 @@ export function registerFacebookTools(server) {
|
|
|
52
50
|
page.category ? ` 카테고리: ${page.category}` : '',
|
|
53
51
|
page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
|
|
54
52
|
page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
|
|
55
|
-
|
|
56
|
-
? remainingDays > 7
|
|
57
|
-
? ` 토큰: ${remainingDays}일 남음`
|
|
58
|
-
: remainingDays > 0
|
|
59
|
-
? ` ⚠️ 토큰 ${remainingDays}일 남음 — 갱신 필요`
|
|
60
|
-
: ` ❌ 토큰 만료 — facebook_save_config로 재저장`
|
|
61
|
-
: '',
|
|
53
|
+
` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth facebook')}`,
|
|
62
54
|
].filter(Boolean).join('\n'),
|
|
63
55
|
}],
|
|
64
56
|
};
|
|
@@ -117,7 +109,7 @@ export function registerFacebookTools(server) {
|
|
|
117
109
|
type: 'text',
|
|
118
110
|
text: [
|
|
119
111
|
`페이지: ${cfg.pageName ?? '(미확인)'} (${cfg.pageId})`,
|
|
120
|
-
|
|
112
|
+
metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth facebook'),
|
|
121
113
|
].filter(Boolean).join('\n'),
|
|
122
114
|
}],
|
|
123
115
|
};
|
|
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
|
|
2
2
|
import { requireInstagramConfig } from '../instagram/config.js';
|
|
3
3
|
import { connectInstagram } from '../instagram/setup.js';
|
|
4
4
|
import * as api from '../instagram/api.js';
|
|
5
|
+
import { metaExpiryMessage } from '../lib/meta-auth.js';
|
|
5
6
|
export function registerInstagramTools(server) {
|
|
6
7
|
server.tool('instagram_save_config', [
|
|
7
8
|
'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
|
|
@@ -22,9 +23,6 @@ export function registerInstagramTools(server) {
|
|
|
22
23
|
server.tool('instagram_get_account', 'Instagram 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
23
24
|
const cfg = requireInstagramConfig();
|
|
24
25
|
const account = await api.getAccount(cfg);
|
|
25
|
-
const remainingDays = cfg.expiresAt
|
|
26
|
-
? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
|
27
|
-
: null;
|
|
28
26
|
return {
|
|
29
27
|
content: [{
|
|
30
28
|
type: 'text',
|
|
@@ -34,13 +32,7 @@ export function registerInstagramTools(server) {
|
|
|
34
32
|
account.account_type ? ` 타입: ${account.account_type}` : '',
|
|
35
33
|
account.followers_count !== undefined ? ` 팔로워: ${account.followers_count.toLocaleString()}` : '',
|
|
36
34
|
account.media_count !== undefined ? ` 게시물: ${account.media_count}` : '',
|
|
37
|
-
|
|
38
|
-
? remainingDays > 7
|
|
39
|
-
? ` 토큰: ${remainingDays}일 남음`
|
|
40
|
-
: remainingDays > 0
|
|
41
|
-
? ` ⚠️ 토큰 ${remainingDays}일 남음 — 곧 갱신 필요`
|
|
42
|
-
: ` ❌ 토큰 만료됨 — instagram_save_config로 재저장`
|
|
43
|
-
: '',
|
|
35
|
+
` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth instagram')}`,
|
|
44
36
|
].filter(Boolean).join('\n'),
|
|
45
37
|
}],
|
|
46
38
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { loadThreadsConfig, requireThreadsConfig } from '../threads/config.js';
|
|
3
|
+
import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
|
|
4
|
+
import * as api from '../threads/api.js';
|
|
5
|
+
import { metaExpiryMessage } from '../lib/meta-auth.js';
|
|
6
|
+
export function registerThreadsTools(server) {
|
|
7
|
+
server.tool('threads_save_config', [
|
|
8
|
+
'Threads 토큰을 ~/.mimi-seed/threads.json (mode 0600)에 저장합니다.',
|
|
9
|
+
'accessToken은 Threads Graph API long-lived 토큰 (약 60일).',
|
|
10
|
+
'userId 미입력 시 토큰으로 자동 조회 (GET /me).',
|
|
11
|
+
'저장 직후 토큰 유효성도 자동 검증.',
|
|
12
|
+
'Instagram 과 별개 계정·별개 토큰입니다 (threads_basic, threads_content_publish 권한 필요).',
|
|
13
|
+
].join(' '), {
|
|
14
|
+
accessToken: z.string().describe('Threads Graph API long-lived access token (약 60일)'),
|
|
15
|
+
userId: z.string().optional().describe('Threads user ID (생략 시 자동 조회)'),
|
|
16
|
+
assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
|
|
17
|
+
}, async ({ accessToken, userId, assumeIssuedNow }) => {
|
|
18
|
+
// 구현은 threads/setup.ts 에 있다 — mimi-seed-social-auth CLI 와 공유한다.
|
|
19
|
+
const result = await connectThreads(accessToken, userId, assumeIssuedNow);
|
|
20
|
+
return { content: [{ type: 'text', text: result.text }] };
|
|
21
|
+
});
|
|
22
|
+
server.tool('threads_refresh_token', [
|
|
23
|
+
'저장된 Threads long-lived 토큰을 만료 전에 공식 refresh_access_token endpoint로 갱신합니다.',
|
|
24
|
+
'성공하면 새 토큰과 실제 expires_in을 저장하고 계정을 다시 검증합니다.',
|
|
25
|
+
'이미 만료·철회된 토큰은 갱신할 수 없으므로 mimi-seed auth threads로 재연결하세요.',
|
|
26
|
+
].join(' '), {}, async () => {
|
|
27
|
+
const cfg = requireThreadsConfig();
|
|
28
|
+
const result = await refreshThreadsToken(cfg.accessToken);
|
|
29
|
+
return { content: [{ type: 'text', text: result.text }], isError: !result.ok };
|
|
30
|
+
});
|
|
31
|
+
server.tool('threads_get_account', 'Threads 계정 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
32
|
+
const cfg = requireThreadsConfig();
|
|
33
|
+
const account = await api.getAccount(cfg);
|
|
34
|
+
return {
|
|
35
|
+
content: [{
|
|
36
|
+
type: 'text',
|
|
37
|
+
text: [
|
|
38
|
+
`@${account.username}${account.name ? ` (${account.name})` : ''}`,
|
|
39
|
+
` ID: ${account.id}`,
|
|
40
|
+
account.threads_biography ? ` 소개: ${account.threads_biography}` : '',
|
|
41
|
+
` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
|
|
42
|
+
].filter(Boolean).join('\n'),
|
|
43
|
+
}],
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
server.tool('threads_post', [
|
|
47
|
+
'Threads에 게시합니다. 텍스트 전용이 기본이며, imageUrl을 주면 이미지 게시.',
|
|
48
|
+
'text에는 링크·멘션(@)·해시태그를 포함할 수 있고 줄바꿈 \\n 사용. 게시물당 최대 500자.',
|
|
49
|
+
'imageUrl은 public URL이어야 합니다 (Graph API 제약). 로컬 파일은 미리 S3/R2/Cloudinary 등에 업로드 필요.',
|
|
50
|
+
'2-step API: container 생성(/threads) → threads_publish. 이미지는 처리 완료까지 자동 대기.',
|
|
51
|
+
].join(' '), {
|
|
52
|
+
text: z.string().max(500).describe('게시할 텍스트 (최대 500자, 멘션/해시태그/줄바꿈 가능)'),
|
|
53
|
+
imageUrl: z.string().url().optional().describe('이미지의 public URL (생략 시 텍스트 전용 게시)'),
|
|
54
|
+
}, async ({ text, imageUrl }) => {
|
|
55
|
+
const cfg = requireThreadsConfig();
|
|
56
|
+
const result = await api.postText(cfg, text, imageUrl);
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: [
|
|
61
|
+
'✅ 게시 완료',
|
|
62
|
+
` media_id: ${result.id}`,
|
|
63
|
+
result.permalink ? ` URL: ${result.permalink}` : '',
|
|
64
|
+
].filter(Boolean).join('\n'),
|
|
65
|
+
}],
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
server.tool('threads_post_carousel', [
|
|
69
|
+
'여러 이미지를 캐러셀로 Threads에 게시합니다. 2~20장 (인스타는 10장, Threads는 20장).',
|
|
70
|
+
'모든 이미지는 public URL이어야 함. 각 이미지 처리 완료까지 자동 대기.',
|
|
71
|
+
'3-step API: 각 이미지 children container → carousel container → publish.',
|
|
72
|
+
].join(' '), {
|
|
73
|
+
imageUrls: z.array(z.string().url()).min(2).max(20).describe('이미지 URL 배열 (2~20장)'),
|
|
74
|
+
text: z.string().max(500).describe('캡션 텍스트 (최대 500자)'),
|
|
75
|
+
}, async ({ imageUrls, text }) => {
|
|
76
|
+
const cfg = requireThreadsConfig();
|
|
77
|
+
const result = await api.postCarousel(cfg, imageUrls, text);
|
|
78
|
+
return {
|
|
79
|
+
content: [{
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: [
|
|
82
|
+
`✅ 캐러셀 ${imageUrls.length}장 게시 완료`,
|
|
83
|
+
` media_id: ${result.id}`,
|
|
84
|
+
result.permalink ? ` URL: ${result.permalink}` : '',
|
|
85
|
+
].filter(Boolean).join('\n'),
|
|
86
|
+
}],
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
// 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
|
|
90
|
+
server.tool('threads_current_config', '현재 저장된 Threads 연결 설정을 확인합니다 (~/.mimi-seed/threads.json).', {}, async () => {
|
|
91
|
+
const cfg = loadThreadsConfig();
|
|
92
|
+
if (!cfg) {
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: '❌ Threads 미설정 → threads_save_config 또는 mimi-seed auth threads' }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
content: [{
|
|
99
|
+
type: 'text',
|
|
100
|
+
text: [
|
|
101
|
+
'✅ Threads 연결됨',
|
|
102
|
+
` @${cfg.username ?? '(username 미저장)'}`,
|
|
103
|
+
` userId: ${cfg.userId}`,
|
|
104
|
+
` ${metaExpiryMessage(cfg.expiresAt, 'mimi-seed auth threads')}`,
|
|
105
|
+
].filter(Boolean).join('\n'),
|
|
106
|
+
}],
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { registerBigqueryTools } from './registers/bigquery.js';
|
|
|
10
10
|
import { registerAuthTools } from './registers/auth.js';
|
|
11
11
|
import { registerCiTools } from './registers/ci.js';
|
|
12
12
|
import { registerInstagramTools } from './registers/instagram.js';
|
|
13
|
+
import { registerThreadsTools } from './registers/threads.js';
|
|
13
14
|
import { registerFacebookTools } from './registers/facebook.js';
|
|
14
15
|
import { registerGoogleAdsTools } from './registers/googleads.js';
|
|
15
16
|
import { registerGscTools } from './registers/gsc.js';
|
|
@@ -42,6 +43,7 @@ export function buildServer(version) {
|
|
|
42
43
|
registerAuthTools(server);
|
|
43
44
|
registerCiTools(server);
|
|
44
45
|
registerInstagramTools(server);
|
|
46
|
+
registerThreadsTools(server);
|
|
45
47
|
registerFacebookTools(server);
|
|
46
48
|
registerGoogleAdsTools(server);
|
|
47
49
|
registerGscTools(server);
|
package/dist/social/setup-cli.js
CHANGED
|
@@ -9,25 +9,29 @@
|
|
|
9
9
|
// mimi-seed-social-auth → 무엇을 연결할지 물어봄
|
|
10
10
|
// mimi-seed-social-auth facebook → Facebook 만
|
|
11
11
|
// mimi-seed-social-auth instagram → Instagram 만
|
|
12
|
+
// mimi-seed-social-auth threads → Threads 만
|
|
13
|
+
// mimi-seed-social-auth all → 세 플랫폼을 순서대로
|
|
12
14
|
//
|
|
13
|
-
// 주의: connectFacebook / connectInstagram 이 돌려주는 result.text 는 MCP 도구와
|
|
14
|
-
// 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
|
|
15
|
+
// 주의: connectFacebook / connectInstagram / connectThreads 이 돌려주는 result.text 는 MCP 도구와
|
|
16
|
+
// 공유하는 텍스트라 여기서 번역하지 않는다 (그대로 출력한다).
|
|
15
17
|
import readline from 'node:readline';
|
|
16
18
|
import { loadFacebookConfig } from '../facebook/config.js';
|
|
17
19
|
import { loadInstagramConfig } from '../instagram/config.js';
|
|
20
|
+
import { loadThreadsConfig } from '../threads/config.js';
|
|
18
21
|
import { connectFacebook } from '../facebook/setup.js';
|
|
19
22
|
import { connectInstagram } from '../instagram/setup.js';
|
|
23
|
+
import { connectThreads, refreshThreadsToken } from '../threads/setup.js';
|
|
20
24
|
import { resolveLang } from '../lib/lang.js';
|
|
21
25
|
// ko 가 원본이고 en 은 `typeof ko` 를 만족해야 한다 — 키를 빠뜨리면 컴파일이 깨진다.
|
|
22
26
|
const ko = {
|
|
23
|
-
title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram)',
|
|
27
|
+
title: ' 🤖 Mimi Seed — 소셜 계정 연결 (Facebook / Instagram / Threads)',
|
|
24
28
|
already: (label, detail) => ` ✅ ${label} 이미 연결됨 (${detail})`,
|
|
25
29
|
reconnect: ' 다시 설정할래? (y/N): ',
|
|
26
30
|
skipped: ' 건너뜀.',
|
|
27
31
|
cancelled: ' 취소됨.',
|
|
28
32
|
verifying: ' 🔎 토큰 검증 중...',
|
|
29
33
|
notSaved: '\n (검증 실패 — 저장하지 않았어.)',
|
|
30
|
-
which: '\n 무엇을 연결할까? [f] Facebook [i] Instagram [
|
|
34
|
+
which: '\n 무엇을 연결할까? [f] Facebook [i] Instagram [t] Threads [a] 전부: ',
|
|
31
35
|
fbHeader: ' ── Facebook 페이지 ──',
|
|
32
36
|
fbHowTo: ' Page Access Token 발급:',
|
|
33
37
|
fbStep1: ' 1. Meta 앱 → Graph API Explorer',
|
|
@@ -44,16 +48,27 @@ const ko = {
|
|
|
44
48
|
igNote: ' (userId 는 토큰에서 자동 조회돼. 토큰 수명은 약 60일.)',
|
|
45
49
|
igAskToken: ' Access Token (IGAA… 또는 EAA…): ',
|
|
46
50
|
igAskUserId: ' Instagram Business Account ID (선택, 엔터 시 자동 조회): ',
|
|
51
|
+
thHeader: ' ── Threads ──',
|
|
52
|
+
thHowTo: ' Threads Graph API long-lived 토큰 발급:',
|
|
53
|
+
thStep1: ' 1. developers.facebook.com → 앱 → "Use cases" 에서 Threads API 추가',
|
|
54
|
+
thStep2: ' 2. 권한: threads_basic, threads_content_publish',
|
|
55
|
+
thStep3: ' 3. Threads 로그인으로 authorize → short-lived → long-lived 토큰 교환',
|
|
56
|
+
thNote: ' (Instagram 과 **별개 계정·별개 토큰**. userId 는 토큰에서 자동 조회돼. 수명 약 60일.)',
|
|
57
|
+
thAskToken: ' Threads Access Token: ',
|
|
58
|
+
thAskUserId: ' Threads user ID (선택, 엔터 시 자동 조회): ',
|
|
59
|
+
thExistingAction: ' [Enter/y] 기존 토큰 자동 갱신 [r] 새 토큰으로 재연결 [n] 건너뛰기: ',
|
|
60
|
+
thRefreshing: ' 🔄 기존 Threads 토큰 갱신 중...',
|
|
61
|
+
thRefreshFallback: ' 자동 갱신에 실패했습니다. 새 토큰을 입력해 재연결할 수 있습니다.',
|
|
47
62
|
};
|
|
48
63
|
const en = {
|
|
49
|
-
title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram)',
|
|
64
|
+
title: ' 🤖 Mimi Seed — Connect social accounts (Facebook / Instagram / Threads)',
|
|
50
65
|
already: (label, detail) => ` ✅ ${label} already connected (${detail})`,
|
|
51
66
|
reconnect: ' Set it up again? (y/N): ',
|
|
52
67
|
skipped: ' Skipped.',
|
|
53
68
|
cancelled: ' Cancelled.',
|
|
54
69
|
verifying: ' 🔎 Verifying the token...',
|
|
55
70
|
notSaved: '\n (Verification failed — nothing was saved.)',
|
|
56
|
-
which: '\n What do you want to connect? [f] Facebook [i] Instagram [
|
|
71
|
+
which: '\n What do you want to connect? [f] Facebook [i] Instagram [t] Threads [a] all: ',
|
|
57
72
|
fbHeader: ' ── Facebook Page ──',
|
|
58
73
|
fbHowTo: ' Get a Page Access Token:',
|
|
59
74
|
fbStep1: ' 1. Meta app → Graph API Explorer',
|
|
@@ -70,6 +85,17 @@ const en = {
|
|
|
70
85
|
igNote: ' (userId is looked up from the token. Tokens last about 60 days.)',
|
|
71
86
|
igAskToken: ' Access Token (IGAA… or EAA…): ',
|
|
72
87
|
igAskUserId: ' Instagram Business Account ID (optional, Enter to look it up): ',
|
|
88
|
+
thHeader: ' ── Threads ──',
|
|
89
|
+
thHowTo: ' Get a long-lived Threads Graph API token:',
|
|
90
|
+
thStep1: ' 1. developers.facebook.com → your app → add the Threads API use case',
|
|
91
|
+
thStep2: ' 2. Permissions: threads_basic, threads_content_publish',
|
|
92
|
+
thStep3: ' 3. Authorize with Threads login → exchange short-lived for a long-lived token',
|
|
93
|
+
thNote: ' (A **separate account and token** from Instagram. userId is looked up from the token. ~60 days.)',
|
|
94
|
+
thAskToken: ' Threads Access Token: ',
|
|
95
|
+
thAskUserId: ' Threads user ID (optional, Enter to look it up): ',
|
|
96
|
+
thExistingAction: ' [Enter/y] refresh current token [r] reconnect with a new token [n] skip: ',
|
|
97
|
+
thRefreshing: ' 🔄 Refreshing the existing Threads token...',
|
|
98
|
+
thRefreshFallback: ' Automatic refresh failed. You can reconnect with a new token.',
|
|
73
99
|
};
|
|
74
100
|
const M = resolveLang() === 'en' ? en : ko;
|
|
75
101
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -145,6 +171,45 @@ async function setupInstagram() {
|
|
|
145
171
|
console.log(M.notSaved);
|
|
146
172
|
return result.ok;
|
|
147
173
|
}
|
|
174
|
+
async function setupThreads() {
|
|
175
|
+
console.log('');
|
|
176
|
+
console.log(M.thHeader);
|
|
177
|
+
const existing = loadThreadsConfig();
|
|
178
|
+
if (existing) {
|
|
179
|
+
console.log(M.already('Threads', existing.username ?? existing.userId));
|
|
180
|
+
const action = (await ask(M.thExistingAction)).toLowerCase();
|
|
181
|
+
if (action === 'n')
|
|
182
|
+
return true;
|
|
183
|
+
if (action !== 'r') {
|
|
184
|
+
console.log(M.thRefreshing);
|
|
185
|
+
const refreshed = await refreshThreadsToken(existing.accessToken);
|
|
186
|
+
console.log('');
|
|
187
|
+
console.log(indent(refreshed.text));
|
|
188
|
+
if (refreshed.ok)
|
|
189
|
+
return true;
|
|
190
|
+
console.log(M.thRefreshFallback);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
console.log(M.thHowTo);
|
|
194
|
+
console.log(M.thStep1);
|
|
195
|
+
console.log(M.thStep2);
|
|
196
|
+
console.log(M.thStep3);
|
|
197
|
+
console.log(M.thNote);
|
|
198
|
+
console.log('');
|
|
199
|
+
const token = await ask(M.thAskToken);
|
|
200
|
+
if (!token) {
|
|
201
|
+
console.log(M.skipped);
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
const userId = await ask(M.thAskUserId);
|
|
205
|
+
console.log(M.verifying);
|
|
206
|
+
const result = await connectThreads(token, userId || undefined);
|
|
207
|
+
console.log('');
|
|
208
|
+
console.log(indent(result.text));
|
|
209
|
+
if (!result.ok)
|
|
210
|
+
console.log(M.notSaved);
|
|
211
|
+
return result.ok;
|
|
212
|
+
}
|
|
148
213
|
function indent(text) {
|
|
149
214
|
return text
|
|
150
215
|
.split('\n')
|
|
@@ -162,6 +227,15 @@ async function main() {
|
|
|
162
227
|
else if (target === 'instagram' || target === 'ig') {
|
|
163
228
|
ok = await setupInstagram();
|
|
164
229
|
}
|
|
230
|
+
else if (target === 'threads' || target === 'th') {
|
|
231
|
+
ok = await setupThreads();
|
|
232
|
+
}
|
|
233
|
+
else if (target === 'all' || target === 'meta') {
|
|
234
|
+
const fb = await setupFacebook();
|
|
235
|
+
const ig = await setupInstagram();
|
|
236
|
+
const th = await setupThreads();
|
|
237
|
+
ok = fb && ig && th;
|
|
238
|
+
}
|
|
165
239
|
else {
|
|
166
240
|
const which = await ask(M.which);
|
|
167
241
|
const c = which.toLowerCase();
|
|
@@ -169,11 +243,20 @@ async function main() {
|
|
|
169
243
|
ok = await setupFacebook();
|
|
170
244
|
else if (c === 'i')
|
|
171
245
|
ok = await setupInstagram();
|
|
246
|
+
else if (c === 't')
|
|
247
|
+
ok = await setupThreads();
|
|
172
248
|
else if (c === 'b') {
|
|
249
|
+
// 기존 b=Facebook+Instagram 동작을 유지한다.
|
|
173
250
|
const fb = await setupFacebook();
|
|
174
251
|
const ig = await setupInstagram();
|
|
175
252
|
ok = fb && ig;
|
|
176
253
|
}
|
|
254
|
+
else if (c === 'a') {
|
|
255
|
+
const fb = await setupFacebook();
|
|
256
|
+
const ig = await setupInstagram();
|
|
257
|
+
const th = await setupThreads();
|
|
258
|
+
ok = fb && ig && th;
|
|
259
|
+
}
|
|
177
260
|
else {
|
|
178
261
|
console.log(M.cancelled);
|
|
179
262
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ThreadsConfig } from './config.js';
|
|
2
|
+
export interface ThreadsAccount {
|
|
3
|
+
id: string;
|
|
4
|
+
username: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
threads_profile_picture_url?: string;
|
|
7
|
+
threads_biography?: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getAccount(cfg: ThreadsConfig): Promise<ThreadsAccount>;
|
|
10
|
+
export declare function fetchUserId(accessToken: string): Promise<string>;
|
|
11
|
+
export interface RefreshedToken {
|
|
12
|
+
accessToken: string;
|
|
13
|
+
expiresInSeconds: number;
|
|
14
|
+
}
|
|
15
|
+
/** 만료 전 long-lived Threads 토큰을 새 60일 토큰으로 갱신한다. */
|
|
16
|
+
export declare function refreshAccessToken(accessToken: string): Promise<RefreshedToken>;
|
|
17
|
+
export interface PublishResult {
|
|
18
|
+
id: string;
|
|
19
|
+
permalink?: string;
|
|
20
|
+
}
|
|
21
|
+
/** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
|
|
22
|
+
export declare function postText(cfg: ThreadsConfig, text: string, imageUrl?: string): Promise<PublishResult>;
|
|
23
|
+
export declare function postCarousel(cfg: ThreadsConfig, imageUrls: string[], text: string): Promise<PublishResult>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { metaApiError } from '../lib/meta-auth.js';
|
|
2
|
+
// Threads Graph API. Instagram 과 달리 base 가 하나뿐이라 토큰 prefix 분기가 없다.
|
|
3
|
+
// graph.threads.net/v1.0
|
|
4
|
+
// 게시 흐름은 인스타와 같은 2단계: container 생성(/threads) → publish(/threads_publish).
|
|
5
|
+
// 차이: (1) 텍스트 전용 게시가 1급 시민(media_type=TEXT), (2) 미디어 컨테이너는 처리 시간이
|
|
6
|
+
// 필요해 publish 전에 status 를 폴링해야 한다, (3) 캐러셀 최대 20장(인스타는 10장).
|
|
7
|
+
const BASE = 'https://graph.threads.net/v1.0';
|
|
8
|
+
const AUTH_BASE = 'https://graph.threads.net';
|
|
9
|
+
async function thFetch(token, endpoint, params, method = 'GET') {
|
|
10
|
+
const url = new URL(`${BASE}${endpoint}`);
|
|
11
|
+
let body;
|
|
12
|
+
if (method === 'GET') {
|
|
13
|
+
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
body = new URLSearchParams(params).toString();
|
|
17
|
+
}
|
|
18
|
+
const res = await fetch(url.toString(), {
|
|
19
|
+
method,
|
|
20
|
+
body,
|
|
21
|
+
headers: method === 'POST' ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
|
|
22
|
+
});
|
|
23
|
+
const text = await res.text();
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
let msg = text;
|
|
26
|
+
let code;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(text);
|
|
29
|
+
if (parsed.error) {
|
|
30
|
+
code = parsed.error.code;
|
|
31
|
+
msg = `${parsed.error.message} (code ${parsed.error.code}${parsed.error.error_subcode ? `/${parsed.error.error_subcode}` : ''})`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* fall through with raw text */ }
|
|
35
|
+
throw metaApiError('threads', res.status, msg, code);
|
|
36
|
+
}
|
|
37
|
+
return JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
export async function getAccount(cfg) {
|
|
40
|
+
return thFetch(cfg.accessToken, `/${cfg.userId}`, {
|
|
41
|
+
fields: 'id,username,name,threads_profile_picture_url,threads_biography',
|
|
42
|
+
access_token: cfg.accessToken,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
// 토큰만으로 Threads user ID 자동 조회 — GET /me?fields=id,username
|
|
46
|
+
export async function fetchUserId(accessToken) {
|
|
47
|
+
const res = await thFetch(accessToken, '/me', { fields: 'id,username', access_token: accessToken });
|
|
48
|
+
if (!res.id) {
|
|
49
|
+
throw new Error('Threads user ID를 조회하지 못했습니다. 토큰에 threads_basic 권한이 있는지 확인하세요.');
|
|
50
|
+
}
|
|
51
|
+
return res.id;
|
|
52
|
+
}
|
|
53
|
+
/** 만료 전 long-lived Threads 토큰을 새 60일 토큰으로 갱신한다. */
|
|
54
|
+
export async function refreshAccessToken(accessToken) {
|
|
55
|
+
const url = new URL(`${AUTH_BASE}/refresh_access_token`);
|
|
56
|
+
url.searchParams.set('grant_type', 'th_refresh_token');
|
|
57
|
+
url.searchParams.set('access_token', accessToken);
|
|
58
|
+
const res = await fetch(url);
|
|
59
|
+
const text = await res.text();
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
let message = text;
|
|
62
|
+
let code;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(text);
|
|
65
|
+
if (parsed.error) {
|
|
66
|
+
code = parsed.error.code;
|
|
67
|
+
message = `${parsed.error.message} (code ${parsed.error.code})`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch { /* raw response */ }
|
|
71
|
+
throw metaApiError('threads', res.status, message, code);
|
|
72
|
+
}
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error('Threads 토큰 갱신 응답을 해석하지 못했습니다. 새 토큰을 발급해 다시 연결하세요.');
|
|
79
|
+
}
|
|
80
|
+
if (!parsed.access_token || !Number.isFinite(parsed.expires_in) || parsed.expires_in <= 0) {
|
|
81
|
+
throw new Error('Threads 토큰 갱신 응답에 access_token 또는 expires_in이 없습니다.');
|
|
82
|
+
}
|
|
83
|
+
return { accessToken: parsed.access_token, expiresInSeconds: parsed.expires_in };
|
|
84
|
+
}
|
|
85
|
+
async function fetchPermalink(cfg, mediaId) {
|
|
86
|
+
try {
|
|
87
|
+
const meta = await thFetch(cfg.accessToken, `/${mediaId}`, { fields: 'permalink', access_token: cfg.accessToken });
|
|
88
|
+
return meta.permalink;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 미디어(이미지/비디오/캐러셀) 컨테이너는 서버 처리 후에야 publish 가 된다.
|
|
95
|
+
// status 가 FINISHED 가 될 때까지 폴링한다. 텍스트 컨테이너는 즉시 FINISHED 라 호출자가 생략한다.
|
|
96
|
+
async function waitForContainer(cfg, containerId) {
|
|
97
|
+
const MAX_ATTEMPTS = 20;
|
|
98
|
+
const INTERVAL_MS = 3000; // 최대 ~60초 대기
|
|
99
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
100
|
+
const { status, error_message } = await thFetch(cfg.accessToken, `/${containerId}`, { fields: 'status,error_message', access_token: cfg.accessToken });
|
|
101
|
+
if (status === 'FINISHED')
|
|
102
|
+
return;
|
|
103
|
+
if (status === 'ERROR' || status === 'EXPIRED') {
|
|
104
|
+
throw new Error(`미디어 처리 실패 (${status})${error_message ? `: ${error_message}` : ''}`);
|
|
105
|
+
}
|
|
106
|
+
await new Promise((r) => setTimeout(r, INTERVAL_MS));
|
|
107
|
+
}
|
|
108
|
+
throw new Error('미디어 처리 대기 시간 초과 (60초). 잠시 후 다시 시도하세요.');
|
|
109
|
+
}
|
|
110
|
+
async function publish(cfg, creationId) {
|
|
111
|
+
const published = await thFetch(cfg.accessToken, `/${cfg.userId}/threads_publish`, { creation_id: creationId, access_token: cfg.accessToken }, 'POST');
|
|
112
|
+
return { id: published.id, permalink: await fetchPermalink(cfg, published.id) };
|
|
113
|
+
}
|
|
114
|
+
/** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
|
|
115
|
+
export async function postText(cfg, text, imageUrl) {
|
|
116
|
+
const params = {
|
|
117
|
+
media_type: imageUrl ? 'IMAGE' : 'TEXT',
|
|
118
|
+
text,
|
|
119
|
+
access_token: cfg.accessToken,
|
|
120
|
+
};
|
|
121
|
+
if (imageUrl)
|
|
122
|
+
params.image_url = imageUrl;
|
|
123
|
+
const container = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, params, 'POST');
|
|
124
|
+
if (imageUrl)
|
|
125
|
+
await waitForContainer(cfg, container.id); // 미디어만 처리 대기
|
|
126
|
+
return publish(cfg, container.id);
|
|
127
|
+
}
|
|
128
|
+
export async function postCarousel(cfg, imageUrls, text) {
|
|
129
|
+
if (imageUrls.length < 2 || imageUrls.length > 20) {
|
|
130
|
+
throw new Error(`캐러셀은 2~20장의 이미지가 필요합니다 (받은 수: ${imageUrls.length}).`);
|
|
131
|
+
}
|
|
132
|
+
// Step 1: 각 이미지를 children container 로 생성 (sequential — 부분 실패 시 명확)
|
|
133
|
+
const childIds = [];
|
|
134
|
+
for (const url of imageUrls) {
|
|
135
|
+
const child = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, { media_type: 'IMAGE', image_url: url, is_carousel_item: 'true', access_token: cfg.accessToken }, 'POST');
|
|
136
|
+
childIds.push(child.id);
|
|
137
|
+
}
|
|
138
|
+
// children 이 모두 처리될 때까지 대기 (아무거나 처리 중이면 carousel 생성이 실패한다)
|
|
139
|
+
for (const id of childIds)
|
|
140
|
+
await waitForContainer(cfg, id);
|
|
141
|
+
// Step 2: carousel container 생성
|
|
142
|
+
const carousel = await thFetch(cfg.accessToken, `/${cfg.userId}/threads`, { media_type: 'CAROUSEL', children: childIds.join(','), text, access_token: cfg.accessToken }, 'POST');
|
|
143
|
+
// 부모 컨테이너도 비동기로 조립되므로 완료 전에 publish 하지 않는다.
|
|
144
|
+
await waitForContainer(cfg, carousel.id);
|
|
145
|
+
// Step 3: publish
|
|
146
|
+
return publish(cfg, carousel.id);
|
|
147
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface ThreadsConfig {
|
|
2
|
+
accessToken: string;
|
|
3
|
+
userId: string;
|
|
4
|
+
expiresAt?: string;
|
|
5
|
+
username?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function loadThreadsConfig(): ThreadsConfig | null;
|
|
8
|
+
export declare function saveThreadsConfig(cfg: ThreadsConfig): void;
|
|
9
|
+
export declare function requireThreadsConfig(): ThreadsConfig;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
const CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'threads.json');
|
|
5
|
+
export function loadThreadsConfig() {
|
|
6
|
+
try {
|
|
7
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
+
if (typeof cfg.accessToken !== 'string' || !cfg.accessToken ||
|
|
9
|
+
typeof cfg.userId !== 'string' || !cfg.userId)
|
|
10
|
+
return null;
|
|
11
|
+
return cfg;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function saveThreadsConfig(cfg) {
|
|
18
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
19
|
+
if (!fs.existsSync(dir)) {
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
21
|
+
}
|
|
22
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
23
|
+
if (process.platform !== 'win32') {
|
|
24
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function requireThreadsConfig() {
|
|
28
|
+
const cfg = loadThreadsConfig();
|
|
29
|
+
if (!cfg) {
|
|
30
|
+
throw new Error('Threads 설정이 없습니다.\n' +
|
|
31
|
+
'threads_save_config 도구로 먼저 설정해주세요.\n' +
|
|
32
|
+
'예: threads_save_config(accessToken="...", userId="...")');
|
|
33
|
+
}
|
|
34
|
+
return cfg;
|
|
35
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ConnectResult } from '../facebook/setup.js';
|
|
2
|
+
export declare function connectThreads(accessToken: string, userId?: string, assumeIssuedNow?: boolean): Promise<ConnectResult>;
|
|
3
|
+
/** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
|
|
4
|
+
export declare function refreshThreadsToken(currentAccessToken: string): Promise<ConnectResult>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Threads 연결 — userId 자동 조회 → 토큰 검증 → 검증 성공 시에만 저장.
|
|
2
|
+
// MCP 도구(threads_save_config)와 setup CLI(mimi-seed-social-auth)가 **공유**하는 구현.
|
|
3
|
+
import { saveThreadsConfig } from './config.js';
|
|
4
|
+
import * as api from './api.js';
|
|
5
|
+
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
|
|
6
|
+
export async function connectThreads(accessToken, userId, assumeIssuedNow = true) {
|
|
7
|
+
let resolvedUserId = userId;
|
|
8
|
+
if (!resolvedUserId) {
|
|
9
|
+
try {
|
|
10
|
+
resolvedUserId = await api.fetchUserId(accessToken);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
text: [
|
|
16
|
+
'❌ userId 자동 조회 실패',
|
|
17
|
+
` ${err.message}`,
|
|
18
|
+
'',
|
|
19
|
+
'userId를 명시적으로 전달하거나 토큰을 다시 확인하세요.',
|
|
20
|
+
].join('\n'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const expiresAt = assumeIssuedNow
|
|
25
|
+
? new Date(Date.now() + SIXTY_DAYS_MS).toISOString()
|
|
26
|
+
: undefined;
|
|
27
|
+
try {
|
|
28
|
+
const account = await api.getAccount({ accessToken, userId: resolvedUserId });
|
|
29
|
+
saveThreadsConfig({
|
|
30
|
+
accessToken,
|
|
31
|
+
userId: resolvedUserId,
|
|
32
|
+
expiresAt,
|
|
33
|
+
username: account.username,
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
text: [
|
|
38
|
+
'✅ Threads 연결 확인 완료',
|
|
39
|
+
` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
|
|
40
|
+
` ID: ${account.id}`,
|
|
41
|
+
expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
|
|
42
|
+
]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join('\n'),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// 검증 실패 → 저장하지 않는다.
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
text: [
|
|
52
|
+
'❌ 토큰 검증 실패',
|
|
53
|
+
` userId: ${resolvedUserId}`,
|
|
54
|
+
` ${err.message}`,
|
|
55
|
+
'',
|
|
56
|
+
'토큰을 다시 발급받거나 userId를 직접 지정해보세요.',
|
|
57
|
+
].join('\n'),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** 기존 long-lived 토큰을 만료 전에 갱신하고, 계정 검증 성공 시에만 덮어쓴다. */
|
|
62
|
+
export async function refreshThreadsToken(currentAccessToken) {
|
|
63
|
+
try {
|
|
64
|
+
const refreshed = await api.refreshAccessToken(currentAccessToken);
|
|
65
|
+
const userId = await api.fetchUserId(refreshed.accessToken);
|
|
66
|
+
const account = await api.getAccount({ accessToken: refreshed.accessToken, userId });
|
|
67
|
+
const expiresAt = new Date(Date.now() + refreshed.expiresInSeconds * 1000).toISOString();
|
|
68
|
+
saveThreadsConfig({
|
|
69
|
+
accessToken: refreshed.accessToken,
|
|
70
|
+
userId,
|
|
71
|
+
username: account.username,
|
|
72
|
+
expiresAt,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
text: [
|
|
77
|
+
'✅ Threads 토큰 갱신 완료',
|
|
78
|
+
` 계정: @${account.username}`,
|
|
79
|
+
` 새 만료일: ${expiresAt.slice(0, 10)}`,
|
|
80
|
+
].join('\n'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
text: [
|
|
87
|
+
'❌ Threads 토큰 자동 갱신 실패 — 기존 설정은 보존했습니다.',
|
|
88
|
+
` ${err.message}`,
|
|
89
|
+
' 복구: mimi-seed auth threads 에서 새 토큰으로 다시 연결하세요.',
|
|
90
|
+
].join('\n'),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
package/package.json
CHANGED