Build a Slack bot for influencer marketing

A /creators slash command that turns a brief into a ranked shortlist in the channel, with the reason each creator fits, free paging and a button to find more like them.

By Elliot Padfield · October 14, 2026

Time
45 minutes
Level
Intermediate
Stack
Node.js 22, TypeScript, Slack Bolt, Influship SDK
Cost
About $0.65 per search; paging is free

You end with

A Slack command anyone on your team can use to find creators from a brief, with reasons, quotes, lookalikes and a spend cap.

The ranked CSV from guide 01 landed well at Fernway. Too well. Now Maya in marketing messages you twice a day: can you find trail runners in Colorado, anyone who does gear reviews under 50K followers, someone like the creator we signed last month. Every question goes through you, and every answer takes you away from the work you were hired to do.

So give Maya the tool. In this guide you build /creators, a Slack command that takes a brief in plain English and posts a ranked shortlist into the channel, with the reason each creator fits and a button to find more like them. Anyone on the team can use it, and you never see the request.

# creator-scouting
M

Maya 10:42

/creators trail runners who post race recaps and gear reviews, 10k-200k followers

CF

Creator FinderApp10:42

Creators for “trail runners who post race recaps and gear reviews”

  • Ty M. @switchback.ty

    18.3K followers · 5.2% engagement · fit 0.78

    Shares ultrarace accounts and discusses trail gear, though the gear post is a positive account rather than a clearly evaluative review.

    Similar
  • Erik L. @fjell.racer

    35.0K followers · 4.7% engagement · fit 0.75

    An accomplished trail runner who posts about a race win, but the supplied sources do not show gear reviews.

    Similar
  • Agi C. @kowhai.trails

    29.3K followers · 2.6% engagement · fit 0.70

    Shares trail-running and training content plus a personal marathon recap, but the supplied sources do not establish gear reviews.

    Similar
  • Kim R. @milesandmud

    11.3K followers · 0.9% engagement · fit 0.70

    Provides a detailed 50-mile race recap with trail sections, but no gear review appears in the supplied evidence.

    “Going into JFK, I hadn’t done any training runs on the AT and didn’t get a course preview in.”

    Similar

Showing 1–5 of 20 · search cost 65 credits · asked by @maya

Next 5
Rendered from a real search for this brief. Handles and names are changed; reasons and quotes are as the API returned them. The first four of five results on the page are shown.

Read the reasons closely. Each one says what matches and what is missing: Kim writes detailed race recaps, but there is no gear review in her posts. That is what lets Maya trust the list. She can see which part of the brief each creator covers before she sends a single DM, and the quote shows her the post it came from.

How it works

The bot is about 400 lines of TypeScript across three files, comments included. A brief costs one paid search that returns 20 ranked creators. Influship stores the search, so paging through it is free, and lookalikes cost a few cents more when someone asks for them:

  1. 1MayaTypes /creators trail runners who review shoes
  2. 2BotAcknowledges within Slack’s 3-second window: "Searching…"free
  3. 3InflushipRanks 20 creators against the brief and stores the search65 cr
  4. 4BotPosts the first 5 into the channel with reasons and quotesfree
  5. 5MayaClicks Next 5: the bot reads the stored searchfree
  6. 6MayaClicks Similar on a creator: 5 lookalikes and why each matches7.5 cr

At those prices a team running 20 searches a day spends about $13. A daily credit cap per workspace, built in step 6, makes sure an enthusiastic Friday afternoon cannot turn into a surprise bill.

Your first call

Everything the bot does starts with one request. Try it from a script before touching Slack. New Influship accounts get 500 starter credits with no card; this call costs 35 (25 per search plus 2 per creator returned).

first-call.ts
import Influship from 'influship';

const client = new Influship(); // reads INFLUSHIP_API_KEY

const search = await client.search.create({
  query: 'trail runners who post race recaps and gear reviews',
  filters: { followers: { max: 200_000 } },
  limit: 5,
});

for (const { primary_profile, match } of search.data) {
  console.log(primary_profile?.username, match.score, match.reasons[0]);
}
TypeScript SDK setup
real output, names and handles changed
$ npx tsx first-call.tsswitchback.ty 0.78 Shares ultrarace accounts and discusses trail gear, though the gear…fjell.racer 0.75 Accomplished trail runner who posts about a race win, but the supplied…kowhai.trails 0.7 Shares trail-running and training content plus a personal marathon…milesandmud 0.7 Provides a detailed 50-mile race recap with trail sections, but no gear…hochtal.runs 0.67 A trail runner who shares race accounts, but no gear reviews are…

