watahub 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/routes/templates.js +8 -4
- package/dist/schemas/common.d.ts +6 -0
- package/dist/schemas/common.js +11 -0
- package/dist/services/meta-client.d.ts +4 -4
- package/dist/services/meta-client.js +18 -4
- package/dist/types/gateway.d.ts +7 -0
- package/package.json +1 -1
- package/dist/services/phone-directory.d.ts +0 -11
- package/dist/services/phone-directory.js +0 -29
package/README.md
CHANGED
|
@@ -136,7 +136,7 @@ Expected response:
|
|
|
136
136
|
{
|
|
137
137
|
"status": "ok",
|
|
138
138
|
"service": "watahub",
|
|
139
|
-
"version": "0.
|
|
139
|
+
"version": "0.2.0",
|
|
140
140
|
"timestamp": 1729620000000
|
|
141
141
|
}
|
|
142
142
|
```
|
|
@@ -298,7 +298,7 @@ pnpm run deploy
|
|
|
298
298
|
| `GET` | `/v1/media/:mediaId` | Resolves media ID to temporary CDN download URL |
|
|
299
299
|
| `POST` | `/v1/media` | Uploads media file (`multipart/form-data`) streaming to Meta |
|
|
300
300
|
| `POST` | `/v1/media/:phoneNumberId` | Uploads media scoped to a specific phone number |
|
|
301
|
-
| `GET` | `/v1/templates/:wabaId` | Lists
|
|
301
|
+
| `GET` | `/v1/templates/:wabaId` | Lists a WABA's message templates from Meta Graph API, one page per call (`?after=`) |
|
|
302
302
|
| `POST` | `/v1/onboarding/connect` | Connects one phone line (exchange, store, subscribe, register). Does not return the token |
|
|
303
303
|
| `GET` | `/v1/onboarding/launch` | Public Embedded Signup parameters for `FB.login` (`appId`, `configurationId`, `apiVersion`) |
|
|
304
304
|
| `POST` | `/v1/onboarding/exchange` | Low-level code exchange. Returns the token. Prefer connect |
|
|
@@ -322,7 +322,7 @@ pnpm run deploy
|
|
|
322
322
|
|
|
323
323
|
## 🚦 Project Status
|
|
324
324
|
|
|
325
|
-
Watahub is currently in active development (**v0.
|
|
325
|
+
Watahub is currently in active development (**v0.2.0**).
|
|
326
326
|
|
|
327
327
|
The core WhatsApp bridge functionality (message dispatch, streaming media uploads, HMAC verification, replay protection, rate limiting, and library exports) is production-ready and fully tested with 100% passing automated tests across Node.js and Cloudflare Workers.
|
|
328
328
|
|
package/dist/config.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Context } from 'hono';
|
|
2
2
|
export declare const GATEWAY_NAME = "watahub";
|
|
3
|
-
export declare const GATEWAY_VERSION = "0.
|
|
4
|
-
export declare const GATEWAY_USER_AGENT = "Watahub-Gateway/0.
|
|
3
|
+
export declare const GATEWAY_VERSION = "0.2.0";
|
|
4
|
+
export declare const GATEWAY_USER_AGENT = "Watahub-Gateway/0.2.0";
|
|
5
5
|
export declare const META_GRAPH_BASE_URL = "https://graph.facebook.com";
|
|
6
6
|
export declare const DEFAULT_META_API_VERSION = "v24.0";
|
|
7
7
|
/**
|
package/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getEnvVar } from './utils/env.js';
|
|
2
2
|
export const GATEWAY_NAME = 'watahub';
|
|
3
|
-
export const GATEWAY_VERSION = '0.
|
|
3
|
+
export const GATEWAY_VERSION = '0.2.0';
|
|
4
4
|
export const GATEWAY_USER_AGENT = `Watahub-Gateway/${GATEWAY_VERSION}`;
|
|
5
5
|
export const META_GRAPH_BASE_URL = 'https://graph.facebook.com';
|
|
6
6
|
export const DEFAULT_META_API_VERSION = 'v24.0';
|
package/dist/routes/templates.js
CHANGED
|
@@ -3,12 +3,12 @@ import { getConfig } from '../config.js';
|
|
|
3
3
|
import { validationError } from '../http/body.js';
|
|
4
4
|
import { errorResponse } from '../http/errors.js';
|
|
5
5
|
import { metaClientFor } from '../http/meta.js';
|
|
6
|
-
import { isGraphId } from '../schemas/common.js';
|
|
6
|
+
import { isGraphId, isPageCursor } from '../schemas/common.js';
|
|
7
7
|
export function createTemplatesRouter(deps) {
|
|
8
8
|
const router = new Hono();
|
|
9
9
|
/**
|
|
10
|
-
* List WhatsApp Message Templates
|
|
11
|
-
* GET /v1/templates or GET /v1/templates/:wabaId
|
|
10
|
+
* List WhatsApp Message Templates, one page at a time
|
|
11
|
+
* GET /v1/templates or GET /v1/templates/:wabaId, then ?after=<paging.after> for the next page
|
|
12
12
|
*/
|
|
13
13
|
router.get('/v1/templates/:wabaId?', async (c) => {
|
|
14
14
|
const wabaId = c.req.param('wabaId') || c.req.query('wabaId') || getConfig(c).metaDefaultWabaId;
|
|
@@ -19,11 +19,15 @@ export function createTemplatesRouter(deps) {
|
|
|
19
19
|
if (!isGraphId(wabaId)) {
|
|
20
20
|
return validationError(c, { wabaId: ['Must be a numeric Meta Graph ID'] });
|
|
21
21
|
}
|
|
22
|
+
const after = c.req.query('after');
|
|
23
|
+
if (after !== undefined && !isPageCursor(after)) {
|
|
24
|
+
return validationError(c, { after: ['Must be a paging cursor from a previous response'] });
|
|
25
|
+
}
|
|
22
26
|
const meta = await metaClientFor(c, deps);
|
|
23
27
|
if (!meta.ok)
|
|
24
28
|
return meta.response;
|
|
25
29
|
try {
|
|
26
|
-
return c.json(
|
|
30
|
+
return c.json(await meta.value.listTemplates(wabaId, after), 200);
|
|
27
31
|
}
|
|
28
32
|
catch (err) {
|
|
29
33
|
return errorResponse(c, err, deps.logger);
|
package/dist/schemas/common.d.ts
CHANGED
|
@@ -6,6 +6,12 @@ import { z } from 'zod';
|
|
|
6
6
|
*/
|
|
7
7
|
export declare const GraphIdSchema: z.ZodString;
|
|
8
8
|
export declare function isGraphId(value: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Graph paging cursors are opaque base64 strings. A cursor only ever travels URL-encoded as a query value,
|
|
11
|
+
* never as a path segment; the shape and bound keep anything else out of the Graph URL.
|
|
12
|
+
*/
|
|
13
|
+
export declare const PageCursorSchema: z.ZodString;
|
|
14
|
+
export declare function isPageCursor(value: string): boolean;
|
|
9
15
|
/**
|
|
10
16
|
* True when the URL is https, carries no credentials and does not point at a
|
|
11
17
|
* loopback, private, link-local or internal host. Hostnames are checked as
|
package/dist/schemas/common.js
CHANGED
|
@@ -8,6 +8,17 @@ export const GraphIdSchema = z.string().regex(/^\d{1,32}$/, 'Must be a numeric M
|
|
|
8
8
|
export function isGraphId(value) {
|
|
9
9
|
return GraphIdSchema.safeParse(value).success;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Graph paging cursors are opaque base64 strings. A cursor only ever travels URL-encoded as a query value,
|
|
13
|
+
* never as a path segment; the shape and bound keep anything else out of the Graph URL.
|
|
14
|
+
*/
|
|
15
|
+
export const PageCursorSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.max(1024)
|
|
18
|
+
.regex(/^[A-Za-z0-9+/=_-]+$/, 'Must be a paging cursor from a previous response');
|
|
19
|
+
export function isPageCursor(value) {
|
|
20
|
+
return PageCursorSchema.safeParse(value).success;
|
|
21
|
+
}
|
|
11
22
|
const BLOCKED_HOST_SUFFIXES = ['.localhost', '.local', '.internal', '.home.arpa'];
|
|
12
23
|
function ipv4Octets(host) {
|
|
13
24
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Direct Meta Graph API client: one request path with timeouts and safe retries.
|
|
3
3
|
*/
|
|
4
4
|
import { type RetryPolicy, type Sleep } from './retry.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { MetaTemplatePage, SendMessagePayload, SendMessageResponse } from '../types/gateway.js';
|
|
6
6
|
export type { RetryPolicy } from './retry.js';
|
|
7
7
|
export { backoffDelayMs } from './retry.js';
|
|
8
8
|
export interface MetaClientConfig {
|
|
@@ -21,7 +21,7 @@ export interface MetaClientConfig {
|
|
|
21
21
|
*/
|
|
22
22
|
export interface MetaGraphClient {
|
|
23
23
|
sendMessage(phoneNumberId: string, payload: SendMessagePayload): Promise<SendMessageResponse>;
|
|
24
|
-
listTemplates(wabaId: string): Promise<
|
|
24
|
+
listTemplates(wabaId: string, after?: string): Promise<MetaTemplatePage>;
|
|
25
25
|
exchangeCodeForToken(code: string, clientId: string, clientSecret: string): Promise<{
|
|
26
26
|
accessToken: string;
|
|
27
27
|
}>;
|
|
@@ -49,9 +49,9 @@ export declare class MetaClient implements MetaGraphClient {
|
|
|
49
49
|
*/
|
|
50
50
|
sendMessage(phoneNumberId: string, payload: SendMessagePayload, overrideToken?: string): Promise<SendMessageResponse>;
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
52
|
+
* One page of the WABA's message templates. Pass the previous page's `paging.after` to get the next one.
|
|
53
53
|
*/
|
|
54
|
-
listTemplates(wabaId: string, overrideToken?: string): Promise<
|
|
54
|
+
listTemplates(wabaId: string, after?: string, overrideToken?: string): Promise<MetaTemplatePage>;
|
|
55
55
|
/**
|
|
56
56
|
* Exchange an Embedded Signup code for the business integration system user token.
|
|
57
57
|
* Unauthenticated: app credentials authenticate the call, no bearer token is sent.
|
|
@@ -57,11 +57,15 @@ export class MetaClient {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
/**
|
|
60
|
-
*
|
|
60
|
+
* One page of the WABA's message templates. Pass the previous page's `paging.after` to get the next one.
|
|
61
61
|
*/
|
|
62
|
-
async listTemplates(wabaId, overrideToken) {
|
|
63
|
-
const
|
|
64
|
-
|
|
62
|
+
async listTemplates(wabaId, after, overrideToken) {
|
|
63
|
+
const query = after ? `?${new URLSearchParams({ after }).toString()}` : '';
|
|
64
|
+
const { data } = ensureOk(await this.get(`${wabaId}/message_templates${query}`, overrideToken));
|
|
65
|
+
return {
|
|
66
|
+
data: Array.isArray(data.data) ? data.data.filter(isRecord) : [],
|
|
67
|
+
paging: { after: nextPageCursor(data.paging) },
|
|
68
|
+
};
|
|
65
69
|
}
|
|
66
70
|
/**
|
|
67
71
|
* Exchange an Embedded Signup code for the business integration system user token.
|
|
@@ -200,6 +204,16 @@ function ensureOk(res) {
|
|
|
200
204
|
throw new WatahubApiError(res.data, res.status);
|
|
201
205
|
return res;
|
|
202
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* Meta keeps `cursors.after` on the last page too; only `next` says another page exists.
|
|
209
|
+
* `next` itself is never returned: it is a Graph URL, not one of ours.
|
|
210
|
+
*/
|
|
211
|
+
function nextPageCursor(paging) {
|
|
212
|
+
if (!isRecord(paging) || typeof paging.next !== 'string' || !isRecord(paging.cursors))
|
|
213
|
+
return undefined;
|
|
214
|
+
const { after } = paging.cursors;
|
|
215
|
+
return typeof after === 'string' && after ? after : undefined;
|
|
216
|
+
}
|
|
203
217
|
/** Meta `/messages` request body. */
|
|
204
218
|
/**
|
|
205
219
|
* Business-scoped user ID: ISO 3166 alpha-2 country code, a dot and up to 128
|
package/dist/types/gateway.d.ts
CHANGED
|
@@ -59,6 +59,13 @@ export interface SendMessageResponse {
|
|
|
59
59
|
}
|
|
60
60
|
/** A message template exactly as Graph returns it (`id`, `name`, `status`, `language`, `components`...) */
|
|
61
61
|
export type MetaMessageTemplate = Record<string, unknown>;
|
|
62
|
+
/** One page of a WABA's templates. `paging.after` is set only when Meta has another page. */
|
|
63
|
+
export interface MetaTemplatePage {
|
|
64
|
+
data: MetaMessageTemplate[];
|
|
65
|
+
paging: {
|
|
66
|
+
after?: string;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
62
69
|
export interface ContactAddress {
|
|
63
70
|
street?: string;
|
|
64
71
|
city?: string;
|
package/package.json
CHANGED
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import type { PhoneConfig, PhoneDirectory } from '../deps.js';
|
|
2
|
-
export declare class InMemoryPhoneDirectory implements PhoneDirectory {
|
|
3
|
-
private readonly phones;
|
|
4
|
-
constructor(initial?: Record<string, PhoneConfig> | Map<string, PhoneConfig>);
|
|
5
|
-
set(phoneNumberId: string, config: PhoneConfig): void;
|
|
6
|
-
get(phoneNumberId: string): PhoneConfig | undefined;
|
|
7
|
-
delete(phoneNumberId: string): boolean;
|
|
8
|
-
clear(): void;
|
|
9
|
-
lookup(phoneNumberId: string): Promise<PhoneConfig | null>;
|
|
10
|
-
}
|
|
11
|
-
export declare const defaultPhoneDirectory: InMemoryPhoneDirectory;
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
export class InMemoryPhoneDirectory {
|
|
2
|
-
phones = new Map();
|
|
3
|
-
constructor(initial = {}) {
|
|
4
|
-
if (initial instanceof Map) {
|
|
5
|
-
this.phones = new Map(initial);
|
|
6
|
-
}
|
|
7
|
-
else {
|
|
8
|
-
for (const [id, cfg] of Object.entries(initial)) {
|
|
9
|
-
this.phones.set(id, cfg);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
set(phoneNumberId, config) {
|
|
14
|
-
this.phones.set(phoneNumberId, config);
|
|
15
|
-
}
|
|
16
|
-
get(phoneNumberId) {
|
|
17
|
-
return this.phones.get(phoneNumberId);
|
|
18
|
-
}
|
|
19
|
-
delete(phoneNumberId) {
|
|
20
|
-
return this.phones.delete(phoneNumberId);
|
|
21
|
-
}
|
|
22
|
-
clear() {
|
|
23
|
-
this.phones.clear();
|
|
24
|
-
}
|
|
25
|
-
lookup(phoneNumberId) {
|
|
26
|
-
return Promise.resolve(this.phones.get(phoneNumberId) ?? null);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
export const defaultPhoneDirectory = new InMemoryPhoneDirectory();
|