mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: add WhatsApp QR channel
This commit is contained in:
parent
f8841da76f
commit
c54463843f
29 changed files with 4565 additions and 224 deletions
57
test/channels/whatsapp/client-ui.test.mjs
Normal file
57
test/channels/whatsapp/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import {
|
||||
EmptyView,
|
||||
ProvisionView,
|
||||
QrPanel,
|
||||
WhatsappAccountCard,
|
||||
} from '../../../plugin-src/client/channels/whatsapp/index.js';
|
||||
|
||||
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
|
||||
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
|
||||
const qr = renderToStaticMarkup(React.createElement(QrPanel, {
|
||||
provision: {
|
||||
qrCodeDataUrl: 'data:image/png;base64,QUJDRA==',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
durationMs: 60_000,
|
||||
},
|
||||
now: Date.now(),
|
||||
}));
|
||||
assert.match(empty, /扫码绑定 WhatsApp 机器人/);
|
||||
assert.match(empty, /生成二维码/);
|
||||
assert.match(qr, /已关联设备/);
|
||||
assert.match(qr, /关联设备/);
|
||||
assert.doesNotMatch(`${empty}${qr}`, /Cloud API|Phone Number ID|Access Token|App Secret|Verify Token|Webhook/);
|
||||
});
|
||||
|
||||
test('WhatsApp QR startup renders a neutral loading state instead of an error card', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(ProvisionView, {
|
||||
provision: { status: 'starting' },
|
||||
busy: true,
|
||||
}));
|
||||
assert.match(markup, /正在生成 WhatsApp 二维码/);
|
||||
assert.match(markup, /aria-busy="true"/);
|
||||
assert.doesNotMatch(markup, /WhatsApp 没有接入完成|WHATSAPP_PROVISION_FAILED|ddt-inlineError/);
|
||||
});
|
||||
|
||||
test('WhatsApp account card uses the unified compact channel layout', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(WhatsappAccountCard, {
|
||||
account: {
|
||||
botId: 'whatsapp-card',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
|
||||
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
assert.match(markup, /data-im-channel-logo="whatsapp"/);
|
||||
assert.match(markup, /WhatsApp Web/);
|
||||
assert.match(markup, /检查连接/);
|
||||
assert.match(markup, /移除接入/);
|
||||
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
|
||||
});
|
||||
355
test/channels/whatsapp/whatsapp.test.mjs
Normal file
355
test/channels/whatsapp/whatsapp.test.mjs
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdtemp, stat } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import { DisconnectReason } from '@whiskeysockets/baileys';
|
||||
|
||||
import {
|
||||
WhatsappConfigStore,
|
||||
deriveWhatsappBotId,
|
||||
} from '../../../src/channels/whatsapp/config-store.mjs';
|
||||
import { WhatsappController } from '../../../src/channels/whatsapp/whatsapp-controller.mjs';
|
||||
import {
|
||||
WhatsappRuntime,
|
||||
normalizeWhatsappMessage,
|
||||
} from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
|
||||
import { createWhatsappWebSession } from '../../../src/channels/whatsapp/whatsapp-web-session.mjs';
|
||||
import {
|
||||
WHATSAPP_ENDPOINTS,
|
||||
createWhatsappRpcHandler,
|
||||
} from '../../../plugin-src/host/channels/whatsapp/rpc.mjs';
|
||||
|
||||
const ACCOUNT_JID = '16505550123@s.whatsapp.net';
|
||||
const AUTH_DIRECTORY = '7fe8c17e-4fb7-4c5b-a9dc-c36525575dd1';
|
||||
|
||||
function linkedConfig(overrides = {}) {
|
||||
return {
|
||||
botId: deriveWhatsappBotId(ACCOUNT_JID),
|
||||
accountJid: ACCOUNT_JID,
|
||||
authDirectory: AUTH_DIRECTORY,
|
||||
name: 'Harness WhatsApp',
|
||||
createdAt: new Date().toISOString(),
|
||||
connectedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('WhatsApp config stores only linked-device metadata with restrictive permissions', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-config-'));
|
||||
const path = join(root, 'config.json');
|
||||
const store = await new WhatsappConfigStore(path).load();
|
||||
await store.save(linkedConfig());
|
||||
assert.equal(store.list()[0].accountJid, ACCOUNT_JID);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
await assert.rejects(() => store.save(linkedConfig({ botId: 'whatsapp_invalid' })));
|
||||
});
|
||||
|
||||
test('WhatsApp Web session reports QR and linked identity without printing either', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-session-'));
|
||||
const events = new EventEmitter();
|
||||
let ended = false;
|
||||
const socket = {
|
||||
ev: events,
|
||||
user: { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' },
|
||||
end: async () => { ended = true; },
|
||||
logout: async () => {},
|
||||
};
|
||||
const qrValues = [];
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: (value) => qrValues.push(value),
|
||||
makeSocket: () => socket,
|
||||
loadAuthState: async () => ({
|
||||
state: {
|
||||
creds: { me: socket.user },
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
},
|
||||
saveCreds: async () => {},
|
||||
}),
|
||||
});
|
||||
events.emit('connection.update', { qr: 'host-only-qr-value' });
|
||||
events.emit('connection.update', { connection: 'open' });
|
||||
assert.deepEqual(qrValues, ['host-only-qr-value']);
|
||||
assert.deepEqual(await session.ready, {
|
||||
accountJid: ACCOUNT_JID,
|
||||
name: 'Harness WhatsApp',
|
||||
});
|
||||
assert.equal((await stat(root)).mode & 0o777, 0o700);
|
||||
await session.close();
|
||||
assert.equal(ended, true);
|
||||
});
|
||||
|
||||
test('WhatsApp Web session restarts the socket after first-time QR pairing', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-restart-'));
|
||||
const sockets = Array.from({ length: 2 }, (_, index) => ({
|
||||
ev: new EventEmitter(),
|
||||
user: index === 1
|
||||
? { id: '16505550123:4@s.whatsapp.net', name: 'Harness WhatsApp' }
|
||||
: undefined,
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
}));
|
||||
let socketIndex = 0;
|
||||
let saveCount = 0;
|
||||
const authState = {
|
||||
creds: {},
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
};
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: () => {},
|
||||
makeSocket: () => sockets[socketIndex++],
|
||||
loadAuthState: async () => ({
|
||||
state: authState,
|
||||
saveCreds: async () => { saveCount += 1; },
|
||||
}),
|
||||
});
|
||||
authState.creds.me = sockets[1].user;
|
||||
sockets[0].ev.emit('creds.update', { me: sockets[1].user });
|
||||
sockets[0].ev.emit('connection.update', {
|
||||
connection: 'close',
|
||||
lastDisconnect: { error: { output: { statusCode: DisconnectReason.restartRequired } } },
|
||||
});
|
||||
for (let index = 0; index < 20 && socketIndex < 2; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(socketIndex, 2);
|
||||
assert.equal(saveCount, 1);
|
||||
assert.equal(session.socket, sockets[1]);
|
||||
sockets[1].ev.emit('connection.update', { connection: 'open' });
|
||||
assert.deepEqual(await session.ready, {
|
||||
accountJid: ACCOUNT_JID,
|
||||
name: 'Harness WhatsApp',
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('WhatsApp Web session accepts recent append events without replaying stale history', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-append-'));
|
||||
const events = new EventEmitter();
|
||||
const received = [];
|
||||
const session = await createWhatsappWebSession({
|
||||
authDir: root,
|
||||
onQr: () => {},
|
||||
onMessage: async (message) => { received.push(message.key.id); },
|
||||
makeSocket: () => ({
|
||||
ev: events,
|
||||
end: async () => {},
|
||||
logout: async () => {},
|
||||
}),
|
||||
loadAuthState: async () => ({
|
||||
state: {
|
||||
creds: {},
|
||||
keys: { get: async () => ({}), set: async () => {} },
|
||||
},
|
||||
saveCreds: async () => {},
|
||||
}),
|
||||
});
|
||||
void session.ready.catch(() => undefined);
|
||||
events.emit('messages.upsert', {
|
||||
type: 'append',
|
||||
messages: [
|
||||
{ key: { id: 'recent' }, messageTimestamp: Math.floor(Date.now() / 1_000) },
|
||||
{ key: { id: 'stale' }, messageTimestamp: Math.floor(Date.now() / 1_000) - 300 },
|
||||
],
|
||||
});
|
||||
events.emit('messages.upsert', {
|
||||
type: 'notify',
|
||||
messages: [{ key: { id: 'notify' } }],
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(received, ['recent', 'notify']);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test('WhatsApp normalizes direct and explicitly mentioned group messages', () => {
|
||||
const direct = normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-1', fromMe: false },
|
||||
message: { conversation: 'hello' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(direct.kind, 'direct');
|
||||
assert.equal(direct.addressed, true);
|
||||
assert.equal(direct.content, 'hello');
|
||||
|
||||
const group = normalizeWhatsappMessage({
|
||||
key: {
|
||||
remoteJid: '120363000000000000@g.us',
|
||||
participant: '16505550999@s.whatsapp.net',
|
||||
id: 'group-1',
|
||||
fromMe: false,
|
||||
},
|
||||
message: {
|
||||
extendedTextMessage: {
|
||||
text: 'question',
|
||||
contextInfo: { mentionedJid: [ACCOUNT_JID] },
|
||||
},
|
||||
},
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(group.kind, 'group');
|
||||
assert.equal(group.addressed, true);
|
||||
assert.equal(normalizeWhatsappMessage({
|
||||
key: { remoteJid: 'status@broadcast', id: 'ignored', fromMe: false },
|
||||
message: { conversation: 'ignored' },
|
||||
}, ACCOUNT_JID), null);
|
||||
|
||||
const selfChat = normalizeWhatsappMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'self-1', fromMe: true },
|
||||
message: { conversation: 'message yourself' },
|
||||
}, ACCOUNT_JID);
|
||||
assert.equal(selfChat.selfChat, true);
|
||||
assert.equal(selfChat.addressed, true);
|
||||
assert.equal(normalizeWhatsappMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'outbound-1', fromMe: true },
|
||||
message: { conversation: 'ordinary outbound message' },
|
||||
}, ACCOUNT_JID), null);
|
||||
});
|
||||
|
||||
test('WhatsApp runtime connects a linked device and replies through Harness', async () => {
|
||||
let callbacks;
|
||||
const calls = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async (...args) => calls.push(['presence', ...args]),
|
||||
readMessages: async () => {},
|
||||
sendMessage: async (jid, content) => {
|
||||
calls.push(['message', jid, content]);
|
||||
return { key: { id: 'reply-1' } };
|
||||
},
|
||||
};
|
||||
const state = {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-1',
|
||||
sessionExists: async () => true,
|
||||
};
|
||||
const harness = {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => 'Harness answer',
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-auth',
|
||||
harness,
|
||||
state,
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: '16505550999@s.whatsapp.net', id: 'direct-2', fromMe: false },
|
||||
message: { conversation: 'hello' },
|
||||
});
|
||||
assert.equal(runtime.status.ready, true);
|
||||
assert.ok(calls.some((call) => call[0] === 'presence' && call[1] === 'composing'));
|
||||
assert.ok(calls.some((call) => call[0] === 'message' && call[2].text === 'Harness answer'));
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp runtime answers self-chat without processing its own reply echo', async () => {
|
||||
let callbacks;
|
||||
let askCount = 0;
|
||||
const sent = [];
|
||||
const socket = {
|
||||
sendPresenceUpdate: async () => {},
|
||||
readMessages: async () => { throw new Error('self-chat must not send a read receipt'); },
|
||||
sendMessage: async (jid, content) => {
|
||||
sent.push([jid, content]);
|
||||
return { key: { id: 'bot-reply-1' } };
|
||||
},
|
||||
};
|
||||
const runtime = new WhatsappRuntime({
|
||||
config: linkedConfig(),
|
||||
authDir: '/tmp/test-whatsapp-self-chat',
|
||||
harness: {
|
||||
ensureRunning: async () => {},
|
||||
sessionExists: async () => true,
|
||||
ask: async () => { askCount += 1; return 'Harness self-chat answer'; },
|
||||
},
|
||||
state: {
|
||||
hasSeen: () => false,
|
||||
markSeen: async () => {},
|
||||
sessionFor: () => 'session-self',
|
||||
sessionExists: async () => true,
|
||||
},
|
||||
createSession: async (options) => {
|
||||
callbacks = options;
|
||||
return {
|
||||
socket,
|
||||
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
|
||||
close: async () => {},
|
||||
logout: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
await runtime.start();
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'owner-message-1', fromMe: true },
|
||||
message: { conversation: 'hello from message yourself' },
|
||||
});
|
||||
await callbacks.onMessage({
|
||||
key: { remoteJid: ACCOUNT_JID, id: 'bot-reply-1', fromMe: true },
|
||||
message: { conversation: 'Harness self-chat answer' },
|
||||
});
|
||||
assert.equal(askCount, 1);
|
||||
assert.deepEqual(sent, [[ACCOUNT_JID, { text: 'Harness self-chat answer' }]]);
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
|
||||
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
|
||||
let sessionOptions;
|
||||
let resolveReady;
|
||||
const ready = new Promise((resolve) => { resolveReady = resolve; });
|
||||
const deletedAuth = [];
|
||||
const controller = new WhatsappController({
|
||||
configStore,
|
||||
authPath: (name) => join(root, 'auth', name),
|
||||
createSession: async (options) => {
|
||||
sessionOptions = options;
|
||||
queueMicrotask(() => options.onQr('raw-linked-device-qr'));
|
||||
return { ready, close: async () => {} };
|
||||
},
|
||||
createRuntime: async () => ({
|
||||
status: {
|
||||
ready: true,
|
||||
connectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
lastCheckedAt: Date.now(),
|
||||
},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
}),
|
||||
deleteAuth: async (name) => deletedAuth.push(name),
|
||||
});
|
||||
t.after(() => controller.close());
|
||||
const handler = createWhatsappRpcHandler(controller, {
|
||||
encodeQr: async () => 'data:image/png;base64,QUJDRA==',
|
||||
});
|
||||
const started = await handler(WHATSAPP_ENDPOINTS.beginProvisioning, {});
|
||||
assert.equal(started.ok, true);
|
||||
assert.match(started.value.qrCodeDataUrl, /^data:image\/png/);
|
||||
assert.doesNotMatch(JSON.stringify(started.value), /raw-linked-device-qr|accountJid|authDirectory/);
|
||||
resolveReady({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' });
|
||||
let status;
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
status = await handler(WHATSAPP_ENDPOINTS.status, {});
|
||||
if (status.value?.bots?.length) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
assert.equal(status.ok, true);
|
||||
assert.equal(status.value.bots[0].connected, true);
|
||||
assert.doesNotMatch(JSON.stringify(status.value), /16505550123@s\.whatsapp\.net|authDirectory/);
|
||||
assert.equal(sessionOptions.signal.aborted, false);
|
||||
assert.deepEqual(deletedAuth, []);
|
||||
});
|
||||
|
|
@ -65,7 +65,7 @@ const QQ_SOURCE_URL = new URL(
|
|||
import.meta.url,
|
||||
);
|
||||
|
||||
test('IM settings renders seven compact logo channel tabs without enable switches', async () => {
|
||||
test('IM settings renders eight compact logo channel tabs without enable switches', async () => {
|
||||
const styles = await readFile(STYLES_URL, 'utf8');
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
|
|
@ -75,6 +75,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
qqRpcCall: async () => ({ ok: true, value: {} }),
|
||||
telegramRpcCall: async () => ({ ok: true, value: {} }),
|
||||
discordRpcCall: async () => ({ ok: true, value: {} }),
|
||||
whatsappRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
|
|
@ -87,6 +88,7 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
assert.match(markup, />QQ</);
|
||||
assert.match(markup, />Telegram</);
|
||||
assert.match(markup, />Discord</);
|
||||
assert.match(markup, />WhatsApp</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.match(markup, /dim-logoDingtalk/);
|
||||
|
|
@ -94,8 +96,9 @@ test('IM settings renders seven compact logo channel tabs without enable switche
|
|||
assert.match(markup, /dim-logoQq/);
|
||||
assert.match(markup, /dim-logoTelegram/);
|
||||
assert.match(markup, /dim-logoDiscord/);
|
||||
assert.match(markup, /dim-logoWhatsapp/);
|
||||
assert.match(styles, /\.dim-logoFeishu svg \{ width: 28px; height: 28px; \}/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 7);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 8);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, /dim-chevron|扫码绑定<\/small>|扫码接入<\/small>/);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import test from 'node:test';
|
|||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes all seven IM channels inside one plugin context', async () => {
|
||||
test('Host composes all eight IM channels inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
|
|
@ -13,6 +13,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
applyQq: async (ctx, config) => calls.push(['qq', ctx, config]),
|
||||
applyTelegram: async (ctx, config) => calls.push(['telegram', ctx, config]),
|
||||
applyDiscord: async (ctx, config) => calls.push(['discord', ctx, config]),
|
||||
applyWhatsapp: async (ctx, config) => calls.push(['whatsapp', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = {
|
||||
|
|
@ -24,6 +25,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
qq: { replyTimeoutMs: 60_000 },
|
||||
telegram: { replyTimeoutMs: 60_000 },
|
||||
discord: { replyTimeoutMs: 60_000 },
|
||||
whatsapp: { replyTimeoutMs: 60_000 },
|
||||
};
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
|
@ -38,6 +40,7 @@ test('Host composes all seven IM channels inside one plugin context', async () =
|
|||
['qq', ctx, { ...config.qq, rpcAuthority: 'trusted-host' }],
|
||||
['telegram', ctx, { ...config.telegram, rpcAuthority: 'trusted-host' }],
|
||||
['discord', ctx, { ...config.discord, rpcAuthority: 'trusted-host' }],
|
||||
['whatsapp', ctx, { ...config.whatsapp, rpcAuthority: 'trusted-host' }],
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue