@yoonion/mimi-seed-mcp 0.15.4 → 0.15.6
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 +4 -2
- package/assets/agent-guide.md +8 -3
- package/dist/index.js +1 -0
- package/dist/registers/auth.js +19 -2
- package/dist/registers/threads.js +24 -0
- package/dist/registers/tiktok.d.ts +2 -0
- package/dist/registers/tiktok.js +60 -0
- package/dist/server.js +2 -0
- package/dist/threads/api.d.ts +2 -0
- package/dist/threads/api.js +14 -0
- package/dist/tiktok-business/api.d.ts +21 -0
- package/dist/tiktok-business/api.js +102 -0
- package/dist/tiktok-business/auth.d.ts +38 -0
- package/dist/tiktok-business/auth.js +67 -0
- package/dist/tiktok-business/config.d.ts +23 -0
- package/dist/tiktok-business/config.js +39 -0
- package/dist/tiktok-business/posting.d.ts +105 -0
- package/dist/tiktok-business/posting.js +383 -0
- package/dist/tiktok-business/setup-cli.d.ts +2 -0
- package/dist/tiktok-business/setup-cli.js +82 -0
- package/dist/tiktok-business/types.d.ts +44 -0
- package/dist/tiktok-business/types.js +1 -0
- package/package.json +2 -1
- package/tool-manifest.json +17 -2
package/README.md
CHANGED
|
@@ -58,6 +58,7 @@ 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
60
|
npx -y @yoonion/mimi-seed-mcp mimi-seed-social-auth # Facebook / Instagram / Threads
|
|
61
|
+
npx -y @yoonion/mimi-seed-mcp mimi-seed-tiktok-business-auth # TikTok Business Organic API
|
|
61
62
|
```
|
|
62
63
|
|
|
63
64
|
각 자격증명을 **어디서 어떻게 발급받는지**는 [`docs/credentials.md`](../../docs/credentials.md) 참고.
|
|
@@ -74,7 +75,7 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
74
75
|
|
|
75
76
|
---
|
|
76
77
|
|
|
77
|
-
## 제공 도구 (150+ 개 ·
|
|
78
|
+
## 제공 도구 (150+ 개 · 20개 영역)
|
|
78
79
|
|
|
79
80
|
| 영역 | 도구 수 | 주요 도구 |
|
|
80
81
|
|------|---------|-----------|
|
|
@@ -90,7 +91,8 @@ export ANTHROPIC_API_KEY=sk-ant-...
|
|
|
90
91
|
| Facebook | 6 | `facebook_post_photo` / `facebook_post_multi_photo` / `facebook_list_pages` |
|
|
91
92
|
| Google Cloud IAM | 5 | `iam_create_service_account` / `iam_create_key` / `iam_add_iam_policy_binding` |
|
|
92
93
|
| BigQuery | 5 | `bigquery_run_query` / `bigquery_list_datasets` / `bigquery_get_table_schema` |
|
|
93
|
-
| Threads |
|
|
94
|
+
| Threads | 7 | `threads_post` / `threads_post_video` / `threads_post_carousel` / `threads_refresh_token` |
|
|
95
|
+
| TikTok Business | 7 | `tiktok_business_plan_video_post` / `tiktok_business_publish_video` / `tiktok_business_get_publish_status` |
|
|
94
96
|
| 점검 / 위험 | 4 | `playstore_check_submission_risks` / `appstore_check_submission_risks` / `screenshot_validate` / `release_status` |
|
|
95
97
|
| Instagram | 4 | `instagram_post_image` / `instagram_post_carousel` / `instagram_save_config` |
|
|
96
98
|
| Android 서명 | 3 | `android_signing_setup` / `android_generate_keystore` / `jenkins_upload_playstore_sa` |
|
package/assets/agent-guide.md
CHANGED
|
@@ -72,7 +72,8 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
|
|
|
72
72
|
| AdMob | `select:admob_list_accounts,admob_list_apps,admob_create_app,admob_create_ad_unit,admob_list_ad_units,admob_get_today_earnings,admob_get_report` |
|
|
73
73
|
| Google Ads (UAC) | `select:googleads_config_status,googleads_save_config,googleads_list_accessible_customers,googleads_list_campaigns,googleads_get_campaign_report,googleads_get_uac_report` |
|
|
74
74
|
| Search Console | `select:gsc_list_sites,gsc_list_sitemaps,gsc_get_sitemap,gsc_submit_sitemap,gsc_inspect_url,gsc_search_analytics` |
|
|
75
|
-
| Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_carousel` |
|
|
75
|
+
| Social posting (Facebook / Instagram / Threads) | `select:facebook_current_config,facebook_save_config,facebook_list_pages,facebook_get_page,facebook_post_photo,facebook_post_multi_photo,instagram_save_config,instagram_get_account,instagram_post_image,instagram_post_carousel,threads_current_config,threads_save_config,threads_refresh_token,threads_get_account,threads_post,threads_post_video,threads_post_carousel` |
|
|
76
|
+
| TikTok Business video publish | `select:tiktok_business_auth_status,tiktok_business_get_account,tiktok_business_get_video_settings,tiktok_business_plan_video_post,tiktok_business_publish_video,tiktok_business_get_publish_status,tiktok_business_list_publish_audits` |
|
|
76
77
|
| Jenkins credentials + jobs | `select:jenkins_status,jenkins_save_config,jenkins_list_credentials,jenkins_create_credential,jenkins_delete_credential,jenkins_upload_keystore,jenkins_upload_playstore_sa,jenkins_list_jobs,jenkins_get_job_config,jenkins_create_job,jenkins_update_job` |
|
|
77
78
|
| CI (GitHub/GitLab) | `select:ci_save_config,ci_list_workflows,ci_trigger_build,ci_get_build_status,ci_list_recent_builds,ci_cancel_build` |
|
|
78
79
|
| Android signing / keystore | `select:android_signing_setup,android_generate_keystore,jenkins_upload_keystore,jenkins_upload_playstore_sa` |
|
|
@@ -86,7 +87,7 @@ you can paste. Pick the row for the job; batching two rows in one `select:` call
|
|
|
86
87
|
|
|
87
88
|
Before any task, call **`mimi_seed_status`** — the setup doctor. It scans your service
|
|
88
89
|
credentials (Google OAuth · Play SA · App Store · Jenkins · CI · Google Ads · Facebook · Instagram ·
|
|
89
|
-
Threads · BigQuery) and returns a ✅/❌ report plus the exact next tool to call for anything
|
|
90
|
+
Threads · TikTok Business · BigQuery) and returns a ✅/❌ report plus the exact next tool to call for anything
|
|
90
91
|
missing. This avoids a late `401`/`403` deep into a workflow.
|
|
91
92
|
|
|
92
93
|
> If the repo has a **`.mimi-seed.json`** manifest at its root, `mimi_seed_status` (and
|
|
@@ -110,6 +111,7 @@ every credential, which also tells them where to obtain each token
|
|
|
110
111
|
| GitHub / GitLab CI | `mimi-seed auth ci` |
|
|
111
112
|
| Google Ads | `mimi-seed auth googleads` — needs the `adwords` OAuth scope; an old token may need `mimi-seed auth login --domains googleads` (adds the grant, keeps the rest) |
|
|
112
113
|
| Facebook / Instagram / Threads | `mimi-seed auth facebook` / `mimi-seed auth instagram` / `mimi-seed auth threads` |
|
|
114
|
+
| TikTok Business | `mimi-seed auth tiktok` → verify with `tiktok_business_auth_status` and `tiktok_business_get_account` |
|
|
113
115
|
|
|
114
116
|
`mimi-seed auth meta` opens the combined social setup entry point when the user wants to review or reconnect
|
|
115
117
|
all three Meta platforms in one pass.
|
|
@@ -146,6 +148,8 @@ Credentials live under `~/.mimi-seed/` (legacy `~/.preseed/` is still read):
|
|
|
146
148
|
| `google-ads.json` | Google Ads developer token + customer id |
|
|
147
149
|
| `facebook.json`, `instagram.json`, `threads.json` | Default/legacy Page / account tokens for social post tools |
|
|
148
150
|
| `social-profiles/<profile>.json` | Named Facebook/Instagram/Threads tokens selected by the current project's `.mimi-seed.json` |
|
|
151
|
+
| `tiktok-business.json` | TikTok API for Business app credentials + short-term/refresh tokens (0600) |
|
|
152
|
+
| `tiktok-business/plans`, `tiktok-business/audit`, `tiktok-business/locks` | Expiring post plans, deduplication/audit records, and atomic publish reservations; signed URL query strings are not written to audit records |
|
|
149
153
|
|
|
150
154
|
Notes that matter in practice:
|
|
151
155
|
|
|
@@ -181,7 +185,8 @@ per-domain inventory is [`docs/domain/tool-catalog.md`](domain/tool-catalog.md).
|
|
|
181
185
|
| **Google Ads (UAC)** | `googleads_list_campaigns` · `googleads_get_uac_report` · `googleads_get_campaign_report` · `googleads_config_status` |
|
|
182
186
|
| **Search Console** | `gsc_inspect_url` · `gsc_search_analytics` · `gsc_submit_sitemap` |
|
|
183
187
|
| **Android signing** | `android_signing_setup` · `android_generate_keystore` · `jenkins_upload_playstore_sa` |
|
|
184
|
-
| **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_refresh_token` |
|
|
188
|
+
| **Facebook / Instagram / Threads** | `facebook_post_photo` · `instagram_post_carousel` · `threads_post` · `threads_post_video` · `threads_refresh_token` |
|
|
189
|
+
| **TikTok Business** | `tiktok_business_auth_status` · `tiktok_business_plan_video_post` · `tiktok_business_publish_video` · `tiktok_business_get_publish_status` |
|
|
185
190
|
| **Checks** | `playstore_check_submission_risks` · `appstore_check_submission_risks` · `screenshot_validate` · `release_status` |
|
|
186
191
|
| **AI / Auth** | `generate_release_notes_from_commits` · `generate_review_reply` · `mimi_seed_status` · `mimi_seed_auth_start` · `mimi_seed_auth_status` · `mimi_seed_remote_sync_credentials` |
|
|
187
192
|
| **Video production** | `youtube_upload_video` · `youtube_get_video_status` · `youtube_update_video_privacy` · `video_plan_from_story` · `video_research_youtube` · `video_search_stock_assets` · `video_synthesize_research` · `video_generate_image` · `video_build_timeline` · `video_render` · `video_job_status` · `video_validate` |
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const SUBCOMMANDS = {
|
|
|
17
17
|
'mimi-seed-jenkins-auth': () => import('./jenkins/setup-cli.js'),
|
|
18
18
|
'mimi-seed-googleads-auth': () => import('./googleads/setup-cli.js'),
|
|
19
19
|
'mimi-seed-social-auth': () => import('./social/setup-cli.js'),
|
|
20
|
+
'mimi-seed-tiktok-business-auth': () => import('./tiktok-business/setup-cli.js'),
|
|
20
21
|
'mimi-seed-firebase': () => import('./firebase/cli.js'),
|
|
21
22
|
'mimi-seed-admob': () => import('./admob/cli.js'),
|
|
22
23
|
'mimi-seed-ga4': () => import('./ga4/cli.js'),
|
package/dist/registers/auth.js
CHANGED
|
@@ -11,6 +11,8 @@ 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
13
|
import { loadThreadsConfig } from '../threads/config.js';
|
|
14
|
+
import { loadTikTokBusinessConfig } from '../tiktok-business/config.js';
|
|
15
|
+
import { tokenFreshness as tiktokTokenFreshness } from '../tiktok-business/auth.js';
|
|
14
16
|
import { metaTokenFreshness } from '../lib/meta-auth.js';
|
|
15
17
|
import { readPackageRootText } from '../lib/package-root.js';
|
|
16
18
|
import { resolveBigQueryAuth } from '../auth/bigquery-auth.js';
|
|
@@ -114,7 +116,7 @@ export function registerAuthTools(server) {
|
|
|
114
116
|
// ── 전체 연결 상태 진단 ────────────────────────────────────────────────────
|
|
115
117
|
server.tool('mimi_seed_status', [
|
|
116
118
|
'⭐ 새 세션을 시작하거나 "뭐가 연결됐지?" 라는 질문엔 이 도구를 먼저 호출하세요.',
|
|
117
|
-
'전체 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / BigQuery)',
|
|
119
|
+
'전체 서비스(Google OAuth / Play SA / App Store / Jenkins / CI / Google Ads / Facebook / Instagram / Threads / TikTok Business / BigQuery)',
|
|
118
120
|
'설정 상태를 한 번에 스캔해 ✅ / ❌ 트래픽 라이트 리포트와 번호 매긴 다음 단계를 반환합니다.',
|
|
119
121
|
'미설정 서비스마다 어떤 도구를 호출하면 되는지 구체적으로 알려줍니다.',
|
|
120
122
|
].join(' '), {}, async () => {
|
|
@@ -197,7 +199,22 @@ export function registerAuthTools(server) {
|
|
|
197
199
|
const threadsTarget = resolveSocialConfigTarget('threads');
|
|
198
200
|
const threads = loadThreadsConfig();
|
|
199
201
|
lines.push(renderMetaConnection('Threads', threads, `${threadsTarget.profile ? `profile: ${threadsTarget.profile}, ` : ''}userId: ${threads?.userId ?? ''}`, `mimi-seed auth threads${threadsTarget.profile ? ` --profile ${threadsTarget.profile}` : ''}`));
|
|
200
|
-
// 10.
|
|
202
|
+
// 10. TikTok Business — 네트워크 없이 refresh token 만료 상태까지 판정.
|
|
203
|
+
const tiktok = loadTikTokBusinessConfig();
|
|
204
|
+
const tiktokFreshness = tiktokTokenFreshness(tiktok);
|
|
205
|
+
if (!tiktok) {
|
|
206
|
+
lines.push('❌ TikTok Business — 미설정 → mimi-seed auth tiktok (선택)');
|
|
207
|
+
}
|
|
208
|
+
else if (tiktokFreshness.state === 'refresh_expired') {
|
|
209
|
+
lines.push(`❌ TikTok Business — refresh token 만료 (${tiktok.openId}) → mimi-seed auth tiktok`);
|
|
210
|
+
}
|
|
211
|
+
else if (tiktokFreshness.state === 'access_expired') {
|
|
212
|
+
lines.push(`⚠️ TikTok Business — 연결됨, 다음 호출에서 access token 자동 갱신 (${tiktok.openId})`);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
lines.push(`✅ TikTok Business — Organic API 연결됨 (${tiktok.openId})`);
|
|
216
|
+
}
|
|
217
|
+
// 11. BigQuery — resolveBigQueryAuth 기준(서비스 계정 우선, OAuth fallback).
|
|
201
218
|
// 이전엔 SA 파일만 검사해 OAuth fallback 이 살아있어도 ❌ 로 오표기했다.
|
|
202
219
|
const bqAuth = resolveBigQueryAuth();
|
|
203
220
|
if (bqAuth?.source === 'service-account') {
|
|
@@ -94,6 +94,30 @@ export function registerThreadsTools(server) {
|
|
|
94
94
|
}],
|
|
95
95
|
};
|
|
96
96
|
});
|
|
97
|
+
server.tool('threads_post_video', [
|
|
98
|
+
'public URL의 영상을 Threads에 게시합니다.',
|
|
99
|
+
'Meta가 videoUrl을 직접 가져가므로 외부에서 인증 없이 접근 가능한 URL이어야 합니다.',
|
|
100
|
+
'3-step API: VIDEO container 생성 → 처리 완료 자동 대기 → threads_publish.',
|
|
101
|
+
'공개 게시 작업이므로 같은 턴에 사용자의 명시 승인을 받은 뒤 호출하세요.',
|
|
102
|
+
].join(' '), {
|
|
103
|
+
videoUrl: z.string().url().describe('Threads가 가져갈 영상의 public URL'),
|
|
104
|
+
text: z.string().max(500).default('').describe('게시할 텍스트 (선택, 최대 500자)'),
|
|
105
|
+
altText: z.string().optional().describe('영상 접근성 대체 텍스트 (선택)'),
|
|
106
|
+
profile: profileSchema,
|
|
107
|
+
}, async ({ videoUrl, text, altText, profile }) => {
|
|
108
|
+
const cfg = requireThreadsConfig({ profile });
|
|
109
|
+
const result = await api.postVideo(cfg, videoUrl, text, altText);
|
|
110
|
+
return {
|
|
111
|
+
content: [{
|
|
112
|
+
type: 'text',
|
|
113
|
+
text: [
|
|
114
|
+
'✅ 영상 게시 완료',
|
|
115
|
+
` media_id: ${result.id}`,
|
|
116
|
+
result.permalink ? ` URL: ${result.permalink}` : '',
|
|
117
|
+
].filter(Boolean).join('\n'),
|
|
118
|
+
}],
|
|
119
|
+
};
|
|
120
|
+
});
|
|
97
121
|
// 진단용 — 현재 저장된 Threads 설정 요약 (facebook_current_config 와 동일한 패턴).
|
|
98
122
|
server.tool('threads_current_config', '현재 선택된 Threads 연결 설정을 확인합니다. 프로젝트 매핑 또는 profile 인자를 따릅니다.', { profile: profileSchema }, async ({ profile }) => {
|
|
99
123
|
const options = { profile };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getBusinessAccount, getVideoSettings, inspectAccessToken } from '../tiktok-business/api.js';
|
|
3
|
+
import { ensureFreshTikTokConfig, ensureTikTokPublishConfig, safeTokenInspection, tokenFreshness, } from '../tiktok-business/auth.js';
|
|
4
|
+
import { safeTikTokBusinessConfig } from '../tiktok-business/config.js';
|
|
5
|
+
import { checkPublishStatus, listPublishAudits, planVideoPost, publishPlannedVideo, } from '../tiktok-business/posting.js';
|
|
6
|
+
import { jsonResult } from '../lib/mcp-response.js';
|
|
7
|
+
const absolutePath = z.string().min(1).refine((value) => value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value), {
|
|
8
|
+
message: '절대경로만 허용합니다.',
|
|
9
|
+
});
|
|
10
|
+
const httpsUrl = z.string().url().refine((value) => new URL(value).protocol === 'https:', {
|
|
11
|
+
message: 'HTTPS URL만 허용합니다.',
|
|
12
|
+
});
|
|
13
|
+
export function registerTikTokBusinessTools(server) {
|
|
14
|
+
server.tool('tiktok_business_auth_status', [
|
|
15
|
+
'TikTok API for Business Organic API 연결과 권한 상태를 확인합니다.',
|
|
16
|
+
'만료 5분 전이면 refresh token으로 access token을 자동 갱신합니다.',
|
|
17
|
+
'토큰·앱 시크릿은 응답하지 않습니다. 미연결이면 `mimi-seed auth tiktok`을 실행하세요.',
|
|
18
|
+
].join(' '), {}, async () => {
|
|
19
|
+
const config = await ensureFreshTikTokConfig();
|
|
20
|
+
const tokenInfo = await inspectAccessToken(config);
|
|
21
|
+
return jsonResult({
|
|
22
|
+
connected: true,
|
|
23
|
+
...safeTikTokBusinessConfig(config),
|
|
24
|
+
freshness: tokenFreshness(config).state,
|
|
25
|
+
tokenInfo: safeTokenInspection(config, tokenInfo),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
server.tool('tiktok_business_get_account', '연결된 owned TikTok Business Account 프로필을 조회하고 현재 토큰으로 접근 가능한지 검증합니다.', {}, async () => jsonResult(await getBusinessAccount(await ensureFreshTikTokConfig())));
|
|
29
|
+
server.tool('tiktok_business_get_video_settings', '게시 전에 계정별 최대 영상 길이와 댓글·듀엣·스티치 설정을 조회합니다.', {}, async () => jsonResult(await getVideoSettings(await ensureTikTokPublishConfig())));
|
|
30
|
+
server.tool('tiktok_business_plan_video_post', [
|
|
31
|
+
'TikTok 공개 게시 전용 계획을 만들고 30분 동안 로컬에 보관합니다.',
|
|
32
|
+
'로컬 원본을 ffprobe로 검사하고 SHA-256으로 중복 게시를 차단하며 계정별 영상 설정과 대조합니다.',
|
|
33
|
+
'videoUrl은 TikTok 앱에서 검증한 도메인의 HTTPS URL이어야 하고 최소 30분 이상 유효해야 합니다.',
|
|
34
|
+
'이 도구는 TikTok에 게시하지 않습니다. 검토 후 tiktok_business_publish_video를 별도로 호출하세요.',
|
|
35
|
+
].join(' '), {
|
|
36
|
+
sourceFilePath: absolutePath.describe('검증·중복 판정할 로컬 .mp4/.mov/.webm 절대경로'),
|
|
37
|
+
videoUrl: httpsUrl.describe('TikTok 서버가 가져갈 영상의 검증된 HTTPS URL'),
|
|
38
|
+
customThumbnailUrl: httpsUrl.optional().describe('선택 썸네일 HTTPS URL'),
|
|
39
|
+
caption: z.string().max(2_200).optional().describe('UTF-16 기준 최대 2,200자, 멘션 최대 30개'),
|
|
40
|
+
isBrandOrganic: z.boolean().default(false).describe('브랜드 자체 홍보 콘텐츠 여부'),
|
|
41
|
+
isBrandedContent: z.boolean().default(false).describe('유료 파트너십/브랜디드 콘텐츠 여부'),
|
|
42
|
+
disableComment: z.boolean().default(false),
|
|
43
|
+
disableDuet: z.boolean().default(false),
|
|
44
|
+
disableStitch: z.boolean().default(false),
|
|
45
|
+
thumbnailOffsetMs: z.number().int().min(0).optional().describe('영상 시작 기준 썸네일 오프셋(ms)'),
|
|
46
|
+
isAiGenerated: z.boolean().default(true).describe('AI 생성·변형 영상 고지'),
|
|
47
|
+
ffmpegPath: z.string().optional().describe('FFmpeg 절대경로. 생략하면 PATH의 ffprobe 사용'),
|
|
48
|
+
}, async (input) => jsonResult(await planVideoPost(input)));
|
|
49
|
+
server.tool('tiktok_business_publish_video', [
|
|
50
|
+
'검증된 30분짜리 계획을 owned TikTok Business Account에 공개 게시합니다.',
|
|
51
|
+
'외부 공개 작업이므로 같은 턴에 사용자의 명시 승인을 받은 뒤 confirmPublish=true로 호출해야 합니다.',
|
|
52
|
+
'POST 타임아웃은 결과 불명으로 감사 로그에 남고 자동 재시도를 막습니다.',
|
|
53
|
+
'성공 응답은 보통 pending이며 tiktok_business_get_publish_status로 완료를 확인하세요.',
|
|
54
|
+
].join(' '), {
|
|
55
|
+
planId: z.string().regex(/^[a-f0-9]{32}$/),
|
|
56
|
+
confirmPublish: z.boolean().default(false).describe('사용자의 공개 게시 명시 승인 확인'),
|
|
57
|
+
}, async ({ planId, confirmPublish }) => jsonResult(await publishPlannedVideo(planId, confirmPublish)));
|
|
58
|
+
server.tool('tiktok_business_get_publish_status', 'TikTok 게시 처리 상태를 조회하고 로컬 감사 로그의 pending/published/failed 상태를 갱신합니다.', { publishId: z.string().min(1).max(128).describe('publish 응답의 share_id 또는 publish_id') }, async ({ publishId }) => jsonResult(await checkPublishStatus(publishId)));
|
|
59
|
+
server.tool('tiktok_business_list_publish_audits', '로컬 TikTok 게시 감사 로그를 최신순으로 조회합니다. 토큰·앱 시크릿·서명 URL 쿼리는 저장하거나 반환하지 않습니다.', { limit: z.number().int().min(1).max(100).default(20) }, async ({ limit }) => jsonResult(listPublishAudits(limit)));
|
|
60
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -18,6 +18,7 @@ import { registerGa4Tools } from './registers/ga4.js';
|
|
|
18
18
|
import { registerJenkinsTools } from './registers/jenkins.js';
|
|
19
19
|
import { registerAndroidTools } from './registers/android.js';
|
|
20
20
|
import { registerVideoTools } from './registers/video.js';
|
|
21
|
+
import { registerTikTokBusinessTools } from './registers/tiktok.js';
|
|
21
22
|
import { registerPrompts } from './prompts.js';
|
|
22
23
|
import { registerResources } from './resources.js';
|
|
23
24
|
/**
|
|
@@ -52,6 +53,7 @@ export function buildServer(version) {
|
|
|
52
53
|
registerJenkinsTools(server);
|
|
53
54
|
registerAndroidTools(server);
|
|
54
55
|
registerVideoTools(server);
|
|
56
|
+
registerTikTokBusinessTools(server);
|
|
55
57
|
registerPrompts(server);
|
|
56
58
|
registerResources(server);
|
|
57
59
|
return server;
|
package/dist/threads/api.d.ts
CHANGED
|
@@ -20,4 +20,6 @@ export interface PublishResult {
|
|
|
20
20
|
}
|
|
21
21
|
/** 텍스트 전용 게시 — Threads 의 핵심 유스케이스. imageUrl 을 주면 이미지 게시. */
|
|
22
22
|
export declare function postText(cfg: ThreadsConfig, text: string, imageUrl?: string): Promise<PublishResult>;
|
|
23
|
+
/** public URL의 영상을 Threads 미디어 컨테이너로 만든 뒤 처리 완료 후 게시한다. */
|
|
24
|
+
export declare function postVideo(cfg: ThreadsConfig, videoUrl: string, text?: string, altText?: string): Promise<PublishResult>;
|
|
23
25
|
export declare function postCarousel(cfg: ThreadsConfig, imageUrls: string[], text: string): Promise<PublishResult>;
|
package/dist/threads/api.js
CHANGED
|
@@ -126,6 +126,20 @@ export async function postText(cfg, text, imageUrl) {
|
|
|
126
126
|
await waitForContainer(cfg, container.id); // 미디어만 처리 대기
|
|
127
127
|
return publish(cfg, container.id);
|
|
128
128
|
}
|
|
129
|
+
/** public URL의 영상을 Threads 미디어 컨테이너로 만든 뒤 처리 완료 후 게시한다. */
|
|
130
|
+
export async function postVideo(cfg, videoUrl, text = '', altText) {
|
|
131
|
+
const params = {
|
|
132
|
+
media_type: 'VIDEO',
|
|
133
|
+
video_url: videoUrl,
|
|
134
|
+
text,
|
|
135
|
+
access_token: cfg.accessToken,
|
|
136
|
+
};
|
|
137
|
+
if (altText)
|
|
138
|
+
params.alt_text = altText;
|
|
139
|
+
const container = await thFetch(cfg.accessToken, '/me/threads', params, 'POST');
|
|
140
|
+
await waitForContainer(cfg, container.id);
|
|
141
|
+
return publish(cfg, container.id);
|
|
142
|
+
}
|
|
129
143
|
export async function postCarousel(cfg, imageUrls, text) {
|
|
130
144
|
if (imageUrls.length < 2 || imageUrls.length > 20) {
|
|
131
145
|
throw new Error(`캐러셀은 2~20장의 이미지가 필요합니다 (받은 수: ${imageUrls.length}).`);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { TikTokBusinessConfig } from './config.js';
|
|
2
|
+
import type { TikTokPublishData, TikTokPublishRequest, TikTokTokenData, TikTokVideoSettings } from './types.js';
|
|
3
|
+
export declare const TIKTOK_API_BASE = "https://business-api.tiktok.com/open_api/v1.3";
|
|
4
|
+
export declare class TikTokApiError extends Error {
|
|
5
|
+
readonly code?: number | undefined;
|
|
6
|
+
readonly requestId?: string | undefined;
|
|
7
|
+
readonly outcomeUnknown: boolean;
|
|
8
|
+
constructor(message: string, code?: number | undefined, requestId?: string | undefined, outcomeUnknown?: boolean);
|
|
9
|
+
}
|
|
10
|
+
export declare function exchangeAuthorizationCode(input: {
|
|
11
|
+
clientId: string;
|
|
12
|
+
clientSecret: string;
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
authCode: string;
|
|
15
|
+
}): Promise<TikTokTokenData>;
|
|
16
|
+
export declare function refreshAccessToken(config: TikTokBusinessConfig): Promise<TikTokTokenData>;
|
|
17
|
+
export declare function inspectAccessToken(config: TikTokBusinessConfig): Promise<Record<string, unknown>>;
|
|
18
|
+
export declare function getBusinessAccount(config: TikTokBusinessConfig): Promise<Record<string, unknown>>;
|
|
19
|
+
export declare function getVideoSettings(config: TikTokBusinessConfig): Promise<TikTokVideoSettings>;
|
|
20
|
+
export declare function publishVideo(config: TikTokBusinessConfig, request: TikTokPublishRequest): Promise<TikTokPublishData>;
|
|
21
|
+
export declare function getPublishStatus(config: TikTokBusinessConfig, publishId: string): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { fetchWithTimeout } from '../lib/http.js';
|
|
2
|
+
export const TIKTOK_API_BASE = 'https://business-api.tiktok.com/open_api/v1.3';
|
|
3
|
+
export class TikTokApiError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
requestId;
|
|
6
|
+
outcomeUnknown;
|
|
7
|
+
constructor(message, code, requestId, outcomeUnknown = false) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.requestId = requestId;
|
|
11
|
+
this.outcomeUnknown = outcomeUnknown;
|
|
12
|
+
this.name = 'TikTokApiError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function parseEnvelope(text, status, endpoint) {
|
|
16
|
+
let body;
|
|
17
|
+
try {
|
|
18
|
+
body = JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new TikTokApiError(`TikTok ${endpoint} 응답을 해석하지 못했습니다 (HTTP ${status}).`);
|
|
22
|
+
}
|
|
23
|
+
if (status < 200 || status >= 300 || body.code !== 0) {
|
|
24
|
+
throw new TikTokApiError(`TikTok ${endpoint} 실패: ${body.message || `HTTP ${status}`} (code ${body.code})`, body.code, body.request_id);
|
|
25
|
+
}
|
|
26
|
+
return body;
|
|
27
|
+
}
|
|
28
|
+
async function tokenPost(endpoint, payload) {
|
|
29
|
+
const response = await fetchWithTimeout(`${TIKTOK_API_BASE}${endpoint}`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'Content-Type': 'application/json' },
|
|
32
|
+
body: JSON.stringify(payload),
|
|
33
|
+
});
|
|
34
|
+
const envelope = parseEnvelope(await response.text(), response.status, endpoint);
|
|
35
|
+
if (!envelope.data?.access_token || !envelope.data.refresh_token || !envelope.data.open_id) {
|
|
36
|
+
throw new TikTokApiError(`TikTok ${endpoint} 응답에 필수 토큰 필드가 없습니다.`);
|
|
37
|
+
}
|
|
38
|
+
return envelope.data;
|
|
39
|
+
}
|
|
40
|
+
export function exchangeAuthorizationCode(input) {
|
|
41
|
+
return tokenPost('/tt_user/oauth2/token/', {
|
|
42
|
+
client_id: input.clientId,
|
|
43
|
+
client_secret: input.clientSecret,
|
|
44
|
+
grant_type: 'authorization_code',
|
|
45
|
+
auth_code: input.authCode,
|
|
46
|
+
redirect_uri: input.redirectUri,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function refreshAccessToken(config) {
|
|
50
|
+
return tokenPost('/tt_user/oauth2/refresh_token/', {
|
|
51
|
+
client_id: config.clientId,
|
|
52
|
+
client_secret: config.clientSecret,
|
|
53
|
+
grant_type: 'refresh_token',
|
|
54
|
+
refresh_token: config.refreshToken,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function authorizedRequest(config, endpoint, method, payload) {
|
|
58
|
+
const url = new URL(`${TIKTOK_API_BASE}${endpoint}`);
|
|
59
|
+
let body;
|
|
60
|
+
if (method === 'GET') {
|
|
61
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
62
|
+
if (value !== undefined)
|
|
63
|
+
url.searchParams.set(key, typeof value === 'string' ? value : JSON.stringify(value));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
body = JSON.stringify(payload);
|
|
68
|
+
}
|
|
69
|
+
let response;
|
|
70
|
+
try {
|
|
71
|
+
response = await fetchWithTimeout(url, {
|
|
72
|
+
method,
|
|
73
|
+
headers: {
|
|
74
|
+
'Access-Token': config.accessToken,
|
|
75
|
+
...(method === 'POST' ? { 'Content-Type': 'application/json' } : {}),
|
|
76
|
+
},
|
|
77
|
+
body,
|
|
78
|
+
}, method === 'POST' ? { maxAttempts: 1 } : {});
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
throw new TikTokApiError(error instanceof Error ? error.message : String(error), undefined, undefined, method === 'POST');
|
|
82
|
+
}
|
|
83
|
+
return parseEnvelope(await response.text(), response.status, endpoint).data;
|
|
84
|
+
}
|
|
85
|
+
export function inspectAccessToken(config) {
|
|
86
|
+
return authorizedRequest(config, '/tt_user/token_info/get/', 'GET', {});
|
|
87
|
+
}
|
|
88
|
+
export function getBusinessAccount(config) {
|
|
89
|
+
return authorizedRequest(config, '/business/get/', 'GET', { business_id: config.openId });
|
|
90
|
+
}
|
|
91
|
+
export function getVideoSettings(config) {
|
|
92
|
+
return authorizedRequest(config, '/business/video/settings/', 'GET', { business_id: config.openId });
|
|
93
|
+
}
|
|
94
|
+
export function publishVideo(config, request) {
|
|
95
|
+
return authorizedRequest(config, '/business/video/publish/', 'POST', { ...request });
|
|
96
|
+
}
|
|
97
|
+
export function getPublishStatus(config, publishId) {
|
|
98
|
+
return authorizedRequest(config, '/business/publish/status/', 'GET', {
|
|
99
|
+
business_id: config.openId,
|
|
100
|
+
publish_id: publishId,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { TikTokTokenData } from './types.js';
|
|
2
|
+
import { type TikTokBusinessConfig } from './config.js';
|
|
3
|
+
declare function expiresAt(seconds: number, now?: number): string;
|
|
4
|
+
export declare function configFromToken(app: Pick<TikTokBusinessConfig, 'clientId' | 'clientSecret' | 'redirectUri'>, token: TikTokTokenData, now?: number): TikTokBusinessConfig;
|
|
5
|
+
export declare function tokenFreshness(config?: TikTokBusinessConfig | null, now?: number): {
|
|
6
|
+
state: "missing";
|
|
7
|
+
config?: undefined;
|
|
8
|
+
accessMs?: undefined;
|
|
9
|
+
refreshMs?: undefined;
|
|
10
|
+
} | {
|
|
11
|
+
state: "refresh_expired";
|
|
12
|
+
config: TikTokBusinessConfig;
|
|
13
|
+
accessMs?: undefined;
|
|
14
|
+
refreshMs?: undefined;
|
|
15
|
+
} | {
|
|
16
|
+
state: "access_expired";
|
|
17
|
+
config: TikTokBusinessConfig;
|
|
18
|
+
accessMs: number;
|
|
19
|
+
refreshMs: number;
|
|
20
|
+
} | {
|
|
21
|
+
state: "fresh";
|
|
22
|
+
config: TikTokBusinessConfig;
|
|
23
|
+
accessMs: number;
|
|
24
|
+
refreshMs: number;
|
|
25
|
+
};
|
|
26
|
+
export declare function ensureFreshTikTokConfig(): Promise<TikTokBusinessConfig>;
|
|
27
|
+
export declare function hasTikTokScope(config: Pick<TikTokBusinessConfig, 'scope'>, required: string): boolean;
|
|
28
|
+
export declare function safeTokenInspection(config: Pick<TikTokBusinessConfig, 'openId' | 'scope'>, tokenInfo: Record<string, unknown>): {
|
|
29
|
+
providerVerified: boolean;
|
|
30
|
+
openIdMatches: boolean | undefined;
|
|
31
|
+
scope: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function ensureTikTokPublishConfig(): Promise<TikTokBusinessConfig>;
|
|
34
|
+
export declare const __testing: {
|
|
35
|
+
expiresAt: typeof expiresAt;
|
|
36
|
+
REFRESH_EARLY_MS: number;
|
|
37
|
+
};
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { loadTikTokBusinessConfig, requireTikTokBusinessConfig, saveTikTokBusinessConfig, } from './config.js';
|
|
2
|
+
import { refreshAccessToken } from './api.js';
|
|
3
|
+
const REFRESH_EARLY_MS = 5 * 60 * 1000;
|
|
4
|
+
function expiresAt(seconds, now = Date.now()) {
|
|
5
|
+
return new Date(now + seconds * 1000).toISOString();
|
|
6
|
+
}
|
|
7
|
+
export function configFromToken(app, token, now = Date.now()) {
|
|
8
|
+
return {
|
|
9
|
+
...app,
|
|
10
|
+
accessToken: token.access_token,
|
|
11
|
+
refreshToken: token.refresh_token,
|
|
12
|
+
accessTokenExpiresAt: expiresAt(token.expires_in, now),
|
|
13
|
+
refreshTokenExpiresAt: expiresAt(token.refresh_token_expires_in, now),
|
|
14
|
+
openId: token.open_id,
|
|
15
|
+
scope: token.scope,
|
|
16
|
+
tokenType: token.token_type,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function tokenFreshness(config = loadTikTokBusinessConfig(), now = Date.now()) {
|
|
20
|
+
if (!config)
|
|
21
|
+
return { state: 'missing' };
|
|
22
|
+
const accessMs = Date.parse(config.accessTokenExpiresAt) - now;
|
|
23
|
+
const refreshMs = Date.parse(config.refreshTokenExpiresAt) - now;
|
|
24
|
+
if (!Number.isFinite(refreshMs) || refreshMs <= 0)
|
|
25
|
+
return { state: 'refresh_expired', config };
|
|
26
|
+
if (!Number.isFinite(accessMs) || accessMs <= REFRESH_EARLY_MS) {
|
|
27
|
+
return { state: 'access_expired', config, accessMs, refreshMs };
|
|
28
|
+
}
|
|
29
|
+
return { state: 'fresh', config, accessMs, refreshMs };
|
|
30
|
+
}
|
|
31
|
+
export async function ensureFreshTikTokConfig() {
|
|
32
|
+
const config = requireTikTokBusinessConfig();
|
|
33
|
+
const freshness = tokenFreshness(config);
|
|
34
|
+
if (freshness.state === 'refresh_expired') {
|
|
35
|
+
throw new Error('TikTok refresh token이 만료되었습니다. `mimi-seed auth tiktok`으로 다시 연결하세요.');
|
|
36
|
+
}
|
|
37
|
+
if (freshness.state === 'fresh')
|
|
38
|
+
return config;
|
|
39
|
+
const token = await refreshAccessToken(config);
|
|
40
|
+
const refreshed = configFromToken(config, token);
|
|
41
|
+
saveTikTokBusinessConfig(refreshed);
|
|
42
|
+
return refreshed;
|
|
43
|
+
}
|
|
44
|
+
export function hasTikTokScope(config, required) {
|
|
45
|
+
return config.scope.split(/[\s,]+/).includes(required);
|
|
46
|
+
}
|
|
47
|
+
export function safeTokenInspection(config, tokenInfo) {
|
|
48
|
+
const providerOpenId = typeof tokenInfo.open_id === 'string' ? tokenInfo.open_id : undefined;
|
|
49
|
+
const providerScope = typeof tokenInfo.scope === 'string'
|
|
50
|
+
? tokenInfo.scope
|
|
51
|
+
: Array.isArray(tokenInfo.scopes) && tokenInfo.scopes.every((scope) => typeof scope === 'string')
|
|
52
|
+
? tokenInfo.scopes.join(',')
|
|
53
|
+
: undefined;
|
|
54
|
+
return {
|
|
55
|
+
providerVerified: true,
|
|
56
|
+
openIdMatches: providerOpenId === undefined ? undefined : providerOpenId === config.openId,
|
|
57
|
+
scope: providerScope ?? config.scope,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export async function ensureTikTokPublishConfig() {
|
|
61
|
+
const config = await ensureFreshTikTokConfig();
|
|
62
|
+
if (!hasTikTokScope(config, 'video.publish')) {
|
|
63
|
+
throw new Error('TikTok 연결에 video.publish 권한이 없습니다. Developer 앱 권한을 승인한 뒤 `mimi-seed auth tiktok`으로 다시 연결하세요.');
|
|
64
|
+
}
|
|
65
|
+
return config;
|
|
66
|
+
}
|
|
67
|
+
export const __testing = { expiresAt, REFRESH_EARLY_MS };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface TikTokBusinessConfig {
|
|
2
|
+
clientId: string;
|
|
3
|
+
clientSecret: string;
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
accessToken: string;
|
|
6
|
+
refreshToken: string;
|
|
7
|
+
accessTokenExpiresAt: string;
|
|
8
|
+
refreshTokenExpiresAt: string;
|
|
9
|
+
openId: string;
|
|
10
|
+
scope: string;
|
|
11
|
+
tokenType: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const TIKTOK_BUSINESS_CONFIG_PATH: string;
|
|
14
|
+
export declare function loadTikTokBusinessConfig(): TikTokBusinessConfig | null;
|
|
15
|
+
export declare function saveTikTokBusinessConfig(config: TikTokBusinessConfig): void;
|
|
16
|
+
export declare function requireTikTokBusinessConfig(): TikTokBusinessConfig;
|
|
17
|
+
export declare function safeTikTokBusinessConfig(config: TikTokBusinessConfig): {
|
|
18
|
+
openId: string;
|
|
19
|
+
scope: string;
|
|
20
|
+
tokenType: string;
|
|
21
|
+
accessTokenExpiresAt: string;
|
|
22
|
+
refreshTokenExpiresAt: string;
|
|
23
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { writeCredentialJson } from '../lib/atomic-write.js';
|
|
5
|
+
export const TIKTOK_BUSINESS_CONFIG_PATH = path.join(os.homedir(), '.mimi-seed', 'tiktok-business.json');
|
|
6
|
+
export function loadTikTokBusinessConfig() {
|
|
7
|
+
try {
|
|
8
|
+
const value = JSON.parse(readFileSync(TIKTOK_BUSINESS_CONFIG_PATH, 'utf8'));
|
|
9
|
+
const required = [
|
|
10
|
+
'clientId', 'clientSecret', 'redirectUri', 'accessToken', 'refreshToken',
|
|
11
|
+
'accessTokenExpiresAt', 'refreshTokenExpiresAt', 'openId', 'scope', 'tokenType',
|
|
12
|
+
];
|
|
13
|
+
if (required.some((key) => typeof value[key] !== 'string' || !value[key]))
|
|
14
|
+
return null;
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveTikTokBusinessConfig(config) {
|
|
22
|
+
writeCredentialJson(TIKTOK_BUSINESS_CONFIG_PATH, config);
|
|
23
|
+
}
|
|
24
|
+
export function requireTikTokBusinessConfig() {
|
|
25
|
+
const config = loadTikTokBusinessConfig();
|
|
26
|
+
if (!config) {
|
|
27
|
+
throw new Error('TikTok Business 설정이 없습니다. 터미널에서 `mimi-seed auth tiktok`을 실행하세요.');
|
|
28
|
+
}
|
|
29
|
+
return config;
|
|
30
|
+
}
|
|
31
|
+
export function safeTikTokBusinessConfig(config) {
|
|
32
|
+
return {
|
|
33
|
+
openId: config.openId,
|
|
34
|
+
scope: config.scope,
|
|
35
|
+
tokenType: config.tokenType,
|
|
36
|
+
accessTokenExpiresAt: config.accessTokenExpiresAt,
|
|
37
|
+
refreshTokenExpiresAt: config.refreshTokenExpiresAt,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { TikTokPublishRequest, TikTokVideoSettings } from './types.js';
|
|
2
|
+
export interface PlanVideoPostInput {
|
|
3
|
+
sourceFilePath: string;
|
|
4
|
+
videoUrl: string;
|
|
5
|
+
customThumbnailUrl?: string;
|
|
6
|
+
caption?: string;
|
|
7
|
+
isBrandOrganic?: boolean;
|
|
8
|
+
isBrandedContent?: boolean;
|
|
9
|
+
disableComment?: boolean;
|
|
10
|
+
disableDuet?: boolean;
|
|
11
|
+
disableStitch?: boolean;
|
|
12
|
+
thumbnailOffsetMs?: number;
|
|
13
|
+
isAiGenerated?: boolean;
|
|
14
|
+
ffmpegPath?: string;
|
|
15
|
+
}
|
|
16
|
+
interface VideoFacts {
|
|
17
|
+
sizeBytes: number;
|
|
18
|
+
width: number;
|
|
19
|
+
height: number;
|
|
20
|
+
durationSec: number;
|
|
21
|
+
fps: number;
|
|
22
|
+
format?: string;
|
|
23
|
+
codec?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface TikTokPostPlan {
|
|
26
|
+
planId: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
expiresAt: string;
|
|
29
|
+
openId: string;
|
|
30
|
+
contentHash: string;
|
|
31
|
+
sourceFilePath: string;
|
|
32
|
+
videoFacts: VideoFacts;
|
|
33
|
+
request: TikTokPublishRequest;
|
|
34
|
+
settings: TikTokVideoSettings;
|
|
35
|
+
warnings: string[];
|
|
36
|
+
}
|
|
37
|
+
export type AuditState = 'pending' | 'published' | 'failed' | 'unknown';
|
|
38
|
+
export interface TikTokPublishAudit {
|
|
39
|
+
planId: string;
|
|
40
|
+
contentHash: string;
|
|
41
|
+
openId: string;
|
|
42
|
+
publishId?: string;
|
|
43
|
+
state: AuditState;
|
|
44
|
+
createdAt: string;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
caption?: string;
|
|
47
|
+
videoUrl: string;
|
|
48
|
+
requestId?: string;
|
|
49
|
+
providerStatus?: Record<string, unknown>;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
declare function safeUrl(value: string): string;
|
|
53
|
+
declare function countMentions(caption: string): number;
|
|
54
|
+
declare function assertCaption(caption: string): void;
|
|
55
|
+
declare function fpsValue(value: unknown): number;
|
|
56
|
+
declare function assertVideoFacts(facts: VideoFacts, settings: TikTokVideoSettings): void;
|
|
57
|
+
declare function assertThumbnailOffset(thumbnailOffsetMs: number | undefined, durationSec: number): void;
|
|
58
|
+
declare function acquirePublishLock(openId: string, contentHash: string, lockDir?: string): void;
|
|
59
|
+
declare function releasePublishLock(openId: string, contentHash: string, lockDir?: string): void;
|
|
60
|
+
export declare function planVideoPost(input: PlanVideoPostInput): Promise<TikTokPostPlan>;
|
|
61
|
+
export declare function loadPostPlan(planId: string): TikTokPostPlan;
|
|
62
|
+
export declare function publishPlannedVideo(planId: string, confirmPublish: boolean): Promise<{
|
|
63
|
+
publishId: string;
|
|
64
|
+
state: string;
|
|
65
|
+
planId: string;
|
|
66
|
+
audit: TikTokPublishAudit;
|
|
67
|
+
}>;
|
|
68
|
+
declare function statusState(status: Record<string, unknown>): AuditState | null;
|
|
69
|
+
declare function shouldReleasePublishLock(previous: AuditState, next: AuditState): boolean;
|
|
70
|
+
export declare function checkPublishStatus(publishId: string): Promise<{
|
|
71
|
+
publishId: string;
|
|
72
|
+
status: Record<string, unknown>;
|
|
73
|
+
audit: {
|
|
74
|
+
state: AuditState;
|
|
75
|
+
updatedAt: string;
|
|
76
|
+
providerStatus: Record<string, unknown>;
|
|
77
|
+
planId: string;
|
|
78
|
+
contentHash: string;
|
|
79
|
+
openId: string;
|
|
80
|
+
publishId?: string;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
caption?: string;
|
|
83
|
+
videoUrl: string;
|
|
84
|
+
requestId?: string;
|
|
85
|
+
error?: string;
|
|
86
|
+
};
|
|
87
|
+
} | {
|
|
88
|
+
publishId: string;
|
|
89
|
+
status: Record<string, unknown>;
|
|
90
|
+
audit: null;
|
|
91
|
+
}>;
|
|
92
|
+
export declare function listPublishAudits(limit?: number): TikTokPublishAudit[];
|
|
93
|
+
export declare const __testing: {
|
|
94
|
+
assertCaption: typeof assertCaption;
|
|
95
|
+
assertThumbnailOffset: typeof assertThumbnailOffset;
|
|
96
|
+
assertVideoFacts: typeof assertVideoFacts;
|
|
97
|
+
acquirePublishLock: typeof acquirePublishLock;
|
|
98
|
+
countMentions: typeof countMentions;
|
|
99
|
+
fpsValue: typeof fpsValue;
|
|
100
|
+
releasePublishLock: typeof releasePublishLock;
|
|
101
|
+
safeUrl: typeof safeUrl;
|
|
102
|
+
shouldReleasePublishLock: typeof shouldReleasePublishLock;
|
|
103
|
+
statusState: typeof statusState;
|
|
104
|
+
};
|
|
105
|
+
export {};
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readdirSync, statSync, unlinkSync, } from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { CREDENTIAL_DIR_MODE, CREDENTIAL_FILE_MODE, writeJsonAtomic } from '../lib/atomic-write.js';
|
|
6
|
+
import { validateVideo } from '../video/render.js';
|
|
7
|
+
import { TikTokApiError, getPublishStatus, getVideoSettings, publishVideo } from './api.js';
|
|
8
|
+
import { ensureTikTokPublishConfig } from './auth.js';
|
|
9
|
+
const STATE_DIR = path.join(os.homedir(), '.mimi-seed', 'tiktok-business');
|
|
10
|
+
const PLAN_DIR = path.join(STATE_DIR, 'plans');
|
|
11
|
+
const AUDIT_DIR = path.join(STATE_DIR, 'audit');
|
|
12
|
+
const LOCK_DIR = path.join(STATE_DIR, 'locks');
|
|
13
|
+
const PLAN_TTL_MS = 30 * 60 * 1000;
|
|
14
|
+
const MAX_FILE_BYTES = 1024 * 1024 * 1024;
|
|
15
|
+
function privateWrite(filePath, value) {
|
|
16
|
+
writeJsonAtomic(filePath, value, { mode: CREDENTIAL_FILE_MODE, dirMode: CREDENTIAL_DIR_MODE });
|
|
17
|
+
}
|
|
18
|
+
function assertHttpsUrl(value, field) {
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`${field}는 유효한 HTTPS URL이어야 합니다.`);
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== 'https:')
|
|
27
|
+
throw new Error(`${field}는 HTTPS URL이어야 합니다.`);
|
|
28
|
+
}
|
|
29
|
+
function safeUrl(value) {
|
|
30
|
+
const url = new URL(value);
|
|
31
|
+
return `${url.origin}${url.pathname}`;
|
|
32
|
+
}
|
|
33
|
+
function countMentions(caption) {
|
|
34
|
+
return caption.match(/(^|\s)@[\p{L}\p{N}._]+/gu)?.length ?? 0;
|
|
35
|
+
}
|
|
36
|
+
function assertCaption(caption) {
|
|
37
|
+
if (caption.length > 2_200)
|
|
38
|
+
throw new Error('TikTok caption은 UTF-16 기준 2,200자 이하여야 합니다.');
|
|
39
|
+
if (countMentions(caption) > 30)
|
|
40
|
+
throw new Error('TikTok caption의 멘션은 최대 30개입니다.');
|
|
41
|
+
}
|
|
42
|
+
function numberValue(value) {
|
|
43
|
+
const parsed = Number(value);
|
|
44
|
+
return Number.isFinite(parsed) ? parsed : NaN;
|
|
45
|
+
}
|
|
46
|
+
function fpsValue(value) {
|
|
47
|
+
if (typeof value !== 'string')
|
|
48
|
+
return numberValue(value);
|
|
49
|
+
const [numerator, denominator = '1'] = value.split('/');
|
|
50
|
+
const den = Number(denominator);
|
|
51
|
+
return den ? Number(numerator) / den : NaN;
|
|
52
|
+
}
|
|
53
|
+
function factsFromValidation(sourceFilePath, validation) {
|
|
54
|
+
const stream = validation.streams.find((item) => typeof item === 'object' && item !== null && item.codec_type === 'video');
|
|
55
|
+
const format = validation.format;
|
|
56
|
+
if (!stream)
|
|
57
|
+
throw new Error('업로드 파일에 비디오 스트림이 없습니다.');
|
|
58
|
+
const facts = {
|
|
59
|
+
sizeBytes: statSync(sourceFilePath).size,
|
|
60
|
+
width: numberValue(stream.width),
|
|
61
|
+
height: numberValue(stream.height),
|
|
62
|
+
durationSec: numberValue(format.duration),
|
|
63
|
+
fps: fpsValue(stream.r_frame_rate),
|
|
64
|
+
format: typeof format.format_name === 'string' ? format.format_name : undefined,
|
|
65
|
+
codec: typeof stream.codec_name === 'string' ? stream.codec_name : undefined,
|
|
66
|
+
};
|
|
67
|
+
if (Object.values(facts).slice(0, 5).some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
|
|
68
|
+
throw new Error('TikTok 규격 검사에 필요한 영상 메타데이터를 읽지 못했습니다.');
|
|
69
|
+
}
|
|
70
|
+
return facts;
|
|
71
|
+
}
|
|
72
|
+
function assertVideoFacts(facts, settings) {
|
|
73
|
+
if (facts.sizeBytes <= 0 || facts.sizeBytes > MAX_FILE_BYTES)
|
|
74
|
+
throw new Error('TikTok 영상은 0바이트 초과, 1GB 이하여야 합니다.');
|
|
75
|
+
if (facts.durationSec < 3 || facts.durationSec > 600)
|
|
76
|
+
throw new Error('TikTok 영상 길이는 3~600초여야 합니다.');
|
|
77
|
+
const accountMax = Number(settings.max_video_post_duration_sec);
|
|
78
|
+
if (Number.isFinite(accountMax) && facts.durationSec > accountMax) {
|
|
79
|
+
throw new Error(`영상 길이 ${facts.durationSec.toFixed(1)}초가 계정 한도 ${accountMax}초를 초과합니다.`);
|
|
80
|
+
}
|
|
81
|
+
if (facts.width < 360 || facts.height < 360)
|
|
82
|
+
throw new Error('TikTok 영상의 가로·세로는 각각 최소 360px이어야 합니다.');
|
|
83
|
+
if (facts.fps < 23 || facts.fps > 60)
|
|
84
|
+
throw new Error('TikTok 영상 FPS는 23~60이어야 합니다.');
|
|
85
|
+
}
|
|
86
|
+
function assertThumbnailOffset(thumbnailOffsetMs, durationSec) {
|
|
87
|
+
if (thumbnailOffsetMs !== undefined && thumbnailOffsetMs > durationSec * 1000) {
|
|
88
|
+
throw new Error('thumbnailOffsetMs는 영상 길이를 초과할 수 없습니다.');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function assertSourceFile(sourceFilePath) {
|
|
92
|
+
if (!path.isAbsolute(sourceFilePath) || !existsSync(sourceFilePath)) {
|
|
93
|
+
throw new Error('sourceFilePath는 존재하는 절대경로여야 합니다.');
|
|
94
|
+
}
|
|
95
|
+
const stat = statSync(sourceFilePath);
|
|
96
|
+
if (!stat.isFile())
|
|
97
|
+
throw new Error('sourceFilePath는 파일이어야 합니다.');
|
|
98
|
+
const ext = path.extname(sourceFilePath).toLowerCase();
|
|
99
|
+
if (!['.mp4', '.mov', '.webm'].includes(ext)) {
|
|
100
|
+
throw new Error('TikTok 지원 영상 형식은 .mp4, .mov, .webm입니다.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function sha256File(filePath) {
|
|
104
|
+
return await new Promise((resolve, reject) => {
|
|
105
|
+
const hash = createHash('sha256');
|
|
106
|
+
const stream = createReadStream(filePath);
|
|
107
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
108
|
+
stream.on('error', reject);
|
|
109
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
function planPath(planId) {
|
|
113
|
+
return path.join(PLAN_DIR, `${planId}.json`);
|
|
114
|
+
}
|
|
115
|
+
function auditKey(openId, contentHash) {
|
|
116
|
+
return createHash('sha256').update(`${openId}:${contentHash}`).digest('hex');
|
|
117
|
+
}
|
|
118
|
+
function auditPath(openId, contentHash) {
|
|
119
|
+
return path.join(AUDIT_DIR, `${auditKey(openId, contentHash)}.json`);
|
|
120
|
+
}
|
|
121
|
+
function publishLockPath(openId, contentHash, lockDir = LOCK_DIR) {
|
|
122
|
+
return path.join(lockDir, `${auditKey(openId, contentHash)}.lock`);
|
|
123
|
+
}
|
|
124
|
+
function acquirePublishLock(openId, contentHash, lockDir = LOCK_DIR) {
|
|
125
|
+
mkdirSync(lockDir, { recursive: true, mode: CREDENTIAL_DIR_MODE });
|
|
126
|
+
let descriptor;
|
|
127
|
+
try {
|
|
128
|
+
descriptor = openSync(publishLockPath(openId, contentHash, lockDir), 'wx', CREDENTIAL_FILE_MODE);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (error.code === 'EEXIST') {
|
|
132
|
+
throw new Error('동일 영상의 게시 예약이 이미 존재합니다. 감사 로그와 TikTok 상태를 먼저 확인하세요.', {
|
|
133
|
+
cause: error,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
closeSync(descriptor);
|
|
139
|
+
}
|
|
140
|
+
function releasePublishLock(openId, contentHash, lockDir = LOCK_DIR) {
|
|
141
|
+
try {
|
|
142
|
+
unlinkSync(publishLockPath(openId, contentHash, lockDir));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (error.code !== 'ENOENT')
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function readJson(filePath) {
|
|
150
|
+
try {
|
|
151
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function existingBlockingAudit(openId, contentHash) {
|
|
158
|
+
const audit = readJson(auditPath(openId, contentHash));
|
|
159
|
+
return audit && ['pending', 'published', 'unknown'].includes(audit.state) ? audit : null;
|
|
160
|
+
}
|
|
161
|
+
function postInfo(input) {
|
|
162
|
+
return {
|
|
163
|
+
caption: input.caption?.trim() || undefined,
|
|
164
|
+
is_brand_organic: input.isBrandOrganic ?? false,
|
|
165
|
+
is_branded_content: input.isBrandedContent ?? false,
|
|
166
|
+
disable_comment: input.disableComment ?? false,
|
|
167
|
+
disable_duet: input.disableDuet ?? false,
|
|
168
|
+
disable_stitch: input.disableStitch ?? false,
|
|
169
|
+
thumbnail_offset: input.thumbnailOffsetMs,
|
|
170
|
+
is_ai_generated: input.isAiGenerated ?? true,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export async function planVideoPost(input) {
|
|
174
|
+
assertSourceFile(input.sourceFilePath);
|
|
175
|
+
assertHttpsUrl(input.videoUrl, 'videoUrl');
|
|
176
|
+
if (input.customThumbnailUrl)
|
|
177
|
+
assertHttpsUrl(input.customThumbnailUrl, 'customThumbnailUrl');
|
|
178
|
+
assertCaption(input.caption ?? '');
|
|
179
|
+
const config = await ensureTikTokPublishConfig();
|
|
180
|
+
const settings = await getVideoSettings(config);
|
|
181
|
+
const validation = await validateVideo(input.sourceFilePath, input.ffmpegPath);
|
|
182
|
+
const facts = factsFromValidation(input.sourceFilePath, validation);
|
|
183
|
+
assertVideoFacts(facts, settings);
|
|
184
|
+
assertThumbnailOffset(input.thumbnailOffsetMs, facts.durationSec);
|
|
185
|
+
const contentHash = await sha256File(input.sourceFilePath);
|
|
186
|
+
const duplicate = existingBlockingAudit(config.openId, contentHash);
|
|
187
|
+
if (duplicate) {
|
|
188
|
+
throw new Error(`동일 영상의 기존 게시 기록이 있습니다 (state=${duplicate.state}, publishId=${duplicate.publishId ?? 'unknown'}). ` +
|
|
189
|
+
'중복 자동 게시를 차단했습니다.');
|
|
190
|
+
}
|
|
191
|
+
const request = {
|
|
192
|
+
business_id: config.openId,
|
|
193
|
+
video_url: input.videoUrl,
|
|
194
|
+
...(input.customThumbnailUrl ? { custom_thumbnail_url: input.customThumbnailUrl } : {}),
|
|
195
|
+
post_info: postInfo(input),
|
|
196
|
+
};
|
|
197
|
+
const planId = createHash('sha256')
|
|
198
|
+
.update(JSON.stringify({ openId: config.openId, contentHash, request }))
|
|
199
|
+
.digest('hex')
|
|
200
|
+
.slice(0, 32);
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const plan = {
|
|
203
|
+
planId,
|
|
204
|
+
createdAt: new Date(now).toISOString(),
|
|
205
|
+
expiresAt: new Date(now + PLAN_TTL_MS).toISOString(),
|
|
206
|
+
openId: config.openId,
|
|
207
|
+
contentHash,
|
|
208
|
+
sourceFilePath: input.sourceFilePath,
|
|
209
|
+
videoFacts: facts,
|
|
210
|
+
request,
|
|
211
|
+
settings,
|
|
212
|
+
warnings: [
|
|
213
|
+
'videoUrl은 TikTok API for Business 앱에 등록·검증된 도메인의 HTTPS URL이어야 합니다.',
|
|
214
|
+
'videoUrl은 리디렉션 없이 영상에 직접 2xx로 응답해야 합니다. TikTok은 3xx 리디렉션을 따라가지 않습니다.',
|
|
215
|
+
'URL은 TikTok이 가져갈 때까지 최소 30분 이상 유효해야 합니다.',
|
|
216
|
+
'이 계획은 공개 게시이며 30분 뒤 만료됩니다.',
|
|
217
|
+
],
|
|
218
|
+
};
|
|
219
|
+
privateWrite(planPath(planId), plan);
|
|
220
|
+
return plan;
|
|
221
|
+
}
|
|
222
|
+
export function loadPostPlan(planId) {
|
|
223
|
+
if (!/^[a-f0-9]{32}$/.test(planId))
|
|
224
|
+
throw new Error('planId 형식이 올바르지 않습니다.');
|
|
225
|
+
const plan = readJson(planPath(planId));
|
|
226
|
+
if (!plan)
|
|
227
|
+
throw new Error(`TikTok 게시 계획을 찾지 못했습니다: ${planId}`);
|
|
228
|
+
if (Date.parse(plan.expiresAt) <= Date.now())
|
|
229
|
+
throw new Error('TikTok 게시 계획이 만료되었습니다. 다시 계획을 생성하세요.');
|
|
230
|
+
return plan;
|
|
231
|
+
}
|
|
232
|
+
export async function publishPlannedVideo(planId, confirmPublish) {
|
|
233
|
+
if (!confirmPublish)
|
|
234
|
+
throw new Error('공개 게시에는 같은 턴의 명시 승인 후 confirmPublish=true가 필요합니다.');
|
|
235
|
+
const plan = loadPostPlan(planId);
|
|
236
|
+
const config = await ensureTikTokPublishConfig();
|
|
237
|
+
if (config.openId !== plan.openId)
|
|
238
|
+
throw new Error('게시 계획을 만든 TikTok 계정과 현재 연결 계정이 다릅니다.');
|
|
239
|
+
const duplicate = existingBlockingAudit(plan.openId, plan.contentHash);
|
|
240
|
+
if (duplicate)
|
|
241
|
+
throw new Error(`중복 게시 차단: 기존 기록 state=${duplicate.state}.`);
|
|
242
|
+
acquirePublishLock(plan.openId, plan.contentHash);
|
|
243
|
+
const now = new Date().toISOString();
|
|
244
|
+
const baseAudit = {
|
|
245
|
+
planId,
|
|
246
|
+
contentHash: plan.contentHash,
|
|
247
|
+
openId: plan.openId,
|
|
248
|
+
state: 'unknown',
|
|
249
|
+
createdAt: now,
|
|
250
|
+
updatedAt: now,
|
|
251
|
+
caption: plan.request.post_info.caption,
|
|
252
|
+
videoUrl: safeUrl(plan.request.video_url),
|
|
253
|
+
};
|
|
254
|
+
try {
|
|
255
|
+
privateWrite(auditPath(plan.openId, plan.contentHash), baseAudit);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
releasePublishLock(plan.openId, plan.contentHash);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
let result;
|
|
262
|
+
try {
|
|
263
|
+
result = await publishVideo(config, plan.request);
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
const knownFailure = error instanceof TikTokApiError && !error.outcomeUnknown;
|
|
267
|
+
const audit = {
|
|
268
|
+
...baseAudit,
|
|
269
|
+
state: knownFailure ? 'failed' : 'unknown',
|
|
270
|
+
updatedAt: new Date().toISOString(),
|
|
271
|
+
requestId: error instanceof TikTokApiError ? error.requestId : undefined,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
};
|
|
274
|
+
privateWrite(auditPath(plan.openId, plan.contentHash), audit);
|
|
275
|
+
if (knownFailure) {
|
|
276
|
+
releasePublishLock(plan.openId, plan.contentHash);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
throw new Error(`${audit.error} 게시 결과가 불명확하므로 자동 재시도하지 마세요.`, { cause: error });
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
const publishId = result.share_id ?? result.publish_id;
|
|
284
|
+
if (!publishId) {
|
|
285
|
+
const error = 'TikTok 성공 응답에 share_id/publish_id가 없어 게시 결과를 확인할 수 없습니다.';
|
|
286
|
+
privateWrite(auditPath(plan.openId, plan.contentHash), {
|
|
287
|
+
...baseAudit,
|
|
288
|
+
updatedAt: new Date().toISOString(),
|
|
289
|
+
error,
|
|
290
|
+
});
|
|
291
|
+
throw new TikTokApiError(`${error} 자동 재시도하지 마세요.`, undefined, undefined, true);
|
|
292
|
+
}
|
|
293
|
+
const audit = {
|
|
294
|
+
...baseAudit,
|
|
295
|
+
publishId,
|
|
296
|
+
state: 'pending',
|
|
297
|
+
updatedAt: new Date().toISOString(),
|
|
298
|
+
};
|
|
299
|
+
try {
|
|
300
|
+
privateWrite(auditPath(plan.openId, plan.contentHash), audit);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new Error('TikTok은 게시 요청을 수락했지만 로컬 감사 로그 갱신에 실패했습니다. 자동 재시도하지 마세요.', {
|
|
304
|
+
cause: error,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return { publishId, state: 'pending', planId, audit };
|
|
308
|
+
}
|
|
309
|
+
function findAuditByPublishId(publishId) {
|
|
310
|
+
try {
|
|
311
|
+
for (const name of readdirSync(AUDIT_DIR)) {
|
|
312
|
+
if (!name.endsWith('.json'))
|
|
313
|
+
continue;
|
|
314
|
+
const audit = readJson(path.join(AUDIT_DIR, name));
|
|
315
|
+
if (audit?.publishId === publishId)
|
|
316
|
+
return audit;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
function statusState(status) {
|
|
325
|
+
const value = status.status ?? status.publish_status ?? status.publishStatus;
|
|
326
|
+
const raw = typeof value === 'string' || typeof value === 'number'
|
|
327
|
+
? String(value).toUpperCase()
|
|
328
|
+
: '';
|
|
329
|
+
if (raw.includes('COMPLETE') || raw.includes('SUCCESS') || raw === 'PUBLISHED')
|
|
330
|
+
return 'published';
|
|
331
|
+
if (raw.includes('FAIL') || raw.includes('REJECT'))
|
|
332
|
+
return 'failed';
|
|
333
|
+
if (raw.includes('PROCESS') || raw.includes('PENDING') || raw.includes('PUBLISH'))
|
|
334
|
+
return 'pending';
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
function shouldReleasePublishLock(previous, next) {
|
|
338
|
+
return previous !== 'failed' && next === 'failed';
|
|
339
|
+
}
|
|
340
|
+
export async function checkPublishStatus(publishId) {
|
|
341
|
+
const config = await ensureTikTokPublishConfig();
|
|
342
|
+
const status = await getPublishStatus(config, publishId);
|
|
343
|
+
const audit = findAuditByPublishId(publishId);
|
|
344
|
+
if (audit) {
|
|
345
|
+
const updated = {
|
|
346
|
+
...audit,
|
|
347
|
+
state: statusState(status) ?? audit.state,
|
|
348
|
+
updatedAt: new Date().toISOString(),
|
|
349
|
+
providerStatus: status,
|
|
350
|
+
};
|
|
351
|
+
privateWrite(auditPath(audit.openId, audit.contentHash), updated);
|
|
352
|
+
if (shouldReleasePublishLock(audit.state, updated.state)) {
|
|
353
|
+
releasePublishLock(audit.openId, audit.contentHash);
|
|
354
|
+
}
|
|
355
|
+
return { publishId, status, audit: updated };
|
|
356
|
+
}
|
|
357
|
+
return { publishId, status, audit: null };
|
|
358
|
+
}
|
|
359
|
+
export function listPublishAudits(limit = 20) {
|
|
360
|
+
try {
|
|
361
|
+
return readdirSync(AUDIT_DIR)
|
|
362
|
+
.filter((name) => name.endsWith('.json'))
|
|
363
|
+
.map((name) => readJson(path.join(AUDIT_DIR, name)))
|
|
364
|
+
.filter((audit) => audit !== null)
|
|
365
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
|
366
|
+
.slice(0, limit);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
export const __testing = {
|
|
373
|
+
assertCaption,
|
|
374
|
+
assertThumbnailOffset,
|
|
375
|
+
assertVideoFacts,
|
|
376
|
+
acquirePublishLock,
|
|
377
|
+
countMentions,
|
|
378
|
+
fpsValue,
|
|
379
|
+
releasePublishLock,
|
|
380
|
+
safeUrl,
|
|
381
|
+
shouldReleasePublishLock,
|
|
382
|
+
statusState,
|
|
383
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import readline from 'node:readline';
|
|
3
|
+
import { exchangeAuthorizationCode, getBusinessAccount } from './api.js';
|
|
4
|
+
import { configFromToken, hasTikTokScope } from './auth.js';
|
|
5
|
+
import { loadTikTokBusinessConfig, saveTikTokBusinessConfig } from './config.js';
|
|
6
|
+
import { resolveLang } from '../lib/lang.js';
|
|
7
|
+
const ko = {
|
|
8
|
+
title: ' 🤖 Mimi Seed — TikTok Business 연결',
|
|
9
|
+
existing: (openId) => ` ✅ 이미 연결됨 (${openId})`,
|
|
10
|
+
reconnect: ' 다시 연결할래? (y/N): ',
|
|
11
|
+
intro: [
|
|
12
|
+
' TikTok for Business Developer 앱과 Organic API 승인이 먼저 필요합니다.',
|
|
13
|
+
' 앱에서 TikTok Accounts > Business Content > Video Publish 권한을 켜고,',
|
|
14
|
+
' TikTok account holder authorization URL로 대상 owned 계정을 승인하세요.',
|
|
15
|
+
' callback URL의 auth_code는 10분 동안 한 번만 사용할 수 있습니다.',
|
|
16
|
+
],
|
|
17
|
+
clientId: ' Client ID (App ID): ',
|
|
18
|
+
clientSecret: ' Client Secret: ',
|
|
19
|
+
redirectUri: ' Redirect URI (앱 설정값과 정확히 같아야 함): ',
|
|
20
|
+
authCode: ' callback URL의 auth_code: ',
|
|
21
|
+
exchanging: ' 🔎 authorization code 교환 및 계정 검증 중...',
|
|
22
|
+
saved: (openId) => ` ✅ 연결 완료 (${openId}) — ~/.mimi-seed/tiktok-business.json (0600)`,
|
|
23
|
+
cancelled: ' 취소됨.',
|
|
24
|
+
};
|
|
25
|
+
const en = {
|
|
26
|
+
title: ' 🤖 Mimi Seed — Connect TikTok Business',
|
|
27
|
+
existing: (openId) => ` ✅ Already connected (${openId})`,
|
|
28
|
+
reconnect: ' Connect again? (y/N): ',
|
|
29
|
+
intro: [
|
|
30
|
+
' You first need an approved TikTok for Business developer app with Organic API access.',
|
|
31
|
+
' Enable TikTok Accounts > Business Content > Video Publish, then authorize the owned',
|
|
32
|
+
' account with the TikTok account-holder authorization URL.',
|
|
33
|
+
' The auth_code in the callback URL is single-use and expires in 10 minutes.',
|
|
34
|
+
],
|
|
35
|
+
clientId: ' Client ID (App ID): ',
|
|
36
|
+
clientSecret: ' Client Secret: ',
|
|
37
|
+
redirectUri: ' Redirect URI (must exactly match the app setting): ',
|
|
38
|
+
authCode: ' auth_code from the callback URL: ',
|
|
39
|
+
exchanging: ' 🔎 Exchanging the authorization code and verifying the account...',
|
|
40
|
+
saved: (openId) => ` ✅ Connected (${openId}) — ~/.mimi-seed/tiktok-business.json (0600)`,
|
|
41
|
+
cancelled: ' Cancelled.',
|
|
42
|
+
};
|
|
43
|
+
const M = resolveLang() === 'en' ? en : ko;
|
|
44
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
const ask = (question) => new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim())));
|
|
46
|
+
async function main() {
|
|
47
|
+
console.log('');
|
|
48
|
+
console.log(M.title);
|
|
49
|
+
const existing = loadTikTokBusinessConfig();
|
|
50
|
+
if (existing) {
|
|
51
|
+
console.log(M.existing(existing.openId));
|
|
52
|
+
if ((await ask(M.reconnect)).toLowerCase() !== 'y') {
|
|
53
|
+
console.log(M.cancelled);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
console.log('');
|
|
58
|
+
for (const line of M.intro)
|
|
59
|
+
console.log(line);
|
|
60
|
+
console.log('');
|
|
61
|
+
const clientId = await ask(M.clientId);
|
|
62
|
+
const clientSecret = await ask(M.clientSecret);
|
|
63
|
+
const redirectUri = await ask(M.redirectUri);
|
|
64
|
+
const authCode = await ask(M.authCode);
|
|
65
|
+
if (![clientId, clientSecret, redirectUri, authCode].every(Boolean)) {
|
|
66
|
+
console.log(M.cancelled);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
console.log(M.exchanging);
|
|
70
|
+
const token = await exchangeAuthorizationCode({ clientId, clientSecret, redirectUri, authCode });
|
|
71
|
+
const config = configFromToken({ clientId, clientSecret, redirectUri }, token);
|
|
72
|
+
if (!hasTikTokScope(config, 'video.publish')) {
|
|
73
|
+
throw new Error('승인된 권한에 video.publish가 없습니다. 앱 권한을 확인한 뒤 다시 승인하세요.');
|
|
74
|
+
}
|
|
75
|
+
await getBusinessAccount(config);
|
|
76
|
+
saveTikTokBusinessConfig(config);
|
|
77
|
+
console.log(M.saved(config.openId));
|
|
78
|
+
}
|
|
79
|
+
main().catch((error) => {
|
|
80
|
+
console.error(`\n ❌ ${error instanceof Error ? error.message : String(error)}\n`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
}).finally(() => rl.close());
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface TikTokEnvelope<T> {
|
|
2
|
+
code: number;
|
|
3
|
+
message: string;
|
|
4
|
+
request_id?: string;
|
|
5
|
+
data?: T;
|
|
6
|
+
}
|
|
7
|
+
export interface TikTokTokenData {
|
|
8
|
+
access_token: string;
|
|
9
|
+
expires_in: number;
|
|
10
|
+
open_id: string;
|
|
11
|
+
refresh_token: string;
|
|
12
|
+
refresh_token_expires_in: number;
|
|
13
|
+
scope: string;
|
|
14
|
+
token_type: string;
|
|
15
|
+
}
|
|
16
|
+
export interface TikTokVideoSettings {
|
|
17
|
+
privacy_level_options?: string[];
|
|
18
|
+
comment_disabled?: boolean;
|
|
19
|
+
duet_disabled?: boolean;
|
|
20
|
+
stitch_disabled?: boolean;
|
|
21
|
+
max_video_post_duration_sec?: number;
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export interface TikTokPostInfo {
|
|
25
|
+
caption?: string;
|
|
26
|
+
is_brand_organic?: boolean;
|
|
27
|
+
is_branded_content?: boolean;
|
|
28
|
+
disable_comment?: boolean;
|
|
29
|
+
disable_duet?: boolean;
|
|
30
|
+
disable_stitch?: boolean;
|
|
31
|
+
thumbnail_offset?: number;
|
|
32
|
+
is_ai_generated?: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface TikTokPublishRequest {
|
|
35
|
+
business_id: string;
|
|
36
|
+
video_url: string;
|
|
37
|
+
custom_thumbnail_url?: string;
|
|
38
|
+
post_info: TikTokPostInfo;
|
|
39
|
+
}
|
|
40
|
+
export interface TikTokPublishData {
|
|
41
|
+
share_id?: string;
|
|
42
|
+
publish_id?: string;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yoonion/mimi-seed-mcp",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
4
4
|
"description": "Mimi Seed MCP server \u2014 Firebase + AdMob + Google Play + App Store management for Claude Code / Codex / Cursor / any MCP client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"mimi-seed-jenkins-auth": "dist/jenkins/setup-cli.js",
|
|
13
13
|
"mimi-seed-googleads-auth": "dist/googleads/setup-cli.js",
|
|
14
14
|
"mimi-seed-social-auth": "dist/social/setup-cli.js",
|
|
15
|
+
"mimi-seed-tiktok-business-auth": "dist/tiktok-business/setup-cli.js",
|
|
15
16
|
"mimi-seed-firebase": "dist/firebase/cli.js",
|
|
16
17
|
"mimi-seed-admob": "dist/admob/cli.js",
|
|
17
18
|
"mimi-seed-ga4": "dist/ga4/cli.js"
|
package/tool-manifest.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "등록된 MCP 도구 + 도메인 메타데이터(label·credential·summary)의 SSOT. src/__tests__/tool-manifest.test.ts 가 실제 서버 등록 목록과 diff 하고 메타데이터 정합성을 검사한다. 도구 추가/삭제/개명 시 이 파일을 함께 갱신할 것 — 산문 문서에는 정확한 개수를 쓰지 말고 이 파일을 가리킬 것. mimi-seed://tools/catalog 리소스가 이 파일을 그대로 서빙한다.",
|
|
3
|
-
"total":
|
|
3
|
+
"total": 222,
|
|
4
4
|
"domains": {
|
|
5
5
|
"admob": {
|
|
6
6
|
"label": "AdMob",
|
|
@@ -318,16 +318,31 @@
|
|
|
318
318
|
"threads": {
|
|
319
319
|
"label": "Threads",
|
|
320
320
|
"credential": "Threads 토큰 (mimi-seed auth threads)",
|
|
321
|
-
"summary": "
|
|
321
|
+
"summary": "텍스트/이미지/영상 포스팅·토큰 갱신",
|
|
322
322
|
"tools": [
|
|
323
323
|
"threads_save_config",
|
|
324
324
|
"threads_refresh_token",
|
|
325
325
|
"threads_get_account",
|
|
326
326
|
"threads_post",
|
|
327
|
+
"threads_post_video",
|
|
327
328
|
"threads_post_carousel",
|
|
328
329
|
"threads_current_config"
|
|
329
330
|
]
|
|
330
331
|
},
|
|
332
|
+
"tiktok": {
|
|
333
|
+
"label": "TikTok Business",
|
|
334
|
+
"credential": "TikTok API for Business OAuth (mimi-seed auth tiktok)",
|
|
335
|
+
"summary": "Organic API 계정 진단·게시 전 검증·공개 영상 게시·상태·중복 방지 감사 로그",
|
|
336
|
+
"tools": [
|
|
337
|
+
"tiktok_business_auth_status",
|
|
338
|
+
"tiktok_business_get_account",
|
|
339
|
+
"tiktok_business_get_video_settings",
|
|
340
|
+
"tiktok_business_plan_video_post",
|
|
341
|
+
"tiktok_business_publish_video",
|
|
342
|
+
"tiktok_business_get_publish_status",
|
|
343
|
+
"tiktok_business_list_publish_audits"
|
|
344
|
+
]
|
|
345
|
+
},
|
|
331
346
|
"video": {
|
|
332
347
|
"label": "영상 제작",
|
|
333
348
|
"credential": "기능별 API 키 + Google OAuth(YouTube 게시) + FFmpeg",
|