Slack: the /horoscope command
Someone in a channel types /horoscope leo, and today’s Leo horoscope appears in the channel. Slack posts the command to your server and waits no longer than 3 seconds for the reply, so the server needs a public HTTPS address. No Bolt library is needed: the whole exchange is one request and one response.
1. The Slack app
Section titled “1. The Slack app”- On api.slack.com/apps click Create New App → From scratch and pick the workspace.
- Slash Commands → Create New Command: command
/horoscope, Request URLhttps://bot.example.com/slack/horoscope, Usage Hintleo. - Basic Information → App Credentials: copy the Signing Secret.
- Install App → Install to Workspace.
- A Server only AstroWay key scoped to
public/horoscope/*, as described in A key for an automation.
export ASTROWAY_API_KEY="aw_live_..."export SLACK_SIGNING_SECRET="..."2. The code
Section titled “2. The code”Save it as slack.mjs and run node slack.mjs:
// Node 22 or newer, no dependencies.import { createServer } from 'node:http';import { createHmac, timingSafeEqual } from 'node:crypto';
const ASTROWAY = 'https://api.astroway.info/v1';const LANG = 'en';// One text per sign per day: at most 12 calls a day, whatever the traffic.const cache = new Map();const SIGNS = ['aries', 'taurus', 'gemini', 'cancer', 'leo', 'virgo', 'libra', 'scorpio', 'sagittarius', 'capricorn', 'aquarius', 'pisces'];
function signedBySlack(raw, timestamp, signature) { // Older than five minutes reads as a replay. if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = 'v0=' + createHmac('sha256', process.env.SLACK_SIGNING_SECRET) .update(`v0:${timestamp}:${raw}`).digest('hex'); return signature?.length === expected.length && timingSafeEqual(Buffer.from(signature), Buffer.from(expected));}
async function horoscope(sign) { const key = `${new Date().toISOString().slice(0, 10)} ${sign}`; if (!cache.has(key)) { const res = await fetch(`${ASTROWAY}/public/horoscope/daily?sign=${sign}&lang=${LANG}`, { headers: { 'X-Api-Key': process.env.ASTROWAY_API_KEY }, }); const json = await res.json(); if (!json.ok) return { response_type: 'ephemeral', text: `AstroWay: ${json.error.message}` }; cache.set(key, `*${sign}, ${json.data.date}*\n${json.data.horoscope}`); } return { response_type: 'in_channel', text: cache.get(key) };}
createServer(async (req, res) => { const chunks = []; for await (const chunk of req) chunks.push(chunk); const raw = Buffer.concat(chunks).toString(); if (!signedBySlack(raw, req.headers['x-slack-request-timestamp'], req.headers['x-slack-signature'])) { res.writeHead(401).end(); return; }
const sign = new URLSearchParams(raw).get('text')?.trim().toLowerCase() ?? ''; const reply = SIGNS.includes(sign) ? await horoscope(sign) : { response_type: 'ephemeral', text: `Usage: /horoscope leo\n${SIGNS.join(', ')}` }; res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(reply));}).listen(process.env.PORT ?? 3000);How it works
Section titled “How it works”- The signature. Slack signs every request: HMAC-SHA256 of
v0:<timestamp>:<body>with your Signing Secret. The code checks it and drops a request older than five minutes, so a foreign or replayed request gets401. in_channelorephemeral. Everyone in the channel sees the horoscope; only the person who typed the command sees a hint or an error.- 3 seconds. The first request for a sign each day goes to the API, the rest come from the cache in a few milliseconds. If you swap the horoscope for something slower, a PDF report for example, answer at once and send the result later to the
response_urlfrom the request body. - The limit. A Free key on
/v1/public/*counts as a call with no key, 30 an hour from the server’s IP; the cache keeps the bot within 12 calls a day. A wrong key gives no401here, it quietly gets the same anonymous limit.