The bot is this call, plus the plumbing to put its answer where your team already works.

Create the Slack app from a manifest

A manifest describes the whole app in one file: the slash command, the button interactivity, and Socket Mode. Socket Mode opens an outbound websocket from your bot to Slack, so you can run it on your laptop without a public URL or a tunnel.

manifest.yml
display_information:
  name: Creator Finder
  description: Find Instagram creators from a brief, powered by the Influship API
features:
  bot_user:
    display_name: Creator Finder
    always_online: true
  slash_commands:
    - command: /creators
      description: Find creators that fit a brief
      usage_hint: trail runners who review shoes, 10k-100k followers
      should_escape: false
oauth_config:
  scopes:
    bot:
      - commands
settings:
  interactivity:
    is_enabled: true
  socket_mode_enabled: true
  org_deploy_enabled: false
  token_rotation_enabled: false
  1. Go to api.slack.com/apps, choose Create New App, then From a manifest, pick your workspace and paste the file above.
  2. Under Basic Information, create an App-Level Token with the connections:write scope. It starts with xapp-.
  3. Under Install App, install it to your workspace and copy the Bot User OAuth Token. It starts with xoxb-.

The only bot scope is commands. The bot never reads messages, and it replies through the response URL Slack attaches to each command and button click, so nobody has to invite it to a channel.

Set up the project

bash
mkdir creator-finder && cd creator-finder
pnpm init
pnpm add @slack/bolt influship
pnpm add -D tsx typescript @types/node

Copy env.example to .env and fill in the three tokens:

.env
# Influship: developers.influship.com > API Keys
INFLUSHIP_API_KEY=

# Slack: OAuth & Permissions > Bot User OAuth Token (starts with xoxb-)
SLACK_BOT_TOKEN=

# Slack: Basic Information > App-Level Tokens, scope connections:write (starts with xapp-)
SLACK_APP_TOKEN=

# Credits the bot may spend per workspace per UTC day (1 credit = $0.01)
DAILY_CREDIT_LIMIT=500

Add "type": "module" and the scripts from the sample package.json. The project ends up with three source files and one test:

src/influship.ts# API calls, paging, the credit cap
src/blocks.ts# Slack messages, built as plain data
src/app.ts# the slash command and buttons
spec/bot.spec.ts# runs without Slack

Search once, page for free

src/influship.ts owns every call to the API. It starts with the client, the page size and the published prices it uses to check the budget:

src/influship.ts
import Influship from 'influship';
import type { CreatorLookalikeResponse } from 'influship/resources/creators';
import type { SearchRetrieveResponse } from 'influship/resources/search';

// Reads INFLUSHIP_API_KEY. A ranked search can take a while, so allow up to 90 seconds.
const client = new Influship({ timeout: 90_000 });

export const PAGE_SIZE = 5;
const SEARCH_LIMIT = 20;

// Published prices in credits (1 credit = $0.01). Used to check the budget before a call.
export const SEARCH_COST = 25 + 2 * SEARCH_LIMIT;
export const SIMILAR_COST = 1.5 * PAGE_SIZE;

export type Creator = SearchRetrieveResponse;
export type Lookalike = CreatorLookalikeResponse;

export type ResultsPage = {
  searchId: string;
  query: string;
  total: number;
  offset: number;
  creators: Creator[];
  nextCursor: string | null;
  credits: number;
  unranked: boolean;
};

People type follower ranges into briefs: "10k-100k followers". Search is good at topics, audiences and content styles, but a size limit is a hard rule, so the bot pulls the range out and sends it as a filter:

src/influship.ts
const FOLLOWER_RANGE = /(\d+(?:\.\d+)?)\s*([km])?\s*(?:-|to)\s*(\d+(?:\.\d+)?)\s*([km])?\s+followers/i;

function toNumber(value: string, unit?: string): number {
  const multiplier = unit?.toLowerCase() === 'm' ? 1_000_000 : unit?.toLowerCase() === 'k' ? 1_000 : 1;
  return Math.round(Number(value) * multiplier);
}

/**
 * "trail runners who review shoes, 10k-100k followers" becomes a query plus a follower
 * filter. Ranges are applied as hard filters; everything else is left to the search.
 */
export function parseCommand(text: string) {
  const range = text.match(FOLLOWER_RANGE);
  const query = text.replace(FOLLOWER_RANGE, '').replace(/[,\s]+$/, '').trim().slice(0, 300);
  if (!range) return { query };

  const min = toNumber(range[1], range[2] ?? range[4]);
  const max = toNumber(range[3], range[4]);
  return { query, followers: { min: Math.min(min, max), max: Math.max(min, max) } };
}

