Skip to content

Commit 4540c6b

Browse files
authored
refactor(signal): move Signal channel code to extensions/signal/src/ (#45531)
Move all Signal channel implementation files from src/signal/ to extensions/signal/src/ and replace originals with re-export shims. This continues the channel plugin migration pattern used by other extensions, keeping backward compatibility via shims while the real code lives in the extension. - Copy 32 .ts files (source + tests) to extensions/signal/src/ - Transform all relative import paths for the new location - Create 2-line re-export shims in src/signal/ for each moved file - Preserve existing extension files (channel.ts, runtime.ts, etc.) - Change tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to support cross-boundary re-exports from extensions/
1 parent 7764f71 commit 4540c6b

65 files changed

Lines changed: 5476 additions & 5398 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

extensions/signal/src/accounts.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
2+
import type { OpenClawConfig } from "../../../src/config/config.js";
3+
import type { SignalAccountConfig } from "../../../src/config/types.js";
4+
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
5+
import { normalizeAccountId } from "../../../src/routing/session-key.js";
6+
7+
export type ResolvedSignalAccount = {
8+
accountId: string;
9+
enabled: boolean;
10+
name?: string;
11+
baseUrl: string;
12+
configured: boolean;
13+
config: SignalAccountConfig;
14+
};
15+
16+
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("signal");
17+
export const listSignalAccountIds = listAccountIds;
18+
export const resolveDefaultSignalAccountId = resolveDefaultAccountId;
19+
20+
function resolveAccountConfig(
21+
cfg: OpenClawConfig,
22+
accountId: string,
23+
): SignalAccountConfig | undefined {
24+
return resolveAccountEntry(cfg.channels?.signal?.accounts, accountId);
25+
}
26+
27+
function mergeSignalAccountConfig(cfg: OpenClawConfig, accountId: string): SignalAccountConfig {
28+
const { accounts: _ignored, ...base } = (cfg.channels?.signal ?? {}) as SignalAccountConfig & {
29+
accounts?: unknown;
30+
};
31+
const account = resolveAccountConfig(cfg, accountId) ?? {};
32+
return { ...base, ...account };
33+
}
34+
35+
export function resolveSignalAccount(params: {
36+
cfg: OpenClawConfig;
37+
accountId?: string | null;
38+
}): ResolvedSignalAccount {
39+
const accountId = normalizeAccountId(params.accountId);
40+
const baseEnabled = params.cfg.channels?.signal?.enabled !== false;
41+
const merged = mergeSignalAccountConfig(params.cfg, accountId);
42+
const accountEnabled = merged.enabled !== false;
43+
const enabled = baseEnabled && accountEnabled;
44+
const host = merged.httpHost?.trim() || "127.0.0.1";
45+
const port = merged.httpPort ?? 8080;
46+
const baseUrl = merged.httpUrl?.trim() || `http://${host}:${port}`;
47+
const configured = Boolean(
48+
merged.account?.trim() ||
49+
merged.httpUrl?.trim() ||
50+
merged.cliPath?.trim() ||
51+
merged.httpHost?.trim() ||
52+
typeof merged.httpPort === "number" ||
53+
typeof merged.autoStart === "boolean",
54+
);
55+
return {
56+
accountId,
57+
enabled,
58+
name: merged.name?.trim() || undefined,
59+
baseUrl,
60+
configured,
61+
config: merged,
62+
};
63+
}
64+
65+
export function listEnabledSignalAccounts(cfg: OpenClawConfig): ResolvedSignalAccount[] {
66+
return listSignalAccountIds(cfg)
67+
.map((accountId) => resolveSignalAccount({ cfg, accountId }))
68+
.filter((account) => account.enabled);
69+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const fetchWithTimeoutMock = vi.fn();
4+
const resolveFetchMock = vi.fn();
5+
6+
vi.mock("../../../src/infra/fetch.js", () => ({
7+
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
8+
}));
9+
10+
vi.mock("../../../src/infra/secure-random.js", () => ({
11+
generateSecureUuid: () => "test-id",
12+
}));
13+
14+
vi.mock("../../../src/utils/fetch-timeout.js", () => ({
15+
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
16+
}));
17+
18+
import { signalRpcRequest } from "./client.js";
19+
20+
function rpcResponse(body: unknown, status = 200): Response {
21+
if (typeof body === "string") {
22+
return new Response(body, { status });
23+
}
24+
return new Response(JSON.stringify(body), { status });
25+
}
26+
27+
describe("signalRpcRequest", () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
resolveFetchMock.mockReturnValue(vi.fn());
31+
});
32+
33+
it("returns parsed RPC result", async () => {
34+
fetchWithTimeoutMock.mockResolvedValueOnce(
35+
rpcResponse({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" }),
36+
);
37+
38+
const result = await signalRpcRequest<{ version: string }>("version", undefined, {
39+
baseUrl: "http://127.0.0.1:8080",
40+
});
41+
42+
expect(result).toEqual({ version: "0.13.22" });
43+
});
44+
45+
it("throws a wrapped error when RPC response JSON is malformed", async () => {
46+
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse("not-json", 502));
47+
48+
await expect(
49+
signalRpcRequest("version", undefined, {
50+
baseUrl: "http://127.0.0.1:8080",
51+
}),
52+
).rejects.toMatchObject({
53+
message: "Signal RPC returned malformed JSON (status 502)",
54+
cause: expect.any(SyntaxError),
55+
});
56+
});
57+
58+
it("throws when RPC response envelope has neither result nor error", async () => {
59+
fetchWithTimeoutMock.mockResolvedValueOnce(rpcResponse({ jsonrpc: "2.0", id: "test-id" }));
60+
61+
await expect(
62+
signalRpcRequest("version", undefined, {
63+
baseUrl: "http://127.0.0.1:8080",
64+
}),
65+
).rejects.toThrow("Signal RPC returned invalid response envelope (status 200)");
66+
});
67+
});

