Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This repository provides:
| [`faq-bot`](./faq-bot) | Auto-replies to inbound WhatsApp messages from configurable FAQ keyword/regex rules. | 0.1.6 | stable |
| [`group-translate`](./group-translate) | Auto-translates group messages between participants' languages via a LibreTranslate backend. Configure in-chat with /tr commands. Admin-gated; disabled until enabled. | 1.0.5 | stable |
| [`gsheets-logger`](./gsheets-logger) | Logs WhatsApp message events to a Google Sheet via a service account. | 0.2.3 | stable |
| [`http-action`](./http-action) | Triggers safe REST API requests from WhatsApp commands and renders JSON responses back to chat. | 0.1.0 | development |
| [`typebot-connector`](./typebot-connector) | Runs a Typebot flow as the brain of a WhatsApp bot: inbound messages drive a Typebot chat session via the live Chat API, and the bot's replies — text, media, and numbered-choice inputs — are sent back to WhatsApp. Auto-starts every chat, handles file-upload steps, and resets when the flow ends or after an idle timeout. Runs sandboxed in the plugin worker; no public URL or webhook required. | 0.1.0 | beta |
| [`voice-transcription`](./voice-transcription) | Transcribes inbound WhatsApp voice notes to text via an OpenAI-compatible speech-to-text backend (self-hosted Speaches/faster-whisper or hosted Groq/OpenAI) and delivers a `message.transcription` event to your webhook — so bots and AI can read and reply to audio. Off the message-delivery path; disabled until enabled. | 1.0.1 | beta |
<!-- END PLUGIN CATALOG -->
Expand Down
16 changes: 16 additions & 0 deletions http-action/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