Then the search itself. It asks for 20 creators in one paid call, and reads the first page of 5 back from the stored search:

src/influship.ts
export async function searchCreators(text: string): Promise<ResultsPage> {
  const { query, followers } = parseCommand(text);
  const { data: search, response } = await client.search
    .create({
      query,
      limit: SEARCH_LIMIT,
      platforms: ['instagram'],
      ...(followers && { filters: { followers } }),
    })
    .withResponse();

  // The search is stored. Reading it back in pages costs nothing.
  const first = await readPage(search.search_id);

  return {
    ...first,
    query,
    offset: 0,
    total: search.total,
    credits: Number(response.headers.get('x-credits-charged') ?? 0),
    unranked: search.quality?.mode === 'retrieval_fallback',
  };
}

export async function readPage(searchId: string, cursor?: string) {
  const page = await client.search.retrieve(searchId, {
    limit: PAGE_SIZE,
    ...(cursor && { cursor }),
  });
  return {
    searchId,
    creators: page.data,
    nextCursor: page.has_more ? page.next_cursor : null,
  };
}
Search and stored-search reference

Why 20 up front? Search costs 25 credits per call plus 2 per creator. One search for 20 costs 65 credits. Four searches for 5 would cost 140 and could return different rankings each time. GET /v1/search/{id} re-reads a stored search for free, so "Next 5" costs nothing.

Turn results into Slack messages

Slack messages are JSON in a format called Block Kit. Building them in their own file, as plain functions with no Slack client, means you can test every message without a workspace. Each creator becomes one section block, with a Similar button that carries the creator's ID:

src/blocks.ts
function creatorBlock(item: Creator): Block {
  const profile = item.relevant_profile ?? item.primary_profile;
  const stats = [
    `${compact(profile?.followers)} followers`,
    profile?.engagement_rate != null ? `${profile.engagement_rate.toFixed(1)}% engagement` : null,
    `fit ${item.match.score.toFixed(2)}`,
  ].filter(Boolean);

  // The first reason is the summary sentence. Back it with a verbatim quote from a post
  // when the evidence has one, so the channel can see why without opening the profile.
  const reason = item.match.reasons[0];
  const quote = item.match.evidence.find(
    (e) => e.provenance === 'post_evidence' && e.evidence_quote,
  )?.evidence_quote;

  const lines = [
    profileLine(item.creator.name, profile),
    stats.join(' · '),
    reason ? `>${escape(truncate(reason, 400))}` : null,
    quote ? `>_“${escape(truncate(quote, 200))}”_` : null,
  ].filter(Boolean);

  return {
    type: 'section',
    block_id: `creator_${item.creator.id}`,
    text: { type: 'mrkdwn', text: lines.join('\n') },
    accessory: {
      type: 'button',
      action_id: 'find_similar',
      text: { type: 'plain_text', text: 'Similar' },
      value: JSON.stringify({ id: item.creator.id, name: truncate(item.creator.name, 80) }),
    },
  };
}

export function resultsMessage(page: ResultsPage, requestedBy?: string): Message {
  const from = page.offset + 1;
  const to = page.offset + page.creators.length;
  const notes = [
    `Showing ${from}–${to} of ${page.total}`,
    page.credits ? `search cost ${page.credits} credits` : 'paging is free',
    requestedBy ? `asked by <@${requestedBy}>` : null,
  ].filter(Boolean);

  const blocks: Block[] = [
    {
      type: 'header',
      text: { type: 'plain_text', text: truncate(`Creators for “${page.query}”`, 150) },
    },
  ];

  if (page.unranked) {
    blocks.push({
      type: 'context',
      elements: [
        {
          type: 'mrkdwn',
          text: ':warning: These results are matched but not AI-ranked this time. Try again for a ranked list.',
        },
      ],
    });
  }

  if (page.creators.length === 0) {
    blocks.push({
      type: 'section',
      text: { type: 'mrkdwn', text: 'No creators matched. Try a broader brief or remove the follower range.' },
    });
  }

  for (const item of page.creators) {
    blocks.push(creatorBlock(item), { type: 'divider' });
  }

  blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: notes.join(' · ') }] });

  if (page.nextCursor) {
    const state: PageState = {
      searchId: page.searchId,
      query: page.query,
      total: page.total,
      offset: to,
      cursor: page.nextCursor,
    };
    blocks.push({
      type: 'actions',
      elements: [
        {
          type: 'button',
          action_id: 'next_page',
          text: { type: 'plain_text', text: 'Next 5' },
          value: JSON.stringify(state),
        },
      ],
    });
  }

  return { text: `Creators for "${page.query}"`, blocks };
}
Block Kit reference

