fix(whatsapp): wait for creds update and fix config path (#38)
This commit is contained in:
committed by
GitHub
Unverified
parent
f950c37a73
commit
29d0db706f
@@ -532,6 +532,7 @@ function registerOpenClawHandlers(): void {
|
|||||||
// Save channel configuration
|
// Save channel configuration
|
||||||
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
|
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
|
||||||
saveChannelConfig(channelType, config);
|
saveChannelConfig(channelType, config);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -625,9 +626,11 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
|
|||||||
// Request WhatsApp QR code
|
// Request WhatsApp QR code
|
||||||
ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => {
|
ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => {
|
||||||
try {
|
try {
|
||||||
|
logger.info('channel:requestWhatsAppQr', { accountId });
|
||||||
await whatsAppLoginManager.start(accountId);
|
await whatsAppLoginManager.start(accountId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('channel:requestWhatsAppQr failed', error);
|
||||||
return { success: false, error: String(error) };
|
return { success: false, error: String(error) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -638,6 +641,7 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
|
|||||||
await whatsAppLoginManager.stop();
|
await whatsAppLoginManager.stop();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('channel:cancelWhatsAppQr failed', error);
|
||||||
return { success: false, error: String(error) };
|
return { success: false, error: String(error) };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -654,12 +658,14 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void {
|
|||||||
|
|
||||||
whatsAppLoginManager.on('success', (data) => {
|
whatsAppLoginManager.on('success', (data) => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
logger.info('whatsapp:login-success', data);
|
||||||
mainWindow.webContents.send('channel:whatsapp-success', data);
|
mainWindow.webContents.send('channel:whatsapp-success', data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
whatsAppLoginManager.on('error', (error) => {
|
whatsAppLoginManager.on('error', (error) => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
logger.error('whatsapp:login-error', error);
|
||||||
mainWindow.webContents.send('channel:whatsapp-error', error);
|
mainWindow.webContents.send('channel:whatsapp-error', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,17 +6,27 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSy
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { homedir } from 'os';
|
import { homedir } from 'os';
|
||||||
import { getOpenClawResolvedDir } from './paths';
|
import { getOpenClawResolvedDir } from './paths';
|
||||||
|
import * as logger from './logger';
|
||||||
|
|
||||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||||
|
|
||||||
|
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||||
|
const PLUGIN_CHANNELS = ['whatsapp'];
|
||||||
|
|
||||||
export interface ChannelConfigData {
|
export interface ChannelConfigData {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginsConfig {
|
||||||
|
entries?: Record<string, ChannelConfigData>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenClawConfig {
|
export interface OpenClawConfig {
|
||||||
channels?: Record<string, ChannelConfigData>;
|
channels?: Record<string, ChannelConfigData>;
|
||||||
|
plugins?: PluginsConfig;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +53,7 @@ export function readOpenClawConfig(): OpenClawConfig {
|
|||||||
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
||||||
return JSON.parse(content) as OpenClawConfig;
|
return JSON.parse(content) as OpenClawConfig;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Failed to read OpenClaw config', error);
|
||||||
console.error('Failed to read OpenClaw config:', error);
|
console.error('Failed to read OpenClaw config:', error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -57,6 +68,7 @@ export function writeOpenClawConfig(config: OpenClawConfig): void {
|
|||||||
try {
|
try {
|
||||||
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Failed to write OpenClaw config', error);
|
||||||
console.error('Failed to write OpenClaw config:', error);
|
console.error('Failed to write OpenClaw config:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -73,6 +85,28 @@ export function saveChannelConfig(
|
|||||||
): void {
|
): void {
|
||||||
const currentConfig = readOpenClawConfig();
|
const currentConfig = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
||||||
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
|
if (!currentConfig.plugins) {
|
||||||
|
currentConfig.plugins = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.plugins.entries) {
|
||||||
|
currentConfig.plugins.entries = {};
|
||||||
|
}
|
||||||
|
currentConfig.plugins.entries[channelType] = {
|
||||||
|
...currentConfig.plugins.entries[channelType],
|
||||||
|
enabled: config.enabled ?? true,
|
||||||
|
};
|
||||||
|
writeOpenClawConfig(currentConfig);
|
||||||
|
logger.info('Plugin channel config saved', {
|
||||||
|
channelType,
|
||||||
|
configFile: CONFIG_FILE,
|
||||||
|
path: `plugins.entries.${channelType}`,
|
||||||
|
});
|
||||||
|
console.log(`Saved plugin channel config for ${channelType}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentConfig.channels) {
|
if (!currentConfig.channels) {
|
||||||
currentConfig.channels = {};
|
currentConfig.channels = {};
|
||||||
}
|
}
|
||||||
@@ -146,6 +180,13 @@ export function saveChannelConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
writeOpenClawConfig(currentConfig);
|
writeOpenClawConfig(currentConfig);
|
||||||
|
logger.info('Channel config saved', {
|
||||||
|
channelType,
|
||||||
|
configFile: CONFIG_FILE,
|
||||||
|
rawKeys: Object.keys(config),
|
||||||
|
transformedKeys: Object.keys(transformedConfig),
|
||||||
|
enabled: currentConfig.channels[channelType]?.enabled,
|
||||||
|
});
|
||||||
console.log(`Saved channel config for ${channelType}`);
|
console.log(`Saved channel config for ${channelType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +330,23 @@ export function listConfiguredChannels(): string[] {
|
|||||||
export function setChannelEnabled(channelType: string, enabled: boolean): void {
|
export function setChannelEnabled(channelType: string, enabled: boolean): void {
|
||||||
const currentConfig = readOpenClawConfig();
|
const currentConfig = readOpenClawConfig();
|
||||||
|
|
||||||
|
// Plugin-based channels go under plugins.entries
|
||||||
|
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||||
|
if (!currentConfig.plugins) {
|
||||||
|
currentConfig.plugins = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.plugins.entries) {
|
||||||
|
currentConfig.plugins.entries = {};
|
||||||
|
}
|
||||||
|
if (!currentConfig.plugins.entries[channelType]) {
|
||||||
|
currentConfig.plugins.entries[channelType] = {};
|
||||||
|
}
|
||||||
|
currentConfig.plugins.entries[channelType].enabled = enabled;
|
||||||
|
writeOpenClawConfig(currentConfig);
|
||||||
|
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!currentConfig.channels) {
|
if (!currentConfig.channels) {
|
||||||
currentConfig.channels = {};
|
currentConfig.channels = {};
|
||||||
}
|
}
|
||||||
@@ -457,10 +515,16 @@ async function validateTelegramCredentials(
|
|||||||
): Promise<CredentialValidationResult> {
|
): Promise<CredentialValidationResult> {
|
||||||
const botToken = config.botToken?.trim();
|
const botToken = config.botToken?.trim();
|
||||||
|
|
||||||
|
const allowedUsers = config.allowedUsers?.trim();
|
||||||
|
|
||||||
if (!botToken) {
|
if (!botToken) {
|
||||||
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!allowedUsers) {
|
||||||
|
return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||||
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
||||||
@@ -553,6 +617,12 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
|||||||
result.errors.push('Telegram: Bot token is required');
|
result.errors.push('Telegram: Bot token is required');
|
||||||
result.valid = false;
|
result.valid = false;
|
||||||
}
|
}
|
||||||
|
// Check allowed users (stored as allowFrom array)
|
||||||
|
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
|
||||||
|
if (!allowedUsers || allowedUsers.length === 0) {
|
||||||
|
result.errors.push('Telegram: Allowed User IDs are required');
|
||||||
|
result.valid = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.errors.length === 0 && result.warnings.length === 0) {
|
if (result.errors.length === 0 && result.warnings.length === 0) {
|
||||||
|
|||||||
@@ -43,7 +43,10 @@ const QRCodeModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index
|
|||||||
const QRErrorCorrectLevelModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js'));
|
const QRErrorCorrectLevelModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js'));
|
||||||
|
|
||||||
// Types from Baileys (approximate since we don't have types for dynamic require)
|
// Types from Baileys (approximate since we don't have types for dynamic require)
|
||||||
type BaileysSocket = any;
|
interface BaileysError extends Error {
|
||||||
|
output?: { statusCode?: number };
|
||||||
|
}
|
||||||
|
type BaileysSocket = ReturnType<typeof makeWASocket>;
|
||||||
type ConnectionState = {
|
type ConnectionState = {
|
||||||
connection: 'close' | 'open' | 'connecting';
|
connection: 'close' | 'open' | 'connecting';
|
||||||
lastDisconnect?: {
|
lastDisconnect?: {
|
||||||
@@ -186,6 +189,18 @@ export class WhatsAppLoginManager extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finish login: close socket and emit success after credentials are saved
|
||||||
|
*/
|
||||||
|
private async finishLogin(accountId: string): Promise<void> {
|
||||||
|
if (!this.active) return;
|
||||||
|
console.log('[WhatsAppLogin] Finishing login, closing socket to hand over to Gateway...');
|
||||||
|
await this.stop();
|
||||||
|
// Delay to ensure socket is fully released before Gateway connects
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
this.emit('success', { accountId });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start WhatsApp pairing process
|
* Start WhatsApp pairing process
|
||||||
*/
|
*/
|
||||||
@@ -226,7 +241,8 @@ export class WhatsAppLoginManager extends EventEmitter {
|
|||||||
|
|
||||||
console.log(`[WhatsAppLogin] Connecting for ${accountId} at ${authDir} (Attempt ${this.retryCount + 1})`);
|
console.log(`[WhatsAppLogin] Connecting for ${accountId} at ${authDir} (Attempt ${this.retryCount + 1})`);
|
||||||
|
|
||||||
let pino: any;
|
|
||||||
|
let pino: (...args: unknown[]) => Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
// Try to resolve pino from baileys context since it's a dependency of baileys
|
// Try to resolve pino from baileys context since it's a dependency of baileys
|
||||||
const baileysRequire = createRequire(join(baileysPath, 'package.json'));
|
const baileysRequire = createRequire(join(baileysPath, 'package.json'));
|
||||||
@@ -268,7 +284,21 @@ export class WhatsAppLoginManager extends EventEmitter {
|
|||||||
// browser: ['ClawX', 'Chrome', '1.0.0'],
|
// browser: ['ClawX', 'Chrome', '1.0.0'],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.ev.on('creds.update', saveCreds);
|
let connectionOpened = false;
|
||||||
|
let credsReceived = false;
|
||||||
|
let credsTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
this.socket.ev.on('creds.update', async () => {
|
||||||
|
await saveCreds();
|
||||||
|
if (connectionOpened && !credsReceived) {
|
||||||
|
credsReceived = true;
|
||||||
|
if (credsTimeout) clearTimeout(credsTimeout);
|
||||||
|
console.log('[WhatsAppLogin] Credentials saved after connection open, finishing login...');
|
||||||
|
// Small delay to ensure file writes are fully flushed
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
await this.finishLogin(accountId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.socket.ev.on('connection.update', async (update: ConnectionState) => {
|
this.socket.ev.on('connection.update', async (update: ConnectionState) => {
|
||||||
try {
|
try {
|
||||||
@@ -282,7 +312,7 @@ export class WhatsAppLoginManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (connection === 'close') {
|
if (connection === 'close') {
|
||||||
const error = (lastDisconnect?.error as any);
|
const error = lastDisconnect?.error as BaileysError | undefined;
|
||||||
const shouldReconnect = error?.output?.statusCode !== DisconnectReason.loggedOut;
|
const shouldReconnect = error?.output?.statusCode !== DisconnectReason.loggedOut;
|
||||||
console.log('[WhatsAppLogin] Connection closed.',
|
console.log('[WhatsAppLogin] Connection closed.',
|
||||||
'Reconnect:', shouldReconnect,
|
'Reconnect:', shouldReconnect,
|
||||||
@@ -317,17 +347,17 @@ export class WhatsAppLoginManager extends EventEmitter {
|
|||||||
this.emit('error', 'Logged out');
|
this.emit('error', 'Logged out');
|
||||||
}
|
}
|
||||||
} else if (connection === 'open') {
|
} else if (connection === 'open') {
|
||||||
console.log('[WhatsAppLogin] Connection opened! Closing socket to hand over to Gateway...');
|
console.log('[WhatsAppLogin] Connection opened! Waiting for credentials to be saved...');
|
||||||
this.retryCount = 0;
|
this.retryCount = 0;
|
||||||
|
connectionOpened = true;
|
||||||
|
|
||||||
// Close socket gracefully to avoid conflict with Gateway
|
// Safety timeout: if creds don't update within 15s, proceed anyway
|
||||||
await this.stop();
|
credsTimeout = setTimeout(async () => {
|
||||||
|
if (!credsReceived && this.active) {
|
||||||
// Add a small delay to ensure socket is fully closed and released
|
console.warn('[WhatsAppLogin] Timed out waiting for creds.update after connection open, proceeding...');
|
||||||
// This prevents "401 Conflict" when Gateway tries to connect immediately
|
await this.finishLogin(accountId);
|
||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
}
|
||||||
|
}, 15000);
|
||||||
this.emit('success', { accountId });
|
|
||||||
}
|
}
|
||||||
} catch (innerErr) {
|
} catch (innerErr) {
|
||||||
console.error('[WhatsAppLogin] Error in connection update:', innerErr);
|
console.error('[WhatsAppLogin] Error in connection update:', innerErr);
|
||||||
|
|||||||
@@ -412,12 +412,29 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedType !== 'whatsapp') return;
|
if (selectedType !== 'whatsapp') return;
|
||||||
|
|
||||||
const onQr = (data: { qr: string; raw: string }) => {
|
const onQr = (...args: unknown[]) => {
|
||||||
|
const data = args[0] as { qr: string; raw: string };
|
||||||
setQrCode(`data:image/png;base64,${data.qr}`);
|
setQrCode(`data:image/png;base64,${data.qr}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSuccess = () => {
|
const onSuccess = async (...args: unknown[]) => {
|
||||||
|
const data = args[0] as { accountId?: string } | undefined;
|
||||||
toast.success('WhatsApp connected successfully!');
|
toast.success('WhatsApp connected successfully!');
|
||||||
|
const accountId = data?.accountId || channelName.trim() || 'default';
|
||||||
|
try {
|
||||||
|
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||||
|
'channel:saveConfig',
|
||||||
|
'whatsapp',
|
||||||
|
{ enabled: true }
|
||||||
|
) as { success?: boolean; error?: string };
|
||||||
|
if (!saveResult?.success) {
|
||||||
|
console.error('Failed to save WhatsApp config:', saveResult?.error);
|
||||||
|
} else {
|
||||||
|
console.info('Saved WhatsApp config for account:', accountId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save WhatsApp config:', error);
|
||||||
|
}
|
||||||
// Register the channel locally so it shows up immediately
|
// Register the channel locally so it shows up immediately
|
||||||
addChannel({
|
addChannel({
|
||||||
type: 'whatsapp',
|
type: 'whatsapp',
|
||||||
@@ -429,16 +446,17 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onError = (err: string) => {
|
const onError = (...args: unknown[]) => {
|
||||||
|
const err = args[0] as string;
|
||||||
console.error('WhatsApp Login Error:', err);
|
console.error('WhatsApp Login Error:', err);
|
||||||
toast.error(`WhatsApp Login Failed: ${err}`);
|
toast.error(`WhatsApp Login Failed: ${err}`);
|
||||||
setQrCode(null);
|
setQrCode(null);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeQrListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-qr', onQr);
|
const removeQrListener = window.electron.ipcRenderer.on('channel:whatsapp-qr', onQr);
|
||||||
const removeSuccessListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-success', onSuccess);
|
const removeSuccessListener = window.electron.ipcRenderer.on('channel:whatsapp-success', onSuccess);
|
||||||
const removeErrorListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-error', onError);
|
const removeErrorListener = window.electron.ipcRenderer.on('channel:whatsapp-error', onError);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (typeof removeQrListener === 'function') removeQrListener();
|
if (typeof removeQrListener === 'function') removeQrListener();
|
||||||
|
|||||||
@@ -129,11 +129,11 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'allowedUsers',
|
key: 'allowedUsers',
|
||||||
label: 'Allowed User IDs (optional)',
|
label: 'Allowed User IDs',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
placeholder: 'e.g. 123456789, 987654321',
|
placeholder: 'e.g. 123456789, 987654321',
|
||||||
description: 'Comma separated list of User IDs allowed to use the bot. Leave empty to allow everyone (if public) or require pairing.',
|
description: 'Comma separated list of User IDs allowed to use the bot. Required for security.',
|
||||||
required: false,
|
required: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
instructions: [
|
instructions: [
|
||||||
@@ -141,6 +141,7 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
|||||||
'Send /newbot and follow the instructions',
|
'Send /newbot and follow the instructions',
|
||||||
'Copy the bot token provided',
|
'Copy the bot token provided',
|
||||||
'Paste the token below',
|
'Paste the token below',
|
||||||
|
'Get your User ID from @userinfobot and paste it below',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
discord: {
|
discord: {
|
||||||
|
|||||||
Reference in New Issue
Block a user