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:
@@ -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
83
src/clients/snapchat.ts
Normal 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
175
src/clients/tiktok.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
145
src/tools.ts
145
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<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');
|
||||
|
||||
Reference in New Issue
Block a user