Four Slack rules shape this code, and each one fails quietly if you miss it:

  • Escape creator text. Names, bios and quotes are written by creators, and Slack reads &, < and > as formatting. The escape() helper runs on everything that came from the API.
  • Respect the limits. 50 blocks per message, 3,000 characters per section, 150 per header and 2,000 per button value. Reasons and quotes are truncated, and 5 creators per page keeps the message well inside the block limit.
  • Keep state in the button. The bot stores nothing between clicks. The Next 5 button carries the search ID and cursor in its value, so any instance of the bot can serve the click.
  • Skip profile pictures. Slack fetches image URLs when a message is posted, and social platform image links expire. One dead avatar can get the whole message rejected, so the bot links to the profile instead.

The formatting helpers and the lookalike message follow the same pattern:

src/blocks.ts
// Slack treats &, < and > as control characters in mrkdwn.
function escape(text: string): string {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function truncate(text: string, max: number): string {
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
}

function compact(count: number | null | undefined): string {
  if (count == null) return 'unknown';
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
  return String(count);
}

function profileLine(name: string, profile: Creator['primary_profile']): string {
  if (!profile) return `*${escape(name)}*`;
  return `*<${profile.url}|${escape(name)}>*  @${escape(profile.username)}`;
}

Wire up the slash command

src/app.ts connects Slack to everything above. The command handler has one hard constraint: Slack shows the user an error if the app does not acknowledge within 3 seconds, and a ranked search takes closer to a minute. So the handler acknowledges first, with a "Searching…" note only the user sees, then searches and replies through the response URL:

src/app.ts
app.command('/creators', async ({ command, ack, respond }) => {
  const text = command.text.trim();
  if (!text) {
    await ack(helpMessage);
    return;
  }
  if (!canSpend(command.team_id, SEARCH_COST)) {
    await ack(OVER_BUDGET);
    return;
  }

  // Slack needs an answer within 3 seconds. Acknowledge now, search after.
  await ack({ response_type: 'ephemeral', text: `Searching for creators: “${text}”…` });

  try {
    const page = await searchCreators(text);
    recordSpend(command.team_id, page.credits);
    await respond({ response_type: 'in_channel', ...resultsMessage(page, command.user_id) });
  } catch (error) {
    await respond({ response_type: 'ephemeral', ...errorMessage(error) });
  }
});
Bolt: listening to slash commands

response_type: 'in_channel' posts the shortlist where everyone can see it, which is the point: the next person with the same question finds the answer in the channel history.

Add the buttons and guard the spend

Next 5 reads the following page of the stored search and replaces the message in place, so the channel does not fill up with pages:

src/app.ts
app.action<BlockAction<ButtonAction>>('next_page', async ({ ack, action, respond }) => {
  await ack();
  const state = JSON.parse(action.value ?? '{}') as PageState;

  try {
    const page = await readPage(state.searchId, state.cursor);
    await respond({
      replace_original: true,
      ...resultsMessage({ ...state, ...page, credits: 0, unranked: false }),
    });
  } catch (error) {
    await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
  }
});

Similar runs a lookalike search seeded with that creator and posts the results as a new message. Each lookalike comes with a line from its own content that explains the match, so Maya can see why it was suggested:

src/app.ts
app.action<BlockAction<ButtonAction>>('find_similar', async ({ ack, action, body, respond }) => {
  await ack();
  const seed = JSON.parse(action.value ?? '{}') as { id: string; name: string };
  const teamId = body.team?.id ?? 'unknown';

  if (!canSpend(teamId, SIMILAR_COST)) {
    await respond({ response_type: 'ephemeral', replace_original: false, text: OVER_BUDGET });
    return;
  }

  try {
    const { creators, credits } = await findSimilar(seed.id);
    recordSpend(teamId, credits);
    await respond({
      response_type: 'in_channel',
      replace_original: false,
      ...similarMessage(seed.name, creators, credits),
    });
  } catch (error) {
    await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
  }
});

await app.start();
console.log('Creator Finder is running. Try /creators in Slack.');
src/influship.ts
export async function findSimilar(creatorId: string) {
  const { data: page, response } = await client.creators
    .lookalike({ seeds: [{ creator_id: creatorId }], limit: PAGE_SIZE })
    .withResponse();
  return {
    creators: page.data,
    credits: Number(response.headers.get('x-credits-charged') ?? 0),
  };
}
Lookalike recipe

Both paid paths check a daily credit cap per workspace before calling the API, then record the real charge from the X-Credits-Charged header afterwards. The default is 500 credits ($5) a day, about seven searches, set with DAILY_CREDIT_LIMIT. Raise it to fit how much your team searches:

src/influship.ts
const DAILY_LIMIT = Number(process.env.DAILY_CREDIT_LIMIT ?? 500);
const spend = new Map<string, { day: string; credits: number }>();

function today(teamId: string) {
  const day = new Date().toISOString().slice(0, 10);
  const entry = spend.get(teamId);
  return entry?.day === day ? entry : { day, credits: 0 };
}

export function canSpend(teamId: string, estimate: number): boolean {
  return today(teamId).credits + estimate <= DAILY_LIMIT;
}

export function recordSpend(teamId: string, credits: number) {
  const entry = today(teamId);
  spend.set(teamId, { day: entry.day, credits: entry.credits + credits });
}

Test it without Slack

The test file starts a tiny local server that stands in for the Influship API and points the SDK at it with INFLUSHIP_BASE_URL. It runs a search, pages to the end, asks for lookalikes, and checks every message against Slack's limits. No tokens, no credits:

terminal
$ pnpm test✔ parses a follower range out of the brief✔ search shows the first page and records the real cost✔ Next 5 reads the stored search and stops on the last page✔ Similar posts lookalikes with shared traits✔ the daily cap blocks spend past the limitℹ pass 5

Read the full test file for the mock server; it is a good template for testing any bot that calls the API.

Run it

terminal
$ pnpm startCreator Finder is running. Try /creators in Slack.

In any channel, type /creators with a brief. An empty /creators shows examples. A few briefs that work well:

  • /creators trail runners who review shoes, 10k-100k followers
  • /creators UK home cooks who post quick weeknight recipes
  • /creators dermatologists who explain skincare ingredients on camera

Specific briefs win. "Fitness" returns a generic list. "Women over 40 posting strength training at home" returns creators Maya can use.

Deploy it

Socket Mode needs a process that stays running, not a serverless function. Any host that runs Node.js works: Railway, Render, Fly.io or a small VM. Set the same four variables from .env, use pnpm start as the start command, and run one instance so the in-memory cap stays accurate.

Socket Mode is ideal for a bot that only your workspace uses. Apps listed in the Slack Marketplace have to receive events over HTTP instead; Bolt supports both, and switching is a change to the App options.

Troubleshooting

/creators failed with the error "dispatch_failed"
The bot is not running or not connected. Start it with pnpm start and check the SLACK_APP_TOKEN.
invalid_auth when the bot starts
The SLACK_BOT_TOKEN is wrong or from another workspace. Copy it again from Install App after reinstalling.
The command is missing from Slack
Reinstall the app after changing the manifest so Slack picks up the command.
Out of credits / Influship API key is missing
Check INFLUSHIP_API_KEY in .env and the balance at developers.influship.com.
Today's credit limit message
The workspace hit DAILY_CREDIT_LIMIT. Raise it, or wait for midnight UTC.

Where to take it next

  • Reply in threads. Add the chat:write scope and post lookalikes with thread_ts so each conversation stays together. The bot then needs to be invited to the channels it posts in.
  • Save a shortlist. Add a button that writes the creators to an Influship shortlist with POST /v1/shortlists, so a search in Slack becomes a list the team can reopen later.
  • Score against a saved brief. Store Fernway's campaign brief once and add a Check fit button that calls the match endpoint from guide 01 for a good, neutral or avoid decision.
  • Send a weekly digest. Run a scheduled search every Monday and post new creators to #creator-scouting.

The complete code

All three files in full, plus the test, manifest, package.json and tsconfig.json.

src/app.ts
import { App, type BlockAction, type ButtonAction } from '@slack/bolt';
import {
  errorMessage,
  helpMessage,
  type PageState,
  resultsMessage,
  similarMessage,
} from './blocks.js';
import {
  canSpend,
  findSimilar,
  readPage,
  recordSpend,
  SEARCH_COST,
  SIMILAR_COST,
  searchCreators,
} from './influship.js';

// Socket Mode opens an outbound websocket to Slack, so the bot runs anywhere, including
// your laptop, without a public URL.
const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
});

