Skip to content
AstroWay/api v2.325.0 · integrations
all systems operational

Slack: the /horoscope command

Last updated View as Markdown Agent setup

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. On api.slack.com/apps click Create New App → From scratch and pick the workspace.
  2. Slash Commands → Create New Command: command /horoscope, Request URL https://bot.example.com/slack/horoscope, Usage Hint leo.
  3. Basic Information → App Credentials: copy the Signing Secret.
  4. Install App → Install to Workspace.
  5. A Server only AstroWay key scoped to public/horoscope/*, as described in A key for an automation.
Terminal window
export ASTROWAY_API_KEY="aw_live_..."
export SLACK_SIGNING_SECRET="..."

Save it as slack.mjs and run node slack.mjs:

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);
  • 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 gets 401.
  • in_channel or ephemeral. 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_url from 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 no 401 here, it quietly gets the same anonymous limit.