-
Notifications
You must be signed in to change notification settings - Fork 1
Add Blooio provider (send + receive) with example and tests #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,109 @@ | ||||||
| // End-to-end GamePigeon bot over Blooio — an Express webhook server. | ||||||
| // No SQLite, no Mac, runs anywhere. Point your Blooio webhook at POST /webhook. | ||||||
| // | ||||||
| // npm i express openpigeon | ||||||
| // BLOOIO_API_KEY=api_xxx BLOOIO_FROM=+15551234567 \ | ||||||
| // BLOOIO_WEBHOOK_SECRET=whsec_xxx ADMIN_TOKEN=secret \ | ||||||
| // node examples/blooio-express-bot.ts | ||||||
| // | ||||||
| // This plays a trivial "pass the turn back" move so you can see the wire loop. | ||||||
| // Swap `decide()` for your own move selection — OpenPigeon has no AI. | ||||||
|
|
||||||
| import crypto from "node:crypto"; | ||||||
| import express from "express"; | ||||||
| import * as op from "openpigeon"; | ||||||
|
|
||||||
| const app = express(); | ||||||
|
|
||||||
| const bloo = new op.Blooio({ | ||||||
| apiKey: process.env.BLOOIO_API_KEY!, | ||||||
| fromNumber: process.env.BLOOIO_FROM!, // your Blooio iMessage number | ||||||
| }); | ||||||
| const BOT_ID = "00000000-0000-4000-8000-OPENPIGEONBOT"; // keep stable per bot | ||||||
| const WEBHOOK_SECRET = process.env.BLOOIO_WEBHOOK_SECRET ?? ""; // whsec_... | ||||||
|
|
||||||
| // Fail closed, but loudly: without the secret every webhook is rejected (401), | ||||||
| // so a bot that forgets this env var would run but never reply. Warn at startup | ||||||
| // (and again on the first rejected request) instead of dying silently. | ||||||
| if (!WEBHOOK_SECRET) { | ||||||
| console.warn( | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The new Prompt for AI agents |
||||||
| "[blooio] BLOOIO_WEBHOOK_SECRET is not set — every /webhook request will be " + | ||||||
| "rejected with 401 and the bot will never reply. Set it to your whsec_... " + | ||||||
| "signing secret (POST /v4/webhooks response) to receive moves.", | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| function decide(move: op.Move): { state?: any; env?: Record<string, string> } { | ||||||
| // re-send the board unchanged (a legal pass). Replace with your logic. | ||||||
| return { state: move.state }; | ||||||
| } | ||||||
|
|
||||||
| // Verify Blooio's `X-Blooio-Signature: t=...,v1=...` header (HMAC-SHA256 over | ||||||
| // `${t}.${rawBody}`) before trusting a webhook — otherwise anyone could POST a | ||||||
| // body and make your bot spend your account. This needs the RAW body, so the | ||||||
| // route below uses express.raw (not express.json). | ||||||
| function verifyBlooioSignature(rawBody: Buffer, header: unknown): boolean { | ||||||
| if (!WEBHOOK_SECRET || typeof header !== "string") return false; | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
| const t = header.split(",").find((p) => p.startsWith("t="))?.slice(2); | ||||||
| const v1 = header.split(",").find((p) => p.startsWith("v1="))?.slice(3); | ||||||
| if (!t || !v1) return false; | ||||||
| if (Math.floor(Date.now() / 1000) - Number(t) > 300) return false; // replay guard | ||||||
| const expected = crypto | ||||||
| .createHmac("sha256", WEBHOOK_SECRET) | ||||||
| .update(`${t}.${rawBody.toString()}`) | ||||||
| .digest("hex"); | ||||||
| const a = Buffer.from(v1, "hex"); | ||||||
| const b = Buffer.from(expected, "hex"); | ||||||
| return a.length === b.length && crypto.timingSafeEqual(a, b); | ||||||
| } | ||||||
|
|
||||||
| app.post( | ||||||
| "/webhook", | ||||||
| express.raw({ type: "application/json" }), | ||||||
| (req, res) => { | ||||||
| if (!verifyBlooioSignature(req.body as Buffer, req.headers["x-blooio-signature"])) { | ||||||
| console.warn( | ||||||
| WEBHOOK_SECRET | ||||||
| ? "[blooio] rejected /webhook: invalid or missing X-Blooio-Signature" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: When a webhook is rejected because its Prompt for AI agents
Suggested change
|
||||||
| : "[blooio] rejected /webhook: BLOOIO_WEBHOOK_SECRET is unset (see startup warning)", | ||||||
| ); | ||||||
| return res.sendStatus(401); | ||||||
| } | ||||||
| // Ack immediately so a provider retry can't double-fire our reply, then do | ||||||
| // the outbound send after responding and log (don't throw) on failure. | ||||||
| res.sendStatus(200); | ||||||
| const inbound = op.fromBlooioWebhook(JSON.parse((req.body as Buffer).toString())); | ||||||
| if (!inbound || inbound.move.isInvite) return; // wait for the first real move | ||||||
| const m = inbound.move; | ||||||
| console.log(`received ${m.game} move #${m.num} from ${inbound.replyTo}`); | ||||||
| const { state, env } = decide(m); | ||||||
| const url = m.reply({ botId: BOT_ID, state, env }); | ||||||
| bloo | ||||||
| .send(url, { to: inbound.replyTo!, caption: "Your move!" }) | ||||||
| .catch((err) => console.error("reply send failed:", err)); | ||||||
| }, | ||||||
| ); | ||||||
|
|
||||||
| // GET /start?to=%2B15551234567&game=pool — send a fresh invite. | ||||||
| // Admin-only: guard with a bearer token so a public host can't spend your | ||||||
| // account. `to` must be URL-encoded (a leading + becomes %2B) and is required. | ||||||
| app.get("/start", async (req, res) => { | ||||||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||||||
| if (!process.env.ADMIN_TOKEN || req.headers.authorization !== `Bearer ${process.env.ADMIN_TOKEN}`) { | ||||||
| return res.sendStatus(401); | ||||||
| } | ||||||
| const to = String(req.query.to ?? ""); | ||||||
| if (!/^\+[1-9]\d{6,14}$/.test(to)) { | ||||||
| return res | ||||||
| .status(400) | ||||||
| .json({ error: "`to` must be an E.164 number, URL-encoded (e.g. ?to=%2B15551234567)" }); | ||||||
| } | ||||||
| const inv = op.invite(String(req.query.game ?? "pool")); | ||||||
| try { | ||||||
| await bloo.send(inv.url, { to, caption: "Wanna play?" }); | ||||||
| } catch (err) { | ||||||
| return res.status(502).json({ error: String(err) }); | ||||||
| } | ||||||
| res.json({ sent: true, game: inv.game, id: inv.id }); | ||||||
| }); | ||||||
|
|
||||||
| app.listen(8000, () => console.log("listening on :8000")); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /** | ||
| * Blooio provider — send GamePigeon balloons over the Blooio v4 REST API and | ||
| * parse the `message.received` webhook. Uses the global `fetch` (Node 18+). | ||
| * | ||
| * const bloo = new Blooio({ apiKey: "api_...", fromNumber: "+1..." }); | ||
| * await bloo.send(url, { to: "+1..." }); | ||
| * | ||
| * const inbound = Blooio.fromWebhook(req.body); | ||
| * if (inbound) await bloo.send(inbound.move.reply({ botId, state }), { | ||
| * to: inbound.replyTo!, | ||
| * }); | ||
| * | ||
| * Blooio delivers inbound app balloons already decoded: the webhook's | ||
| * `data.imessage_app.url` is the app-state URL your bot reads, so you don't have | ||
| * to decode anything Apple-specific yourself. Balloons render natively on the | ||
| * recipient's own GamePigeon extension. A Blooio iMessage number is self-serve, | ||
| * so the send path below works as soon as you have an API key. | ||
| */ | ||
|
|
||
| import { APP_NAME, BUNDLE_ID, TEAM_ID } from "../identity.ts"; | ||
| import { Inbound, looksLikeGamePigeon } from "../inbound.ts"; | ||
| import { read } from "../move.ts"; | ||
|
|
||
| const BASE = "https://api.blooio.com/v4"; | ||
|
|
||
| /** | ||
| * The `imessage_app` content carrying a GamePigeon balloon (Blooio v4 shape). | ||
| * Blooio is transport-only: it forwards the app-state `url` under your app's | ||
| * identity and the recipient's GamePigeon draws the board — no image attached. | ||
| */ | ||
| export function appPart(url: string, caption = "GamePigeon"): Record<string, unknown> { | ||
| return { | ||
| bundle_id: BUNDLE_ID, | ||
| team_id: TEAM_ID, | ||
| url, | ||
| app_name: APP_NAME, | ||
| caption, | ||
| }; | ||
| } | ||
|
|
||
| export interface SendOpts { | ||
| to?: string; | ||
| caption?: string; | ||
| /** Override the sending channel for this message (defaults to `fromNumber`). */ | ||
| from?: string; | ||
| } | ||
|
|
||
| export class Blooio { | ||
| private apiKey: string; | ||
| private fromNumber: string; | ||
| constructor(opts: { apiKey: string; fromNumber: string }) { | ||
| if (!opts?.apiKey) throw new Error("Blooio: `apiKey` is required"); | ||
| if (!opts?.fromNumber) throw new Error("Blooio: `fromNumber` is required"); | ||
| this.apiKey = opts.apiKey; | ||
| this.fromNumber = opts.fromNumber; | ||
| } | ||
|
|
||
| private async post(channel: string, body: unknown): Promise<any> { | ||
| const res = await fetch(`${BASE}/channels/${encodeURIComponent(channel)}/messages`, { | ||
| method: "POST", | ||
| headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| const text = await res.text(); | ||
| if (!res.ok) throw new Error(`Blooio ${res.status}: ${text}`); | ||
| return text ? JSON.parse(text) : {}; | ||
| } | ||
|
|
||
| /** Send any GamePigeon URL (invite or move) as a balloon. */ | ||
| async send(url: string, opts: SendOpts = {}): Promise<any> { | ||
| if (!opts.to) throw new Error("Blooio.send: `to` is required"); | ||
| return this.post(opts.from ?? this.fromNumber, { | ||
| to: opts.to, | ||
| imessage_app: appPart(url, opts.caption ?? "GamePigeon"), | ||
| }); | ||
| } | ||
|
|
||
| /** Send a plain text message (e.g. an agent's chat reply). */ | ||
| async sendText(text: string, opts: SendOpts = {}): Promise<any> { | ||
| if (!opts.to) throw new Error("Blooio.sendText: `to` is required"); | ||
| return this.post(opts.from ?? this.fromNumber, { to: opts.to, text }); | ||
| } | ||
|
|
||
| /** | ||
| * Parse a Blooio webhook body into an Inbound, or null if not a GamePigeon | ||
| * move. Blooio delivers the balloon already decoded, so `data.imessage_app.url` | ||
| * carries the app-state URL (event `message.received`, `message_type` | ||
| * "imessage_app"); `data.sender` is the reply target and `data.chat_id` the | ||
| * conversation. | ||
| */ | ||
| static fromWebhook(body: any): Inbound | null { | ||
| if (!body || typeof body !== "object") return null; | ||
| const type = String(body.type ?? body.event_type ?? ""); | ||
| if (type && !type.includes("received")) return null; // inbound messages only | ||
| const data = body.data ?? body; | ||
| // Only decode a genuine app balloon — never a plain text message that merely | ||
| // quotes a GamePigeon URL. Blooio labels balloons `message_type: | ||
| // "imessage_app"` and puts the decoded app-state URL on `data.imessage_app.url`, | ||
| // so read that field directly instead of scanning the whole payload. | ||
| if (data.message_type && data.message_type !== "imessage_app") return null; | ||
| const app = data.imessage_app; | ||
| const url = | ||
| app && typeof app === "object" && typeof app.url === "string" ? app.url : null; | ||
| if (!url || !looksLikeGamePigeon(url)) return null; | ||
| const sender = data.sender ?? {}; | ||
| const replyTo = | ||
| (typeof sender === "object" ? sender.identifier ?? sender.handle : sender) ?? | ||
| data.contact?.identifier ?? | ||
| null; | ||
| const chatId = data.chat_id ?? null; | ||
| return new Inbound(read(url), replyTo, chatId, url, "blooio"); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.