const OVER_BUDGET = "This workspace has used today's Influship credit limit. It resets at midnight UTC.";

// ---------- /creators <brief> ----------

app.command('/creators', async ({ command, ack, respond }) => {
  const text = command.text.trim();
  if (!text) {
    await ack(helpMessage);
    return;
  }
  if (!canSpend(command.team_id, SEARCH_COST)) {
    await ack(OVER_BUDGET);
    return;
  }

  // Slack needs an answer within 3 seconds. Acknowledge now, search after.
  await ack({ response_type: 'ephemeral', text: `Searching for creators: “${text}”…` });

  try {
    const page = await searchCreators(text);
    recordSpend(command.team_id, page.credits);
    await respond({ response_type: 'in_channel', ...resultsMessage(page, command.user_id) });
  } catch (error) {
    await respond({ response_type: 'ephemeral', ...errorMessage(error) });
  }
});

// ---------- Next 5: free, reads the stored search ----------

app.action<BlockAction<ButtonAction>>('next_page', async ({ ack, action, respond }) => {
  await ack();
  const state = JSON.parse(action.value ?? '{}') as PageState;

  try {
    const page = await readPage(state.searchId, state.cursor);
    await respond({
      replace_original: true,
      ...resultsMessage({ ...state, ...page, credits: 0, unranked: false }),
    });
  } catch (error) {
    await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
  }
});

