@chbo297/infoflow 2026.2.23

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/src/monitor.ts ADDED
@@ -0,0 +1,177 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
3
+ import type { ResolvedInfoflowAccount } from "./channel.js";
4
+ import {
5
+ parseAndDispatchInfoflowRequest,
6
+ loadRawBody,
7
+ type WebhookTarget,
8
+ } from "./infoflow-req-parse.js";
9
+ import { getInfoflowWebhookLog } from "./logging.js";
10
+ import { getInfoflowRuntime } from "./runtime.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type InfoflowMonitorOptions = {
17
+ account: ResolvedInfoflowAccount;
18
+ config: OpenClawConfig;
19
+ runtime: unknown;
20
+ abortSignal: AbortSignal;
21
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
22
+ };
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** webhook path for Infoflow. */
29
+ const INFOFLOW_WEBHOOK_PATH = "/webhook/infoflow";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Webhook target registry
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const webhookTargets = new Map<string, WebhookTarget[]>();
36
+
37
+ /** Normalizes a webhook path: trim, ensure leading slash, strip trailing slash (except "/"). */
38
+ function normalizeWebhookPath(raw: string): string {
39
+ const trimmed = raw.trim();
40
+ if (!trimmed) {
41
+ return "/";
42
+ }
43
+ const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
44
+ if (withSlash.length > 1 && withSlash.endsWith("/")) {
45
+ return withSlash.slice(0, -1);
46
+ }
47
+ return withSlash;
48
+ }
49
+
50
+ /** Registers a webhook target for a path. Returns an unregister function to remove it. */
51
+ function registerInfoflowWebhookTarget(target: WebhookTarget): () => void {
52
+ const key = normalizeWebhookPath(target.path);
53
+ const normalizedTarget = { ...target, path: key };
54
+ const existing = webhookTargets.get(key) ?? [];
55
+ const next = [...existing, normalizedTarget];
56
+ webhookTargets.set(key, next);
57
+ return () => {
58
+ const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
59
+ if (updated.length > 0) {
60
+ webhookTargets.set(key, updated);
61
+ } else {
62
+ webhookTargets.delete(key);
63
+ }
64
+ };
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // HTTP handler (registered via api.registerHttpHandler)
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Checks if the request path matches a registered Infoflow webhook path.
73
+ */
74
+ function isInfoflowPath(requestPath: string): boolean {
75
+ const normalized = normalizeWebhookPath(requestPath);
76
+ return webhookTargets.has(normalized);
77
+ }
78
+
79
+ /**
80
+ * Handles incoming Infoflow webhook HTTP requests.
81
+ *
82
+ * - Routes by path to registered targets (supports exact and suffix match).
83
+ * - Only allows POST.
84
+ * - Delegates body reading, echostr verification, authentication,
85
+ * and message dispatch to infoflow_req_parse.
86
+ */
87
+ export async function handleInfoflowWebhookRequest(
88
+ req: IncomingMessage,
89
+ res: ServerResponse,
90
+ ): Promise<boolean> {
91
+ const core = getInfoflowRuntime();
92
+ const verbose = core.logging.shouldLogVerbose();
93
+
94
+ const url = new URL(req.url ?? "/", "http://localhost");
95
+ const requestPath = normalizeWebhookPath(url.pathname);
96
+
97
+ if (verbose) {
98
+ // Log the full request URL
99
+ getInfoflowWebhookLog().debug?.(`[infoflow] request: url=${url}`);
100
+ }
101
+
102
+ // Check if path matches Infoflow webhook pattern
103
+ if (!isInfoflowPath(requestPath)) {
104
+ return false;
105
+ }
106
+
107
+ // Get registered targets for the actual request path
108
+ const targets = webhookTargets.get(requestPath);
109
+ if (!targets || targets.length === 0) {
110
+ return false;
111
+ }
112
+
113
+ if (req.method !== "POST") {
114
+ res.statusCode = 405;
115
+ res.setHeader("Allow", "POST");
116
+ res.end("Method Not Allowed");
117
+ return true;
118
+ }
119
+
120
+ // Load raw body once
121
+ const bodyResult = await loadRawBody(req);
122
+ if (!bodyResult.ok) {
123
+ getInfoflowWebhookLog().error(`[infoflow] failed to read body: ${bodyResult.error}`);
124
+ res.statusCode = bodyResult.error === "payload too large" ? 413 : 400;
125
+ res.end(bodyResult.error);
126
+ return true;
127
+ }
128
+
129
+ let result;
130
+ try {
131
+ result = await parseAndDispatchInfoflowRequest(req, bodyResult.raw, targets);
132
+ } catch (err) {
133
+ const errMsg = err instanceof Error ? err.message : String(err);
134
+ getInfoflowWebhookLog().error(`[infoflow] webhook handler error: ${errMsg}`);
135
+ res.statusCode = 500;
136
+ res.end("internal error");
137
+ return true;
138
+ }
139
+
140
+ if (verbose) {
141
+ getInfoflowWebhookLog().debug?.(
142
+ `[infoflow] dispatch result: handled=${result.handled}, status=${result.handled ? result.statusCode : "N/A"}`,
143
+ );
144
+ }
145
+
146
+ if (result.handled) {
147
+ const looksLikeJson = result.body.startsWith("{");
148
+ if (looksLikeJson) {
149
+ res.setHeader("Content-Type", "application/json");
150
+ }
151
+ res.statusCode = result.statusCode;
152
+ res.end(result.body);
153
+ } else {
154
+ res.statusCode = 200;
155
+ res.end("OK");
156
+ }
157
+ return true;
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Monitor lifecycle
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /** Registers this account's webhook target and returns an unregister (stop) function. */
165
+ export async function startInfoflowMonitor(options: InfoflowMonitorOptions): Promise<() => void> {
166
+ const core = getInfoflowRuntime();
167
+
168
+ const unregister = registerInfoflowWebhookTarget({
169
+ account: options.account,
170
+ config: options.config,
171
+ core,
172
+ path: INFOFLOW_WEBHOOK_PATH,
173
+ statusSink: options.statusSink,
174
+ });
175
+
176
+ return unregister;
177
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ createReplyPrefixOptions,
3
+ type OpenClawConfig,
4
+ type ReplyPayload,
5
+ } from "openclaw/plugin-sdk";
6
+ import { getInfoflowSendLog } from "./logging.js";
7
+ import { getInfoflowRuntime } from "./runtime.js";
8
+ import { sendInfoflowMessage } from "./send.js";
9
+ import type { InfoflowAtOptions, InfoflowMessageContentItem } from "./types.js";
10
+
11
+ export type CreateInfoflowReplyDispatcherParams = {
12
+ cfg: OpenClawConfig;
13
+ agentId: string;
14
+ accountId: string;
15
+ /** Target: "group:<id>" for group chat, username for private chat */
16
+ to: string;
17
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
18
+ /** AT options for @mentioning members in group messages */
19
+ atOptions?: InfoflowAtOptions;
20
+ };
21
+
22
+ /**
23
+ * Builds dispatcherOptions and replyOptions for dispatchReplyWithBufferedBlockDispatcher.
24
+ * Encapsulates prefix options, chunked deliver (send via Infoflow API + statusSink), and onError.
25
+ */
26
+ export function createInfoflowReplyDispatcher(params: CreateInfoflowReplyDispatcherParams) {
27
+ const { cfg, agentId, accountId, to, statusSink, atOptions } = params;
28
+ const core = getInfoflowRuntime();
29
+
30
+ const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
31
+ cfg,
32
+ agentId,
33
+ channel: "infoflow",
34
+ accountId,
35
+ });
36
+
37
+ // Check if target is a group (format: group:<id>)
38
+ const isGroup = /^group:\d+$/i.test(to);
39
+
40
+ const deliver = async (payload: ReplyPayload) => {
41
+ const text = payload.text ?? "";
42
+ if (!text.trim()) {
43
+ return;
44
+ }
45
+
46
+ // Chunk text to 4000 chars max (Infoflow limit)
47
+ const chunks = core.channel.text.chunkText(text, 4000);
48
+ // Only include @mentions in the first chunk (avoid duplicate @s)
49
+ let isFirstChunk = true;
50
+
51
+ for (const chunk of chunks) {
52
+ const contents: InfoflowMessageContentItem[] = [{ type: "markdown", content: chunk }];
53
+
54
+ // Add AT content for group messages (first chunk only)
55
+ if (isFirstChunk && isGroup && atOptions) {
56
+ if (atOptions.atAll) {
57
+ contents.push({ type: "at", content: "all" });
58
+ } else if (atOptions.atUserIds?.length) {
59
+ contents.push({ type: "at", content: atOptions.atUserIds.join(",") });
60
+ }
61
+ }
62
+ isFirstChunk = false;
63
+
64
+ const result = await sendInfoflowMessage({ cfg, to, contents, accountId });
65
+
66
+ if (result.ok) {
67
+ statusSink?.({ lastOutboundAt: Date.now() });
68
+ } else if (result.error) {
69
+ getInfoflowSendLog().error(`[infoflow] Failed to send message: ${result.error}`);
70
+ }
71
+ }
72
+ };
73
+
74
+ const onError = (err: unknown) => {
75
+ getInfoflowSendLog().error(`[infoflow] reply failed: ${String(err)}`);
76
+ };
77
+
78
+ return {
79
+ dispatcherOptions: {
80
+ ...prefixOptions,
81
+ deliver,
82
+ onError,
83
+ },
84
+ replyOptions: {
85
+ onModelSelected,
86
+ },
87
+ };
88
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setInfoflowRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getInfoflowRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Infoflow runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }