@elizaos/plugin-wechat 2.0.0-alpha.537

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/channel.ts ADDED
@@ -0,0 +1,314 @@
1
+ import { Bot } from "./bot";
2
+ import { startCallbackServer } from "./callback-server";
3
+ import { LoginExpiredError, ProxyClient } from "./proxy-client";
4
+ import { ReplyDispatcher } from "./reply-dispatcher";
5
+ import type {
6
+ ResolvedWechatAccount,
7
+ WechatConfig,
8
+ WechatMessageContext,
9
+ } from "./types";
10
+ import { displayQRUrl } from "./utils/qrcode";
11
+
12
+ const HEALTH_CHECK_INTERVAL_MS = 60_000;
13
+ const LOGIN_POLL_INTERVAL_MS = 5_000;
14
+ const LOGIN_TIMEOUT_MS = 5 * 60_000;
15
+
16
+ export interface ChannelOptions {
17
+ config: WechatConfig;
18
+ onMessage: (
19
+ accountId: string,
20
+ msg: WechatMessageContext,
21
+ ) => void | Promise<void>;
22
+ }
23
+
24
+ export class WechatChannel {
25
+ private readonly config: WechatConfig;
26
+ private readonly onMessage: (
27
+ accountId: string,
28
+ msg: WechatMessageContext,
29
+ ) => void | Promise<void>;
30
+ private readonly accounts = new Map<
31
+ string,
32
+ {
33
+ client: ProxyClient;
34
+ dispatcher: ReplyDispatcher;
35
+ bot: Bot;
36
+ }
37
+ >();
38
+ private readonly callbackServers: Array<{
39
+ close: () => Promise<void>;
40
+ port: number;
41
+ }> = [];
42
+ private readonly loginPromises = new Map<string, Promise<void>>();
43
+ private healthTimer: ReturnType<typeof setInterval> | null = null;
44
+ private abortController: AbortController | null = null;
45
+
46
+ constructor(options: ChannelOptions) {
47
+ this.config = options.config;
48
+ this.onMessage = options.onMessage;
49
+ }
50
+
51
+ async start(): Promise<void> {
52
+ this.abortController = new AbortController();
53
+ const resolved = this.resolveAccounts();
54
+
55
+ if (resolved.length === 0) {
56
+ console.warn("[wechat] No configured accounts found");
57
+ return;
58
+ }
59
+
60
+ const webhookAccountsByPort = new Map<
61
+ number,
62
+ Array<{ accountId: string; apiKey: string }>
63
+ >();
64
+ for (const account of resolved) {
65
+ const existing = webhookAccountsByPort.get(account.webhookPort) ?? [];
66
+ existing.push({ accountId: account.id, apiKey: account.apiKey });
67
+ webhookAccountsByPort.set(account.webhookPort, existing);
68
+ }
69
+
70
+ for (const [webhookPort, accounts] of webhookAccountsByPort) {
71
+ try {
72
+ this.callbackServers.push(
73
+ await startCallbackServer({
74
+ port: webhookPort,
75
+ accounts,
76
+ onMessage: (accountId, msg) => this.routeIncoming(accountId, msg),
77
+ signal: this.abortController.signal,
78
+ }),
79
+ );
80
+ } catch (err) {
81
+ const accountIds = accounts.map((a) => a.accountId).join(", ");
82
+ console.error(
83
+ `[wechat] Failed to bind webhook server on port ${webhookPort} for accounts [${accountIds}]:`,
84
+ err,
85
+ );
86
+ }
87
+ }
88
+
89
+ // Initialize each account
90
+ for (const account of resolved) {
91
+ const client = new ProxyClient(account);
92
+ const dispatcher = new ReplyDispatcher({ client });
93
+ const bot = new Bot({
94
+ onMessage: (msg) => this.onMessage(account.id, msg),
95
+ featuresGroups: this.config.features?.groups,
96
+ featuresImages: this.config.features?.images,
97
+ });
98
+
99
+ this.accounts.set(account.id, { client, dispatcher, bot });
100
+
101
+ // Login flow
102
+ await this.ensureLoggedIn(account.id, client);
103
+ const webhookUrl = `http://localhost:${account.webhookPort}/webhook/wechat/${account.id}`;
104
+
105
+ try {
106
+ await client.registerWebhook(webhookUrl);
107
+ console.log(
108
+ `[wechat] Account "${account.id}" registered webhook at ${webhookUrl}`,
109
+ );
110
+ } catch (err) {
111
+ console.error(
112
+ `[wechat] Failed to register webhook for "${account.id}":`,
113
+ err,
114
+ );
115
+ throw new Error(
116
+ `Webhook registration failed for account "${account.id}": ${err instanceof Error ? err.message : String(err)}`,
117
+ );
118
+ }
119
+ }
120
+
121
+ // Periodic health check
122
+ this.healthTimer = setInterval(
123
+ () => this.healthCheck(),
124
+ HEALTH_CHECK_INTERVAL_MS,
125
+ );
126
+ }
127
+
128
+ async stop(): Promise<void> {
129
+ if (this.healthTimer) {
130
+ clearInterval(this.healthTimer);
131
+ this.healthTimer = null;
132
+ }
133
+
134
+ for (const [, { bot }] of this.accounts) {
135
+ bot.stop();
136
+ }
137
+ this.accounts.clear();
138
+
139
+ if (this.abortController) {
140
+ this.abortController.abort();
141
+ this.abortController = null;
142
+ }
143
+
144
+ const servers = this.callbackServers.splice(0);
145
+ await Promise.all(
146
+ servers.map((server) => server.close().catch(() => undefined)),
147
+ );
148
+ }
149
+
150
+ async sendText(accountId: string, to: string, text: string): Promise<void> {
151
+ const entry = this.accounts.get(accountId);
152
+ if (!entry) throw new Error(`Unknown account: ${accountId}`);
153
+
154
+ try {
155
+ await entry.dispatcher.sendText(to, text);
156
+ } catch (err) {
157
+ if (err instanceof LoginExpiredError) {
158
+ await this.ensureLoggedIn(accountId, entry.client);
159
+ await entry.dispatcher.sendText(to, text);
160
+ } else {
161
+ throw err;
162
+ }
163
+ }
164
+ }
165
+
166
+ async sendImage(
167
+ accountId: string,
168
+ to: string,
169
+ imagePath: string,
170
+ caption?: string,
171
+ ): Promise<void> {
172
+ const entry = this.accounts.get(accountId);
173
+ if (!entry) throw new Error(`Unknown account: ${accountId}`);
174
+
175
+ try {
176
+ await entry.dispatcher.sendImage(to, imagePath, caption);
177
+ } catch (err) {
178
+ if (err instanceof LoginExpiredError) {
179
+ await this.ensureLoggedIn(accountId, entry.client);
180
+ await entry.dispatcher.sendImage(to, imagePath, caption);
181
+ } else {
182
+ throw err;
183
+ }
184
+ }
185
+ }
186
+
187
+ private routeIncoming(accountId: string, msg: WechatMessageContext): void {
188
+ const entry = this.accounts.get(accountId);
189
+ if (!entry) {
190
+ console.warn(
191
+ `[wechat] Received webhook for unknown account "${accountId}"`,
192
+ );
193
+ return;
194
+ }
195
+
196
+ entry.bot.handleIncoming(msg);
197
+ }
198
+
199
+ private async ensureLoggedIn(
200
+ accountId: string,
201
+ client: ProxyClient,
202
+ ): Promise<void> {
203
+ const existing = this.loginPromises.get(accountId);
204
+ if (existing) {
205
+ return existing;
206
+ }
207
+
208
+ const promise = this.doLogin(accountId, client).finally(() => {
209
+ this.loginPromises.delete(accountId);
210
+ });
211
+ this.loginPromises.set(accountId, promise);
212
+ return promise;
213
+ }
214
+
215
+ private async doLogin(accountId: string, client: ProxyClient): Promise<void> {
216
+ const status = await client.getStatus();
217
+
218
+ if (status.loginState === "logged_in") {
219
+ console.log(
220
+ `[wechat] Account "${accountId}" logged in as ${status.nickName ?? status.wcId}`,
221
+ );
222
+ return;
223
+ }
224
+
225
+ console.log(
226
+ `[wechat] Account "${accountId}" needs login — generating QR code...`,
227
+ );
228
+ const qrUrl = await client.getQRCode();
229
+ displayQRUrl(qrUrl);
230
+
231
+ const timeoutMs = this.config.loginTimeoutMs ?? LOGIN_TIMEOUT_MS;
232
+ const deadline = Date.now() + timeoutMs;
233
+ while (Date.now() < deadline) {
234
+ await sleep(LOGIN_POLL_INTERVAL_MS);
235
+
236
+ if (this.abortController?.signal.aborted) {
237
+ throw new Error("Login aborted");
238
+ }
239
+
240
+ const result = await client.checkLogin();
241
+
242
+ if (result.status === "logged_in") {
243
+ console.log(
244
+ `[wechat] Account "${accountId}" logged in as ${result.nickName ?? result.wcId}`,
245
+ );
246
+ return;
247
+ }
248
+
249
+ if (result.status === "need_verify") {
250
+ console.log(
251
+ `[wechat] Verification needed: ${result.verifyUrl ?? "check your phone"}`,
252
+ );
253
+ }
254
+ }
255
+
256
+ throw new Error(
257
+ `[wechat] Login timed out for account "${accountId}" after ${Math.round(timeoutMs / 1000)} seconds`,
258
+ );
259
+ }
260
+
261
+ private async healthCheck(): Promise<void> {
262
+ for (const [accountId, { client }] of this.accounts) {
263
+ try {
264
+ const status = await client.getStatus();
265
+ if (status.loginState !== "logged_in") {
266
+ console.warn(
267
+ `[wechat] Account "${accountId}" login expired — attempting re-login`,
268
+ );
269
+ await this.ensureLoggedIn(accountId, client);
270
+ }
271
+ } catch (err) {
272
+ console.error(`[wechat] Health check failed for "${accountId}":`, err);
273
+ }
274
+ }
275
+ }
276
+
277
+ private resolveAccounts(): ResolvedWechatAccount[] {
278
+ const accounts: ResolvedWechatAccount[] = [];
279
+ const rawPort = Number(process.env.ELIZA_WECHAT_WEBHOOK_PORT);
280
+ const envPort =
281
+ Number.isFinite(rawPort) && rawPort > 0 ? rawPort : undefined;
282
+ const defaultPort = envPort ?? this.config.webhookPort ?? 18790;
283
+ const defaultDevice = this.config.deviceType ?? "ipad";
284
+
285
+ if (this.config.accounts) {
286
+ for (const [id, acc] of Object.entries(this.config.accounts)) {
287
+ if (acc.enabled === false) continue;
288
+ accounts.push({
289
+ id,
290
+ apiKey: acc.apiKey,
291
+ proxyUrl: acc.proxyUrl,
292
+ deviceType: acc.deviceType ?? defaultDevice,
293
+ webhookPort: acc.webhookPort ?? defaultPort,
294
+ wcId: acc.wcId,
295
+ nickName: acc.nickName,
296
+ });
297
+ }
298
+ } else if (this.config.apiKey && this.config.proxyUrl) {
299
+ accounts.push({
300
+ id: "default",
301
+ apiKey: this.config.apiKey,
302
+ proxyUrl: this.config.proxyUrl,
303
+ deviceType: defaultDevice,
304
+ webhookPort: defaultPort,
305
+ });
306
+ }
307
+
308
+ return accounts;
309
+ }
310
+ }
311
+
312
+ function sleep(ms: number): Promise<void> {
313
+ return new Promise((resolve) => setTimeout(resolve, ms));
314
+ }
package/src/index.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { WechatChannel } from "./channel";
2
+ import { deliverIncomingWechatMessage } from "./runtime-bridge";
3
+ import type { WechatConfig, WechatMessageContext } from "./types";
4
+
5
+ export const WECHAT_PLUGIN_PACKAGE = "@elizaos/plugin-wechat" as const;
6
+
7
+ export function isWechatConnectorConfigured(
8
+ config: WechatConfig | Record<string, unknown> | null | undefined,
9
+ ): boolean {
10
+ if (!config || config.enabled === false) {
11
+ return false;
12
+ }
13
+
14
+ if (config.apiKey) {
15
+ return true;
16
+ }
17
+
18
+ const accounts = config.accounts;
19
+ if (accounts && typeof accounts === "object") {
20
+ return Object.values(
21
+ accounts as Record<string, Record<string, unknown>>,
22
+ ).some((account) => {
23
+ if (account.enabled === false) {
24
+ return false;
25
+ }
26
+ return Boolean(account.apiKey);
27
+ });
28
+ }
29
+
30
+ return false;
31
+ }
32
+
33
+ export interface Plugin {
34
+ name: string;
35
+ description: string;
36
+ init?: (
37
+ config: Record<string, unknown>,
38
+ runtime: unknown,
39
+ ) => Promise<void | (() => Promise<void>)>;
40
+ }
41
+
42
+ let channel: WechatChannel | null = null;
43
+
44
+ const wechatPlugin: Plugin = {
45
+ name: "wechat",
46
+ description: "WeChat messaging via proxy API",
47
+
48
+ async init(config: Record<string, unknown>, runtime: unknown) {
49
+ const wechatConfig = (config as { connectors?: { wechat?: WechatConfig } })
50
+ ?.connectors?.wechat;
51
+
52
+ if (!wechatConfig) {
53
+ console.warn("[wechat] No wechat config found in connectors — skipping");
54
+ return;
55
+ }
56
+
57
+ if (wechatConfig.enabled === false) {
58
+ console.log("[wechat] Plugin disabled via config");
59
+ return;
60
+ }
61
+
62
+ channel = new WechatChannel({
63
+ config: wechatConfig,
64
+ onMessage: async (accountId: string, msg: WechatMessageContext) => {
65
+ await deliverIncomingWechatMessage({
66
+ runtime,
67
+ accountId,
68
+ message: msg,
69
+ sendText: async (replyAccountId, to, text) => {
70
+ if (!channel) {
71
+ throw new Error("[wechat] Channel is not available for replies");
72
+ }
73
+ await channel.sendText(replyAccountId, to, text);
74
+ },
75
+ });
76
+ },
77
+ });
78
+
79
+ await channel.start();
80
+ console.log("[wechat] Plugin initialized");
81
+
82
+ // Return cleanup function
83
+ return async () => {
84
+ if (channel) {
85
+ await channel.stop();
86
+ channel = null;
87
+ console.log("[wechat] Plugin stopped");
88
+ }
89
+ };
90
+ },
91
+ };
92
+
93
+ export default wechatPlugin;
94
+ export { Bot } from "./bot";
95
+ export { WechatChannel } from "./channel";
96
+ export { ProxyClient } from "./proxy-client";
97
+ export { ReplyDispatcher } from "./reply-dispatcher";
98
+ export { deliverIncomingWechatMessage } from "./runtime-bridge";
99
+ export type { WechatConfig, WechatMessageContext } from "./types";
100
+ export { wechatPlugin };
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ describe("proxy-client 429 body consumption", () => {
9
+ it("consumes response body before retry on 429", () => {
10
+ const source = readFileSync(
11
+ path.join(__dirname, "proxy-client.ts"),
12
+ "utf-8",
13
+ );
14
+
15
+ const idx = source.indexOf("res.status === 429");
16
+ expect(idx).toBeGreaterThan(-1);
17
+
18
+ const block = source.slice(idx, idx + 500);
19
+ expect(block).toContain("res.text()");
20
+ expect(block.indexOf("res.text()")).toBeLessThan(
21
+ block.indexOf("continue;"),
22
+ );
23
+ });
24
+ });
@@ -0,0 +1,46 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { ProxyClient } from "./proxy-client";
3
+
4
+ describe("ProxyClient", () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks();
7
+ });
8
+
9
+ it("rejects insecure proxy URLs", () => {
10
+ expect(
11
+ () =>
12
+ new ProxyClient({
13
+ id: "main",
14
+ apiKey: "main-key",
15
+ proxyUrl: "http://127.0.0.1:8787",
16
+ deviceType: "ipad",
17
+ webhookPort: 18790,
18
+ }),
19
+ ).toThrow("proxyUrl must use https://");
20
+ });
21
+
22
+ it("sends X-Device-Type header in requests", async () => {
23
+ const client = new ProxyClient({
24
+ id: "test-account",
25
+ apiKey: "test-key",
26
+ proxyUrl: "https://proxy.example.com",
27
+ deviceType: "mac",
28
+ webhookPort: 18790,
29
+ });
30
+
31
+ let capturedHeaders: Record<string, string> = {};
32
+ vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
33
+ capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
34
+ return new Response(
35
+ JSON.stringify({ code: 1000, data: { status: "logged_in" } }),
36
+ { status: 200, headers: { "content-type": "application/json" } },
37
+ );
38
+ });
39
+
40
+ await client.getStatus();
41
+
42
+ expect(capturedHeaders["X-Device-Type"]).toBe("mac");
43
+ expect(capturedHeaders["X-Account-ID"]).toBe("test-account");
44
+ expect(capturedHeaders["X-API-Key"]).toBe("test-key");
45
+ });
46
+ });
@@ -0,0 +1,189 @@
1
+ import type {
2
+ AccountStatus,
3
+ ProxyApiResponse,
4
+ ResolvedWechatAccount,
5
+ } from "./types";
6
+
7
+ const SUCCESS = 1000;
8
+ const LOGIN_NEEDED = 1001;
9
+ const REQUEST_TIMEOUT_MS = 30_000;
10
+
11
+ export class ProxyClient {
12
+ private readonly apiKey: string;
13
+ private readonly baseUrl: string;
14
+ private readonly accountId: string;
15
+ private readonly deviceType: string;
16
+
17
+ constructor(account: ResolvedWechatAccount) {
18
+ this.apiKey = account.apiKey;
19
+ this.baseUrl = normalizeProxyUrl(account.proxyUrl);
20
+ this.accountId = account.id;
21
+ this.deviceType = account.deviceType ?? "ipad";
22
+ }
23
+
24
+ private async request<T>(
25
+ path: string,
26
+ body?: Record<string, unknown>,
27
+ ): Promise<ProxyApiResponse<T>> {
28
+ const url = `${this.baseUrl}${path}`;
29
+ const headers: Record<string, string> = {
30
+ "Content-Type": "application/json",
31
+ "X-API-Key": this.apiKey,
32
+ "X-Account-ID": this.accountId,
33
+ "X-Device-Type": this.deviceType,
34
+ };
35
+
36
+ let lastError: Error | undefined;
37
+ for (let attempt = 0; attempt < 3; attempt++) {
38
+ try {
39
+ const res = await fetch(url, {
40
+ method: "POST",
41
+ headers,
42
+ body: body ? JSON.stringify(body) : undefined,
43
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
44
+ });
45
+
46
+ if (res.status === 429) {
47
+ const retryAfter = res.headers.get("Retry-After");
48
+ const delay = retryAfter
49
+ ? Number.parseInt(retryAfter, 10) * 1000
50
+ : Math.min(1000 * 2 ** attempt, 8000);
51
+ // Consume the response body to release the connection
52
+ await res.text().catch(() => {});
53
+ await sleep(delay);
54
+ continue;
55
+ }
56
+
57
+ const json = (await res.json()) as ProxyApiResponse<T>;
58
+ return json;
59
+ } catch (err) {
60
+ lastError = err instanceof Error ? err : new Error(String(err));
61
+ const delay = Math.min(1000 * 2 ** attempt, 8000);
62
+ await sleep(delay);
63
+ }
64
+ }
65
+
66
+ throw lastError ?? new Error(`Request failed after 3 attempts: ${path}`);
67
+ }
68
+
69
+ async getStatus(): Promise<AccountStatus> {
70
+ const res = await this.request<AccountStatus>("/api/status");
71
+ if (res.code === LOGIN_NEEDED) {
72
+ return {
73
+ valid: true,
74
+ loginState: "waiting",
75
+ };
76
+ }
77
+ if (res.code !== SUCCESS && res.code !== 1002) {
78
+ throw new Error(`getStatus failed: ${res.message ?? res.code}`);
79
+ }
80
+ return requireData(res, "getStatus");
81
+ }
82
+
83
+ async getQRCode(): Promise<string> {
84
+ const res = await this.request<{ qrCodeUrl: string }>("/api/qrcode");
85
+ if (res.code !== SUCCESS) {
86
+ throw new Error(`getQRCode failed: ${res.message ?? res.code}`);
87
+ }
88
+ return requireData(res, "getQRCode").qrCodeUrl;
89
+ }
90
+
91
+ async checkLogin(): Promise<{
92
+ status: "waiting" | "need_verify" | "logged_in";
93
+ verifyUrl?: string;
94
+ wcId?: string;
95
+ nickName?: string;
96
+ }> {
97
+ const res = await this.request<{
98
+ status: "waiting" | "need_verify" | "logged_in";
99
+ verifyUrl?: string;
100
+ wcId?: string;
101
+ nickName?: string;
102
+ }>("/api/check-login");
103
+ if (res.code !== SUCCESS && res.code !== 1002) {
104
+ throw new Error(`checkLogin failed: ${res.message ?? res.code}`);
105
+ }
106
+ return requireData(res, "checkLogin");
107
+ }
108
+
109
+ async sendText(to: string, text: string): Promise<void> {
110
+ const res = await this.request("/api/send-text", { to, text });
111
+ if (res.code === LOGIN_NEEDED) {
112
+ throw new LoginExpiredError();
113
+ }
114
+ if (res.code !== SUCCESS && res.code !== 1002) {
115
+ throw new Error(`sendText failed: ${res.message ?? res.code}`);
116
+ }
117
+ }
118
+
119
+ async sendImage(to: string, imagePath: string, text?: string): Promise<void> {
120
+ const res = await this.request("/api/send-image", {
121
+ to,
122
+ imagePath,
123
+ text,
124
+ });
125
+ if (res.code === LOGIN_NEEDED) {
126
+ throw new LoginExpiredError();
127
+ }
128
+ if (res.code !== SUCCESS && res.code !== 1002) {
129
+ throw new Error(`sendImage failed: ${res.message ?? res.code}`);
130
+ }
131
+ }
132
+
133
+ async getContacts(): Promise<{
134
+ friends: Array<{ wxid: string; name: string }>;
135
+ chatrooms: Array<{ wxid: string; name: string }>;
136
+ }> {
137
+ const res = await this.request<{
138
+ friends: Array<{ wxid: string; name: string }>;
139
+ chatrooms: Array<{ wxid: string; name: string }>;
140
+ }>("/api/contacts");
141
+ if (res.code !== SUCCESS) {
142
+ throw new Error(`getContacts failed: ${res.message ?? res.code}`);
143
+ }
144
+ return requireData(res, "getContacts");
145
+ }
146
+
147
+ async registerWebhook(url: string): Promise<void> {
148
+ const res = await this.request("/api/webhook/register", {
149
+ webhookUrl: url,
150
+ });
151
+ if (res.code !== SUCCESS && res.code !== 1002) {
152
+ throw new Error(`registerWebhook failed: ${res.message ?? res.code}`);
153
+ }
154
+ }
155
+
156
+ get needsLogin(): boolean {
157
+ return false; // Caller checks via getStatus()
158
+ }
159
+ }
160
+
161
+ export class LoginExpiredError extends Error {
162
+ constructor() {
163
+ super("WeChat login expired — re-login required");
164
+ this.name = "LoginExpiredError";
165
+ }
166
+ }
167
+
168
+ function sleep(ms: number): Promise<void> {
169
+ return new Promise((resolve) => setTimeout(resolve, ms));
170
+ }
171
+
172
+ function normalizeProxyUrl(proxyUrl: string): string {
173
+ const parsed = new URL(proxyUrl);
174
+ if (parsed.protocol !== "https:") {
175
+ throw new Error("[wechat] proxyUrl must use https://");
176
+ }
177
+ if (parsed.username || parsed.password) {
178
+ throw new Error("[wechat] proxyUrl must not include credentials");
179
+ }
180
+ parsed.hash = "";
181
+ return parsed.toString().replace(/\/$/, "");
182
+ }
183
+
184
+ function requireData<T>(response: ProxyApiResponse<T>, action: string): T {
185
+ if (response.data === undefined) {
186
+ throw new Error(`${action} failed: missing response data`);
187
+ }
188
+ return response.data;
189
+ }