refactor: migrate messaging plugins to sdk
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
buildGatewayReloadPlan,
|
||||
diffConfigPaths,
|
||||
@@ -23,6 +26,52 @@ describe("diffConfigPaths", () => {
|
||||
});
|
||||
|
||||
describe("buildGatewayReloadPlan", () => {
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
const telegramPlugin: ChannelPlugin = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
id: "telegram",
|
||||
label: "Telegram",
|
||||
selectionLabel: "Telegram",
|
||||
docsPath: "/channels/telegram",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
reload: { configPrefixes: ["channels.telegram"] },
|
||||
};
|
||||
const whatsappPlugin: ChannelPlugin = {
|
||||
id: "whatsapp",
|
||||
meta: {
|
||||
id: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
selectionLabel: "WhatsApp",
|
||||
docsPath: "/channels/whatsapp",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
reload: { configPrefixes: ["web"], noopPrefixes: ["channels.whatsapp"] },
|
||||
};
|
||||
const registry = createTestRegistry([
|
||||
{ pluginId: "telegram", plugin: telegramPlugin, source: "test" },
|
||||
{ pluginId: "whatsapp", plugin: whatsappPlugin, source: "test" },
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(registry);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
});
|
||||
|
||||
it("marks gateway changes as restart required", () => {
|
||||
const plan = buildGatewayReloadPlan(["gateway.port"]);
|
||||
expect(plan.restartGateway).toBe(true);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import chokidar from "chokidar";
|
||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import type { ClawdbotConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
|
||||
|
||||
export type GatewayReloadSettings = {
|
||||
@@ -85,8 +86,14 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
|
||||
];
|
||||
|
||||
let cachedReloadRules: ReloadRule[] | null = null;
|
||||
let cachedRegistry: ReturnType<typeof getActivePluginRegistry> | null = null;
|
||||
|
||||
function listReloadRules(): ReloadRule[] {
|
||||
const registry = getActivePluginRegistry();
|
||||
if (registry !== cachedRegistry) {
|
||||
cachedReloadRules = null;
|
||||
cachedRegistry = registry;
|
||||
}
|
||||
if (cachedReloadRules) return cachedReloadRules;
|
||||
// Channel docking: plugins contribute hot reload/no-op prefixes here.
|
||||
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
|
||||
|
||||
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { rawDataToString } from "../infra/ws.js";
|
||||
@@ -141,6 +141,7 @@ describe("gateway wizard (e2e)", () => {
|
||||
process.env.HOME = tempHome;
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||
vi.resetModules();
|
||||
|
||||
const wizardToken = `wiz-${randomUUID()}`;
|
||||
const port = await getFreeGatewayPort();
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createIMessageTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
extractHookToken,
|
||||
normalizeAgentPayload,
|
||||
@@ -85,6 +85,15 @@ describe("gateway hooks helpers", () => {
|
||||
expect(explicitNoDeliver.value.deliver).toBe(false);
|
||||
}
|
||||
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "imessage",
|
||||
source: "test",
|
||||
plugin: createIMessageTestPlugin(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const imsg = normalizeAgentPayload(
|
||||
{ message: "yo", channel: "imsg" },
|
||||
{ idFactory: () => "x" },
|
||||
@@ -95,7 +104,7 @@ describe("gateway hooks helpers", () => {
|
||||
}
|
||||
|
||||
setActivePluginRegistry(
|
||||
createRegistry([
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
@@ -117,19 +126,7 @@ describe("gateway hooks helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels,
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
const emptyRegistry = createTestRegistry([]);
|
||||
|
||||
const createMSTeamsPlugin = (params: { aliases?: string[] }): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
|
||||
@@ -8,9 +8,15 @@ const mocks = vi.hoisted(() => ({
|
||||
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../config/config.js")>(
|
||||
"../../config/config.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/index.js", () => ({
|
||||
getChannelPlugin: () => ({ outbound: {} }),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/ind
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { createOutboundSendDeps } from "../../cli/deps.js";
|
||||
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import type { OutboundChannel } from "../../infra/outbound/targets.js";
|
||||
@@ -15,7 +16,28 @@ import {
|
||||
validateSendParams,
|
||||
} from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type InflightResult = {
|
||||
ok: boolean;
|
||||
payload?: Record<string, unknown>;
|
||||
error?: ReturnType<typeof errorShape>;
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const inflightByContext = new WeakMap<
|
||||
GatewayRequestContext,
|
||||
Map<string, Promise<InflightResult>>
|
||||
>();
|
||||
|
||||
const getInflightMap = (context: GatewayRequestContext) => {
|
||||
let inflight = inflightByContext.get(context);
|
||||
if (!inflight) {
|
||||
inflight = new Map();
|
||||
inflightByContext.set(context, inflight);
|
||||
}
|
||||
return inflight;
|
||||
};
|
||||
|
||||
export const sendHandlers: GatewayRequestHandlers = {
|
||||
send: async ({ params, respond, context }) => {
|
||||
@@ -42,13 +64,22 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
idempotencyKey: string;
|
||||
};
|
||||
const idem = request.idempotencyKey;
|
||||
const cached = context.dedupe.get(`send:${idem}`);
|
||||
const dedupeKey = `send:${idem}`;
|
||||
const cached = context.dedupe.get(dedupeKey);
|
||||
if (cached) {
|
||||
respond(cached.ok, cached.payload, cached.error, {
|
||||
cached: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inflightMap = getInflightMap(context);
|
||||
const inflight = inflightMap.get(dedupeKey);
|
||||
if (inflight) {
|
||||
const result = await inflight;
|
||||
const meta = result.meta ? { ...result.meta, cached: true } : { cached: true };
|
||||
respond(result.ok, result.payload, result.error, meta);
|
||||
return;
|
||||
}
|
||||
const to = request.to.trim();
|
||||
const message = request.message.trim();
|
||||
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
|
||||
@@ -66,79 +97,99 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
typeof request.accountId === "string" && request.accountId.trim().length
|
||||
? request.accountId.trim()
|
||||
: undefined;
|
||||
try {
|
||||
const outboundChannel = channel as Exclude<OutboundChannel, "none">;
|
||||
const plugin = getChannelPlugin(channel as ChannelId);
|
||||
if (!plugin) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel: outboundChannel,
|
||||
to,
|
||||
cfg,
|
||||
accountId,
|
||||
mode: "explicit",
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)));
|
||||
return;
|
||||
}
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: outboundChannel,
|
||||
to: resolved.to,
|
||||
accountId,
|
||||
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
|
||||
gifPlayback: request.gifPlayback,
|
||||
mirror:
|
||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||
? {
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
config: cfg,
|
||||
}),
|
||||
text: message,
|
||||
mediaUrls: request.mediaUrl ? [request.mediaUrl] : undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const outboundChannel = channel as Exclude<OutboundChannel, "none">;
|
||||
const plugin = getChannelPlugin(channel as ChannelId);
|
||||
if (!plugin) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = results.at(-1);
|
||||
if (!result) {
|
||||
throw new Error("No delivery result");
|
||||
const work = (async (): Promise<InflightResult> => {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const resolved = resolveOutboundTarget({
|
||||
channel: outboundChannel,
|
||||
to,
|
||||
cfg,
|
||||
accountId,
|
||||
mode: "explicit",
|
||||
});
|
||||
if (!resolved.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)),
|
||||
meta: { channel },
|
||||
};
|
||||
}
|
||||
const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined;
|
||||
const results = await deliverOutboundPayloads({
|
||||
cfg,
|
||||
channel: outboundChannel,
|
||||
to: resolved.to,
|
||||
accountId,
|
||||
payloads: [{ text: message, mediaUrl: request.mediaUrl }],
|
||||
gifPlayback: request.gifPlayback,
|
||||
deps: outboundDeps,
|
||||
mirror:
|
||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||
? {
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
agentId: resolveSessionAgentId({
|
||||
sessionKey: request.sessionKey.trim(),
|
||||
config: cfg,
|
||||
}),
|
||||
text: message,
|
||||
mediaUrls: request.mediaUrl ? [request.mediaUrl] : undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const result = results.at(-1);
|
||||
if (!result) {
|
||||
throw new Error("No delivery result");
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
channel,
|
||||
};
|
||||
if ("chatId" in result) payload.chatId = result.chatId;
|
||||
if ("channelId" in result) payload.channelId = result.channelId;
|
||||
if ("toJid" in result) payload.toJid = result.toJid;
|
||||
if ("conversationId" in result) {
|
||||
payload.conversationId = result.conversationId;
|
||||
}
|
||||
context.dedupe.set(dedupeKey, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
payload,
|
||||
meta: { channel },
|
||||
};
|
||||
} catch (err) {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
context.dedupe.set(dedupeKey, {
|
||||
ts: Date.now(),
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
return { ok: false, error, meta: { channel, error: formatForLog(err) } };
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
runId: idem,
|
||||
messageId: result.messageId,
|
||||
channel,
|
||||
};
|
||||
if ("chatId" in result) payload.chatId = result.chatId;
|
||||
if ("channelId" in result) payload.channelId = result.channelId;
|
||||
if ("toJid" in result) payload.toJid = result.toJid;
|
||||
if ("conversationId" in result) {
|
||||
payload.conversationId = result.conversationId;
|
||||
}
|
||||
context.dedupe.set(`send:${idem}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload,
|
||||
});
|
||||
respond(true, payload, undefined, { channel });
|
||||
} catch (err) {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
context.dedupe.set(`send:${idem}`, {
|
||||
ts: Date.now(),
|
||||
ok: false,
|
||||
error,
|
||||
});
|
||||
respond(false, undefined, error, { channel, error: formatForLog(err) });
|
||||
})();
|
||||
|
||||
inflightMap.set(dedupeKey, work);
|
||||
try {
|
||||
const result = await work;
|
||||
respond(result.ok, result.payload, result.error, result.meta);
|
||||
} finally {
|
||||
inflightMap.delete(dedupeKey);
|
||||
}
|
||||
},
|
||||
poll: async ({ params, respond, context }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.j
|
||||
import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { whatsappPlugin } from "../../extensions/whatsapp/src/channel.js";
|
||||
import {
|
||||
agentCommand,
|
||||
connectOk,
|
||||
@@ -53,6 +54,44 @@ vi.mock("./server-plugins.js", async () => {
|
||||
const _BASE_IMAGE_PNG =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X3mIAAAAASUVORK5CYII=";
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels,
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Bot Framework; enterprise support.",
|
||||
aliases: params?.aliases,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
const defaultRegistry = createRegistry([
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
source: "test",
|
||||
plugin: whatsappPlugin,
|
||||
},
|
||||
]);
|
||||
|
||||
function expectChannels(call: Record<string, unknown>, channel: string) {
|
||||
expect(call.channel).toBe(channel);
|
||||
expect(call.messageChannel).toBe(channel);
|
||||
@@ -60,8 +99,8 @@ function expectChannels(call: Record<string, unknown>, channel: string) {
|
||||
|
||||
describe("gateway server agent", () => {
|
||||
beforeEach(() => {
|
||||
registryState.registry = emptyRegistry;
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
registryState.registry = defaultRegistry;
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -439,34 +478,3 @@ describe("gateway server agent", () => {
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({
|
||||
plugins: [],
|
||||
tools: [],
|
||||
channels,
|
||||
providers: [],
|
||||
gatewayHandlers: {},
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const emptyRegistry = createRegistry([]);
|
||||
|
||||
const createMSTeamsPlugin = (params?: { aliases?: string[] }): ChannelPlugin => ({
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams (Bot Framework)",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "Bot Framework; enterprise support.",
|
||||
aliases: params?.aliases,
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,6 +66,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
waitCalls: [] as string[],
|
||||
waitResults: new Map<string, boolean>(),
|
||||
},
|
||||
sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
}));
|
||||
|
||||
const testConfigRoot = {
|
||||
@@ -74,6 +75,7 @@ const testConfigRoot = {
|
||||
|
||||
export const setTestConfigRoot = (root: string) => {
|
||||
testConfigRoot.value = root;
|
||||
process.env.CLAWDBOT_CONFIG_PATH = path.join(root, "clawdbot.json");
|
||||
};
|
||||
|
||||
export const bridgeStartCalls = hoisted.bridgeStartCalls;
|
||||
@@ -342,10 +344,33 @@ vi.mock("../commands/status.js", () => ({
|
||||
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
vi.mock("../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }),
|
||||
sendMessageWhatsApp: (...args: unknown[]) =>
|
||||
(hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
vi.mock("../channels/web/index.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../channels/web/index.js")>(
|
||||
"../channels/web/index.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
sendMessageWhatsApp: (...args: unknown[]) =>
|
||||
(hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
agentCommand,
|
||||
}));
|
||||
vi.mock("../cli/deps.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../cli/deps.js")>("../cli/deps.js");
|
||||
const base = actual.createDefaultDeps();
|
||||
return {
|
||||
...actual,
|
||||
createDefaultDeps: () => ({
|
||||
...base,
|
||||
sendMessageWhatsApp: (...args: unknown[]) =>
|
||||
(hoisted.sendWhatsAppMock as (...args: unknown[]) => unknown)(...args),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||
|
||||
Reference in New Issue
Block a user