Skip to content

Commit 1e1b930

Browse files
committed
fix(signal): honor active api mode
1 parent ed6999c commit 1e1b930

10 files changed

Lines changed: 86 additions & 29 deletions

File tree

extensions/signal/src/channel.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,9 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
347347
probeAccount: async ({ account, timeoutMs }) => {
348348
const baseUrl = account.baseUrl;
349349
const { probeSignal } = await loadSignalProbeModule();
350-
return await probeSignal(baseUrl, timeoutMs);
350+
return await probeSignal(baseUrl, timeoutMs, {
351+
apiMode: account.config?.apiMode ?? "auto",
352+
});
351353
},
352354
formatCapabilitiesProbe: ({ probe }) =>
353355
probe?.version ? [{ text: `Signal daemon: ${probe.version}` }] : [],

extensions/signal/src/client-adapter.test.ts

Lines changed: 43 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import * as runtimeConfigModule from "openclaw/plugin-sdk/runtime-config-snapshot";
21
import { describe, expect, it, vi, beforeEach } from "vitest";
32
import {
4-
signalRpcRequest,
3+
signalRpcRequest as signalRpcRequestImpl,
54
detectSignalApiMode,
6-
signalCheck,
7-
streamSignalEvents,
8-
fetchAttachment,
5+
signalCheck as signalCheckImpl,
6+
streamSignalEvents as streamSignalEventsImpl,
7+
fetchAttachment as fetchAttachmentImpl,
8+
type SignalApiMode,
99
} from "./client-adapter.js";
1010
import * as containerClientModule from "./client-container.js";
1111
import * as nativeClientModule from "./client.js";
@@ -17,7 +17,7 @@ const mockContainerCheck = vi.fn();
1717
const mockContainerRpcRequest = vi.fn();
1818
const mockContainerFetchAttachment = vi.fn();
1919
const mockStreamContainerEvents = vi.fn();
20-
const mockGetRuntimeConfig = vi.fn(() => ({}));
20+
let currentApiMode: SignalApiMode = "auto";
2121