// ---------- Similar: lookalikes for one creator ----------

app.action<BlockAction<ButtonAction>>('find_similar', async ({ ack, action, body, respond }) => {
  await ack();
  const seed = JSON.parse(action.value ?? '{}') as { id: string; name: string };
  const teamId = body.team?.id ?? 'unknown';

  if (!canSpend(teamId, SIMILAR_COST)) {
    await respond({ response_type: 'ephemeral', replace_original: false, text: OVER_BUDGET });
    return;
  }

  try {
    const { creators, credits } = await findSimilar(seed.id);
    recordSpend(teamId, credits);
    await respond({
      response_type: 'in_channel',
      replace_original: false,
      ...similarMessage(seed.name, creators, credits),
    });
  } catch (error) {
    await respond({ response_type: 'ephemeral', replace_original: false, ...errorMessage(error) });
  }
});

await app.start();
console.log('Creator Finder is running. Try /creators in Slack.');
src/influship.ts
import Influship from 'influship';
import type { CreatorLookalikeResponse } from 'influship/resources/creators';
import type { SearchRetrieveResponse } from 'influship/resources/search';

// Reads INFLUSHIP_API_KEY. A ranked search can take a while, so allow up to 90 seconds.
const client = new Influship({ timeout: 90_000 });

export const PAGE_SIZE = 5;
const SEARCH_LIMIT = 20;

// Published prices in credits (1 credit = $0.01). Used to check the budget before a call.
export const SEARCH_COST = 25 + 2 * SEARCH_LIMIT;
export const SIMILAR_COST = 1.5 * PAGE_SIZE;

export type Creator = SearchRetrieveResponse;
export type Lookalike = CreatorLookalikeResponse;

export type ResultsPage = {
  searchId: string;
  query: string;
  total: number;
  offset: number;
  creators: Creator[];
  nextCursor: string | null;
  credits: number;
  unranked: boolean;
};

// ---------- parse the slash command ----------

const FOLLOWER_RANGE = /(\d+(?:\.\d+)?)\s*([km])?\s*(?:-|to)\s*(\d+(?:\.\d+)?)\s*([km])?\s+followers/i;

function toNumber(value: string, unit?: string): number {
  const multiplier = unit?.toLowerCase() === 'm' ? 1_000_000 : unit?.toLowerCase() === 'k' ? 1_000 : 1;
  return Math.round(Number(value) * multiplier);
}

/**
 * "trail runners who review shoes, 10k-100k followers" becomes a query plus a follower
 * filter. Ranges are applied as hard filters; everything else is left to the search.
 */
export function parseCommand(text: string) {
  const range = text.match(FOLLOWER_RANGE);
  const query = text.replace(FOLLOWER_RANGE, '').replace(/[,\s]+$/, '').trim().slice(0, 300);
  if (!range) return { query };

  const min = toNumber(range[1], range[2] ?? range[4]);
  const max = toNumber(range[3], range[4]);
  return { query, followers: { min: Math.min(min, max), max: Math.max(min, max) } };
}

// ---------- search once, page for free ----------

export async function searchCreators(text: string): Promise<ResultsPage> {
  const { query, followers } = parseCommand(text);
  const { data: search, response } = await client.search
    .create({
      query,
      limit: SEARCH_LIMIT,
      platforms: ['instagram'],
      ...(followers && { filters: { followers } }),
    })
    .withResponse();

  // The search is stored. Reading it back in pages costs nothing.
  const first = await readPage(search.search_id);

  return {
    ...first,
    query,
    offset: 0,
    total: search.total,
    credits: Number(response.headers.get('x-credits-charged') ?? 0),
    unranked: search.quality?.mode === 'retrieval_fallback',
  };
}

export async function readPage(searchId: string, cursor?: string) {
  const page = await client.search.retrieve(searchId, {
    limit: PAGE_SIZE,
    ...(cursor && { cursor }),
  });
  return {
    searchId,
    creators: page.data,
    nextCursor: page.has_more ? page.next_cursor : null,
  };
}

// ---------- creators like this one ----------

export async function findSimilar(creatorId: string) {
  const { data: page, response } = await client.creators
    .lookalike({ seeds: [{ creator_id: creatorId }], limit: PAGE_SIZE })
    .withResponse();
  return {
    creators: page.data,
    credits: Number(response.headers.get('x-credits-charged') ?? 0),
  };
}

// ---------- a daily credit cap per workspace ----------

const DAILY_LIMIT = Number(process.env.DAILY_CREDIT_LIMIT ?? 500);
const spend = new Map<string, { day: string; credits: number }>();

function today(teamId: string) {
  const day = new Date().toISOString().slice(0, 10);
  const entry = spend.get(teamId);
  return entry?.day === day ? entry : { day, credits: 0 };
}

export function canSpend(teamId: string, estimate: number): boolean {
  return today(teamId).credits + estimate <= DAILY_LIMIT;
}

export function recordSpend(teamId: string, credits: number) {
  const entry = today(teamId);
  spend.set(teamId, { day: entry.day, credits: entry.credits + credits });
}
src/blocks.ts
import type { types } from '@slack/bolt';
import { APIError } from 'influship';
import type { Creator, Lookalike, ResultsPage } from './influship.js';

type Block = types.KnownBlock;
export type Message = { text: string; blocks: Block[] };

/** What a "Next 5" button needs to fetch the following page. Stored in the button value. */
export type PageState = {
  searchId: string;
  query: string;
  total: number;
  offset: number;
  cursor: string;
};

// ---------- formatting helpers ----------

// Slack treats &, < and > as control characters in mrkdwn.
function escape(text: string): string {
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function truncate(text: string, max: number): string {
  return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
}

function compact(count: number | null | undefined): string {
  if (count == null) return 'unknown';
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
  return String(count);
}

function profileLine(name: string, profile: Creator['primary_profile']): string {
  if (!profile) return `*${escape(name)}*`;
  return `*<${profile.url}|${escape(name)}>*  @${escape(profile.username)}`;
}

// ---------- the search results message ----------

function creatorBlock(item: Creator): Block {
  const profile = item.relevant_profile ?? item.primary_profile;
  const stats = [
    `${compact(profile?.followers)} followers`,
    profile?.engagement_rate != null ? `${profile.engagement_rate.toFixed(1)}% engagement` : null,
    `fit ${item.match.score.toFixed(2)}`,
  ].filter(Boolean);

  // The first reason is the summary sentence. Back it with a verbatim quote from a post
  // when the evidence has one, so the channel can see why without opening the profile.
  const reason = item.match.reasons[0];
  const quote = item.match.evidence.find(
    (e) => e.provenance === 'post_evidence' && e.evidence_quote,
  )?.evidence_quote;

  const lines = [
    profileLine(item.creator.name, profile),
    stats.join(' · '),
    reason ? `>${escape(truncate(reason, 400))}` : null,
    quote ? `>_“${escape(truncate(quote, 200))}”_` : null,
  ].filter(Boolean);

  return {
    type: 'section',
    block_id: `creator_${item.creator.id}`,
    text: { type: 'mrkdwn', text: lines.join('\n') },
    accessory: {
      type: 'button',
      action_id: 'find_similar',
      text: { type: 'plain_text', text: 'Similar' },
      value: JSON.stringify({ id: item.creator.id, name: truncate(item.creator.name, 80) }),
    },
  };
}

export function resultsMessage(page: ResultsPage, requestedBy?: string): Message {
  const from = page.offset + 1;
  const to = page.offset + page.creators.length;
  const notes = [
    `Showing ${from}–${to} of ${page.total}`,
    page.credits ? `search cost ${page.credits} credits` : 'paging is free',
    requestedBy ? `asked by <@${requestedBy}>` : null,
  ].filter(Boolean);

  const blocks: Block[] = [
    {
      type: 'header',
      text: { type: 'plain_text', text: truncate(`Creators for “${page.query}”`, 150) },
    },
  ];

  if (page.unranked) {
    blocks.push({
      type: 'context',
      elements: [
        {
          type: 'mrkdwn',
          text: ':warning: These results are matched but not AI-ranked this time. Try again for a ranked list.',
        },
      ],
    });
  }

  if (page.creators.length === 0) {
    blocks.push({
      type: 'section',
      text: { type: 'mrkdwn', text: 'No creators matched. Try a broader brief or remove the follower range.' },
    });
  }

  for (const item of page.creators) {
    blocks.push(creatorBlock(item), { type: 'divider' });
  }

  blocks.push({ type: 'context', elements: [{ type: 'mrkdwn', text: notes.join(' · ') }] });

  if (page.nextCursor) {
    const state: PageState = {
      searchId: page.searchId,
      query: page.query,
      total: page.total,
      offset: to,
      cursor: page.nextCursor,
    };
    blocks.push({
      type: 'actions',
      elements: [
        {
          type: 'button',
          action_id: 'next_page',
          text: { type: 'plain_text', text: 'Next 5' },
          value: JSON.stringify(state),
        },
      ],
    });
  }

  return { text: `Creators for "${page.query}"`, blocks };
}

// ---------- the lookalike message ----------

export function similarMessage(seedName: string, creators: Lookalike[], credits: number): Message {
  const blocks: Block[] = [
    {
      type: 'section',
      text: { type: 'mrkdwn', text: `*Creators like ${escape(seedName)}*` },
    },
  ];

  for (const item of creators) {
    // shared_traits are full sentences from the creator's content; one is enough here.
    const trait = item.similarity.shared_traits[0];
    blocks.push({
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: [
          profileLine(item.creator.name, item.primary_profile),
          `${compact(item.primary_profile?.followers)} followers · similarity ${item.similarity.score.toFixed(2)}`,
          trait ? `>${escape(truncate(trait, 200))}` : null,
        ]
          .filter(Boolean)
          .join('\n'),
      },
    });
  }

  blocks.push({
    type: 'context',
    elements: [{ type: 'mrkdwn', text: `${creators.length} lookalikes · cost ${credits} credits` }],
  });

  return { text: `Creators like ${seedName}`, blocks };
}

