339 lines
9.7 KiB
TypeScript
339 lines
9.7 KiB
TypeScript
|
|
/**
|
||
|
|
* PIT Channel 主文件
|
||
|
|
* @module channel
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type {
|
||
|
|
ChannelPlugin,
|
||
|
|
ChannelMeta,
|
||
|
|
ChannelCapabilities,
|
||
|
|
ChannelMessagingAdapter,
|
||
|
|
ChannelConfigAdapter,
|
||
|
|
ChannelOutboundAdapter,
|
||
|
|
ChannelGatewayAdapter,
|
||
|
|
ChannelStatusAdapter,
|
||
|
|
OpenClawConfig,
|
||
|
|
} from "openclaw/plugin-sdk";
|
||
|
|
|
||
|
|
import type {
|
||
|
|
ResolvedPITBotAccount,
|
||
|
|
PITUserMessage,
|
||
|
|
PITConnectionState
|
||
|
|
} from "./types.js";
|
||
|
|
import type { Gateway } from "./gateway.js";
|
||
|
|
import {
|
||
|
|
DEFAULT_ACCOUNT_ID,
|
||
|
|
listPITBotAccountIds,
|
||
|
|
resolvePITBotAccount,
|
||
|
|
applyPITBotAccountConfig,
|
||
|
|
validateConfig,
|
||
|
|
} from "./config.js";
|
||
|
|
import { startGateway } from "./gateway.js";
|
||
|
|
import { chunkText } from "./utils/chunker.js";
|
||
|
|
import { createLogger } from "./utils/logger.js";
|
||
|
|
import { registerWebUIRoutes } from "./webui/routes.js";
|
||
|
|
|
||
|
|
const MODULE = "pit-bot";
|
||
|
|
|
||
|
|
// Gateway 实例映射
|
||
|
|
const gateways = new Map<string, Gateway>();
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PIT Bot Channel Plugin
|
||
|
|
*/
|
||
|
|
export const pitBotPlugin: ChannelPlugin<ResolvedPITBotAccount> = {
|
||
|
|
id: "pit-bot",
|
||
|
|
|
||
|
|
meta: {
|
||
|
|
id: "pit-bot",
|
||
|
|
label: "PIT Bot",
|
||
|
|
selectionLabel: "PIT Bot",
|
||
|
|
docsPath: "/docs/channels/pit-bot",
|
||
|
|
blurb: "Connect to PIT Router for multi-agent support",
|
||
|
|
order: 60,
|
||
|
|
} as ChannelMeta,
|
||
|
|
|
||
|
|
capabilities: {
|
||
|
|
chatTypes: ["direct"],
|
||
|
|
media: true,
|
||
|
|
reactions: false,
|
||
|
|
threads: false,
|
||
|
|
blockStreaming: false,
|
||
|
|
} as ChannelCapabilities,
|
||
|
|
|
||
|
|
reload: { configPrefixes: ["channels.pit-bot"] },
|
||
|
|
|
||
|
|
messaging: {
|
||
|
|
normalizeTarget: (target: string) => {
|
||
|
|
const normalized = target.replace(/^pit-bot:/i, "");
|
||
|
|
if (normalized.startsWith("user:")) {
|
||
|
|
return { ok: true, to: normalized };
|
||
|
|
}
|
||
|
|
return { ok: true, to: `user:${normalized}` };
|
||
|
|
},
|
||
|
|
|
||
|
|
targetResolver: {
|
||
|
|
looksLikeId: (id: string) => {
|
||
|
|
const normalized = id.replace(/^pit-bot:/i, "");
|
||
|
|
return normalized.startsWith("user:") || normalized.length > 0;
|
||
|
|
},
|
||
|
|
hint: "user:<userId> or <userId>",
|
||
|
|
},
|
||
|
|
} as ChannelMessagingAdapter,
|
||
|
|
|
||
|
|
config: {
|
||
|
|
listAccountIds: (cfg: OpenClawConfig) => listPITBotAccountIds(cfg),
|
||
|
|
resolveAccount: (cfg: OpenClawConfig, accountId: string) => resolvePITBotAccount(cfg, accountId),
|
||
|
|
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||
|
|
|
||
|
|
applyAccountName: (cfg: OpenClawConfig, accountId: string, name: string) => {
|
||
|
|
applyPITBotAccountConfig(cfg, accountId, { name });
|
||
|
|
},
|
||
|
|
|
||
|
|
deleteAccount: (cfg: OpenClawConfig, accountId: string) => {
|
||
|
|
const accounts = cfg.getSection<Record<string, unknown>>("channels.pit-bot.accounts");
|
||
|
|
if (accounts && accountId in accounts) {
|
||
|
|
delete accounts[accountId];
|
||
|
|
cfg.set("channels.pit-bot.accounts", accounts);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
setAccountEnabled: (cfg: OpenClawConfig, accountId: string, enabled: boolean) => {
|
||
|
|
applyPITBotAccountConfig(cfg, accountId, { enabled });
|
||
|
|
},
|
||
|
|
|
||
|
|
validateAccountConfig: (cfg: OpenClawConfig, accountId: string) => {
|
||
|
|
const account = resolvePITBotAccount(cfg, accountId);
|
||
|
|
return validateConfig(account.config);
|
||
|
|
},
|
||
|
|
} as ChannelConfigAdapter<ResolvedPITBotAccount>,
|
||
|
|
|
||
|
|
outbound: {
|
||
|
|
deliveryMode: "direct",
|
||
|
|
chunker: chunkText,
|
||
|
|
chunkerMode: "markdown",
|
||
|
|
textChunkLimit: 4000,
|
||
|
|
|
||
|
|
sendText: async ({ to, text, accountId, replyToId }: { to: string; text: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => {
|
||
|
|
const log = createLogger(`${MODULE}:outbound`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const gateway = gateways.get(accountId);
|
||
|
|
if (!gateway) {
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: "",
|
||
|
|
error: new Error(`Gateway not available for account ${accountId}`),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await gateway.sendText(to, text, replyToId);
|
||
|
|
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: result.messageId ?? "",
|
||
|
|
error: result.error ? new Error(result.error) : undefined,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
log.error("sendText failed", { to, accountId, error: message });
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: "",
|
||
|
|
error: new Error(message),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }: { to: string; text?: string; mediaUrl: string; accountId: string; replyToId?: string }): Promise<{ channel: string; messageId: string; error?: Error }> => {
|
||
|
|
const log = createLogger(`${MODULE}:outbound`);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const gateway = gateways.get(accountId);
|
||
|
|
if (!gateway) {
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: "",
|
||
|
|
error: new Error(`Gateway not available for account ${accountId}`),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = await gateway.sendMedia(to, mediaUrl ?? "", text, replyToId);
|
||
|
|
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: result.messageId ?? "",
|
||
|
|
error: result.error ? new Error(result.error) : undefined,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
log.error("sendMedia failed", { to, accountId, error: message });
|
||
|
|
return {
|
||
|
|
channel: "pit-bot",
|
||
|
|
messageId: "",
|
||
|
|
error: new Error(message),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
},
|
||
|
|
} as ChannelOutboundAdapter<ResolvedPITBotAccount>,
|
||
|
|
|
||
|
|
gateway: {
|
||
|
|
startAccount: async (ctx: {
|
||
|
|
account: ResolvedPITBotAccount;
|
||
|
|
abortSignal?: AbortSignal;
|
||
|
|
cfg: OpenClawConfig;
|
||
|
|
log?: { info: (msg: string) => void; error: (msg: string) => void };
|
||
|
|
setStatus: (state: Partial<PITConnectionState>) => void;
|
||
|
|
getStatus: () => PITConnectionState;
|
||
|
|
emitMessage: (msg: unknown) => void;
|
||
|
|
}) => {
|
||
|
|
const { account, abortSignal, cfg } = ctx;
|
||
|
|
const log = createLogger(`${MODULE}:${account.accountId}`);
|
||
|
|
|
||
|
|
log.info("Starting gateway");
|
||
|
|
|
||
|
|
try {
|
||
|
|
const gateway = await startGateway({
|
||
|
|
account,
|
||
|
|
abortSignal,
|
||
|
|
cfg,
|
||
|
|
log,
|
||
|
|
setStatus: (state) => {
|
||
|
|
const current = ctx.getStatus();
|
||
|
|
ctx.setStatus({ ...current, ...state });
|
||
|
|
},
|
||
|
|
getStatus: () => ctx.getStatus(),
|
||
|
|
onMessage: (message: PITUserMessage) => {
|
||
|
|
ctx.emitMessage({
|
||
|
|
channel: "pit-bot",
|
||
|
|
accountId: account.accountId,
|
||
|
|
chatId: `pit-bot:user:${message.userId}`,
|
||
|
|
chatType: "direct",
|
||
|
|
senderId: message.userId,
|
||
|
|
text: message.content,
|
||
|
|
attachments: message.attachments?.map(a => ({
|
||
|
|
type: a.type,
|
||
|
|
url: a.url,
|
||
|
|
filename: a.filename,
|
||
|
|
size: a.size,
|
||
|
|
})),
|
||
|
|
replyToId: message.replyTo,
|
||
|
|
metadata: {
|
||
|
|
sessionId: message.sessionId,
|
||
|
|
timestamp: message.timestamp,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
gateways.set(account.accountId, gateway);
|
||
|
|
|
||
|
|
ctx.setStatus({
|
||
|
|
...ctx.getStatus(),
|
||
|
|
running: true,
|
||
|
|
connected: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
log.info("Gateway started successfully");
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : String(error);
|
||
|
|
log.error("Failed to start gateway", { error: message });
|
||
|
|
ctx.setStatus({
|
||
|
|
...ctx.getStatus(),
|
||
|
|
running: false,
|
||
|
|
connected: false,
|
||
|
|
lastError: message,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
},
|
||
|
|
|
||
|
|
stopAccount: async (ctx: { account: ResolvedPITBotAccount }) => {
|
||
|
|
const { account } = ctx;
|
||
|
|
const log = createLogger(`${MODULE}:${account.accountId}`);
|
||
|
|
|
||
|
|
log.info("Stopping gateway");
|
||
|
|
|
||
|
|
const gateway = gateways.get(account.accountId);
|
||
|
|
if (gateway) {
|
||
|
|
gateway.disconnect();
|
||
|
|
gateways.delete(account.accountId);
|
||
|
|
}
|
||
|
|
|
||
|
|
log.info("Gateway stopped");
|
||
|
|
},
|
||
|
|
|
||
|
|
restartAccount: async (ctx: {
|
||
|
|
account: ResolvedPITBotAccount;
|
||
|
|
abortSignal?: AbortSignal;
|
||
|
|
cfg: OpenClawConfig;
|
||
|
|
setStatus: (state: Partial<PITConnectionState>) => void;
|
||
|
|
getStatus: () => PITConnectionState;
|
||
|
|
emitMessage: (msg: unknown) => void;
|
||
|
|
}) => {
|
||
|
|
const { account } = ctx;
|
||
|
|
const log = createLogger(`${MODULE}:${account.accountId}`);
|
||
|
|
|
||
|
|
log.info("Restarting gateway");
|
||
|
|
|
||
|
|
const gateway = gateways.get(account.accountId);
|
||
|
|
if (gateway) {
|
||
|
|
gateway.disconnect();
|
||
|
|
gateways.delete(account.accountId);
|
||
|
|
}
|
||
|
|
|
||
|
|
await pitBotPlugin.gateway!.startAccount!(ctx as never);
|
||
|
|
},
|
||
|
|
} as ChannelGatewayAdapter<ResolvedPITBotAccount>,
|
||
|
|
|
||
|
|
status: {
|
||
|
|
defaultRuntime: {
|
||
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
||
|
|
running: false,
|
||
|
|
connected: false,
|
||
|
|
},
|
||
|
|
|
||
|
|
formatStatus: (runtime: PITConnectionState) => {
|
||
|
|
const parts: string[] = [];
|
||
|
|
|
||
|
|
parts.push(runtime.connected ? "🟢 Connected" : "🔴 Disconnected");
|
||
|
|
|
||
|
|
if (runtime.sessionId) {
|
||
|
|
parts.push(`Session: ${runtime.sessionId.slice(0, 8)}...`);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (runtime.lastError) {
|
||
|
|
parts.push(`Error: ${runtime.lastError}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
return parts.join(" | ");
|
||
|
|
},
|
||
|
|
} as ChannelStatusAdapter,
|
||
|
|
|
||
|
|
// 初始化钩子
|
||
|
|
init: async (api: unknown) => {
|
||
|
|
const log = createLogger(MODULE);
|
||
|
|
log.info("PIT Bot plugin initializing");
|
||
|
|
|
||
|
|
registerWebUIRoutes(api as never);
|
||
|
|
|
||
|
|
log.info("PIT Bot plugin initialized");
|
||
|
|
},
|
||
|
|
|
||
|
|
// 清理钩子
|
||
|
|
cleanup: async () => {
|
||
|
|
const log = createLogger(MODULE);
|
||
|
|
log.info("PIT Bot plugin cleaning up");
|
||
|
|
|
||
|
|
for (const [accountId, gateway] of gateways.entries()) {
|
||
|
|
log.info(`Disconnecting gateway: ${accountId}`);
|
||
|
|
gateway.disconnect();
|
||
|
|
}
|
||
|
|
gateways.clear();
|
||
|
|
|
||
|
|
log.info("PIT Bot plugin cleaned up");
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
export default pitBotPlugin;
|