2222
beforeEach(() => {
2323
vi.spyOn(nativeClientModule, "signalCheck").mockImplementation(mockNativeCheck as any);
@@ -35,17 +35,30 @@ beforeEach(() => {
3535
vi.spyOn(containerClientModule, "streamContainerEvents").mockImplementation(
3636
mockStreamContainerEvents as any,
3737
);
38-
vi.spyOn(runtimeConfigModule, "getRuntimeConfig").mockImplementation(mockGetRuntimeConfig as any);
3938
});
4039

41-
function setApiMode(mode: "native" | "container" | "auto") {
42-
mockGetRuntimeConfig.mockReturnValue({
43-
channels: {
44-
signal: {
45-
apiMode: mode,
46-
},
47-
},
48-
});
40+
function setApiMode(mode: SignalApiMode) {
41+
currentApiMode = mode;
42+
}
43+
44+
function signalRpcRequest<T = unknown>(
45+
method: string,
46+
params: Record<string, unknown> | undefined,
47+
opts: Parameters<typeof signalRpcRequestImpl>[2],
48+
) {
49+
return signalRpcRequestImpl<T>(method, params, { ...opts, apiMode: currentApiMode });
50+
}
51+
52+
function signalCheck(baseUrl: string, timeoutMs?: number) {
53+
return signalCheckImpl(baseUrl, timeoutMs, { apiMode: currentApiMode });
54+
}
55+
56+
function streamSignalEvents(params: Parameters<typeof streamSignalEventsImpl>[0]) {
57+
return streamSignalEventsImpl({ ...params, apiMode: currentApiMode });
58+
}
59+
60+
function fetchAttachment(params: Parameters<typeof fetchAttachmentImpl>[0]) {
61+
return fetchAttachmentImpl({ ...params, apiMode: currentApiMode });
4962
}
5063

5164
describe("detectSignalApiMode", () => {
@@ -174,6 +187,21 @@ describe("signalRpcRequest", () => {
174187
expect(mockNativeRpcRequest).not.toHaveBeenCalled();
175188
});
176189

190+
it("uses explicit apiMode from the active config", async () => {
191+
setApiMode("native");
192+
mockContainerRpcRequest.mockResolvedValue({ timestamp: 1700000000000 });
193+
194+
const result = await signalRpcRequestImpl(
195+
"send",
196+
{ message: "Hello", account: "+14259798283", recipient: ["+15550001111"] },
197+
{ baseUrl: "http://localhost:8080", apiMode: "container" },
198+
);
199+
200+
expect(result).toEqual({ timestamp: 1700000000000 });
201+
expect(mockContainerRpcRequest).toHaveBeenCalled();
202+
expect(mockNativeRpcRequest).not.toHaveBeenCalled();
203+
});
204+
177205
it("passes all RPC methods through to native", async () => {
178206
mockNativeRpcRequest.mockResolvedValue({});
179207

extensions/signal/src/client-adapter.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
* only need to change their import path.
77
*/
88

9-
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
109
import {
1110
containerCheck,
1211
containerRpcRequest,
@@ -28,6 +27,8 @@ export type SignalSseEvent = {
2827
data?: string;
2928
};
3029

30+
export type SignalApiMode = "native" | "container" | "auto";
31+
3132
// Re-export the options type so consumers can import it from the adapter.
3233
export type { SignalRpcOptions } from "./client.js";
3334

@@ -37,8 +38,7 @@ const detectedModeCache = new Map<
3738
{ mode: "native" | "container"; expiresAt: number; receiveAccount?: string }
3839
>();
3940

40-
function getConfiguredApiMode(): "native" | "container" | "auto" {
41-
const configured = getRuntimeConfig().channels?.signal?.apiMode;
41+
function resolveConfiguredApiMode(configured?: SignalApiMode): SignalApiMode {
4242
if (configured === "native" || configured === "container") {
4343
return configured;
4444
}
@@ -87,8 +87,9 @@ async function resolveApiModeForOperation(params: {
8787
account?: string;
8888
requireContainerReceive?: boolean;
8989
timeoutMs?: number;
90+
apiMode?: SignalApiMode;
9091
}): Promise<"native" | "container"> {
91-
const configured = getConfiguredApiMode();
92+
const configured = resolveConfiguredApiMode(params.apiMode);
9293

9394
if (configured === "native" || configured === "container") {
9495
return configured;
@@ -137,13 +138,14 @@ export async function detectSignalApiMode(
137138
export async function signalRpcRequest<T = unknown>(
138139
method: string,
139140
params: Record<string, unknown> | undefined,
140-
opts: SignalRpcOptions & { accountId?: string },
141+
opts: SignalRpcOptions & { accountId?: string; apiMode?: SignalApiMode },
141142
): Promise<T> {
142143
const mode = await resolveApiModeForOperation({
143144
baseUrl: opts.baseUrl,
144145
accountId: opts.accountId,
145146
account: typeof params?.account === "string" ? params.account : undefined,
146147
timeoutMs: opts.timeoutMs,
148+
apiMode: opts.apiMode,
147149
});
148150
if (mode === "native") {
149151
return nativeRpcRequest<T>(method, params, opts);
@@ -157,8 +159,9 @@ export async function signalRpcRequest<T = unknown>(
157159
export async function signalCheck(
158160
baseUrl: string,
159161
timeoutMs = DEFAULT_TIMEOUT_MS,
162+
options: { apiMode?: SignalApiMode } = {},
160163
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
161-
const configured = getConfiguredApiMode();
164+
const configured = resolveConfiguredApiMode(options.apiMode);
162165
const mode =
163166
configured === "auto"
164167
? await resolveAutoApiMode(baseUrl, timeoutMs).catch((error: unknown) => {
@@ -186,13 +189,15 @@ export async function streamSignalEvents(params: {
186189
timeoutMs?: number;
187190
onEvent: (event: SignalSseEvent) => void;
188191
logger?: { log?: (msg: string) => void; error?: (msg: string) => void };
192+
apiMode?: SignalApiMode;
189193
}): Promise<void> {
190194
const mode = await resolveApiModeForOperation({
191195
baseUrl: params.baseUrl,
192196
accountId: params.accountId,
193197
account: params.account,
194198
requireContainerReceive: true,
195199
timeoutMs: resolveAutoProbeTimeoutMs(params.timeoutMs),
200+
apiMode: params.apiMode,
196201
});
197202

198203
if (mode === "container") {
@@ -227,12 +232,14 @@ export async function fetchAttachment(params: {
227232
groupId?: string;
228233
timeoutMs?: number;
229234
maxResponseBytes?: number;
235+
apiMode?: SignalApiMode;
230236
}): Promise<Buffer | null> {
231237
const mode = await resolveApiModeForOperation({
232238
baseUrl: params.baseUrl,
233239
accountId: params.accountId,
234240
account: params.account,
235241
timeoutMs: params.timeoutMs,
242+
apiMode: params.apiMode,
236243
});
237244
if (mode === "container") {
238245
return containerFetchAttachment(params.attachmentId, {

extensions/signal/src/client-container.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ describe("containerSendMessage", () => {
361361

362362
const callArgs = mockFetch.mock.calls[0];
363363
const body = JSON.parse(callArgs[1].body);
364-
expect(body.message).toBe("**Bold** \\\\* not italic");
364+
expect(body.message).toBe("**Bold** \\* not italic");
365365
});
366366

367367
it("includes attachments as base64 data URIs", async () => {

extensions/signal/src/client-container.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@ async function filesToBase64DataUris(filePaths: string[]): Promise<string[]> {
392392
}
393393

394394
function escapeContainerStyledText(text: string): string {
395-
return text.replace(/[\\*~`|]/g, (char) => `\\\\${char}`);
395+
return text.replace(/[\\*~`|]/g, (char) => `\\${char}`);
396396
}
397397

398398
function renderContainerStyledText(

extensions/signal/src/monitor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ function deriveSignalAttachmentRpcMaxResponseBytes(maxBytes: number): number | u
272272
async function fetchAttachment(params: {
273273
baseUrl: string;
274274
account?: string;
275+
apiMode?: "native" | "container" | "auto";
275276
attachment: SignalAttachment;
276277
sender?: string;
277278
groupId?: string;
@@ -303,6 +304,7 @@ async function fetchAttachment(params: {
303304
const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, {
304305
baseUrl: params.baseUrl,
305306
maxResponseBytes: deriveSignalAttachmentRpcMaxResponseBytes(params.maxBytes),
307+
apiMode: params.apiMode,
306308
});
307309
if (!result?.data) {
308310
return null;
@@ -498,7 +500,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
498500
ignoreAttachments,
499501
sendReadReceipts,
500502
readReceiptsViaDaemon,
501-
fetchAttachment,
503+
fetchAttachment: (params) => fetchAttachment({ ...params, apiMode: configuredApiMode }),
502504
deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }),
503505
resolveSignalReactionTargets,
504506
isSignalReactionMessage,
@@ -513,6 +515,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
513515
runtime,
514516
// signal-cli can keep the SSE event endpoint idle until the next inbound event.
515517
timeoutMs: 0,
518+
apiMode: configuredApiMode,
516519
policy: opts.reconnectPolicy,
517520
onEvent: (event) => {
518521
void handleEvent(event).catch((err) => {

extensions/signal/src/probe.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
22
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3-
import { signalCheck, signalRpcRequest } from "./client-adapter.js";
3+
import { type SignalApiMode, signalCheck, signalRpcRequest } from "./client-adapter.js";
44

55
export type SignalProbe = BaseProbeResult & {
66
status?: number | null;
@@ -21,7 +21,11 @@ function parseSignalVersion(value: unknown): string | null {
2121
return null;
2222
}
2323

24-
export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<SignalProbe> {
24+
export async function probeSignal(
25+
baseUrl: string,
26+
timeoutMs: number,
27+
options: { apiMode?: SignalApiMode } = {},
28+
): Promise<SignalProbe> {
2529
const started = Date.now();
2630
const result: SignalProbe = {
2731
ok: false,
@@ -30,7 +34,8 @@ export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<S
3034
elapsedMs: 0,
3135
version: null,
3236
};
33-
const check = await signalCheck(baseUrl, timeoutMs);
37+
const apiMode = options.apiMode ?? "native";
38+
const check = await signalCheck(baseUrl, timeoutMs, { apiMode });
3439
if (!check.ok) {
3540
return {
3641
...result,
@@ -43,6 +48,7 @@ export async function probeSignal(baseUrl: string, timeoutMs: number): Promise<S
4348
const version = await signalRpcRequest("version", undefined, {
4449
baseUrl,
4550
timeoutMs,
51+
apiMode,
4652
});
4753
result.version = parseSignalVersion(version);
4854
} catch (err) {

extensions/signal/src/send-reactions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ async function sendReactionSignalCore(params: {
7979
errors: SignalReactionErrorMessages;
8080
}): Promise<SignalReactionResult> {
8181
const cfg = requireRuntimeConfig(params.opts.cfg, "Signal reactions");
82+
const apiMode = cfg.channels?.signal?.apiMode;
8283
const accountInfo = resolveSignalAccount({
8384
cfg,
8485
accountId: params.opts.accountId,
@@ -126,6 +127,7 @@ async function sendReactionSignalCore(params: {
126127
const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", requestParams, {
127128
baseUrl,
128129
timeoutMs: params.opts.timeoutMs,
130+
apiMode,
129131
});
130132

131133
return {

extensions/signal/src/send.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export async function sendMessageSignal(
172172
opts: SignalSendOpts,
173173
): Promise<SignalSendResult> {
174174
const cfg = requireRuntimeConfig(opts.cfg, "Signal send");
175+
const apiMode = cfg.channels?.signal?.apiMode;
175176
const accountInfo = resolveSignalAccount({
176177
cfg,
177178
accountId: opts.accountId,
@@ -256,6 +257,7 @@ export async function sendMessageSignal(
256257
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
257258
baseUrl,
258259
timeoutMs: opts.timeoutMs,
260+
apiMode,
259261
});
260262
const timestamp = result?.timestamp;
261263
const messageId = timestamp ? String(timestamp) : "unknown";
@@ -276,6 +278,7 @@ export async function sendTypingSignal(
276278
opts: SignalRpcOpts & { stop?: boolean },
277279
): Promise<boolean> {
278280
const accountInfo = await resolveSignalRpcAccountInfo(opts);
281+
const cfg = requireRuntimeConfig(opts.cfg, "Signal typing");
279282
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
280283
const targetParams = buildTargetParams(parseTarget(to), {
281284
recipient: true,
@@ -294,6 +297,7 @@ export async function sendTypingSignal(
294297
await signalRpcRequest("sendTyping", params, {
295298
baseUrl,
296299
timeoutMs: opts.timeoutMs,
300+
apiMode: cfg.channels?.signal?.apiMode,
297301
});
298302
return true;
299303
}
@@ -307,6 +311,7 @@ export async function sendReadReceiptSignal(
307311
return false;
308312
}
309313
const accountInfo = await resolveSignalRpcAccountInfo(opts);
314+
const cfg = requireRuntimeConfig(opts.cfg, "Signal read receipt");
310315
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
311316
const targetParams = buildTargetParams(parseTarget(to), {
312317
recipient: true,
@@ -325,6 +330,7 @@ export async function sendReadReceiptSignal(
325330
await signalRpcRequest("sendReceipt", params, {
326331
baseUrl,
327332
timeoutMs: opts.timeoutMs,
333+
apiMode: cfg.channels?.signal?.apiMode,
328334
});
329335
return true;
330336
}

extensions/signal/src/sse-reconnect.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
type BackoffPolicy,
77
type RuntimeEnv,
88
} from "openclaw/plugin-sdk/runtime-env";
9-
import { type SignalSseEvent, streamSignalEvents } from "./client-adapter.js";
9+
import { type SignalApiMode, type SignalSseEvent, streamSignalEvents } from "./client-adapter.js";
1010

1111
const DEFAULT_RECONNECT_POLICY: BackoffPolicy = {
1212
initialMs: 1_000,
@@ -22,6 +22,7 @@ type RunSignalSseLoopParams = {
2222
runtime: RuntimeEnv;
2323
onEvent: (event: SignalSseEvent) => void;
2424
timeoutMs?: number;
25+
apiMode?: SignalApiMode;
2526
policy?: Partial<BackoffPolicy>;
2627
};
2728

@@ -32,6 +33,7 @@ export async function runSignalSseLoop({
3233
runtime,
3334
onEvent,
3435
timeoutMs,
36+
apiMode,
3537
policy,
3638
}: RunSignalSseLoopParams) {
3739
const reconnectPolicy = {
@@ -57,6 +59,7 @@ export async function runSignalSseLoop({
5759
account,
5860
abortSignal,
5961
timeoutMs,
62+
apiMode,
6063
onEvent: (event: SignalSseEvent) => {
6164
reconnectAttempts = 0;
6265
onEvent(event);

0 commit comments

Comments
 (0)