From 6c7e56769e6fd0401ca5977dcce754690ea0f3d0 Mon Sep 17 00:00:00 2001 From: Garfield Date: Fri, 8 May 2026 11:38:32 -0400 Subject: [PATCH] feat: TikTok and Snapchat integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TikTok: getUserProfile, getUserVideos, createVideo (PULL_FROM_URL), getVideoStatus via Content Posting API v2. Full multi-tenant credential isolation and audit logging on write operations. Snapchat: getMe (Login Kit), getAdAccounts (Marketing API). createSnap throws with a clear explanation that Creative Kit is mobile-only — no server-side posting API exists. Platform type, validPlatforms list, and /api/connections endpoint all updated to include tiktok and snapchat. Architecture diagram updated. Co-Authored-By: Claude Sonnet 4.6 --- product/incubation/architecture-system.puml | 10 ++ src/clients/snapchat.ts | 83 ++++++++++ src/clients/tiktok.ts | 175 ++++++++++++++++++++ src/index.ts | 4 +- src/multitenancy/credential-store.ts | 2 +- src/tools.ts | 145 ++++++++++++++++ 6 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 src/clients/snapchat.ts create mode 100644 src/clients/tiktok.ts diff --git a/product/incubation/architecture-system.puml b/product/incubation/architecture-system.puml index 079d6be..44456b8 100644 --- a/product/incubation/architecture-system.puml +++ b/product/incubation/architecture-system.puml @@ -72,6 +72,8 @@ package "hermes-mcp | Node.js / TypeScript | hermes.squaremcp.com" { [discord.ts] as c_dc [instagram.ts] as c_ig [twitter.ts] as c_tw + [tiktok.ts] as c_tt + [snapchat.ts] as c_sc [obsidian.ts] as c_ob } @@ -109,6 +111,8 @@ cloud "External Platform APIs" { [Telegram Bot API] as tg_api [Discord API v10] as dc_api [Twitter API v2] as tw_api + [TikTok Content\nPosting API v2] as tt_api + [Snapchat\nMarketing API] as sc_api [Obsidian Vault\nfilesystem] as ob_vault } @@ -134,6 +138,8 @@ dispatch --> c_tg dispatch --> c_dc dispatch --> c_ig dispatch --> c_tw +dispatch --> c_tt +dispatch --> c_sc dispatch --> c_ob c_wa --> cred_store : getCredential @@ -142,6 +148,8 @@ c_tg --> cred_store : getCredential c_dc --> cred_store : getCredential c_ig --> cred_store : getCredential c_tw --> cred_store : getCredential +c_tt --> cred_store : getCredential +c_sc --> cred_store : getCredential cred_store --> redis wh_router --> redis @@ -153,6 +161,8 @@ c_li --> li_api c_tg --> tg_api c_dc --> dc_api c_tw --> tw_api +c_tt --> tt_api +c_sc --> sc_api c_ob --> ob_vault oauth --> mysql diff --git a/src/clients/snapchat.ts b/src/clients/snapchat.ts new file mode 100644 index 0000000..3eedf76 --- /dev/null +++ b/src/clients/snapchat.ts @@ -0,0 +1,83 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; + +const SNAPCHAT_API_BASE = 'https://api.snapchat.com/v1'; + +function getEnvToken(account: string): string { + const envKey = `SNAPCHAT_${account.toUpperCase()}_ACCESS_TOKEN`; + return process.env[envKey] ?? ''; +} + +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('snapchat'); + if (!creds) throw new Error('Snapchat not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing Snapchat credentials. Set SNAPCHAT_{ACCOUNT}_ACCESS_TOKEN'); + return token; +} + +async function snapchatRequest(endpoint: string, accessToken: string) { + const res = await fetch(`${SNAPCHAT_API_BASE}${endpoint}`, { + headers: { 'Authorization': `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(15000), + }); + + if (!res.ok) { + const error = await res.text(); + throw new Error(`Snapchat API error (${res.status}): ${error}`); + } + + return res.json(); +} + +export async function getMe( + args: { account?: string }, + customer?: Customer +): Promise<{ + id: string; + display_name: string; + bitmoji_avatar_url?: string; +}> { + const accessToken = await resolveToken(args, customer); + const data = await snapchatRequest('/me', accessToken); + const me = data.data?.me ?? data; + + return { + id: me.externalId ?? me.id ?? '', + display_name: me.displayName ?? me.display_name ?? '', + bitmoji_avatar_url: me.bitmoji?.avatarUrl ?? me.bitmoji_avatar_url, + }; +} + +// Snapchat Creative Kit is a mobile-only SDK — there is no server-side +// endpoint for publishing Snaps or Stories. This stub documents that +// constraint rather than silently doing nothing. +export async function createSnap( + _args: { image_url?: string; video_url?: string; caption?: string; account?: string }, + _customer?: Customer +): Promise { + throw new Error( + 'Snapchat Creative Kit is a mobile-only SDK — it cannot post Snaps from a server. ' + + 'To share content to Snapchat, integrate the Creative Kit iOS/Android SDK in a mobile app. ' + + 'See https://kit.snapchat.com/creative-kit for docs.' + ); +} + +export async function getAdAccounts( + args: { account?: string }, + customer?: Customer +): Promise> { + // Snapchat Marketing API — available for ad account management + const accessToken = await resolveToken(args, customer); + const data = await snapchatRequest('/me/adaccounts', accessToken); + + return (data.adaccounts ?? []).map((a: Record) => ({ + id: String((a.adaccount as Record)?.id ?? a.id ?? ''), + name: String((a.adaccount as Record)?.name ?? a.name ?? ''), + status: String((a.adaccount as Record)?.status ?? a.status ?? ''), + currency: String((a.adaccount as Record)?.currency_code ?? a.currency ?? ''), + })); +} diff --git a/src/clients/tiktok.ts b/src/clients/tiktok.ts new file mode 100644 index 0000000..a61a29f --- /dev/null +++ b/src/clients/tiktok.ts @@ -0,0 +1,175 @@ +import type { Customer } from '../billing/middleware.js'; +import type { OAuthCredentials } from '../multitenancy/credential-store.js'; +import { createToolAudit } from '../multitenancy/audit-log.js'; + +const TIKTOK_API_BASE = 'https://open.tiktokapis.com/v2'; + +function getEnvToken(account: string): string { + const envKey = `TIKTOK_${account.toUpperCase()}_ACCESS_TOKEN`; + return process.env[envKey] ?? ''; +} + +async function resolveToken(args: { account?: string }, customer?: Customer): Promise { + if (customer) { + const creds = await customer.getCredential('tiktok'); + if (!creds) throw new Error('TikTok not connected for this account'); + return creds.accessToken; + } + const token = getEnvToken(args.account ?? 'default'); + if (!token) throw new Error('Missing TikTok credentials. Set TIKTOK_{ACCOUNT}_ACCESS_TOKEN'); + return token; +} + +async function tiktokRequest( + endpoint: string, + accessToken: string, + method: 'GET' | 'POST' = 'GET', + body?: unknown, + params?: Record +) { + const url = new URL(`${TIKTOK_API_BASE}${endpoint}`); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + + const res = await fetch(url.toString(), { + method, + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(15000), + }); + + const data = await res.json(); + if (data.error?.code && data.error.code !== 'ok') { + throw new Error(`TikTok API error: ${data.error.message ?? data.error.code}`); + } + return data.data ?? data; +} + +export async function getUserProfile( + args: { account?: string }, + customer?: Customer +): Promise<{ + open_id: string; + display_name: string; + avatar_url?: string; + bio?: string; + is_verified: boolean; + follower_count: number; + following_count: number; + likes_count: number; + video_count: number; +}> { + const accessToken = await resolveToken(args, customer); + + const fields = 'open_id,union_id,avatar_url,display_name,bio_description,is_verified,follower_count,following_count,likes_count,video_count'; + const data = await tiktokRequest('/user/info/', accessToken, 'GET', undefined, { fields }); + const u = data.user ?? data; + + return { + open_id: u.open_id ?? '', + display_name: u.display_name ?? '', + avatar_url: u.avatar_url, + bio: u.bio_description, + is_verified: u.is_verified ?? false, + follower_count: u.follower_count ?? 0, + following_count: u.following_count ?? 0, + likes_count: u.likes_count ?? 0, + video_count: u.video_count ?? 0, + }; +} + +export async function getUserVideos( + args: { max_count?: number; account?: string }, + customer?: Customer +): Promise> { + const accessToken = await resolveToken(args, customer); + + const fields = 'id,title,video_description,duration,cover_image_url,share_url,view_count,like_count,comment_count,share_count,create_time'; + const data = await tiktokRequest('/video/list/', accessToken, 'POST', { + max_count: Math.min(args.max_count ?? 10, 20), + fields: fields.split(','), + }); + + return (data.videos ?? []).map((v: Record) => ({ + id: String(v.id ?? ''), + title: v.title as string | undefined, + video_description: v.video_description as string | undefined, + duration: Number(v.duration ?? 0), + cover_image_url: v.cover_image_url as string | undefined, + share_url: v.share_url as string | undefined, + view_count: Number(v.view_count ?? 0), + like_count: Number(v.like_count ?? 0), + comment_count: Number(v.comment_count ?? 0), + share_count: Number(v.share_count ?? 0), + create_time: Number(v.create_time ?? 0), + })); +} + +export async function createVideo( + args: { video_url: string; title?: string; description?: string; account?: string }, + customer?: Customer +): Promise<{ publish_id: string; status: string }> { + const audit = customer ? createToolAudit(customer.id, 'tiktok:createVideo') : null; + const auditArgs = { title: args.title }; + const accessToken = await resolveToken(args, customer); + + // Step 1: initialise upload + const init = await tiktokRequest('/post/video/init/', accessToken, 'POST', { + post_info: { + title: args.title ?? '', + description: args.description ?? '', + disable_duet: false, + disable_comment: false, + disable_stitch: false, + privacy_level: 'PUBLIC_TO_EVERYONE', + }, + source_info: { + source: 'PULL_FROM_URL', + video_url: args.video_url, + }, + }); + + const publishId = init.publish_id ?? ''; + + try { + const result = { publish_id: publishId, status: 'processing' }; + if (audit) await audit.success(auditArgs); + return result; + } catch (err) { + if (audit) await audit.error(auditArgs, String(err)); + throw err; + } +} + +export async function getVideoStatus( + args: { publish_id: string; account?: string }, + customer?: Customer +): Promise<{ publish_id: string; status: string; fail_reason?: string }> { + const accessToken = await resolveToken(args, customer); + + const data = await tiktokRequest('/post/publish/status/fetch/', accessToken, 'POST', { + publish_id: args.publish_id, + }); + + return { + publish_id: args.publish_id, + status: String(data.status ?? 'unknown'), + fail_reason: data.fail_reason as string | undefined, + }; +} diff --git a/src/index.ts b/src/index.ts index 746d14d..54a9d9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -663,7 +663,7 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => { const platform = req.params.platform as Platform; const { accessToken, refreshToken, expiresAt, scope } = req.body as Record; - const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter']; + const validPlatforms: Platform[] = ['linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat']; if (!validPlatforms.includes(platform)) { res.status(400).json({ error: 'unknown_platform' }); return; @@ -687,7 +687,7 @@ app.post('/api/connect/:platform', meterMiddleware, async (req, res) => { // Get connection status for a customer app.get('/api/connections', meterMiddleware, async (req, res) => { const customer = (req as unknown as { customer: Customer }).customer; - const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'obsidian']; + const platforms: Platform[] = ['email', 'whatsapp', 'linkedin', 'telegram', 'discord', 'instagram', 'twitter', 'tiktok', 'snapchat', 'obsidian']; const status: Record = {}; for (const platform of platforms) { diff --git a/src/multitenancy/credential-store.ts b/src/multitenancy/credential-store.ts index 3392b1a..25d2d86 100644 --- a/src/multitenancy/credential-store.ts +++ b/src/multitenancy/credential-store.ts @@ -26,7 +26,7 @@ function decrypt(ciphertext: string): string { return decipher.update(encrypted) + decipher.final('utf8'); } -export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'obsidian'; +export type Platform = 'email' | 'whatsapp' | 'linkedin' | 'telegram' | 'discord' | 'instagram' | 'twitter' | 'tiktok' | 'snapchat' | 'obsidian'; export interface EmailCredentials { host: string; diff --git a/src/tools.ts b/src/tools.ts index 437e82b..7d9759e 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -9,6 +9,8 @@ import { getMe as getTelegramMe, sendMessage as sendTelegramMessage, sendPhoto a import { getMe as getDiscordMe, getGuilds, getChannels, sendMessage as sendDiscordMessage, getMessages as getDiscordMessages } from './clients/discord.js'; import { getProfile as getInstagramProfile, getMedia as getInstagramMedia, createPost as createInstagramPost } from './clients/instagram.js'; import { searchTweets, getUserProfile, getUserTweets, createTweet } from './clients/twitter.js'; +import { getUserProfile as getTikTokProfile, getUserVideos, createVideo, getVideoStatus } from './clients/tiktok.js'; +import { getMe as getSnapchatMe, createSnap, getAdAccounts } from './clients/snapchat.js'; const ACCOUNT_PARAM = { account: { @@ -517,6 +519,97 @@ export const tools: Tool[] = [ }, }, }, + + // ── TikTok tools ───────────────────────────────────────────── + { + name: 'tiktok_get_profile', + description: + 'Get the TikTok user profile including follower count, following count, likes, and video count.', + inputSchema: { + type: 'object', + properties: { + account: { type: 'string', description: 'Which TikTok account to use (default: "default")' }, + }, + }, + }, + { + name: 'tiktok_get_videos', + description: + 'List recent videos from the authenticated TikTok account with view, like, comment, and share counts.', + inputSchema: { + type: 'object', + properties: { + max_count: { type: 'number', description: 'Max videos to return (default: 10, max: 20)' }, + account: { type: 'string', description: 'Which TikTok account to use (default: "default")' }, + }, + }, + }, + { + name: 'tiktok_create_video', + description: + 'Post a video to TikTok by providing a publicly accessible video URL. Returns a publish_id to check status.', + inputSchema: { + type: 'object', + required: ['video_url'], + properties: { + video_url: { type: 'string', description: 'Publicly accessible URL of the video to post' }, + title: { type: 'string', description: 'Video title (max 150 chars)' }, + description: { type: 'string', description: 'Video description / caption' }, + account: { type: 'string', description: 'Which TikTok account to use (default: "default")' }, + }, + }, + }, + { + name: 'tiktok_get_video_status', + description: + 'Check the processing status of a TikTok video upload using the publish_id returned by tiktok_create_video.', + inputSchema: { + type: 'object', + required: ['publish_id'], + properties: { + publish_id: { type: 'string', description: 'Publish ID returned by tiktok_create_video' }, + account: { type: 'string', description: 'Which TikTok account to use (default: "default")' }, + }, + }, + }, + + // ── Snapchat tools ─────────────────────────────────────────── + { + name: 'snapchat_get_me', + description: + 'Get the authenticated Snapchat user profile (display name, bitmoji avatar). Uses Snapchat Login Kit.', + inputSchema: { + type: 'object', + properties: { + account: { type: 'string', description: 'Which Snapchat account to use (default: "default")' }, + }, + }, + }, + { + name: 'snapchat_create_snap', + description: + '[NOT SUPPORTED] Snapchat Creative Kit is a mobile-only SDK — posting Snaps from a server is not possible. Use the iOS/Android Creative Kit SDK instead.', + inputSchema: { + type: 'object', + properties: { + image_url: { type: 'string', description: 'Image URL (not usable server-side)' }, + video_url: { type: 'string', description: 'Video URL (not usable server-side)' }, + caption: { type: 'string', description: 'Caption text (not usable server-side)' }, + account: { type: 'string', description: 'Which Snapchat account to use (default: "default")' }, + }, + }, + }, + { + name: 'snapchat_get_ad_accounts', + description: + 'List Snapchat Ads Manager ad accounts. Use when the user wants to manage Snapchat advertising campaigns.', + inputSchema: { + type: 'object', + properties: { + account: { type: 'string', description: 'Which Snapchat account to use (default: "default")' }, + }, + }, + }, ]; function acct(args: Record): Account { @@ -779,6 +872,58 @@ export async function handleToolCall( }, customer); break; + // ── TikTok ───────────────────────────────────────────────── + case 'tiktok_get_profile': + result = await getTikTokProfile({ + account: args.account as string | undefined, + }, customer); + break; + + case 'tiktok_get_videos': + result = await getUserVideos({ + max_count: (args.max_count as number) ?? 10, + account: args.account as string | undefined, + }, customer); + break; + + case 'tiktok_create_video': + result = await createVideo({ + video_url: args.video_url as string, + title: args.title as string | undefined, + description: args.description as string | undefined, + account: args.account as string | undefined, + }, customer); + break; + + case 'tiktok_get_video_status': + result = await getVideoStatus({ + publish_id: args.publish_id as string, + account: args.account as string | undefined, + }, customer); + break; + + // ── Snapchat ──────────────────────────────────────────────── + case 'snapchat_get_me': + result = await getSnapchatMe({ + account: args.account as string | undefined, + }, customer); + break; + + case 'snapchat_create_snap': + result = await createSnap({ + image_url: args.image_url as string | undefined, + video_url: args.video_url as string | undefined, + caption: args.caption as string | undefined, + account: args.account as string | undefined, + }, customer); + break; + + case 'snapchat_get_ad_accounts': + result = await getAdAccounts({ + account: args.account as string | undefined, + }, customer); + break; + // Legacy Yahoo-prefixed names — keep working for any cached Claude sessions case 'yahoo_get_profile': result = await getProfile('yahoo');