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

View File

@@ -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

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,
};
}

View File

@@ -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<string, string>;
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<string, boolean> = {};
for (const platform of platforms) {

View File

@@ -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;

View File

@@ -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<string, unknown>): 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');