feat: TikTok and Snapchat integrations

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 <noreply@anthropic.com>
This commit is contained in:
Garfield
2026-05-08 11:38:32 -04:00
parent 7ada43a1d7
commit 6c7e56769e
6 changed files with 416 additions and 3 deletions

83
src/clients/snapchat.ts Normal file
View File

@@ -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<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('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<never> {
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<Array<{ id: string; name: string; status: string; currency: string }>> {
// 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<string, unknown>) => ({
id: String((a.adaccount as Record<string, unknown>)?.id ?? a.id ?? ''),
name: String((a.adaccount as Record<string, unknown>)?.name ?? a.name ?? ''),
status: String((a.adaccount as Record<string, unknown>)?.status ?? a.status ?? ''),
currency: String((a.adaccount as Record<string, unknown>)?.currency_code ?? a.currency ?? ''),
}));
}

175
src/clients/tiktok.ts Normal file
View File

@@ -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<string> {
if (customer) {
const creds = await customer.getCredential<OAuthCredentials>('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<string, string>
) {
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<Array<{
id: string;
title?: string;
video_description?: string;
duration: number;
cover_image_url?: string;
share_url?: string;
view_count: number;
like_count: number;
comment_count: number;
share_count: number;
create_time: number;
}>> {
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<string, unknown>) => ({
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,
};
}