All notable changes to HTTP Action Bot are listed here. Versions follow [Semantic Versioning](https://semver.org/),
and the top entry's version must match `manifest.json`.

## [0.1.0] — 2026-07-11

### Added
- Plugin scaffold: `manifest.json`, `IPlugin` lifecycle (`onEnable`, `healthCheck`), `message:received` hook with off-dispatch handling and inbound guards (`fromMe`, empty body, missing ids, group opt-in).
- Config layer (`config.ts`): fixed-https `baseUrl` (an `allowConfigHosts` key, required — no code-side default), server-relative path validation (rejects protocol-relative `//`, absolute URLs, fragments, control/null chars), dangerous-header blocklist (hop-by-hop + `x-forwarded-*`), CRLF injection rejection, `actions` JSON-string parsing, per-action structural validation, optional `bodyTemplate` for POST.
- Template engine (`url-template.ts`): prototype-safe dot-path access, `renderText` (replies), `renderPath` (URL-encoded segments), `renderJson` (JSON-safe body), bounded path depth + placeholder count.
- HTTP client (`client.ts`): fixed-origin URL build, encoded query, auth (none/bearer/apikey), rendered headers, `application/json` POST with re-parsed body, 256 KiB response cap, invalid-JSON guard. Mirrors `ctx.net.fetch(url, init)`.
- Matcher (`matcher.ts`): `exact`/`prefix` + case toggle + quoted-argument parsing, first-match-wins.
- Handler (`handleMessage`): match → fetch → status mapping (2xx/404/other) → render → `conversations.send` (quoted text reply), with default templates and a 4000-char reply cap.
- Reliability (`reliability.ts`): storage-backed idempotency (`claim`, fail-closed, 3-day TTL) + throttled `prune` so storage can't grow unbounded + in-memory per-chat `allowCooldown` (fail-open, LRU-capped).
- Test suites for every module (node:test); passes typecheck, `catalog:check`, build, and the loader contract. Order-status, stock-lookup, and ticket-creation use cases run end-to-end through the real message path.
93 changes: 93 additions & 0 deletions http-action/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# HTTP Action Bot

> Jalankan REST API aman dari command WhatsApp dan ubah respons JSON menjadi balasan chat.

<!-- BEGIN DETAILS (generated by scripts/catalog.mjs — do not edit by hand) -->
| Field | Value |
| ----- | ----- |
| **Identifier** | `http-action` |
| **Version** | 0.1.0 |
| **Released** | 2026-07-11 |
| **Status** | development |
| **Author** | Yudhi Armyndharis |
| **License** | MIT |
| **Type** | `extension` |
| **Requires OpenWA** | ≥ null (tested null) |
| **Keywords** | api, rest, automation, connector, whatsapp, openwa |
| **Repository** | [OpenWA-plugins/http-action](https://github.com/rmyndharis/OpenWA-plugins/tree/main/http-action) |
<!-- END DETAILS -->

## Features

- Trigger `exact` / `prefix` dari body pesan.
- Method `GET` / `POST` JSON ke satu origin HTTPS tetap (`baseUrl`).
- Auth: none / Bearer / API-key header.
- Dot-path response mapping + reply template teks.
- Direct chat aktif default; group opt-in. Cooldown per chat + dedup message ID.
- **Safe by default:** origin tetap dari config (allowConfigHosts), path wajib relative, header berbahaya ditolak, secret dimasking.

## What it does

Saat pengguna mengetik command yang cocok (mis. `cek-order INV-001`), plugin memanggil endpoint API, membaca field dari respons JSON, dan membalas dengan teks yang dirender dari template. Satu request per pesan, satu balasan.

## Setup

1. Siapkan endpoint API HTTPS yang ingin dihubungkan.
2. Tentukan command + path + template untuk tiap aksi (lihat contoh config).
3. Isi `baseUrl` (wajib HTTPS) dan auth bila perlu.

## Install

Download ZIP dari [GitHub Release](https://github.com/rmyndharis/OpenWA-plugins/releases) (`http-action-vX.Y.Z`), lalu upload via dashboard OpenWA (`POST /plugins/install`). Plugin load dalam kondisi disabled; enable setelah config diisi.

## Configuration

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `baseUrl` | string | — *(required)* | Origin API HTTPS. Host auto-added ke allowlist outbound. Wajib non-empty. |
| `authType` | enum | `none` | `none` · `bearer` · `apikey` |
| `authToken` | secret | — | Bearer token / API key (sesuai `authType`). Dimasking `***`. |
| `apiKeyHeader` | string | `X-API-Key` | Nama header untuk `authType=apikey`. |
| `respondInGroups` | boolean | `false` | Balas juga di group chat. |
| `timeoutMs` | number | `3000` | Timeout request (min 500). |
| `cooldownSeconds` | number | `3` | Cooldown per chat. |
| `actions` | textarea | — *(required)* | Array action sebagai JSON string (lihat contoh di configSchema). |

```json
{
"baseUrl": "https://erp.example.com",
"authType": "bearer",
"authToken": "***",
"actions": [
{
"id": "check-order",
"match": { "type": "prefix", "value": "cek-order " },
"request": { "method": "GET", "path": "/orders/{{args.0}}" },
"replyTemplate": "Pesanan {{response.orderId}}: {{response.status}}\nResi: {{response.trackingNumber}}",
"notFoundTemplate": "Pesanan tidak ditemukan.",
"errorTemplate": "Layanan status pesanan bermasalah. Coba lagi nanti."
}
]
}
```

## Compatibility

> `minOpenWAVersion` / `testedOpenWAVersion` belum diisi — plugin masih development. Akan diisi setelah uji install + runtime pada versi OpenWA nyata.

## Security

- **Origin tetap:** `baseUrl` wajib HTTPS tanpa credential; host hanya dari config (allowConfigHosts), tidak pernah dari pesan.
- **Path aman:** wajib relative (`/...`), bukan protocol-relative (`//`) atau absolute URL; fragment & control char ditolak.
- **Header:** hop-by-hop + `x-forwarded-*` + CRLF ditolak (anti header injection).
- **No arbitrary code:** tidak ada `eval`/regex bebas/loop. Satu request per pesan.
- **Redirect lintas host:** di luar kendali plugin (tidak ada opsi `redirect` di `ctx.net.fetch`) — bergantung pada host proxy. Lihat catatan di spesifikasi internal.
- **Secret:** `authToken` ditandai `secret` di configSchema (dimasking `***`, di-preserve saat round-trip).

## Changelog

See [CHANGELOG.md](./CHANGELOG.md).

## License

MIT
142 changes: 142 additions & 0 deletions http-action/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { HttpActionClient, type FetchLike, type FetchResponse } from './client.ts';
import { readConfig, type HttpAction, type HttpActionConfig } from './config.ts';

interface Captured {
url?: string;
init?: { method?: string; headers?: Record<string, string>; body?: string; timeoutMs?: number };
}

// A recording fake mirroring PluginNetCapability.fetch(url, init). Returns a canned response, captures the call.
function fakeFetch(capture: Captured, res: { ok?: boolean; status?: number; body: string }): FetchLike {
return async (url: string, init?: Captured['init']): Promise<FetchResponse> => {
capture.url = url;
capture.init = init;
return { ok: res.ok ?? true, status: res.status ?? 200, statusText: 'OK', headers: {}, body: res.body };
};
}

function cfgWith(over: Record<string, unknown> = {}): { config: HttpActionConfig; action: HttpAction } {
const config = readConfig({
baseUrl: 'https://api.example.com',
actions: JSON.stringify([{
id: 'check', match: { type: 'prefix', value: 'cek ' },
request: { method: 'GET', path: '/orders/{{args.0}}', query: { zone: '{{args.1}}' }, headers: { 'X-Trace': '{{message.id}}' } },
replyTemplate: '{{response.status}}',
}, {
id: 'create', match: { type: 'prefix', value: 'buat ' },
request: { method: 'POST', path: '/tickets', bodyTemplate: '{"desc":"{{args.0}}"}' },
replyTemplate: '{{response.id}}',
}]),
...over,
});
return { config, action: config.actions[0] };
}

test('GET builds the URL from baseUrl + rendered (encoded) path', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['INV 001'] });
assert.equal(cap.url, 'https://api.example.com/orders/INV%20001');
});

