@yoonion/mimi-seed-mcp 0.3.23 → 0.3.25
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/dist/facebook/api.d.ts +16 -0
- package/dist/facebook/api.js +98 -0
- package/dist/facebook/config.d.ts +9 -0
- package/dist/facebook/config.js +31 -0
- package/dist/index.js +2 -0
- package/dist/instagram/api.d.ts +2 -0
- package/dist/instagram/api.js +41 -14
- package/dist/registers/facebook.d.ts +2 -0
- package/dist/registers/facebook.js +195 -0
- package/dist/registers/instagram.js +44 -16
- package/package.json +1 -1
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FacebookConfig } from './config.js';
|
|
2
|
+
export interface FacebookPage {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
category?: string;
|
|
6
|
+
followers_count?: number;
|
|
7
|
+
fan_count?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PostResult {
|
|
10
|
+
id: string;
|
|
11
|
+
permalink?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare function getPage(cfg: FacebookConfig): Promise<FacebookPage>;
|
|
14
|
+
export declare function postPhoto(cfg: FacebookConfig, imageUrl: string, caption: string): Promise<PostResult>;
|
|
15
|
+
export declare function postMultiPhoto(cfg: FacebookConfig, imageUrls: string[], caption: string): Promise<PostResult>;
|
|
16
|
+
export declare function listAccessiblePages(userAccessToken: string): Promise<FacebookPage[]>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const BASE = 'https://graph.facebook.com/v21.0';
|
|
2
|
+
async function fbPost(pageAccessToken, endpoint, params) {
|
|
3
|
+
const body = new URLSearchParams({ ...params, access_token: pageAccessToken });
|
|
4
|
+
const res = await fetch(`${BASE}${endpoint}`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
7
|
+
body: body.toString(),
|
|
8
|
+
});
|
|
9
|
+
const text = await res.text();
|
|
10
|
+
let json;
|
|
11
|
+
try {
|
|
12
|
+
json = JSON.parse(text);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
16
|
+
}
|
|
17
|
+
if (json.error) {
|
|
18
|
+
const err = json.error;
|
|
19
|
+
throw new Error(`${err.message} (code ${err.code})`);
|
|
20
|
+
}
|
|
21
|
+
return json;
|
|
22
|
+
}
|
|
23
|
+
async function fbGet(pageAccessToken, endpoint, params = {}) {
|
|
24
|
+
const qs = new URLSearchParams({ ...params, access_token: pageAccessToken });
|
|
25
|
+
const res = await fetch(`${BASE}${endpoint}?${qs}`);
|
|
26
|
+
const json = await res.json();
|
|
27
|
+
if (json.error) {
|
|
28
|
+
const err = json.error;
|
|
29
|
+
throw new Error(`${err.message} (code ${err.code})`);
|
|
30
|
+
}
|
|
31
|
+
return json;
|
|
32
|
+
}
|
|
33
|
+
export async function getPage(cfg) {
|
|
34
|
+
const data = await fbGet(cfg.pageAccessToken, `/${cfg.pageId}`, {
|
|
35
|
+
fields: 'id,name,category,followers_count,fan_count',
|
|
36
|
+
});
|
|
37
|
+
return data;
|
|
38
|
+
}
|
|
39
|
+
// Step 1: upload photo as unpublished, returns photo ID
|
|
40
|
+
async function uploadUnpublishedPhoto(cfg, imageUrl) {
|
|
41
|
+
const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/photos`, {
|
|
42
|
+
url: imageUrl,
|
|
43
|
+
published: 'false',
|
|
44
|
+
});
|
|
45
|
+
return result.id;
|
|
46
|
+
}
|
|
47
|
+
export async function postPhoto(cfg, imageUrl, caption) {
|
|
48
|
+
const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/photos`, {
|
|
49
|
+
url: imageUrl,
|
|
50
|
+
message: caption,
|
|
51
|
+
published: 'true',
|
|
52
|
+
});
|
|
53
|
+
const postId = result.post_id ?? result.id;
|
|
54
|
+
let permalink;
|
|
55
|
+
try {
|
|
56
|
+
const info = await fbGet(cfg.pageAccessToken, `/${postId}`, { fields: 'permalink_url' });
|
|
57
|
+
permalink = info.permalink_url;
|
|
58
|
+
}
|
|
59
|
+
catch { /* best-effort */ }
|
|
60
|
+
return { id: postId, permalink };
|
|
61
|
+
}
|
|
62
|
+
export async function postMultiPhoto(cfg, imageUrls, caption) {
|
|
63
|
+
if (imageUrls.length < 2 || imageUrls.length > 10) {
|
|
64
|
+
throw new Error('이미지는 2~10장이어야 합니다.');
|
|
65
|
+
}
|
|
66
|
+
// Step 1: upload each as unpublished photo
|
|
67
|
+
const photoIds = [];
|
|
68
|
+
for (const url of imageUrls) {
|
|
69
|
+
const id = await uploadUnpublishedPhoto(cfg, url);
|
|
70
|
+
photoIds.push(id);
|
|
71
|
+
}
|
|
72
|
+
// Step 2: create feed post with all photos attached
|
|
73
|
+
const attachedMedia = photoIds.map(id => JSON.stringify({ media_fbid: id }));
|
|
74
|
+
const result = await fbPost(cfg.pageAccessToken, `/${cfg.pageId}/feed`, {
|
|
75
|
+
message: caption,
|
|
76
|
+
attached_media: `[${attachedMedia.join(',')}]`,
|
|
77
|
+
});
|
|
78
|
+
const postId = result.id;
|
|
79
|
+
let permalink;
|
|
80
|
+
try {
|
|
81
|
+
const info = await fbGet(cfg.pageAccessToken, `/${postId}`, { fields: 'permalink_url' });
|
|
82
|
+
permalink = info.permalink_url;
|
|
83
|
+
}
|
|
84
|
+
catch { /* best-effort */ }
|
|
85
|
+
return { id: postId, permalink };
|
|
86
|
+
}
|
|
87
|
+
// Fetch all pages accessible with a User Access Token
|
|
88
|
+
export async function listAccessiblePages(userAccessToken) {
|
|
89
|
+
const qs = new URLSearchParams({
|
|
90
|
+
fields: 'id,name,category',
|
|
91
|
+
access_token: userAccessToken,
|
|
92
|
+
});
|
|
93
|
+
const res = await fetch(`${BASE}/me/accounts?${qs}`);
|
|
94
|
+
const json = await res.json();
|
|
95
|
+
if (json.error)
|
|
96
|
+
throw new Error(`${json.error.message} (code ${json.error.code})`);
|
|
97
|
+
return json.data ?? [];
|
|
98
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface FacebookConfig {
|
|
2
|
+
pageAccessToken: string;
|
|
3
|
+
pageId: string;
|
|
4
|
+
pageName?: string;
|
|
5
|
+
expiresAt?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function loadFacebookConfig(): FacebookConfig | null;
|
|
8
|
+
export declare function saveFacebookConfig(cfg: FacebookConfig): void;
|
|
9
|
+
export declare function requireFacebookConfig(): FacebookConfig;
|
|
@@ -0,0 +1,31 @@
|
|
|
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', 'facebook.json');
|
|
5
|
+
export function loadFacebookConfig() {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function saveFacebookConfig(cfg) {
|
|
14
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
15
|
+
if (!fs.existsSync(dir)) {
|
|
16
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
17
|
+
}
|
|
18
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
19
|
+
if (process.platform !== 'win32') {
|
|
20
|
+
fs.chmodSync(CONFIG_PATH, 0o600);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function requireFacebookConfig() {
|
|
24
|
+
const cfg = loadFacebookConfig();
|
|
25
|
+
if (!cfg) {
|
|
26
|
+
throw new Error('Facebook 설정이 없습니다.\n' +
|
|
27
|
+
'facebook_save_config 도구로 먼저 설정해주세요.\n' +
|
|
28
|
+
'예: facebook_save_config(pageAccessToken="EAA...", pageId="...")');
|
|
29
|
+
}
|
|
30
|
+
return cfg;
|
|
31
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerBigqueryTools } from './registers/bigquery.js';
|
|
|
12
12
|
import { registerAuthTools } from './registers/auth.js';
|
|
13
13
|
import { registerCiTools } from './registers/ci.js';
|
|
14
14
|
import { registerInstagramTools } from './registers/instagram.js';
|
|
15
|
+
import { registerFacebookTools } from './registers/facebook.js';
|
|
15
16
|
import { registerPrompts } from './prompts.js';
|
|
16
17
|
import { registerResources } from './resources.js';
|
|
17
18
|
const server = new McpServer({
|
|
@@ -29,6 +30,7 @@ registerBigqueryTools(server);
|
|
|
29
30
|
registerAuthTools(server);
|
|
30
31
|
registerCiTools(server);
|
|
31
32
|
registerInstagramTools(server);
|
|
33
|
+
registerFacebookTools(server);
|
|
32
34
|
registerPrompts(server);
|
|
33
35
|
registerResources(server);
|
|
34
36
|
// `npx -y @yoonion/mimi-seed-mcp <subcommand>` 처리.
|
package/dist/instagram/api.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { InstagramConfig } from './config.js';
|
|
2
|
+
export declare function apiBaseFor(token: string): string;
|
|
2
3
|
export interface InstagramAccount {
|
|
3
4
|
id: string;
|
|
4
5
|
username: string;
|
|
@@ -9,6 +10,7 @@ export interface InstagramAccount {
|
|
|
9
10
|
media_count?: number;
|
|
10
11
|
}
|
|
11
12
|
export declare function getAccount(cfg: InstagramConfig): Promise<InstagramAccount>;
|
|
13
|
+
export declare function fetchUserId(accessToken: string): Promise<string>;
|
|
12
14
|
export interface PublishResult {
|
|
13
15
|
id: string;
|
|
14
16
|
permalink?: string;
|
package/dist/instagram/api.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// 두 가지 Meta API:
|
|
2
|
+
// IGAA... = Instagram API with Instagram Login (2024 신규) — graph.instagram.com
|
|
3
|
+
// EAA... = Instagram Graph API via Facebook Login — graph.facebook.com
|
|
4
|
+
// 토큰 prefix로 자동 분기. 엔드포인트 path는 둘 다 동일.
|
|
5
|
+
export function apiBaseFor(token) {
|
|
6
|
+
if (token.startsWith('IGAA'))
|
|
7
|
+
return 'https://graph.instagram.com/v21.0';
|
|
8
|
+
return 'https://graph.facebook.com/v21.0';
|
|
9
|
+
}
|
|
10
|
+
async function igFetch(token, endpoint, params, method = 'GET') {
|
|
11
|
+
const url = new URL(`${apiBaseFor(token)}${endpoint}`);
|
|
4
12
|
let body;
|
|
5
13
|
if (method === 'GET') {
|
|
6
14
|
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
|
|
@@ -28,17 +36,36 @@ async function igFetch(endpoint, params, method = 'GET') {
|
|
|
28
36
|
return JSON.parse(text);
|
|
29
37
|
}
|
|
30
38
|
export async function getAccount(cfg) {
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
// graph.instagram.com 은 name/profile_picture_url 필드를 받지 않을 수 있어
|
|
40
|
+
// 두 API 공통으로 안전한 필드만 요청
|
|
41
|
+
const fields = cfg.accessToken.startsWith('IGAA')
|
|
42
|
+
? 'id,username,account_type,followers_count,media_count'
|
|
43
|
+
: 'id,username,name,profile_picture_url,account_type,followers_count,media_count';
|
|
44
|
+
return igFetch(cfg.accessToken, `/${cfg.userId}`, {
|
|
45
|
+
fields,
|
|
33
46
|
access_token: cfg.accessToken,
|
|
34
47
|
});
|
|
35
48
|
}
|
|
49
|
+
// 토큰만으로 user ID 자동 조회 (양쪽 API 지원)
|
|
50
|
+
export async function fetchUserId(accessToken) {
|
|
51
|
+
if (accessToken.startsWith('IGAA')) {
|
|
52
|
+
// Instagram Login: GET /me?fields=user_id,username
|
|
53
|
+
const res = await igFetch(accessToken, '/me', { fields: 'user_id,username', access_token: accessToken });
|
|
54
|
+
// graph.instagram.com 은 v21.0에서 user_id 또는 id 둘 중 하나 반환
|
|
55
|
+
return res.user_id ?? res.id;
|
|
56
|
+
}
|
|
57
|
+
// Facebook Login: GET /me/accounts → instagram_business_account.id
|
|
58
|
+
const res = await igFetch(accessToken, '/me/accounts', { fields: 'instagram_business_account', access_token: accessToken });
|
|
59
|
+
const id = res.data?.[0]?.instagram_business_account?.id;
|
|
60
|
+
if (!id) {
|
|
61
|
+
throw new Error('Facebook Page에 연결된 Instagram Business Account를 찾지 못했습니다.\n' +
|
|
62
|
+
'Facebook Page → Settings → Linked Accounts에서 IG 계정을 연결하세요.');
|
|
63
|
+
}
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
36
66
|
async function fetchPermalink(cfg, mediaId) {
|
|
37
67
|
try {
|
|
38
|
-
const meta = await igFetch(`/${mediaId}`, {
|
|
39
|
-
fields: 'permalink',
|
|
40
|
-
access_token: cfg.accessToken,
|
|
41
|
-
});
|
|
68
|
+
const meta = await igFetch(cfg.accessToken, `/${mediaId}`, { fields: 'permalink', access_token: cfg.accessToken });
|
|
42
69
|
return meta.permalink;
|
|
43
70
|
}
|
|
44
71
|
catch {
|
|
@@ -47,9 +74,9 @@ async function fetchPermalink(cfg, mediaId) {
|
|
|
47
74
|
}
|
|
48
75
|
export async function postImage(cfg, imageUrl, caption) {
|
|
49
76
|
// Step 1: image container 생성
|
|
50
|
-
const container = await igFetch(`/${cfg.userId}/media`, { image_url: imageUrl, caption, access_token: cfg.accessToken }, 'POST');
|
|
77
|
+
const container = await igFetch(cfg.accessToken, `/${cfg.userId}/media`, { image_url: imageUrl, caption, access_token: cfg.accessToken }, 'POST');
|
|
51
78
|
// Step 2: publish
|
|
52
|
-
const published = await igFetch(`/${cfg.userId}/media_publish`, { creation_id: container.id, access_token: cfg.accessToken }, 'POST');
|
|
79
|
+
const published = await igFetch(cfg.accessToken, `/${cfg.userId}/media_publish`, { creation_id: container.id, access_token: cfg.accessToken }, 'POST');
|
|
53
80
|
return {
|
|
54
81
|
id: published.id,
|
|
55
82
|
permalink: await fetchPermalink(cfg, published.id),
|
|
@@ -62,18 +89,18 @@ export async function postCarousel(cfg, imageUrls, caption) {
|
|
|
62
89
|
// Step 1: 각 이미지를 children container로 생성 (sequential — 일부 실패 시 부분 결과 명확)
|
|
63
90
|
const childIds = [];
|
|
64
91
|
for (const url of imageUrls) {
|
|
65
|
-
const child = await igFetch(`/${cfg.userId}/media`, { image_url: url, is_carousel_item: 'true', access_token: cfg.accessToken }, 'POST');
|
|
92
|
+
const child = await igFetch(cfg.accessToken, `/${cfg.userId}/media`, { image_url: url, is_carousel_item: 'true', access_token: cfg.accessToken }, 'POST');
|
|
66
93
|
childIds.push(child.id);
|
|
67
94
|
}
|
|
68
95
|
// Step 2: carousel container 생성
|
|
69
|
-
const carousel = await igFetch(`/${cfg.userId}/media`, {
|
|
96
|
+
const carousel = await igFetch(cfg.accessToken, `/${cfg.userId}/media`, {
|
|
70
97
|
media_type: 'CAROUSEL',
|
|
71
98
|
children: childIds.join(','),
|
|
72
99
|
caption,
|
|
73
100
|
access_token: cfg.accessToken,
|
|
74
101
|
}, 'POST');
|
|
75
102
|
// Step 3: publish
|
|
76
|
-
const published = await igFetch(`/${cfg.userId}/media_publish`, { creation_id: carousel.id, access_token: cfg.accessToken }, 'POST');
|
|
103
|
+
const published = await igFetch(cfg.accessToken, `/${cfg.userId}/media_publish`, { creation_id: carousel.id, access_token: cfg.accessToken }, 'POST');
|
|
77
104
|
return {
|
|
78
105
|
id: published.id,
|
|
79
106
|
permalink: await fetchPermalink(cfg, published.id),
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { loadFacebookConfig, requireFacebookConfig, saveFacebookConfig } from '../facebook/config.js';
|
|
3
|
+
import * as api from '../facebook/api.js';
|
|
4
|
+
export function registerFacebookTools(server) {
|
|
5
|
+
server.tool('facebook_save_config', [
|
|
6
|
+
'Facebook 페이지 액세스 토큰을 ~/.mimi-seed/facebook.json (mode 0600)에 저장합니다.',
|
|
7
|
+
'pageAccessToken: Graph API Explorer 또는 /me/accounts로 발급한 Page Access Token (EAA...).',
|
|
8
|
+
'pageId 미입력 시 토큰으로 자동 조회 (/me → id 필드).',
|
|
9
|
+
'저장 직후 페이지 정보를 조회해 토큰 유효성도 자동 검증.',
|
|
10
|
+
].join(' '), {
|
|
11
|
+
pageAccessToken: z.string().describe('Facebook Page Access Token (EAA..., long-lived 권장)'),
|
|
12
|
+
pageId: z.string().optional().describe('Facebook Page ID (생략 시 토큰에서 자동 조회)'),
|
|
13
|
+
}, async ({ pageAccessToken, pageId }) => {
|
|
14
|
+
// Resolve pageId
|
|
15
|
+
let resolvedPageId = pageId;
|
|
16
|
+
if (!resolvedPageId) {
|
|
17
|
+
try {
|
|
18
|
+
const me = await api.listAccessiblePages(pageAccessToken);
|
|
19
|
+
if (me.length === 0) {
|
|
20
|
+
// Try /me directly (already a Page Access Token)
|
|
21
|
+
const res = await fetch(`https://graph.facebook.com/v21.0/me?fields=id,name&access_token=${pageAccessToken}`);
|
|
22
|
+
const data = await res.json();
|
|
23
|
+
if (data.error)
|
|
24
|
+
throw new Error(`${data.error.message} (code ${data.error.code})`);
|
|
25
|
+
resolvedPageId = data.id;
|
|
26
|
+
}
|
|
27
|
+
else if (me.length === 1) {
|
|
28
|
+
resolvedPageId = me[0].id;
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const list = me.map(p => ` • ${p.name} (${p.id})`).join('\n');
|
|
32
|
+
return {
|
|
33
|
+
content: [{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: [
|
|
36
|
+
'여러 페이지에 접근 가능합니다. pageId를 명시해주세요:',
|
|
37
|
+
list,
|
|
38
|
+
].join('\n'),
|
|
39
|
+
}],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: `❌ pageId 자동 조회 실패: ${err.message}`,
|
|
48
|
+
}],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const cfg = { pageAccessToken, pageId: resolvedPageId };
|
|
54
|
+
const page = await api.getPage(cfg);
|
|
55
|
+
saveFacebookConfig({
|
|
56
|
+
pageAccessToken,
|
|
57
|
+
pageId: resolvedPageId,
|
|
58
|
+
pageName: page.name,
|
|
59
|
+
expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
content: [{
|
|
63
|
+
type: 'text',
|
|
64
|
+
text: [
|
|
65
|
+
`✅ Facebook 페이지 연결 완료`,
|
|
66
|
+
` 페이지: ${page.name}`,
|
|
67
|
+
` ID: ${page.id}`,
|
|
68
|
+
page.category ? ` 카테고리: ${page.category}` : '',
|
|
69
|
+
page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
|
|
70
|
+
page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
|
|
71
|
+
].filter(Boolean).join('\n'),
|
|
72
|
+
}],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
return {
|
|
77
|
+
content: [{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: [
|
|
80
|
+
`❌ 토큰 검증 실패`,
|
|
81
|
+
` ${err.message}`,
|
|
82
|
+
``,
|
|
83
|
+
`Page Access Token을 다시 확인하거나 facebook_list_pages로 페이지 목록을 조회하세요.`,
|
|
84
|
+
].join('\n'),
|
|
85
|
+
}],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
server.tool('facebook_list_pages', 'User Access Token으로 접근 가능한 Facebook 페이지 목록을 조회합니다. 페이지별 Page Access Token도 함께 반환.', {
|
|
90
|
+
userAccessToken: z.string().describe('Facebook User Access Token (EAA...)'),
|
|
91
|
+
}, async ({ userAccessToken }) => {
|
|
92
|
+
const pages = await api.listAccessiblePages(userAccessToken);
|
|
93
|
+
if (pages.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
content: [{
|
|
96
|
+
type: 'text',
|
|
97
|
+
text: '접근 가능한 페이지가 없습니다. pages_show_list 권한이 있는 토큰인지 확인하세요.',
|
|
98
|
+
}],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
content: [{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: [
|
|
105
|
+
`접근 가능한 페이지 ${pages.length}개:`,
|
|
106
|
+
...pages.map(p => ` • ${p.name} (ID: ${p.id})${p.category ? ` — ${p.category}` : ''}`),
|
|
107
|
+
].join('\n'),
|
|
108
|
+
}],
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
server.tool('facebook_get_page', 'Facebook 페이지 정보 조회 + 저장된 토큰 유효성 검증.', {}, async () => {
|
|
112
|
+
const cfg = requireFacebookConfig();
|
|
113
|
+
const page = await api.getPage(cfg);
|
|
114
|
+
const remainingDays = cfg.expiresAt
|
|
115
|
+
? Math.round((new Date(cfg.expiresAt).getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
|
116
|
+
: null;
|
|
117
|
+
return {
|
|
118
|
+
content: [{
|
|
119
|
+
type: 'text',
|
|
120
|
+
text: [
|
|
121
|
+
`${page.name} (ID: ${page.id})`,
|
|
122
|
+
page.category ? ` 카테고리: ${page.category}` : '',
|
|
123
|
+
page.followers_count !== undefined ? ` 팔로워: ${page.followers_count.toLocaleString()}` : '',
|
|
124
|
+
page.fan_count !== undefined ? ` 좋아요: ${page.fan_count.toLocaleString()}` : '',
|
|
125
|
+
remainingDays !== null
|
|
126
|
+
? remainingDays > 7
|
|
127
|
+
? ` 토큰: ${remainingDays}일 남음`
|
|
128
|
+
: remainingDays > 0
|
|
129
|
+
? ` ⚠️ 토큰 ${remainingDays}일 남음 — 갱신 필요`
|
|
130
|
+
: ` ❌ 토큰 만료 — facebook_save_config로 재저장`
|
|
131
|
+
: '',
|
|
132
|
+
].filter(Boolean).join('\n'),
|
|
133
|
+
}],
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
server.tool('facebook_post_photo', [
|
|
137
|
+
'단일 이미지를 Facebook 페이지에 게시합니다.',
|
|
138
|
+
'imageUrl은 public URL이어야 합니다.',
|
|
139
|
+
].join(' '), {
|
|
140
|
+
imageUrl: z.string().url().describe('이미지의 public URL (HTTPS 권장)'),
|
|
141
|
+
caption: z.string().describe('게시글 본문'),
|
|
142
|
+
}, async ({ imageUrl, caption }) => {
|
|
143
|
+
const cfg = requireFacebookConfig();
|
|
144
|
+
const result = await api.postPhoto(cfg, imageUrl, caption);
|
|
145
|
+
return {
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: [
|
|
149
|
+
`✅ 게시 완료`,
|
|
150
|
+
` post_id: ${result.id}`,
|
|
151
|
+
result.permalink ? ` URL: ${result.permalink}` : '',
|
|
152
|
+
].filter(Boolean).join('\n'),
|
|
153
|
+
}],
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
server.tool('facebook_post_multi_photo', [
|
|
157
|
+
'여러 이미지를 하나의 게시물로 Facebook 페이지에 게시합니다. 2~10장.',
|
|
158
|
+
'모든 이미지는 public URL이어야 합니다.',
|
|
159
|
+
'각 이미지를 unpublished photo로 업로드한 뒤 하나의 feed 게시물로 묶습니다.',
|
|
160
|
+
].join(' '), {
|
|
161
|
+
imageUrls: z.array(z.string().url()).min(2).max(10).describe('이미지 URL 배열 (2~10장)'),
|
|
162
|
+
caption: z.string().describe('게시글 본문'),
|
|
163
|
+
}, async ({ imageUrls, caption }) => {
|
|
164
|
+
const cfg = requireFacebookConfig();
|
|
165
|
+
const result = await api.postMultiPhoto(cfg, imageUrls, caption);
|
|
166
|
+
return {
|
|
167
|
+
content: [{
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: [
|
|
170
|
+
`✅ ${imageUrls.length}장 게시 완료`,
|
|
171
|
+
` post_id: ${result.id}`,
|
|
172
|
+
result.permalink ? ` URL: ${result.permalink}` : '',
|
|
173
|
+
].filter(Boolean).join('\n'),
|
|
174
|
+
}],
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
// Config load helper
|
|
178
|
+
server.tool('facebook_current_config', '현재 저장된 Facebook 페이지 설정을 확인합니다.', {}, async () => {
|
|
179
|
+
const cfg = loadFacebookConfig();
|
|
180
|
+
if (!cfg) {
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text', text: '저장된 Facebook 설정 없음. facebook_save_config로 등록하세요.' }],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
content: [{
|
|
187
|
+
type: 'text',
|
|
188
|
+
text: [
|
|
189
|
+
`페이지: ${cfg.pageName ?? '(미확인)'} (${cfg.pageId})`,
|
|
190
|
+
cfg.expiresAt ? `토큰 만료(추정): ${cfg.expiresAt.slice(0, 10)}` : '',
|
|
191
|
+
].filter(Boolean).join('\n'),
|
|
192
|
+
}],
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
}
|
|
@@ -3,49 +3,77 @@ import { requireInstagramConfig, saveInstagramConfig } from '../instagram/config
|
|
|
3
3
|
import * as api from '../instagram/api.js';
|
|
4
4
|
export function registerInstagramTools(server) {
|
|
5
5
|
server.tool('instagram_save_config', [
|
|
6
|
-
'Instagram
|
|
7
|
-
'accessToken
|
|
8
|
-
'
|
|
9
|
-
'
|
|
10
|
-
'
|
|
6
|
+
'Instagram 토큰을 ~/.mimi-seed/instagram.json (mode 0600)에 저장합니다.',
|
|
7
|
+
'accessToken은 두 형식 모두 자동 감지:',
|
|
8
|
+
' IGAA... = Instagram API with Instagram Login (Meta 신규, FB Page 불필요)',
|
|
9
|
+
' EAA... = Instagram Graph API via Facebook Login (FB Page+IG Business 연결 필요)',
|
|
10
|
+
'userId 미입력 시 토큰으로 자동 조회 (/me 또는 /me/accounts).',
|
|
11
|
+
'저장 직후 토큰 유효성도 자동 검증.',
|
|
11
12
|
].join(' '), {
|
|
12
|
-
accessToken: z.string().describe('Long-lived access token (60일
|
|
13
|
-
userId: z.string().describe('Instagram Business Account ID'),
|
|
14
|
-
assumeIssuedNow: z.boolean().default(true).describe('
|
|
13
|
+
accessToken: z.string().describe('Long-lived access token (60일)'),
|
|
14
|
+
userId: z.string().optional().describe('Instagram Business Account ID (생략 시 자동 조회)'),
|
|
15
|
+
assumeIssuedNow: z.boolean().default(true).describe('expiresAt = 지금 + 60일 자동 계산'),
|
|
15
16
|
}, async ({ accessToken, userId, assumeIssuedNow }) => {
|
|
17
|
+
const apiType = accessToken.startsWith('IGAA') ? 'Instagram Login' : 'Facebook Login';
|
|
18
|
+
// userId 자동 조회
|
|
19
|
+
let resolvedUserId = userId;
|
|
20
|
+
if (!resolvedUserId) {
|
|
21
|
+
try {
|
|
22
|
+
resolvedUserId = await api.fetchUserId(accessToken);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return {
|
|
26
|
+
content: [{
|
|
27
|
+
type: 'text',
|
|
28
|
+
text: [
|
|
29
|
+
`❌ userId 자동 조회 실패 (${apiType})`,
|
|
30
|
+
` ${err.message}`,
|
|
31
|
+
``,
|
|
32
|
+
`userId를 명시적으로 전달하거나 토큰을 다시 확인하세요.`,
|
|
33
|
+
].join('\n'),
|
|
34
|
+
}],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
16
38
|
const expiresAt = assumeIssuedNow
|
|
17
39
|
? new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString()
|
|
18
40
|
: undefined;
|
|
19
|
-
saveInstagramConfig({ accessToken, userId, expiresAt });
|
|
20
41
|
// 검증 — 잘못된 토큰이면 즉시 알림
|
|
21
42
|
try {
|
|
22
|
-
const account = await api.getAccount({ accessToken, userId });
|
|
23
|
-
// 검증 성공
|
|
24
|
-
saveInstagramConfig({
|
|
43
|
+
const account = await api.getAccount({ accessToken, userId: resolvedUserId });
|
|
44
|
+
// 검증 성공 → 최종 저장
|
|
45
|
+
saveInstagramConfig({
|
|
46
|
+
accessToken,
|
|
47
|
+
userId: resolvedUserId,
|
|
48
|
+
expiresAt,
|
|
49
|
+
username: account.username,
|
|
50
|
+
});
|
|
25
51
|
return {
|
|
26
52
|
content: [{
|
|
27
53
|
type: 'text',
|
|
28
54
|
text: [
|
|
29
|
-
`✅ Instagram 연결 확인
|
|
55
|
+
`✅ Instagram 연결 확인 완료 (${apiType})`,
|
|
30
56
|
` 계정: @${account.username}${account.name ? ` (${account.name})` : ''}`,
|
|
31
57
|
` ID: ${account.id}`,
|
|
32
58
|
account.account_type ? ` 타입: ${account.account_type}` : '',
|
|
33
59
|
account.followers_count !== undefined ? ` 팔로워: ${account.followers_count.toLocaleString()}` : '',
|
|
60
|
+
account.media_count !== undefined ? ` 게시물: ${account.media_count}` : '',
|
|
34
61
|
expiresAt ? ` 토큰 만료(추정): ${expiresAt.slice(0, 10)}` : '',
|
|
35
62
|
].filter(Boolean).join('\n'),
|
|
36
63
|
}],
|
|
37
64
|
};
|
|
38
65
|
}
|
|
39
66
|
catch (err) {
|
|
67
|
+
// 검증 실패 — 저장은 하지 않고 사용자에게 알림
|
|
40
68
|
return {
|
|
41
69
|
content: [{
|
|
42
70
|
type: 'text',
|
|
43
71
|
text: [
|
|
44
|
-
|
|
72
|
+
`❌ 토큰 검증 실패 (${apiType})`,
|
|
73
|
+
` userId: ${resolvedUserId}`,
|
|
45
74
|
` ${err.message}`,
|
|
46
75
|
``,
|
|
47
|
-
|
|
48
|
-
`instagram_save_config 로 재저장 가능.`,
|
|
76
|
+
`토큰을 다시 발급받거나 userId를 직접 지정해보세요.`,
|
|
49
77
|
].join('\n'),
|
|
50
78
|
}],
|
|
51
79
|
};
|
package/package.json
CHANGED