@pipes.bot/pipes-bot-channel 0.1.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/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { createPipesBotService } from "./src/service.js";
2
+ import { setPipesBotRuntime } from "./src/runtime.js";
3
+ import { pipesBotChannelPlugin } from "./src/channel.js";
4
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
5
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
6
+
7
+ const plugin = {
8
+ id: "pipes-bot-channel",
9
+ name: "PipesBot Channel",
10
+ description: "WhatsApp channel via PipesBot managed proxy",
11
+ configSchema: emptyPluginConfigSchema(),
12
+
13
+ register(api: OpenClawPluginApi): void {
14
+ setPipesBotRuntime(api.runtime);
15
+ api.registerChannel({ plugin: pipesBotChannelPlugin });
16
+ api.registerService(createPipesBotService());
17
+ api.logger.info("pipes-bot-channel: registered");
18
+ },
19
+ };
20
+
21
+ export default plugin;
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "pipes-bot-channel",
3
+ "channels": ["pipes-bot"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "apiKey": {
9
+ "type": "string",
10
+ "minLength": 1,
11
+ "pattern": "^pk_"
12
+ }
13
+ }
14
+ },
15
+ "uiHints": {
16
+ "apiKey": {
17
+ "label": "PipesBot API Key",
18
+ "sensitive": true,
19
+ "placeholder": "pk_..."
20
+ }
21
+ }
22
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@pipes.bot/pipes-bot-channel",
3
+ "version": "0.1.0",
4
+ "description": "WhatsApp channel via PipesBot managed proxy (OpenClaw plugin)",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "license": "MIT",
8
+ "dependencies": {
9
+ "ws": "^8.18.0"
10
+ },
11
+ "devDependencies": {
12
+ "@types/ws": "^8.5.13",
13
+ "openclaw": "*",
14
+ "typescript": "^5.0.0"
15
+ },
16
+ "openclaw": {
17
+ "extensions": ["./index.ts"]
18
+ },
19
+ "files": [
20
+ "index.ts",
21
+ "src/**/*.ts",
22
+ "openclaw.plugin.json"
23
+ ]
24
+ }
package/src/channel.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
2
+ import { getConnectionStatus } from "./status-store.js";
3
+
4
+ export const pipesBotChannelPlugin: ChannelPlugin = {
5
+ id: "pipes-bot-channel",
6
+
7
+ meta: {
8
+ id: "pipes-bot-channel",
9
+ label: "PipesBot (WhatsApp)",
10
+ selectionLabel: "PipesBot (WhatsApp)",
11
+ docsPath: "/docs/channels/pipes-bot",
12
+ blurb: "WhatsApp via PipesBot managed proxy",
13
+ },
14
+
15
+ capabilities: {
16
+ chatTypes: ["direct"],
17
+ media: true,
18
+ },
19
+
20
+ config: {
21
+ listAccountIds: () => ["default"],
22
+ resolveAccount: () => ({ apiKey: "configured" }),
23
+ isConfigured: () => true,
24
+ },
25
+
26
+ status: {
27
+ defaultRuntime: { accountId: "default", connected: false, lastError: null },
28
+
29
+ buildAccountSnapshot: ({ runtime }) => {
30
+ const status = getConnectionStatus();
31
+ return {
32
+ accountId: "default",
33
+ connected: status.connected,
34
+ running: !status.authFailed,
35
+ lastConnectedAt: status.lastConnectedAt,
36
+ lastError: status.authFailed
37
+ ? "Authentication failed -- check your pk_ API key"
38
+ : (status.lastError ?? runtime?.lastError ?? null),
39
+ };
40
+ },
41
+ },
42
+ };
@@ -0,0 +1,204 @@
1
+ import WebSocket from "ws";
2
+ import { EventEmitter } from "node:events";
3
+ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
4
+
5
+ // ── Constants ────────────────────────────────────────────────────────────────
6
+
7
+ const BASE_MS = 1_000;
8
+ const MAX_MS = 120_000; // 2 min cap (user decision)
9
+ const PING_INTERVAL_MS = 25_000;
10
+ const PONG_TIMEOUT_MS = 50_000; // 75s total with ping interval = WS-03
11
+
12
+ // ── Types ────────────────────────────────────────────────────────────────────
13
+
14
+ export type ConnectionState = "connected" | "disconnected" | "reconnecting";
15
+
16
+ type PluginLogger = OpenClawPluginServiceContext["logger"];
17
+
18
+ // ── Module-level helpers ─────────────────────────────────────────────────────
19
+
20
+ function computeBackoffDelay(attempt: number): number {
21
+ return Math.floor(Math.random() * Math.min(MAX_MS, BASE_MS * Math.pow(2, attempt)));
22
+ }
23
+
24
+ function buildWsUrl(apiKey: string): string {
25
+ return `wss://api.pipes.bot/ws?apiKey=${encodeURIComponent(apiKey)}`;
26
+ }
27
+
28
+ // ── PipesBotConnection ───────────────────────────────────────────────────────
29
+
30
+ export class PipesBotConnection extends EventEmitter {
31
+ private readonly apiKey: string;
32
+ private readonly logger: PluginLogger;
33
+
34
+ private state: ConnectionState = "disconnected";
35
+ private ws: WebSocket | null = null;
36
+ private shouldReconnect = false;
37
+ private reconnectAttempts = 0;
38
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
39
+ private pingTimer: ReturnType<typeof setInterval> | null = null;
40
+ private pongTimer: ReturnType<typeof setTimeout> | null = null;
41
+
42
+ constructor(apiKey: string, logger: PluginLogger) {
43
+ super();
44
+ this.apiKey = apiKey;
45
+ this.logger = logger;
46
+ }
47
+
48
+ // ── Public API ─────────────────────────────────────────────────────────────
49
+
50
+ connect(): void {
51
+ this.shouldReconnect = true;
52
+ this.doConnect();
53
+ }
54
+
55
+ disconnect(): void {
56
+ // Ordered teardown -- shouldReconnect must be false before ws.close()
57
+ // so the close handler does not schedule a reconnect.
58
+ this.shouldReconnect = false;
59
+ this.stopHeartbeat();
60
+
61
+ if (this.reconnectTimer !== null) {
62
+ clearTimeout(this.reconnectTimer);
63
+ this.reconnectTimer = null;
64
+ }
65
+
66
+ if (this.ws !== null) {
67
+ this.ws.close(1000);
68
+ this.ws = null;
69
+ }
70
+
71
+ this.setState("disconnected");
72
+ }
73
+
74
+ send(message: object): boolean {
75
+ if (this.state !== "connected" || this.ws === null) {
76
+ return false;
77
+ }
78
+ this.ws.send(JSON.stringify(message));
79
+ return true;
80
+ }
81
+
82
+ getState(): ConnectionState {
83
+ return this.state;
84
+ }
85
+
86
+ // ── Private methods ────────────────────────────────────────────────────────
87
+
88
+ private doConnect(): void {
89
+ const ws = new WebSocket(buildWsUrl(this.apiKey), {
90
+ handshakeTimeout: 10_000,
91
+ });
92
+ this.ws = ws;
93
+
94
+ ws.on("unexpected-response", (_req, response) => {
95
+ const { statusCode } = response;
96
+
97
+ if (statusCode === 401 || statusCode === 403) {
98
+ // Permanent auth failure -- stop reconnecting
99
+ this.shouldReconnect = false;
100
+ this.logger.error(
101
+ `pipes-bot-channel: WebSocket authentication failed (HTTP ${statusCode}) -- stopping`
102
+ );
103
+ this.emit("error", new Error(`WebSocket auth failed: HTTP ${statusCode}`));
104
+ } else {
105
+ // Transient failure -- let close handler schedule reconnect
106
+ this.logger.warn(
107
+ `pipes-bot-channel: WebSocket upgrade failed (HTTP ${statusCode}) -- will reconnect`
108
+ );
109
+ }
110
+ });
111
+
112
+ ws.on("open", () => {
113
+ this.reconnectAttempts = 0;
114
+ this.setState("connected");
115
+ this.startHeartbeat(ws);
116
+ this.logger.info("pipes-bot-channel: WebSocket connected");
117
+ });
118
+
119
+ ws.on("message", (data) => {
120
+ try {
121
+ const parsed = JSON.parse(data.toString()) as object;
122
+ this.emit("message", parsed);
123
+ } catch {
124
+ this.logger.warn("pipes-bot-channel: received non-JSON WebSocket message -- ignoring");
125
+ }
126
+ });
127
+
128
+ ws.on("error", (err) => {
129
+ // Log only -- the close event fires next and handles reconnect scheduling
130
+ this.logger.error(`pipes-bot-channel: WebSocket error: ${err.message}`);
131
+ });
132
+
133
+ ws.on("close", () => {
134
+ this.stopHeartbeat();
135
+ this.ws = null;
136
+
137
+ if (this.shouldReconnect) {
138
+ this.scheduleReconnect();
139
+ } else {
140
+ this.setState("disconnected");
141
+ }
142
+ });
143
+ }
144
+
145
+ private scheduleReconnect(): void {
146
+ this.setState("reconnecting");
147
+ this.reconnectAttempts += 1;
148
+ const delay = computeBackoffDelay(this.reconnectAttempts);
149
+ this.logger.info(
150
+ `pipes-bot-channel: reconnect attempt ${this.reconnectAttempts} in ${delay}ms`
151
+ );
152
+
153
+ this.reconnectTimer = setTimeout(() => {
154
+ this.reconnectTimer = null;
155
+ this.doConnect();
156
+ }, delay);
157
+ }
158
+
159
+ private setState(next: ConnectionState): void {
160
+ if (this.state === next) return;
161
+ this.state = next;
162
+ this.emit("state", next);
163
+
164
+ if (next === "connected" || next === "disconnected") {
165
+ this.logger.info(`pipes-bot-channel: state -> ${next}`);
166
+ }
167
+ // reconnecting state is logged in scheduleReconnect with attempt details
168
+ }
169
+
170
+ private startHeartbeat(ws: WebSocket): void {
171
+ this.pingTimer = setInterval(() => {
172
+ ws.ping();
173
+
174
+ if (this.pongTimer !== null) {
175
+ clearTimeout(this.pongTimer);
176
+ }
177
+ this.pongTimer = setTimeout(() => {
178
+ this.logger.error(
179
+ "pipes-bot-channel: pong timeout -- terminating dead connection"
180
+ );
181
+ // ws.terminate() forces close code 1006 (abnormal closure)
182
+ ws.terminate();
183
+ }, PONG_TIMEOUT_MS);
184
+ }, PING_INTERVAL_MS);
185
+
186
+ ws.on("pong", () => {
187
+ if (this.pongTimer !== null) {
188
+ clearTimeout(this.pongTimer);
189
+ this.pongTimer = null;
190
+ }
191
+ });
192
+ }
193
+
194
+ private stopHeartbeat(): void {
195
+ if (this.pingTimer !== null) {
196
+ clearInterval(this.pingTimer);
197
+ this.pingTimer = null;
198
+ }
199
+ if (this.pongTimer !== null) {
200
+ clearTimeout(this.pongTimer);
201
+ this.pongTimer = null;
202
+ }
203
+ }
204
+ }
package/src/inbound.ts ADDED
@@ -0,0 +1,245 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { buildAgentMediaPayload, toLocationContext, type NormalizedLocation } from "openclaw/plugin-sdk";
3
+ import { getPipesBotRuntime } from "./runtime.js";
4
+ import { downloadPipesBotMedia } from "./media.js";
5
+ import {
6
+ isPipesBotMessage,
7
+ type PluginLogger,
8
+ type PipesBotContact,
9
+ type PipesBotMessageData,
10
+ } from "./types.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Public export
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export async function handleInbound(
17
+ msg: unknown,
18
+ opts: {
19
+ apiKey: string;
20
+ accountId: string;
21
+ send: (payload: object) => boolean;
22
+ logger: PluginLogger;
23
+ },
24
+ ): Promise<void> {
25
+ // 1. Type guard — reject anything that isn't a whatsapp_message envelope
26
+ if (!isPipesBotMessage(msg)) return;
27
+ const data = msg.data;
28
+
29
+ const core = getPipesBotRuntime();
30
+ const cfg = core.config.loadConfig() as OpenClawConfig;
31
+ const { apiKey, accountId, send, logger } = opts;
32
+
33
+ // 2. Skip unsupported message types
34
+ if (data.type === "unsupported") return;
35
+
36
+ // 3. Skip reaction removals (no emoji present)
37
+ if (data.type === "reaction" && !data.reaction?.emoji) return;
38
+
39
+ // 4. Resolve body text
40
+ const rawBody = resolveRawBody(data);
41
+ const body = rawBody;
42
+
43
+ // 5. Download media for media message types
44
+ const mediaPayload = await resolveMediaPayload(data, apiKey, logger, core);
45
+
46
+ // 6. Save contacts as VCF if this is a contacts message
47
+ const contactsPayload =
48
+ data.type === "contacts" && data.contacts && data.contacts.length > 0
49
+ ? await resolveContactsPayload(data.contacts, core)
50
+ : {};
51
+
52
+ // 7. Location context
53
+ const locationCtx = resolveLocationCtx(data);
54
+
55
+ // 8. ReplyToId (reactions only)
56
+ const replyToId =
57
+ data.type === "reaction" ? data.reaction?.messageId : undefined;
58
+
59
+ // 9. SessionKey — prefixed with accountId for multi-account safety
60
+ const sessionKey = `${accountId}:${data.conversationId}`;
61
+
62
+ // 10. Resolve agent route
63
+ const route = core.channel.routing.resolveAgentRoute({
64
+ cfg,
65
+ channel: "pipes-bot-channel",
66
+ accountId,
67
+ peer: { kind: "direct", id: data.conversationId },
68
+ });
69
+
70
+ // 11. Resolve session store path
71
+ const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
72
+ agentId: route.agentId,
73
+ });
74
+
75
+ // 12. Finalize inbound context — normalizes Body, sets BodyForAgent, etc.
76
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
77
+ Body: body,
78
+ RawBody: rawBody,
79
+ CommandBody: rawBody,
80
+ From: `pipes-bot-channel:${data.fromNumber}`,
81
+ To: `pipes-bot-channel:${data.poolNumberPhoneNumber}`,
82
+ SessionKey: sessionKey,
83
+ AccountId: accountId,
84
+ ChatType: "direct" as const,
85
+ SenderName: data.fromName ?? undefined,
86
+ SenderId: data.fromNumber,
87
+ Provider: "pipes-bot-channel",
88
+ Surface: "pipes-bot-channel",
89
+ MessageSid: data.messageId,
90
+ Timestamp: new Date(data.timestamp).getTime(),
91
+ ConversationLabel: data.fromName ?? data.fromNumber,
92
+ ReplyToId: replyToId,
93
+ OriginatingChannel: "pipes-bot-channel",
94
+ OriginatingTo: `pipes-bot-channel:${data.conversationId}`,
95
+ ...mediaPayload,
96
+ ...contactsPayload,
97
+ ...locationCtx,
98
+ });
99
+
100
+ // 13. Record inbound session for multi-turn context
101
+ await core.channel.session.recordInboundSession({
102
+ storePath,
103
+ sessionKey: ctxPayload.SessionKey ?? sessionKey,
104
+ ctx: ctxPayload,
105
+ onRecordError: (err) => {
106
+ logger.error(`pipes-bot-channel: session record failed: ${String(err)}`);
107
+ },
108
+ });
109
+
110
+ // 14. Dispatch to agent pipeline
111
+ // Uses dispatchReplyWithBufferedBlockDispatcher — simpler than the
112
+ // createReplyDispatcherWithTyping pattern because PipesBot has no
113
+ // typing indicator API (typing callbacks are no-ops).
114
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
115
+ ctx: ctxPayload,
116
+ cfg,
117
+ dispatcherOptions: {
118
+ deliver: async (payload) => {
119
+ const sent = send({
120
+ type: "whatsapp_reply",
121
+ data: {
122
+ conversationId: data.conversationId,
123
+ poolNumberId: data.poolNumberId,
124
+ toNumber: data.fromNumber,
125
+ text: payload.text ?? "",
126
+ },
127
+ });
128
+ if (!sent) {
129
+ logger.warn(
130
+ `pipes-bot-channel: send returned false (disconnected?) for conversation ${data.conversationId}`,
131
+ );
132
+ }
133
+ },
134
+ onError: (err, info) => {
135
+ logger.error(
136
+ `pipes-bot-channel: ${info.kind} reply failed: ${String(err)}`,
137
+ );
138
+ },
139
+ },
140
+ replyOptions: {},
141
+ });
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Private helpers
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** Resolve the text body for each message type. */
149
+ function resolveRawBody(data: PipesBotMessageData): string {
150
+ switch (data.type) {
151
+ case "text":
152
+ return data.body;
153
+ case "reaction":
154
+ // Emoji is guaranteed non-null here (removals are skipped upstream)
155
+ return data.reaction?.emoji ?? "";
156
+ case "location":
157
+ case "contacts":
158
+ // Body stays empty — structured data is in locationCtx / contactsPayload
159
+ return "";
160
+ default:
161
+ // image, audio, video, document, sticker — caption or empty
162
+ return data.body ?? "";
163
+ }
164
+ }
165
+
166
+ /** Download media for image/audio/video/document/sticker message types. */
167
+ async function resolveMediaPayload(
168
+ data: PipesBotMessageData,
169
+ apiKey: string,
170
+ logger: PluginLogger,
171
+ _core: ReturnType<typeof getPipesBotRuntime>,
172
+ ): Promise<ReturnType<typeof buildAgentMediaPayload>> {
173
+ const mediaTypes: Array<
174
+ "image" | "audio" | "video" | "document" | "sticker"
175
+ > = ["image", "audio", "video", "document", "sticker"];
176
+ if (!mediaTypes.includes(data.type as never)) return {};
177
+
178
+ if (!data.media || data.media.unavailable || !data.media.downloadUrl) {
179
+ if (data.media?.unavailable) {
180
+ logger.warn(
181
+ `pipes-bot-channel: media unavailable for message ${data.messageId} — proceeding as text-only`,
182
+ );
183
+ }
184
+ return {};
185
+ }
186
+
187
+ const result = await downloadPipesBotMedia({
188
+ downloadUrl: data.media.downloadUrl,
189
+ mimeType: data.media.mimeType,
190
+ fileName: data.media.fileName,
191
+ apiKey,
192
+ logger,
193
+ });
194
+
195
+ if (!result) return {};
196
+
197
+ return buildAgentMediaPayload([
198
+ { path: result.path, contentType: result.contentType },
199
+ ]);
200
+ }
201
+
202
+ /** Save contacts array as a VCF file and return an agent media payload. */
203
+ async function resolveContactsPayload(
204
+ contacts: PipesBotContact[],
205
+ core: ReturnType<typeof getPipesBotRuntime>,
206
+ ): Promise<ReturnType<typeof buildAgentMediaPayload>> {
207
+ const vcard = contacts.map(formatVCard).join("\n");
208
+ const saved = await core.channel.media.saveMediaBuffer(
209
+ Buffer.from(vcard, "utf-8"),
210
+ "text/vcard",
211
+ "inbound",
212
+ );
213
+ return buildAgentMediaPayload([
214
+ { path: saved.path, contentType: "text/vcard" },
215
+ ]);
216
+ }
217
+
218
+ /**
219
+ * Convert a location message to OpenClaw's structured location context fields.
220
+ * Returns an empty object for non-location types.
221
+ */
222
+ function resolveLocationCtx(
223
+ data: PipesBotMessageData,
224
+ ): ReturnType<typeof toLocationContext> | Record<string, never> {
225
+ if (data.type !== "location" || !data.location) return {};
226
+ const loc: NormalizedLocation = {
227
+ latitude: data.location.latitude,
228
+ longitude: data.location.longitude,
229
+ name: data.location.name,
230
+ address: data.location.address,
231
+ };
232
+ return toLocationContext(loc);
233
+ }
234
+
235
+ /** Format a single contact as a vCard 3.0 string. */
236
+ function formatVCard(contact: PipesBotContact): string {
237
+ const name = contact.name?.formatted_name ?? "Unknown";
238
+ const phones = (contact.phones ?? [])
239
+ .map((p) => `TEL;TYPE=${p.type ?? "CELL"}:${p.phone}`)
240
+ .join("\n");
241
+ const lines = ["BEGIN:VCARD", "VERSION:3.0", `FN:${name}`];
242
+ if (phones) lines.push(phones);
243
+ lines.push("END:VCARD");
244
+ return lines.join("\n");
245
+ }
package/src/media.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { getPipesBotRuntime } from "./runtime.js";
2
+ import type { PluginLogger } from "./types.js";
3
+
4
+ const PIPES_BOT_API_BASE = "https://api.pipes.bot";
5
+
6
+ export async function downloadPipesBotMedia(params: {
7
+ downloadUrl: string;
8
+ mimeType: string;
9
+ fileName?: string;
10
+ apiKey: string;
11
+ logger: PluginLogger;
12
+ }): Promise<{ path: string; contentType: string } | null> {
13
+ const core = getPipesBotRuntime();
14
+ const absoluteUrl = `${PIPES_BOT_API_BASE}${params.downloadUrl}`;
15
+
16
+ try {
17
+ const fetched = await core.channel.media.fetchRemoteMedia({
18
+ url: absoluteUrl,
19
+ fetchImpl: (input, init) => {
20
+ const headers = new Headers((init?.headers as HeadersInit | undefined) ?? undefined);
21
+ headers.set("Authorization", `Bearer ${params.apiKey}`);
22
+ return fetch(input, { ...init, headers });
23
+ },
24
+ filePathHint: params.fileName,
25
+ });
26
+
27
+ const saved = await core.channel.media.saveMediaBuffer(
28
+ fetched.buffer,
29
+ fetched.contentType ?? params.mimeType,
30
+ "inbound",
31
+ );
32
+
33
+ return {
34
+ path: saved.path,
35
+ contentType: saved.contentType ?? params.mimeType,
36
+ };
37
+ } catch (err) {
38
+ params.logger.error(
39
+ `pipes-bot-channel: media download failed for ${params.downloadUrl}: ${String(err)}`,
40
+ );
41
+ return null;
42
+ }
43
+ }
@@ -0,0 +1,114 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+ import { isPipesBotReplyStatus } from "./types.js";
3
+ import type { PluginLogger } from "./types.js";
4
+
5
+ // ── Failure classification ────────────────────────────────────────────────────
6
+
7
+ export type ReplyFailureKind =
8
+ | "SERVICE_WINDOW_EXPIRED"
9
+ | "RATE_LIMITED"
10
+ | "FORBIDDEN"
11
+ | "NOT_FOUND"
12
+ | "SEND_FAILED"
13
+ | "UNKNOWN";
14
+
15
+ // ── Pending reply tracking ────────────────────────────────────────────────────
16
+
17
+ type PendingReply = {
18
+ conversationId: string;
19
+ trackedAt: number;
20
+ expiresAt: number;
21
+ };
22
+
23
+ const PENDING_TTL_MS = 30_000; // 30 seconds
24
+
25
+ const pendingReplies = new Map<string, PendingReply>();
26
+
27
+ /**
28
+ * Track an outbound reply for reply_status correlation.
29
+ * Performs lazy eviction of expired entries on each call.
30
+ */
31
+ export function trackOutboundReply(conversationId: string): void {
32
+ // Lazy eviction of expired entries
33
+ const now = Date.now();
34
+ for (const [key, entry] of pendingReplies) {
35
+ if (now > entry.expiresAt) {
36
+ pendingReplies.delete(key);
37
+ }
38
+ }
39
+
40
+ pendingReplies.set(conversationId, {
41
+ conversationId,
42
+ trackedAt: now,
43
+ expiresAt: now + PENDING_TTL_MS,
44
+ });
45
+ }
46
+
47
+ // ── Reply status handler ──────────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Handle a reply_status event from PipesBot.
51
+ * - Logs success events
52
+ * - Classifies and logs failure events
53
+ * - Enqueues agent-facing system event for SERVICE_WINDOW_EXPIRED failures
54
+ */
55
+ export function handleReplyStatus(
56
+ msg: unknown,
57
+ opts: { logger: PluginLogger; core: PluginRuntime }
58
+ ): void {
59
+ if (!isPipesBotReplyStatus(msg)) return;
60
+
61
+ const { data } = msg;
62
+
63
+ // Look up and remove pending entry (we no longer need it after reply_status)
64
+ const pending = pendingReplies.get(data.conversationId);
65
+ pendingReplies.delete(data.conversationId);
66
+
67
+ if (data.success) {
68
+ opts.logger.info(
69
+ `pipes-bot-channel: reply delivered to ${data.conversationId} (messageId: ${data.messageId})`
70
+ );
71
+ return;
72
+ }
73
+
74
+ // Failure path
75
+ const kind = classifyReplyFailure(data.code);
76
+ const message = buildFailureMessage(kind, data.error);
77
+
78
+ opts.logger.error(
79
+ `pipes-bot-channel: reply failed for ${data.conversationId}: [${kind}] ${data.error ?? "unknown error"}`
80
+ );
81
+
82
+ // Enqueue agent-facing system event if we had context for this conversation
83
+ if (pending && data.conversationId) {
84
+ opts.core.system.enqueueSystemEvent(message, {
85
+ sessionKey: `default:${data.conversationId}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ // ── Private helpers ───────────────────────────────────────────────────────────
91
+
92
+ function classifyReplyFailure(code?: string): ReplyFailureKind {
93
+ switch (code) {
94
+ case "SERVICE_WINDOW_EXPIRED":
95
+ return "SERVICE_WINDOW_EXPIRED";
96
+ case "RATE_LIMITED":
97
+ return "RATE_LIMITED";
98
+ case "FORBIDDEN":
99
+ return "FORBIDDEN";
100
+ case "NOT_FOUND":
101
+ return "NOT_FOUND";
102
+ case "SEND_FAILED":
103
+ return "SEND_FAILED";
104
+ default:
105
+ return "UNKNOWN";
106
+ }
107
+ }
108
+
109
+ function buildFailureMessage(kind: ReplyFailureKind, raw?: string): string {
110
+ if (kind === "SERVICE_WINDOW_EXPIRED") {
111
+ return "Unable to send reply: the 24-hour WhatsApp messaging window has expired. The contact must send a new message to reopen the conversation.";
112
+ }
113
+ return `Reply failed: ${raw ?? kind}`;
114
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setPipesBotRuntime(next: PluginRuntime): void {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getPipesBotRuntime(): PluginRuntime {
10
+ if (!runtime) throw new Error("pipes-bot-channel: runtime not initialized");
11
+ return runtime;
12
+ }
package/src/service.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
2
+ import { PipesBotConnection } from "./connection.js";
3
+ import { handleInbound } from "./inbound.js";
4
+ import { isPipesBotReplyStatus } from "./types.js";
5
+ import { handleReplyStatus, trackOutboundReply } from "./outbound.js";
6
+ import { getPipesBotRuntime } from "./runtime.js";
7
+ import { updateConnectionStatus } from "./status-store.js";
8
+ import type { ConnectionState } from "./connection.js";
9
+
10
+ export function createPipesBotService(): OpenClawPluginService {
11
+ let connection: PipesBotConnection | null = null;
12
+
13
+ return {
14
+ id: "pipes-bot-channel",
15
+
16
+ start(ctx: OpenClawPluginServiceContext): void {
17
+ const pluginConfig = ctx.config.plugins?.entries?.["pipes-bot-channel"]
18
+ ?.config as { apiKey?: string } | undefined;
19
+
20
+ const apiKey = pluginConfig?.apiKey;
21
+
22
+ if (!apiKey || !apiKey.startsWith("pk_")) {
23
+ ctx.logger.error(
24
+ "pipes-bot-channel: invalid or missing pk_ API key -- not starting"
25
+ );
26
+ return;
27
+ }
28
+
29
+ connection = new PipesBotConnection(apiKey, ctx.logger);
30
+
31
+ connection.on("state", (state: ConnectionState) => {
32
+ updateConnectionStatus({
33
+ connected: state === "connected",
34
+ ...(state === "connected"
35
+ ? { lastConnectedAt: Date.now(), lastError: null }
36
+ : {}),
37
+ });
38
+ });
39
+
40
+ connection.on("error", (err: Error) => {
41
+ const isAuthFailure = err.message.includes("auth failed");
42
+ updateConnectionStatus({
43
+ connected: false,
44
+ authFailed: isAuthFailure,
45
+ lastError: err.message,
46
+ });
47
+ ctx.logger.error(`pipes-bot-channel: fatal connection error: ${err.message}`);
48
+ });
49
+
50
+ connection.on("message", (msg: object) => {
51
+ // Route reply_status events to outbound handler
52
+ if (isPipesBotReplyStatus(msg)) {
53
+ handleReplyStatus(msg, { logger: ctx.logger, core: getPipesBotRuntime() });
54
+ return;
55
+ }
56
+
57
+ // Route whatsapp_message events to inbound handler
58
+ void handleInbound(msg, {
59
+ apiKey,
60
+ accountId: "default",
61
+ send: (payload: object) => {
62
+ const sent = connection!.send(payload);
63
+ if (sent) {
64
+ // Track outbound for reply_status correlation
65
+ const replyData = (payload as { data?: { conversationId?: string } }).data;
66
+ if (replyData?.conversationId) {
67
+ trackOutboundReply(replyData.conversationId);
68
+ }
69
+ }
70
+ return sent;
71
+ },
72
+ logger: ctx.logger,
73
+ }).catch((err: unknown) => {
74
+ ctx.logger.error(`pipes-bot-channel: inbound error: ${String(err)}`);
75
+ });
76
+ });
77
+
78
+ connection.connect();
79
+ },
80
+
81
+ stop(ctx: OpenClawPluginServiceContext): void {
82
+ connection?.disconnect();
83
+ connection = null;
84
+ updateConnectionStatus({ connected: false, authFailed: false, lastError: null });
85
+ ctx.logger.info("pipes-bot-channel: service stopped");
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,21 @@
1
+ export type PipesBotConnectionStatus = {
2
+ connected: boolean;
3
+ authFailed: boolean;
4
+ lastConnectedAt: number | null;
5
+ lastError: string | null;
6
+ };
7
+
8
+ let _status: PipesBotConnectionStatus = {
9
+ connected: false,
10
+ authFailed: false,
11
+ lastConnectedAt: null,
12
+ lastError: null,
13
+ };
14
+
15
+ export function updateConnectionStatus(patch: Partial<PipesBotConnectionStatus>): void {
16
+ _status = { ..._status, ...patch };
17
+ }
18
+
19
+ export function getConnectionStatus(): PipesBotConnectionStatus {
20
+ return { ..._status };
21
+ }
package/src/types.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
2
+
3
+ // ── PipesBot message media field ─────────────────────────────────────────────
4
+
5
+ export type PipesBotMedia = {
6
+ mediaId?: string;
7
+ downloadUrl?: string;
8
+ mimeType: string;
9
+ fileName?: string;
10
+ byteSize: number;
11
+ unavailable?: boolean;
12
+ };
13
+
14
+ // ── PipesBot message location field ──────────────────────────────────────────
15
+
16
+ export type PipesBotLocation = {
17
+ latitude: number;
18
+ longitude: number;
19
+ name?: string;
20
+ address?: string;
21
+ };
22
+
23
+ // ── PipesBot message contacts field ──────────────────────────────────────────
24
+
25
+ export type PipesBotContact = {
26
+ name: { formatted_name: string };
27
+ phones?: Array<{ phone: string; type?: string }>;
28
+ emails?: Array<{ email: string; type?: string }>;
29
+ };
30
+
31
+ // ── PipesBot message reaction field ──────────────────────────────────────────
32
+
33
+ export type PipesBotReaction = {
34
+ messageId: string;
35
+ emoji?: string;
36
+ };
37
+
38
+ // ── PipesBot message data (all fields, type-specific fields optional) ─────────
39
+
40
+ export type PipesBotMessageData = {
41
+ messageId: string;
42
+ conversationId: string;
43
+ poolNumberId: string;
44
+ poolNumberPhoneNumber: string;
45
+ fromNumber: string;
46
+ fromName?: string;
47
+ text: string;
48
+ body: string;
49
+ timestamp: string;
50
+ label?: string;
51
+ type:
52
+ | "text"
53
+ | "image"
54
+ | "audio"
55
+ | "video"
56
+ | "document"
57
+ | "sticker"
58
+ | "location"
59
+ | "contacts"
60
+ | "reaction"
61
+ | "unsupported";
62
+ // Type-specific optional fields
63
+ media?: PipesBotMedia;
64
+ location?: PipesBotLocation;
65
+ contacts?: PipesBotContact[];
66
+ reaction?: PipesBotReaction;
67
+ };
68
+
69
+ // ── PipesBot message envelope ─────────────────────────────────────────────────
70
+
71
+ export type PipesBotMessage = {
72
+ type: "whatsapp_message";
73
+ data: PipesBotMessageData;
74
+ };
75
+
76
+ // ── Type guard ────────────────────────────────────────────────────────────────
77
+
78
+ export function isPipesBotMessage(msg: unknown): msg is PipesBotMessage {
79
+ return (
80
+ typeof msg === "object" &&
81
+ msg !== null &&
82
+ "type" in msg &&
83
+ (msg as { type: unknown }).type === "whatsapp_message" &&
84
+ "data" in msg
85
+ );
86
+ }
87
+
88
+ // ── PipesBot reply_status envelope ───────────────────────────────────────────
89
+
90
+ export type PipesBotReplyStatus = {
91
+ type: "reply_status";
92
+ data: {
93
+ conversationId: string;
94
+ success: boolean;
95
+ messageId?: string; // Present on success
96
+ error?: string; // Present on failure
97
+ code?: string; // Present on failure
98
+ };
99
+ };
100
+
101
+ export function isPipesBotReplyStatus(msg: unknown): msg is PipesBotReplyStatus {
102
+ return (
103
+ typeof msg === "object" &&
104
+ msg !== null &&
105
+ "type" in msg &&
106
+ (msg as { type: unknown }).type === "reply_status" &&
107
+ "data" in msg
108
+ );
109
+ }
110
+
111
+ // ── PluginLogger convenience alias ────────────────────────────────────────────
112
+
113
+ export type PluginLogger = OpenClawPluginServiceContext["logger"];