test('GET has no body', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
assert.equal(cap.init?.body, undefined);
});

test('GET appends an encoded query string from a templated value', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X', 'jakarta barat'] });
assert.match(cap.url ?? '', /\?zone=jakarta%20barat$/);
});

test('action header values are rendered', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1' } });
assert.equal(cap.init?.headers?.['X-Trace'], 'm1');
});

test('a header value with CR/LF (via an attacker-controlled arg) is rejected, never sent', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
// action.request.headers = { 'X-Trace': '{{message.id}}' }; send a body whose id carries a newline
await assert.rejects(
() => new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'], message: { id: 'm1\nInjected: evil' } }),
/CR\/LF|header/i,
);
assert.equal(cap.url, undefined); // fetch was never called
});

test('bearer auth adds Authorization: Bearer <token>', async () => {
const { config, action } = cfgWith({ authType: 'bearer', authToken: 'tok123' });
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
assert.equal(cap.init?.headers?.['Authorization'], 'Bearer tok123');
});

test('apikey auth adds the configured header name', async () => {
const { config, action } = cfgWith({ authType: 'apikey', authToken: 'key123', apiKeyHeader: 'X-Api-Key' });
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
assert.equal(cap.init?.headers?.['X-Api-Key'], 'key123');
assert.equal(cap.init?.headers?.['Authorization'], undefined);
});

test('none auth adds no auth header', async () => {
const { config, action } = cfgWith();
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
assert.equal(cap.init?.headers?.['Authorization'], undefined);
});

test('POST sends a rendered, re-parsed JSON body with application/json content type', async () => {
const { config } = cfgWith();
const action = config.actions[1]; // 'create' POST
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['internet mati'] });
assert.equal(cap.init?.method, 'POST');
assert.equal(cap.init?.headers?.['Content-Type'], 'application/json');
assert.deepEqual(JSON.parse(cap.init?.body ?? '{}'), { desc: 'internet mati' });
});

test('parses a JSON response body into data', async () => {
const { config, action } = cfgWith();
const client = new HttpActionClient(fakeFetch({}, { body: JSON.stringify({ status: 'shipped' }) }), config);
const out = await client.run(action, { args: ['X'] });
assert.deepEqual(out.data, { status: 'shipped' });
assert.equal(out.status, 200);
});

test('a response body over the 256 KiB cap is rejected', async () => {
const { config, action } = cfgWith();
const big = 'x'.repeat(256 * 1024 + 1);
const client = new HttpActionClient(fakeFetch({}, { body: big }), config);
await assert.rejects(() => client.run(action, { args: ['X'] }), /too large|RESPONSE_TOO_LARGE/i);
});

test('a non-JSON body on a 2xx response throws (UPSTREAM_INVALID_JSON)', async () => {
const { config, action } = cfgWith();
const client = new HttpActionClient(fakeFetch({}, { ok: true, status: 200, body: 'not json' }), config);
await assert.rejects(() => client.run(action, { args: ['X'] }), /invalid json|UPSTREAM_INVALID_JSON/i);
});

test('a non-ok status (404) is returned, not thrown, with the status', async () => {
const { config, action } = cfgWith();
const client = new HttpActionClient(fakeFetch({}, { ok: false, status: 404, body: '{"error":"none"}' }), config);
const out = await client.run(action, { args: ['X'] });
assert.equal(out.status, 404);
assert.deepEqual(out.data, { error: 'none' });
});