// ---------- help and errors ----------

export const helpMessage: Message = {
  text: 'Describe the creators you want after /creators.',
  blocks: [
    {
      type: 'section',
      text: {
        type: 'mrkdwn',
        text: [
          '*Describe the creators you want.* For example:',
          '`/creators trail runners who review shoes`',
          '`/creators UK home cooks who post quick weeknight recipes, 10k-100k followers`',
        ].join('\n'),
      },
    },
  ],
};

export function errorMessage(error: unknown): Message {
  let text = 'Something went wrong talking to Influship. Try again in a minute.';
  if (error instanceof APIError) {
    if (error.status === 402) text = 'The Influship account is out of credits. Ask an admin to add billing.';
    else if (error.status === 429) text = 'Influship is rate limiting this workspace. Try again in a minute.';
    else if (error.status === 401) text = 'The Influship API key is missing or invalid.';
    const requestId = error.headers?.get('x-request-id');
    if (requestId) text += ` (request ${requestId})`;
  }
  return { text, blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }] };
}

500 starter credits. No card.

Enough for about seven searches with lookalikes.

Get API key

Questions

How much does an influencer search Slack bot cost to run?

Each /creators search costs 65 credits ($0.65): 25 for the search plus 2 for each of the 20 creators it returns. Paging through those results is free because the bot re-reads the stored search. A Similar click costs 7.5 credits for five lookalikes. A team running 20 searches a day spends about $13.

Do I need a public URL or server to run a Slack bot?

Not with Socket Mode. The bot opens an outbound websocket to Slack, so it runs on a laptop or any always-on host without a public URL. Apps distributed in the Slack Marketplace need to receive events over HTTP instead.

Does the bot need to be invited to channels?

No. It replies through the response URL Slack attaches to each slash command and button click, and its only scope is commands. If you want threaded replies with chat.postMessage, add the chat:write scope and invite it to those channels.

Which platforms can the bot search?

Natural-language creator search and lookalikes cover Instagram creators. For TikTok and YouTube, the API’s live platform endpoints can look up profiles, videos and search results, which you can add as separate commands.

How do I stop the bot from spending too much?

The bot checks a daily credit cap per workspace before every paid call and records the real charge from the X-Credits-Charged response header. Set DAILY_CREDIT_LIMIT to the number of credits you are comfortable with. Your Influship balance is the hard backstop: an empty balance returns a 402, which the bot reports in Slack.

Run it on your own list.

Start with 500 one-time credits. No card, no expiry. Every request returns what it cost.