import type { Gateway, GatewayChannelKey } from "../../../gateway/index.js";
import type { ChannelAdapter, ChannelHandle, ChannelLogger, ChannelStartDeps } from "../protocol/ChannelAdapter.js";
import { SlackSessionMapper } from "./SlackSessionMapper.js";
import { renderSlackEvent } from "./slack-render.js";
let BoltApp: any;
try {
BoltApp = require("@slack/bolt").App;
} catch {
}
const MAX_MESSAGE_LENGTH = 39000;
export type SlackChannelOptions = {
botToken?: string;
appToken?: string;
mapper?: SlackSessionMapper;
};
export class SlackChannel implements ChannelAdapter {
readonly channelKey: GatewayChannelKey = "slack";
private readonly mapper: SlackSessionMapper;
private readonly botToken?: string;
private readonly appToken?: string;
private gateway?: Gateway;
private logger?: ChannelLogger;
private app: any = null;
private botUserId: string | null = null;
private activeChats = new Set<string>();
constructor(options: SlackChannelOptions = {}) {
this.mapper = options.mapper ?? new SlackSessionMapper();
this.botToken = options.botToken ?? process.env.SLACK_BOT_TOKEN;
this.appToken = options.appToken ?? process.env.SLACK_APP_TOKEN;
}
async start(deps: ChannelStartDeps): Promise<ChannelHandle> {
this.gateway = deps.gateway;
this.logger = deps.logger;
if (!BoltApp) {
this.logger?.error?.("slack: @slack/bolt not installed; run `npm install @slack/bolt`");
return { stop: async () => undefined };
}
if (!this.botToken) {
this.logger?.error?.("slack: SLACK_BOT_TOKEN not set");
return { stop: async () => undefined };
}
if (!this.appToken) {
this.logger?.error?.("slack: SLACK_APP_TOKEN not set (required for Socket Mode)");
return { stop: async () => undefined };
}
try {
this.app = new BoltApp({
token: this.botToken,
appToken: this.appToken,
socketMode: true,
});
this.app.event("message", async ({ event }: any) => {
try {
await this.handleSlackMessage(event);
} catch (e) {
this.logger?.error?.(`slack: message handler error: ${e}`);
}
});
this.app.error(async (err: any) => {
this.logger?.error?.(`slack: bolt error: ${err}`);
});
await this.app.start();
const auth = await this.app.client.auth.test({ token: this.botToken });
this.botUserId = (auth.user_id as string) ?? null;
this.logger?.info?.(`slack: Socket Mode connected as ${auth.user ?? auth.user_id}`);
} catch (e) {
this.logger?.error?.(`slack: start failed: ${e}`);
return { stop: async () => undefined };
}
return {
stop: async (reason?: string) => {
this.logger?.info?.(`slack: stopping (${reason ?? "no reason"})`);
if (this.app) {
try { await this.app.stop(); } catch { }
this.app = null;
}
this.botUserId = null;
},
};
}
private async handleSlackMessage(event: any): Promise<void> {
if (!event) return;
if (event.bot_id || event.subtype === "bot_message") return;
if (event.subtype === "message_changed" || event.subtype === "message_deleted") return;
const userId = event.user as string | undefined;
if (userId && this.botUserId && userId === this.botUserId) return;
const channelId = event.channel as string | undefined;
if (!channelId) return;
const text = String(event.text ?? "").replace(/<@[^>]+>/g, "").trim();
const threadTs = (event.thread_ts as string | undefined) ?? undefined;
const ts = event.ts as string | undefined;
const chatId = threadTs && threadTs !== ts ? `${channelId}:${threadTs}` : channelId;
if (!text) return;
if (this.activeChats.has(chatId)) {
this.logger?.info?.(`slack: chat ${chatId} already active, skipping`);
return;
}
const mapped = this.mapper.resolve({ chatId, text });
const sendCtx = { channelId, threadTs };
if (mapped.command === "new" && !mapped.message) {
await this.sendReply(sendCtx, "已创建新会话。");
return;
}
if (!mapped.message) return;
this.activeChats.add(chatId);
try {
await this.processMessage(sendCtx, mapped.sessionKey, mapped.message);
} finally {
this.activeChats.delete(chatId);
}
}
private async processMessage(
ctx: { channelId: string; threadTs?: string },
sessionKey: string,
message: string,
): Promise<void> {
if (!this.gateway) return;
let replyText = "";
try {
for await (const event of this.gateway.submitTurn({
sessionKey,
channelKey: "slack",
message,
})) {
const fragment = renderSlackEvent(event);
if (fragment != null) replyText += fragment;
}
} catch (e) {
this.logger?.error?.(`slack: submitTurn error: ${e}`);
replyText = "处理消息时发生错误,请重试。";
}
const finalText = replyText.trim();
if (finalText) {
await this.sendReply(ctx, finalText);
}
}
private async sendReply(
ctx: { channelId: string; threadTs?: string },
text: string,
): Promise<void> {
if (!this.app) return;
const formatted = formatSlackMrkdwn(text);
const chunks = chunkText(formatted, MAX_MESSAGE_LENGTH);
for (const chunk of chunks) {
try {
await this.app.client.chat.postMessage({
channel: ctx.channelId,
text: chunk,
mrkdwn: true,
...(ctx.threadTs ? { thread_ts: ctx.threadTs } : {}),
});
} catch (e) {
this.logger?.error?.(`slack: postMessage failed: ${e}`);
}
}
}
}
function formatSlackMrkdwn(content: string): string {
if (!content) return content;
let t = content;
t = t.replace(/\*\*(.+?)\*\*/g, "*$1*");
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "<$2|$1>");
return t;
}
function chunkText(content: string, max: number): string[] {
if (content.length <= max) return [content];
const out: string[] = [];
let rest = content;
while (rest.length > max) {
let split = rest.lastIndexOf("\n", max);
if (split < max / 2) split = rest.lastIndexOf(" ", max);
if (split < max / 2) split = max;
out.push(rest.slice(0, split));
rest = rest.slice(split).replace(/^\n+/, "");
}
if (rest) out.push(rest);
return out;
}