blob: e7680291347fbd11da9ebb0c56d95bc248cc4b61 [file]
import { EventEmitter } from 'node:events';
import { join } from 'node:path';
import {
hasBotChannelCredentials,
type BotChannelSettings,
type BotChatSettings,
type BotProvider,
} from '@maka/core';
import { generalizedErrorMessage } from '@maka/core/redaction';
import { BOT_PROVIDERS } from '@maka/core/settings';
import { DingTalkBotBridge } from './dingtalk-bridge.js';
import { FeishuBotBridge } from './feishu-bridge.js';
import { DiscordBotBridge } from './discord-bridge.js';
import { QQBotBridge } from './qq-bridge.js';
import { SlackBotBridge } from './slack-bridge.js';
import { SimpleBotBridge } from './simple-bridge.js';
import type {
BotBridge,
BotIncomingMessage,
BotPlatform,
BotSendOptions,
BotStatus,
SendCapable,
} from './types.js';
import { WechatBridge } from './wechat-bridge.js';
import { WeComBotBridge } from './wecom-bridge.js';
import { WhatsAppBotBridge } from './whatsapp-bridge.js';
export interface BotRegistryDeps {
onIncomingMessage: (message: BotIncomingMessage) => void;
onStatusChange: (status: BotStatus) => void;
/** Main-owned local directory for channel session state such as WhatsApp. */
botDataDir?: string;
}
export class BotRegistry extends EventEmitter {
private bridges = new Map<BotPlatform, BotBridge>();
private statuses = new Map<BotPlatform, BotStatus>();
private applyQueue: Promise<void> = Promise.resolve();
constructor(private readonly deps: BotRegistryDeps) {
super();
}
async applySettings(settings: BotChatSettings): Promise<void> {
const next = this.applyQueue.then(
() => this.applySettingsNow(settings),
() => this.applySettingsNow(settings),
);
this.applyQueue = next.catch(() => {});
return next;
}
getStatus(platform: BotPlatform): BotStatus {
return (
this.bridges.get(platform)?.getStatus() ??
this.statuses.get(platform) ??
defaultStatus(platform)
);
}
allStatuses(): Record<BotProvider, BotStatus> {
return Object.fromEntries(
BOT_PROVIDERS.map((provider) => [provider, this.getStatus(provider)]),
) as Record<BotProvider, BotStatus>;
}
async sendMessage(
platform: BotPlatform,
chatId: string,
text: string,
options?: BotSendOptions,
): Promise<string | null> {
const bridge = this.bridges.get(platform) as (BotBridge & Partial<SendCapable>) | undefined;
if (!bridge || typeof bridge.sendMessage !== 'function') return null;
return bridge.sendMessage(chatId, text, options);
}
/**
* PR-BOT-TYPING-INDICATOR-0: best-effort typing affordance. Returns
* `false` when no bridge is registered for the platform or when the
* bridge does not implement `sendTypingIndicator`. Never throws —
* typing is decorative.
*/
async sendTypingIndicator(platform: BotPlatform, chatId: string): Promise<boolean> {
const bridge = this.bridges.get(platform) as (BotBridge & Partial<SendCapable>) | undefined;
if (!bridge || typeof bridge.sendTypingIndicator !== 'function') return false;
return bridge.sendTypingIndicator(chatId);
}
async stopAll(): Promise<void> {
const next = this.applyQueue.then(
() => this.stopAllNow(),
() => this.stopAllNow(),
);
this.applyQueue = next.catch(() => {});
return next;
}
private async applySettingsNow(settings: BotChatSettings): Promise<void> {
await Promise.all(
BOT_PROVIDERS.map((provider) => this.reconcileOne(provider, settings.channels[provider])),
);
}
private async stopAllNow(): Promise<void> {
await Promise.all([...this.bridges.values()].map((bridge) => bridge.stop().catch(() => {})));
this.bridges.clear();
this.statuses.clear();
}
private async reconcileOne(platform: BotPlatform, settings: BotChannelSettings): Promise<void> {
const existing = this.bridges.get(platform);
if (!settings.enabled) {
if (existing) {
await existing.stop().catch(() => {});
this.bridges.delete(platform);
}
this.statuses.set(platform, defaultStatus(platform));
this.deps.onStatusChange(this.getStatus(platform));
return;
}
if (!isImplemented(platform)) {
const status = scaffoldStatus(platform, settings);
this.statuses.set(platform, status);
this.deps.onStatusChange(status);
return;
}
if (existing) {
const update = (
existing as { updateSettings?: (next: BotChannelSettings) => { needsRestart: boolean } }
).updateSettings;
if (update && !update.call(existing, settings).needsRestart) return;
await existing.stop().catch(() => {});
// Drop our 'message' / 'statusChange' listeners on the old
// bridge before dereferencing it. Some bridges (Discord
// gateway, WeChat poll) hold async tasks that can still emit
// after `stop()` returns; without removeAllListeners those
// emissions would race-call onIncomingMessage / onStatusChange
// against a bridge the registry already considers gone.
try {
(existing as BotBridge & EventEmitter).removeAllListeners('message');
(existing as BotBridge & EventEmitter).removeAllListeners('statusChange');
} catch {
// best-effort — non-EventEmitter bridges don't have listeners to clear.
}
}
this.statuses.delete(platform);
const bridge =
platform === 'wechat'
? new WechatBridge(settings)
: platform === 'wecom'
? new WeComBotBridge(settings)
: platform === 'feishu'
? new FeishuBotBridge(settings)
: platform === 'discord'
? new DiscordBotBridge(platform, settings)
: platform === 'dingtalk'
? new DingTalkBotBridge(platform, settings)
: platform === 'qq'
? new QQBotBridge(platform, settings)
: platform === 'slack'
? new SlackBotBridge(settings)
: platform === 'whatsapp'
? new WhatsAppBotBridge(
settings,
this.deps.botDataDir
? join(this.deps.botDataDir, 'whatsapp', 'default')
: undefined,
)
: new SimpleBotBridge(platform, settings);
this.wire(bridge);
this.bridges.set(platform, bridge);
await bridge
.start()
.catch((error) =>
console.error(`[BotRegistry] ${platform} start failed: ${generalizedErrorMessage(error)}`),
);
}
private wire(bridge: BotBridge): void {
const emitter = bridge as BotBridge & EventEmitter;
emitter.on('message', (message: BotIncomingMessage) => this.deps.onIncomingMessage(message));
emitter.on('statusChange', (status: BotStatus) => this.deps.onStatusChange(status));
}
}
function isImplemented(platform: BotPlatform): boolean {
return (
platform === 'telegram' ||
platform === 'feishu' ||
platform === 'wecom' ||
platform === 'wechat' ||
platform === 'discord' ||
platform === 'dingtalk' ||
platform === 'qq' ||
platform === 'slack' ||
platform === 'whatsapp'
);
}
function defaultStatus(platform: BotPlatform): BotStatus {
return {
platform,
running: false,
readiness: 'scaffolded',
reason: 'disabled',
connection: 'none',
};
}
function scaffoldStatus(platform: BotPlatform, settings: BotChannelSettings): BotStatus {
// PR-HEALTH-1 (xuan msg `e4887ffd`, I1 — bot readiness single-authority,
// read path): the previous behavior inherited `settings.readiness ===
// 'credentials_valid'` blindly, which leaked stale persisted state into
// `BotStatus.readiness` for unimplemented platforms (everything except
// telegram in V0.2). The settings write path (settings.ts
// `coerceReadinessForCurrentState`) already downgrades implausible
// persisted states; this read path drops the special-case to make the
// gate doubly safe.
//
// Authoritative readiness sources, post-PR-HEALTH-1:
// 1. Live bridge (`SimpleBotBridge` for telegram) — writes its own
// `readiness` field during lifecycle; surfaced via `BotBridge.getStatus()`.
// 2. Settings-derived for unimplemented platforms — computed FRESH
// from current `channel.{enabled, token, appId, appSecret}` via
// `readinessFromSettings`. Persisted `settings.readiness` is no
// longer trusted at the read boundary.
return {
platform,
running: false,
readiness: readinessFromSettings(settings),
reason:
settings.token.trim() || settings.appId || settings.appSecret
? 'scaffold-only'
: 'unimplemented',
connection: 'none',
};
}
function readinessFromSettings(settings: BotChannelSettings): BotStatus['readiness'] {
if (!settings.enabled) return 'scaffolded';
if (!hasBotChannelCredentials(settings)) return 'scaffolded';
return 'configured';
}