test('timeoutMs from config is passed to fetch init', async () => {
const { config, action } = cfgWith({ timeoutMs: 2500 });
const cap: Captured = {};
await new HttpActionClient(fakeFetch(cap, { body: '{}' }), config).run(action, { args: ['X'] });
assert.equal(cap.init?.timeoutMs, 2500);
});
97 changes: 97 additions & 0 deletions http-action/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Fixed-origin HTTP client for HTTP Action Bot. Takes the host `ctx.net.fetch` (injected, so this tests
// without OpenWA) and a validated config; builds a safe request per action + template context and parses
// the JSON response. Pure modulo the injected fetch.
//
// The injected fetch matches the real PluginNetCapability.fetch(url, init) — URL is the first positional
// arg, init carries method/headers/body/timeoutMs (NOT a `url` field). Security: origin is fixed to
// cfg.baseUrl (config-validated https, allowConfigHosts); the path is rendered via renderPath (URL-encodes
// each arg segment so an arg can't add a segment or change origin); query values are encodeURIComponent'd;
// the POST body is JSON-escaped by renderJson and re-parsed before send. Response capped at 256 KiB.

import type { HttpAction, HttpActionConfig } from './config.ts';
import { renderPath, renderText, renderJson, renderHeader, type TemplateContext } from './url-template.ts';

const MAX_RESPONSE_BYTES = 256 * 1024;

export interface FetchInit {
method?: string;
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
}

export interface FetchResponse {
ok: boolean;
status: number;
statusText?: string;
headers: Record<string, string>;
body: string;
}

/** Mirrors PluginNetCapability.fetch(url, init) so ctx.net.fetch.bind(ctx.net) drops straight in. */
export type FetchLike = (url: string, init?: FetchInit) => Promise<FetchResponse>;

export interface ActionResult {
status: number;
data: unknown; // parsed JSON, or undefined when a non-ok body wasn't JSON
}

export class HttpActionClient {
constructor(private readonly fetch: FetchLike, private readonly cfg: HttpActionConfig) {}

async run(action: HttpAction, ctx: TemplateContext): Promise<ActionResult> {
const { url, init } = this.buildRequest(action, ctx);
const res = await this.fetch(url, init);
if (res.body.length > MAX_RESPONSE_BYTES) {
throw new Error('http-action: upstream response too large (RESPONSE_TOO_LARGE)');
}
let data: unknown;
try {
data = res.body.length ? JSON.parse(res.body) : undefined;
} catch {
if (res.ok) throw new Error('http-action: upstream returned invalid JSON (UPSTREAM_INVALID_JSON)');
data = undefined; // non-ok body may be a plaintext error; the handler maps via status template
}
return { status: res.status, data };
}

private buildRequest(action: HttpAction, ctx: TemplateContext): { url: string; init: FetchInit } {
const path = renderPath(action.request.path, ctx);
let url = this.cfg.baseUrl + path;

if (action.request.query) {
const qs = Object.entries(action.request.query)
.map(([k, v]) => [k, renderText(v, ctx)] as const)
.filter(([, val]) => val !== '') // omit params whose value renders empty (e.g. a missing arg)
.map(([k, val]) => `${encodeURIComponent(k)}=${encodeURIComponent(val)}`)
.join('&');
if (qs) url += `?${qs}`;
}

const headers: Record<string, string> = {};
if (action.request.headers) {
for (const [k, v] of Object.entries(action.request.headers)) headers[k] = renderHeader(v, ctx);
}

// Auth — authToken is a configSchema secret; never logged by the caller.
if (this.cfg.authType === 'bearer') headers['Authorization'] = `Bearer ${this.cfg.authToken}`;
else if (this.cfg.authType === 'apikey') headers[this.cfg.apiKeyHeader] = this.cfg.authToken ?? '';

const init: FetchInit = { method: action.request.method, headers, timeoutMs: this.cfg.timeoutMs };

if (action.request.method === 'POST') {
headers['Content-Type'] = 'application/json';
if (action.request.bodyTemplate) {
const rendered = renderJson(action.request.bodyTemplate, ctx);
try {
JSON.parse(rendered); // validate before send (anti JSON injection via an arg)
} catch {
throw new Error('http-action: rendered request body is not valid JSON');
}
init.body = rendered;
}
}

return { url, init };
}
}
Loading
Loading