extensions/signal/src/client.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { resolveFetch } from "../../../src/infra/fetch.js";
2+
import { generateSecureUuid } from "../../../src/infra/secure-random.js";
3+
import { fetchWithTimeout } from "../../../src/utils/fetch-timeout.js";
4+
5+
export type SignalRpcOptions = {
6+
baseUrl: string;
7+
timeoutMs?: number;
8+
};
9+
10+
export type SignalRpcError = {
11+
code?: number;
12+
message?: string;
13+
data?: unknown;
14+
};
15+
16+
export type SignalRpcResponse<T> = {
17+
jsonrpc?: string;
18+
result?: T;
19+
error?: SignalRpcError;
20+
id?: string | number | null;
21+
};
22+
23+
export type SignalSseEvent = {
24+
event?: string;
25+
data?: string;
26+
id?: string;
27+
};
28+
29+
const DEFAULT_TIMEOUT_MS = 10_000;
30+
31+
function normalizeBaseUrl(url: string): string {
32+
const trimmed = url.trim();
33+
if (!trimmed) {
34+
throw new Error("Signal base URL is required");
35+
}
36+
if (/^https?:\/\//i.test(trimmed)) {
37+
return trimmed.replace(/\/+$/, "");
38+
}
39+
return `http://${trimmed}`.replace(/\/+$/, "");
40+
}
41+
42+
function getRequiredFetch(): typeof fetch {
43+
const fetchImpl = resolveFetch();
44+
if (!fetchImpl) {
45+
throw new Error("fetch is not available");
46+
}
47+
return fetchImpl;
48+
}
49+
50+
function parseSignalRpcResponse<T>(text: string, status: number): SignalRpcResponse<T> {
51+
let parsed: unknown;
52+
try {
53+
parsed = JSON.parse(text);
54+
} catch (err) {
55+
throw new Error(`Signal RPC returned malformed JSON (status ${status})`, { cause: err });
56+
}
57+
58+
if (!parsed || typeof parsed !== "object") {
59+
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
60+
}
61+
62+
const rpc = parsed as SignalRpcResponse<T>;
63+
const hasResult = Object.hasOwn(rpc, "result");
64+
if (!rpc.error && !hasResult) {
65+
throw new Error(`Signal RPC returned invalid response envelope (status ${status})`);
66+
}
67+
return rpc;
68+
}
69+
70+
export async function signalRpcRequest<T = unknown>(
71+
method: string,
72+
params: Record<string, unknown> | undefined,
73+
opts: SignalRpcOptions,
74+
): Promise<T> {
75+
const baseUrl = normalizeBaseUrl(opts.baseUrl);
76+
const id = generateSecureUuid();
77+
const body = JSON.stringify({
78+
jsonrpc: "2.0",
79+
method,
80+
params,
81+
id,
82+
});
83+
const res = await fetchWithTimeout(
84+
`${baseUrl}/api/v1/rpc`,
85+
{
86+
method: "POST",
87+
headers: { "Content-Type": "application/json" },
88+
body,
89+
},
90+
opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
91+
getRequiredFetch(),
92+
);
93+
if (res.status === 201) {
94+
return undefined as T;
95+
}
96+
const text = await res.text();
97+
if (!text) {
98+
throw new Error(`Signal RPC empty response (status ${res.status})`);
99+
}
100+
const parsed = parseSignalRpcResponse<T>(text, res.status);
101+
if (parsed.error) {
102+
const code = parsed.error.code ?? "unknown";
103+
const msg = parsed.error.message ?? "Signal RPC error";
104+
throw new Error(`Signal RPC ${code}: ${msg}`);
105+
}
106+
return parsed.result as T;
107+
}
108+
109+
export async function signalCheck(
110+
baseUrl: string,
111+
timeoutMs = DEFAULT_TIMEOUT_MS,
112+
): Promise<{ ok: boolean; status?: number | null; error?: string | null }> {
113+
const normalized = normalizeBaseUrl(baseUrl);
114+
try {
115+
const res = await fetchWithTimeout(
116+
`${normalized}/api/v1/check`,
117+
{ method: "GET" },
118+
timeoutMs,
119+
getRequiredFetch(),
120+
);
121+
if (!res.ok) {
122+
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
123+
}
124+
return { ok: true, status: res.status, error: null };
125+
} catch (err) {
126+
return {
127+
ok: false,
128+
status: null,
129+
error: err instanceof Error ? err.message : String(err),
130+
};
131+
}
132+
}
133+
134+
export async function streamSignalEvents(params: {
135+
baseUrl: string;
136+
account?: string;
137+
abortSignal?: AbortSignal;
138+
onEvent: (event: SignalSseEvent) => void;
139+
}): Promise<void> {
140+
const baseUrl = normalizeBaseUrl(params.baseUrl);
141+
const url = new URL(`${baseUrl}/api/v1/events`);
142+
if (params.account) {
143+
url.searchParams.set("account", params.account);
144+
}
145+
146+
const fetchImpl = resolveFetch();
147+
if (!fetchImpl) {
148+
throw new Error("fetch is not available");
149+
}
150+
const res = await fetchImpl(url, {
151+
method: "GET",
152+
headers: { Accept: "text/event-stream" },
153+
signal: params.abortSignal,
154+
});
155+
if (!res.ok || !res.body) {
156+
throw new Error(`Signal SSE failed (${res.status} ${res.statusText || "error"})`);
157+
}
158+
159+
const reader = res.body.getReader();
160+
const decoder = new TextDecoder();
161+
let buffer = "";
162+
let currentEvent: SignalSseEvent = {};
163+
164+
const flushEvent = () => {
165+
if (!currentEvent.data && !currentEvent.event && !currentEvent.id) {
166+
return;
167+
}
168+
params.onEvent({
169+
event: currentEvent.event,
170+
data: currentEvent.data,
171+
id: currentEvent.id,
172+
});
173+
currentEvent = {};
174+
};
175+
176+
while (true) {
177+
const { value, done } = await reader.read();
178+
if (done) {
179+
break;
180+
}
181+
buffer += decoder.decode(value, { stream: true });
182+
let lineEnd = buffer.indexOf("\n");
183+
while (lineEnd !== -1) {
184+
let line = buffer.slice(0, lineEnd);
185+
buffer = buffer.slice(lineEnd + 1);
186+
if (line.endsWith("\r")) {
187+
line = line.slice(0, -1);
188+
}
189+
190+
if (line === "") {
191+
flushEvent();
192+
lineEnd = buffer.indexOf("\n");
193+
continue;
194+
}
195+
if (line.startsWith(":")) {
196+
lineEnd = buffer.indexOf("\n");
197+
continue;
198+
}
199+
const [rawField, ...rest] = line.split(":");
200+
const field = rawField.trim();
201+
const rawValue = rest.join(":");
202+
const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue;
203+
if (field === "event") {
204+
currentEvent.event = value;
205+
} else if (field === "data") {
206+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${value}` : value;
207+
} else if (field === "id") {
208+
currentEvent.id = value;
209+
}
210+
lineEnd = buffer.indexOf("\n");
211+
}
212+
}
213+
214+
flushEvent();
215+
}

0 commit comments

Comments
 (0)