385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
import { createSlackAdapter } from "@chat-adapter/slack";
|
|
import { verifySlackSignature } from "@chat-adapter/slack/webhook";
|
|
import { createPostgresState } from "@chat-adapter/state-pg";
|
|
import type { Attachment, Message, Thread } from "chat";
|
|
import { Chat } from "chat";
|
|
import { runMessage } from "./agent/runMessage/runMessage.js";
|
|
import { editSupersededApprovalCards } from "./internal/approvals/actions/editSupersededApprovalCards.js";
|
|
import { handleApprovalAction } from "./internal/approvals/actions/handleApprovalAction.js";
|
|
import { handleViewPayloadAction } from "./internal/approvals/actions/handleViewPayloadAction.js";
|
|
import { postApprovalRequest } from "./internal/approvals/actions/postApprovalRequest.js";
|
|
import { handleStopAction } from "./internal/runs/handleStopAction.js";
|
|
import { dispatchThreadMessage } from "./internal/runs/runCoordinator.js";
|
|
import {
|
|
type ActiveRun,
|
|
closeRun,
|
|
registerRun,
|
|
runKeyForThread,
|
|
} from "./internal/runs/runRegistry.js";
|
|
import { decrypt } from "./lib/crypto.js";
|
|
import { env } from "./lib/env.js";
|
|
import {
|
|
addLeafContext,
|
|
createLeafSessionContext,
|
|
logger as rootLogger,
|
|
} from "./lib/logger.js";
|
|
import { getSlackWorkspaceId } from "./providers/slack/context.js";
|
|
import {
|
|
getSlackEventWorkspaceId,
|
|
normalizeSlackEventsBody,
|
|
} from "./providers/slack/events.js";
|
|
import {
|
|
fetchSlackAttachmentFallback,
|
|
getSlackFilesFromRaw,
|
|
} from "./providers/slack/files.js";
|
|
import { findInstallationWithOrg } from "./providers/slack/installations.js";
|
|
import { getRecentMessages } from "./providers/slack/threadContext.js";
|
|
import type { ChatContextMessage } from "./types.js";
|
|
import {
|
|
finishLoading,
|
|
type LoadingState,
|
|
type ReplyTarget,
|
|
startLoading,
|
|
} from "./ui/progress.js";
|
|
import { createStatusTicker } from "./ui/statusTicker.js";
|
|
|
|
export const chatAdapterNames = ["slack"];
|
|
|
|
const getSlackAdminProvider = () =>
|
|
`slack_admin:${env.SLACK_CLIENT_ID}` as const;
|
|
|
|
const findSlackInstallationForWorkspace = async ({
|
|
workspaceId,
|
|
}: {
|
|
workspaceId: string;
|
|
}) => {
|
|
return (
|
|
(await findInstallationWithOrg(getSlackAdminProvider(), workspaceId)) ??
|
|
(await findInstallationWithOrg("slack", workspaceId))
|
|
);
|
|
};
|
|
|
|
export const bot = new Chat({
|
|
userName: env.CHAT_NAME,
|
|
adapters: {
|
|
slack: createSlackAdapter({
|
|
clientId: env.SLACK_CLIENT_ID,
|
|
clientSecret: env.SLACK_CLIENT_SECRET,
|
|
installationProvider: {
|
|
getInstallation: async (workspaceId) => {
|
|
const installation = await findSlackInstallationForWorkspace({
|
|
workspaceId,
|
|
});
|
|
if (!installation) return null;
|
|
return {
|
|
botToken: decrypt(installation.bot_access_token),
|
|
botUserId: installation.bot_user_id ?? undefined,
|
|
teamName: installation.workspace_name,
|
|
};
|
|
},
|
|
},
|
|
webhookVerifier: async (request, body) => {
|
|
await verifySlackSignature(body, request.headers, {
|
|
signingSecret: env.SLACK_SIGNING_SECRET,
|
|
});
|
|
const workspaceId = getSlackEventWorkspaceId(body);
|
|
const installation = workspaceId
|
|
? await findSlackInstallationForWorkspace({ workspaceId })
|
|
: null;
|
|
return normalizeSlackEventsBody({
|
|
body,
|
|
botUserId: installation?.bot_user_id,
|
|
});
|
|
},
|
|
userName: env.CHAT_NAME,
|
|
}),
|
|
},
|
|
state: createPostgresState({
|
|
keyPrefix: "chat",
|
|
url: env.CHAT_STATE_DATABASE_URL,
|
|
}),
|
|
// Handlers run immediately; the run coordinator serializes new runs per
|
|
// thread and routes mid-run messages (stop keywords, live follow-ups).
|
|
concurrency: "concurrent",
|
|
});
|
|
|
|
// One key per physical Slack thread, shared by message and dispatch paths.
|
|
const slackRunKey = ({
|
|
channelId,
|
|
raw,
|
|
threadId,
|
|
}: {
|
|
channelId: string;
|
|
raw: unknown;
|
|
threadId: string;
|
|
}) =>
|
|
runKeyForThread({
|
|
channelId,
|
|
provider: "slack",
|
|
threadId,
|
|
workspaceId: getSlackWorkspaceId(raw),
|
|
});
|
|
|
|
const runAndReply = async ({
|
|
channelId,
|
|
attachments,
|
|
providerUserId,
|
|
raw,
|
|
recentMessages,
|
|
runKey,
|
|
target,
|
|
text,
|
|
threadId,
|
|
}: {
|
|
attachments?: Attachment[];
|
|
channelId: string;
|
|
providerUserId: string;
|
|
raw: unknown;
|
|
recentMessages?: ChatContextMessage[];
|
|
runKey: string;
|
|
target: ReplyTarget;
|
|
text: string;
|
|
threadId: string;
|
|
}) => {
|
|
const loading: LoadingState = null;
|
|
let bootstrapLoading: LoadingState = null;
|
|
let logger = rootLogger;
|
|
let run: ActiveRun | undefined;
|
|
const ticker = createStatusTicker(target);
|
|
try {
|
|
const workspaceId = getSlackWorkspaceId(raw);
|
|
const installation = await findSlackInstallationForWorkspace({
|
|
workspaceId,
|
|
});
|
|
if (!installation) {
|
|
logger.warn("Slack installation not found", {
|
|
event: "leaf.slack_installation_missing",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const session = createLeafSessionContext({
|
|
channelId,
|
|
provider: installation.provider,
|
|
providerUserId,
|
|
threadId,
|
|
workspaceId,
|
|
});
|
|
logger = addLeafContext(rootLogger, {
|
|
...session.context,
|
|
agent_run_id: session.agentRunId,
|
|
org_id: installation.org_id,
|
|
org_slug: installation.org_slug,
|
|
});
|
|
logger.info("Received Slack message", {
|
|
event: "leaf.slack_message_received",
|
|
data: {
|
|
attachment_count: attachments?.length ?? 0,
|
|
text_length: text.length,
|
|
},
|
|
});
|
|
if (!text.trim() && !attachments?.length) {
|
|
logger.info("Skipping empty Slack message", {
|
|
event: "leaf.slack_message_skipped",
|
|
data: { reason: "empty" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const isFollowUp = recentMessages?.some((m) => m.isBot) ?? false;
|
|
// First message in a thread shows a one-time "Starting Autumn" card that
|
|
// stays pending until the managed agent reports ready; follow-ups skip it.
|
|
bootstrapLoading = isFollowUp
|
|
? null
|
|
: await startLoading(target, { showPlan: true });
|
|
// The live status ticker only starts cycling once the bootstrap card
|
|
// resolves, so the two loading states never show at the same time.
|
|
const completeBootstrap = async () => {
|
|
if (!bootstrapLoading) return;
|
|
const card = bootstrapLoading;
|
|
bootstrapLoading = null;
|
|
await finishLoading(target, card, "Autumn started.");
|
|
ticker.thinking();
|
|
};
|
|
run = registerRun({ key: runKey, kind: "message" });
|
|
// Follow-ups have no bootstrap card, so the status starts right away.
|
|
if (isFollowUp) {
|
|
ticker.thinking();
|
|
}
|
|
const logAction = (message: string) => ticker.activity(message);
|
|
const logKeyed = ({ message }: { key: string; message: string }) =>
|
|
ticker.activity(message);
|
|
run.logAction = logAction;
|
|
const rawFiles = getSlackFilesFromRaw({ raw });
|
|
const botToken = decrypt(installation.bot_access_token);
|
|
|
|
const output = await runMessage({
|
|
agentRunId: session.agentRunId,
|
|
attachmentFetchFallback: ({ attachment }) =>
|
|
fetchSlackAttachmentFallback({
|
|
attachment,
|
|
botToken,
|
|
rawFiles,
|
|
}),
|
|
attachments,
|
|
installation,
|
|
logger,
|
|
onAction: logAction,
|
|
onActionKeyed: logKeyed,
|
|
onAgentReady: completeBootstrap,
|
|
onApprovalsSuperseded: (approvals) =>
|
|
editSupersededApprovalCards({ approvals, logger, target }),
|
|
onThinking: ticker.thinking,
|
|
onTurnComplete: async (turnText) => {
|
|
await target.post({ markdown: turnText });
|
|
},
|
|
providerUserId,
|
|
recentMessages,
|
|
run,
|
|
text,
|
|
channelId,
|
|
threadId,
|
|
});
|
|
|
|
if (output.finishReason === "stopped") {
|
|
await finishLoading(target, loading, "Stopped.");
|
|
const stoppedBy = run.stop?.byUserId;
|
|
const notice =
|
|
output.stopReason === "timeout"
|
|
? "_I stopped because the run was taking too long. Send a new message to continue._"
|
|
: `_Stopped${stoppedBy ? ` by <@${stoppedBy}>` : ""}. Nothing further was run._`;
|
|
await target.post({
|
|
markdown: [output.text, notice]
|
|
.filter((part): part is string => Boolean(part?.trim()))
|
|
.join("\n\n"),
|
|
});
|
|
logger.info("Posted stopped run notice", {
|
|
event: "leaf.slack_run_stopped",
|
|
data: { stop_reason: output.stopReason ?? "user" },
|
|
});
|
|
return;
|
|
}
|
|
|
|
const postedApproval = await postApprovalRequest({
|
|
channelId,
|
|
installation,
|
|
loading,
|
|
logAction,
|
|
logger,
|
|
output,
|
|
providerUserId,
|
|
target,
|
|
});
|
|
if (postedApproval) return;
|
|
|
|
await finishLoading(target, loading, "Done.");
|
|
await target.post({ markdown: output.text || "Done." });
|
|
logger.info("Posted Slack response", {
|
|
event: "leaf.slack_response_posted",
|
|
data: {
|
|
has_text: Boolean(output.text),
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error("[chat] Message failed", error, {
|
|
event: "leaf.slack_message_failed",
|
|
});
|
|
await finishLoading(target, bootstrapLoading, "Couldn't start Autumn.");
|
|
await finishLoading(target, loading, "Request failed.");
|
|
await target.post({
|
|
markdown: "I could not complete that request. Please try again.",
|
|
});
|
|
} finally {
|
|
ticker.stop();
|
|
if (run) closeRun({ key: run.key, run });
|
|
}
|
|
};
|
|
|
|
const handleMessage = async (thread: Thread, message: Message) => {
|
|
// Never respond to other bots (including a second Autumn app on the same
|
|
// workspace) — otherwise two bots reply to each other in an infinite loop.
|
|
if (message.author.isBot === true) {
|
|
rootLogger.info("Skipping bot-authored Slack message", {
|
|
event: "leaf.slack_message_skipped",
|
|
data: { reason: "bot_author" },
|
|
});
|
|
return;
|
|
}
|
|
const runKey = slackRunKey({
|
|
channelId: thread.channelId,
|
|
raw: message.raw,
|
|
threadId: thread.id,
|
|
});
|
|
await dispatchThreadMessage({
|
|
hasAttachments: Boolean(message.attachments?.length),
|
|
onFollowUpInjected: async () => {
|
|
try {
|
|
await thread.adapter.addReaction(thread.id, message.id, "eyes");
|
|
} catch {
|
|
// The reaction is a best-effort ack.
|
|
}
|
|
},
|
|
providerUserId: message.author.userId,
|
|
runKey,
|
|
// Recent messages are fetched when the run actually starts, so a
|
|
// mutex-queued message still sees the turns that finished before it.
|
|
runNewMessage: async () =>
|
|
runAndReply({
|
|
target: thread,
|
|
attachments: message.attachments,
|
|
raw: message.raw,
|
|
runKey,
|
|
text: message.text,
|
|
channelId: thread.channelId,
|
|
providerUserId: message.author.userId,
|
|
threadId: thread.id,
|
|
recentMessages: await getRecentMessages(thread, message),
|
|
}),
|
|
text: message.text,
|
|
});
|
|
};
|
|
|
|
bot.onDirectMessage(handleMessage);
|
|
|
|
bot.onNewMention(async (thread, message) => {
|
|
await thread.subscribe();
|
|
await handleMessage(thread, message);
|
|
});
|
|
|
|
bot.onSubscribedMessage(handleMessage);
|
|
|
|
bot.onSlashCommand(async (event) => {
|
|
const runKey = slackRunKey({
|
|
channelId: event.channel.id,
|
|
raw: event.raw,
|
|
threadId: event.channel.id,
|
|
});
|
|
await dispatchThreadMessage({
|
|
hasAttachments: false,
|
|
providerUserId: event.user.userId,
|
|
runKey,
|
|
runNewMessage: () =>
|
|
runAndReply({
|
|
target: event.channel,
|
|
raw: event.raw,
|
|
runKey,
|
|
text: event.text || event.command,
|
|
channelId: event.channel.id,
|
|
providerUserId: event.user.userId,
|
|
threadId: event.channel.id,
|
|
}),
|
|
text: event.text || event.command,
|
|
});
|
|
});
|
|
|
|
bot.onAction(
|
|
["approve_billing_action", "cancel_billing_action"],
|
|
handleApprovalAction,
|
|
);
|
|
|
|
bot.onAction(["view_approval_payload"], handleViewPayloadAction);
|
|
|
|
bot.onAction(["stop_agent_run"], handleStopAction);
|
|
|
|
bot.registerSingleton();
|