347 lines
11 KiB
TypeScript
347 lines
11 KiB
TypeScript
export type ProviderStatusIssue = {
|
|
provider: "discord" | "telegram" | "whatsapp";
|
|
accountId: string;
|
|
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
|
|
message: string;
|
|
fix?: string;
|
|
};
|
|
|
|
type DiscordIntentSummary = {
|
|
messageContent?: "enabled" | "limited" | "disabled";
|
|
};
|
|
|
|
type DiscordApplicationSummary = {
|
|
intents?: DiscordIntentSummary;
|
|
};
|
|
|
|
type DiscordAccountStatus = {
|
|
accountId?: unknown;
|
|
enabled?: unknown;
|
|
configured?: unknown;
|
|
application?: unknown;
|
|
audit?: unknown;
|
|
};
|
|
|
|
type TelegramAccountStatus = {
|
|
accountId?: unknown;
|
|
enabled?: unknown;
|
|
configured?: unknown;
|
|
allowUnmentionedGroups?: unknown;
|
|
audit?: unknown;
|
|
};
|
|
|
|
type WhatsAppAccountStatus = {
|
|
accountId?: unknown;
|
|
enabled?: unknown;
|
|
linked?: unknown;
|
|
connected?: unknown;
|
|
running?: unknown;
|
|
reconnectAttempts?: unknown;
|
|
lastError?: unknown;
|
|
};
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim().length > 0
|
|
? value.trim()
|
|
: undefined;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function readDiscordAccountStatus(value: unknown): DiscordAccountStatus | null {
|
|
if (!isRecord(value)) return null;
|
|
return {
|
|
accountId: value.accountId,
|
|
enabled: value.enabled,
|
|
configured: value.configured,
|
|
application: value.application,
|
|
audit: value.audit,
|
|
};
|
|
}
|
|
|
|
function readDiscordApplicationSummary(
|
|
value: unknown,
|
|
): DiscordApplicationSummary {
|
|
if (!isRecord(value)) return {};
|
|
const intentsRaw = value.intents;
|
|
if (!isRecord(intentsRaw)) return {};
|
|
return {
|
|
intents: {
|
|
messageContent:
|
|
intentsRaw.messageContent === "enabled" ||
|
|
intentsRaw.messageContent === "limited" ||
|
|
intentsRaw.messageContent === "disabled"
|
|
? intentsRaw.messageContent
|
|
: undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
type DiscordPermissionsAuditSummary = {
|
|
unresolvedChannels?: number;
|
|
channels?: Array<{
|
|
channelId: string;
|
|
ok?: boolean;
|
|
missing?: string[];
|
|
error?: string | null;
|
|
}>;
|
|
};
|
|
|
|
function readDiscordPermissionsAuditSummary(
|
|
value: unknown,
|
|
): DiscordPermissionsAuditSummary {
|
|
if (!isRecord(value)) return {};
|
|
const unresolvedChannels =
|
|
typeof value.unresolvedChannels === "number" &&
|
|
Number.isFinite(value.unresolvedChannels)
|
|
? value.unresolvedChannels
|
|
: undefined;
|
|
const channelsRaw = value.channels;
|
|
const channels = Array.isArray(channelsRaw)
|
|
? (channelsRaw
|
|
.map((entry) => {
|
|
if (!isRecord(entry)) return null;
|
|
const channelId = asString(entry.channelId);
|
|
if (!channelId) return null;
|
|
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
|
const missing = Array.isArray(entry.missing)
|
|
? entry.missing.map((v) => asString(v)).filter(Boolean)
|
|
: undefined;
|
|
const error = asString(entry.error) ?? null;
|
|
return {
|
|
channelId,
|
|
ok,
|
|
missing: missing?.length ? missing : undefined,
|
|
error,
|
|
};
|
|
})
|
|
.filter(Boolean) as DiscordPermissionsAuditSummary["channels"])
|
|
: undefined;
|
|
return { unresolvedChannels, channels };
|
|
}
|
|
|
|
function readTelegramAccountStatus(
|
|
value: unknown,
|
|
): TelegramAccountStatus | null {
|
|
if (!isRecord(value)) return null;
|
|
return {
|
|
accountId: value.accountId,
|
|
enabled: value.enabled,
|
|
configured: value.configured,
|
|
allowUnmentionedGroups: value.allowUnmentionedGroups,
|
|
audit: value.audit,
|
|
};
|
|
}
|
|
|
|
type TelegramGroupMembershipAuditSummary = {
|
|
unresolvedGroups?: number;
|
|
hasWildcardUnmentionedGroups?: boolean;
|
|
groups?: Array<{
|
|
chatId: string;
|
|
ok?: boolean;
|
|
status?: string | null;
|
|
error?: string | null;
|
|
}>;
|
|
};
|
|
|
|
function readTelegramGroupMembershipAuditSummary(
|
|
value: unknown,
|
|
): TelegramGroupMembershipAuditSummary {
|
|
if (!isRecord(value)) return {};
|
|
const unresolvedGroups =
|
|
typeof value.unresolvedGroups === "number" &&
|
|
Number.isFinite(value.unresolvedGroups)
|
|
? value.unresolvedGroups
|
|
: undefined;
|
|
const hasWildcardUnmentionedGroups =
|
|
typeof value.hasWildcardUnmentionedGroups === "boolean"
|
|
? value.hasWildcardUnmentionedGroups
|
|
: undefined;
|
|
const groupsRaw = value.groups;
|
|
const groups = Array.isArray(groupsRaw)
|
|
? (groupsRaw
|
|
.map((entry) => {
|
|
if (!isRecord(entry)) return null;
|
|
const chatId = asString(entry.chatId);
|
|
if (!chatId) return null;
|
|
const ok = typeof entry.ok === "boolean" ? entry.ok : undefined;
|
|
const status = asString(entry.status) ?? null;
|
|
const error = asString(entry.error) ?? null;
|
|
return { chatId, ok, status, error };
|
|
})
|
|
.filter(Boolean) as TelegramGroupMembershipAuditSummary["groups"])
|
|
: undefined;
|
|
return { unresolvedGroups, hasWildcardUnmentionedGroups, groups };
|
|
}
|
|
|
|
function readWhatsAppAccountStatus(
|
|
value: unknown,
|
|
): WhatsAppAccountStatus | null {
|
|
if (!isRecord(value)) return null;
|
|
return {
|
|
accountId: value.accountId,
|
|
enabled: value.enabled,
|
|
linked: value.linked,
|
|
connected: value.connected,
|
|
running: value.running,
|
|
reconnectAttempts: value.reconnectAttempts,
|
|
lastError: value.lastError,
|
|
};
|
|
}
|
|
|
|
export function collectProvidersStatusIssues(
|
|
payload: Record<string, unknown>,
|
|
): ProviderStatusIssue[] {
|
|
const issues: ProviderStatusIssue[] = [];
|
|
|
|
const discordAccountsRaw = payload.discordAccounts;
|
|
if (Array.isArray(discordAccountsRaw)) {
|
|
for (const entry of discordAccountsRaw) {
|
|
const account = readDiscordAccountStatus(entry);
|
|
if (!account) continue;
|
|
const accountId = asString(account.accountId) ?? "default";
|
|
const enabled = account.enabled !== false;
|
|
const configured = account.configured === true;
|
|
if (!enabled || !configured) continue;
|
|
|
|
const app = readDiscordApplicationSummary(account.application);
|
|
const messageContent = app.intents?.messageContent;
|
|
if (messageContent === "disabled") {
|
|
issues.push({
|
|
provider: "discord",
|
|
accountId,
|
|
kind: "intent",
|
|
message:
|
|
"Message Content Intent is disabled. Bot may not see normal channel messages.",
|
|
fix: "Enable Message Content Intent in Discord Dev Portal → Bot → Privileged Gateway Intents, or require mention-only operation.",
|
|
});
|
|
}
|
|
|
|
const audit = readDiscordPermissionsAuditSummary(account.audit);
|
|
if (audit.unresolvedChannels && audit.unresolvedChannels > 0) {
|
|
issues.push({
|
|
provider: "discord",
|
|
accountId,
|
|
kind: "config",
|
|
message: `Some configured guild channels are not numeric IDs (unresolvedChannels=${audit.unresolvedChannels}). Permission audit can only check numeric channel IDs.`,
|
|
fix: "Use numeric channel IDs as keys in discord.guilds.*.channels (then rerun providers status --probe).",
|
|
});
|
|
}
|
|
for (const channel of audit.channels ?? []) {
|
|
if (channel.ok === true) continue;
|
|
const missing = channel.missing?.length
|
|
? ` missing ${channel.missing.join(", ")}`
|
|
: "";
|
|
const error = channel.error ? `: ${channel.error}` : "";
|
|
issues.push({
|
|
provider: "discord",
|
|
accountId,
|
|
kind: "permissions",
|
|
message: `Channel ${channel.channelId} permission check failed.${missing}${error}`,
|
|
fix: "Ensure the bot role can view + send in this channel (and that channel overrides don't deny it).",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const telegramAccountsRaw = payload.telegramAccounts;
|
|
if (Array.isArray(telegramAccountsRaw)) {
|
|
for (const entry of telegramAccountsRaw) {
|
|
const account = readTelegramAccountStatus(entry);
|
|
if (!account) continue;
|
|
const accountId = asString(account.accountId) ?? "default";
|
|
const enabled = account.enabled !== false;
|
|
const configured = account.configured === true;
|
|
if (!enabled || !configured) continue;
|
|
|
|
if (account.allowUnmentionedGroups === true) {
|
|
issues.push({
|
|
provider: "telegram",
|
|
accountId,
|
|
kind: "config",
|
|
message:
|
|
"Config allows unmentioned group messages (requireMention=false). Telegram Bot API privacy mode will block most group messages unless disabled.",
|
|
fix: "In BotFather run /setprivacy → Disable for this bot (then restart the gateway).",
|
|
});
|
|
}
|
|
|
|
const audit = readTelegramGroupMembershipAuditSummary(account.audit);
|
|
if (audit.hasWildcardUnmentionedGroups === true) {
|
|
issues.push({
|
|
provider: "telegram",
|
|
accountId,
|
|
kind: "config",
|
|
message:
|
|
'Telegram groups config uses "*" with requireMention=false; membership probing is not possible without explicit group IDs.',
|
|
fix: "Add explicit numeric group ids under telegram.groups (or per-account groups) to enable probing.",
|
|
});
|
|
}
|
|
if (audit.unresolvedGroups && audit.unresolvedGroups > 0) {
|
|
issues.push({
|
|
provider: "telegram",
|
|
accountId,
|
|
kind: "config",
|
|
message: `Some configured Telegram groups are not numeric IDs (unresolvedGroups=${audit.unresolvedGroups}). Membership probe can only check numeric group IDs.`,
|
|
fix: "Use numeric chat IDs (e.g. -100...) as keys in telegram.groups for requireMention=false groups.",
|
|
});
|
|
}
|
|
for (const group of audit.groups ?? []) {
|
|
if (group.ok === true) continue;
|
|
const status = group.status ? ` status=${group.status}` : "";
|
|
const err = group.error ? `: ${group.error}` : "";
|
|
issues.push({
|
|
provider: "telegram",
|
|
accountId,
|
|
kind: "runtime",
|
|
message: `Group ${group.chatId} not reachable by bot.${status}${err}`,
|
|
fix: "Invite the bot to the group, then DM the bot once (/start) and restart the gateway.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const whatsappAccountsRaw = payload.whatsappAccounts;
|
|
if (Array.isArray(whatsappAccountsRaw)) {
|
|
for (const entry of whatsappAccountsRaw) {
|
|
const account = readWhatsAppAccountStatus(entry);
|
|
if (!account) continue;
|
|
const accountId = asString(account.accountId) ?? "default";
|
|
const enabled = account.enabled !== false;
|
|
if (!enabled) continue;
|
|
const linked = account.linked === true;
|
|
const running = account.running === true;
|
|
const connected = account.connected === true;
|
|
const reconnectAttempts =
|
|
typeof account.reconnectAttempts === "number"
|
|
? account.reconnectAttempts
|
|
: null;
|
|
const lastError = asString(account.lastError);
|
|
|
|
if (!linked) {
|
|
issues.push({
|
|
provider: "whatsapp",
|
|
accountId,
|
|
kind: "auth",
|
|
message: "Not linked (no WhatsApp Web session).",
|
|
fix: "Run: clawdbot providers login (scan QR on the gateway host).",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (running && !connected) {
|
|
issues.push({
|
|
provider: "whatsapp",
|
|
accountId,
|
|
kind: "runtime",
|
|
message: `Linked but disconnected${reconnectAttempts != null ? ` (reconnectAttempts=${reconnectAttempts})` : ""}${lastError ? `: ${lastError}` : "."}`,
|
|
fix: "Run: clawdbot doctor (or restart the gateway). If it persists, relink via providers login and check logs.",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
}
|