Files
PIT_Channel/src/channel.ts

339 lines
9.7 KiB
TypeScript
Raw Normal View History

/**
* 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;