Slack: add some fixes and connect it all up
This commit is contained in:
@@ -86,12 +86,11 @@ export async function listSlackReactions(
|
||||
export async function sendSlackMessage(
|
||||
to: string,
|
||||
content: string,
|
||||
opts: SlackActionClientOpts & { mediaUrl?: string; replyTo?: string } = {},
|
||||
opts: SlackActionClientOpts & { mediaUrl?: string } = {},
|
||||
) {
|
||||
return await sendMessageSlack(to, content, {
|
||||
token: opts.token,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
threadTs: opts.replyTo,
|
||||
client: opts.client,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ export {
|
||||
unpinSlackMessage,
|
||||
} from "./actions.js";
|
||||
export { monitorSlackProvider } from "./monitor.js";
|
||||
export { probeSlack } from "./probe.js";
|
||||
export { sendMessageSlack } from "./send.js";
|
||||
export { resolveSlackAppToken, resolveSlackBotToken } from "./token.js";
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import type {
|
||||
ReplyToMode,
|
||||
SlackReactionNotificationMode,
|
||||
SlackSlashCommandConfig,
|
||||
} from "../config/config.js";
|
||||
@@ -26,7 +25,6 @@ export type MonitorSlackOpts = {
|
||||
appToken?: string;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
replyToMode?: ReplyToMode;
|
||||
mediaMaxMb?: number;
|
||||
slashCommand?: SlackSlashCommandConfig;
|
||||
};
|
||||
@@ -135,18 +133,6 @@ type SlackChannelConfigResolved = {
|
||||
requireMention: boolean;
|
||||
};
|
||||
|
||||
export function resolveSlackReplyTarget(opts: {
|
||||
replyToMode: ReplyToMode;
|
||||
replyToId?: string;
|
||||
hasReplied: boolean;
|
||||
}): string | undefined {
|
||||
if (opts.replyToMode === "off") return undefined;
|
||||
const replyToId = opts.replyToId?.trim();
|
||||
if (!replyToId) return undefined;
|
||||
if (opts.replyToMode === "all") return replyToId;
|
||||
return opts.hasReplied ? undefined : replyToId;
|
||||
}
|
||||
|
||||
function normalizeSlackSlug(raw?: string) {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
@@ -353,7 +339,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const groupDmChannels = normalizeAllowList(dmConfig?.groupChannels);
|
||||
const channelsConfig = cfg.slack?.channels;
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const replyToMode = opts.replyToMode ?? cfg.slack?.replyToMode ?? "off";
|
||||
const reactionMode = cfg.slack?.reactionNotifications ?? "own";
|
||||
const reactionAllowlist = cfg.slack?.reactionAllowlist ?? [];
|
||||
const slashCommand = resolveSlackSlashCommandConfig(
|
||||
@@ -583,6 +568,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const senderName = sender?.name ?? message.user;
|
||||
const roomLabel = channelName ? `#${channelName}` : `#${message.channel}`;
|
||||
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
const inboundLabel = isDirectMessage
|
||||
? `Slack DM from ${senderName}`
|
||||
: `Slack message in ${roomLabel} from ${senderName}`;
|
||||
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
||||
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
||||
});
|
||||
|
||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||
const body = formatAgentEnvelope({
|
||||
surface: "Slack",
|
||||
@@ -634,7 +627,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = rawBody.replace(/\s+/g, " ").slice(0, 160);
|
||||
logVerbose(
|
||||
`slack inbound: channel=${message.channel} from=${ctxPayload.From} preview="${preview}"`,
|
||||
);
|
||||
@@ -656,7 +648,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
})
|
||||
@@ -685,7 +676,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
target: replyTarget,
|
||||
token: botToken,
|
||||
runtime,
|
||||
replyToMode,
|
||||
textLimit,
|
||||
});
|
||||
if (shouldLogVerbose()) {
|
||||
@@ -1222,49 +1212,36 @@ async function deliverReplies(params: {
|
||||
target: string;
|
||||
token: string;
|
||||
runtime: RuntimeEnv;
|
||||
replyToMode: ReplyToMode;
|
||||
textLimit: number;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
for (const payload of params.replies) {
|
||||
const mediaList =
|
||||
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const replyToId = payload.replyToId;
|
||||
if (!text && mediaList.length === 0) continue;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of chunkText(text, chunkLimit)) {
|
||||
const threadTs = resolveSlackReplyTarget({
|
||||
replyToMode: params.replyToMode,
|
||||
replyToId,
|
||||
hasReplied,
|
||||
});
|
||||
const threadTs = undefined;
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed || trimmed === SILENT_REPLY_TOKEN) continue;
|
||||
await sendMessageSlack(params.target, trimmed, {
|
||||
token: params.token,
|
||||
threadTs,
|
||||
});
|
||||
if (threadTs && !hasReplied) hasReplied = true;
|
||||
}
|
||||
} else {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : "";
|
||||
first = false;
|
||||
const threadTs = resolveSlackReplyTarget({
|
||||
replyToMode: params.replyToMode,
|
||||
replyToId,
|
||||
hasReplied,
|
||||
});
|
||||
const threadTs = undefined;
|
||||
await sendMessageSlack(params.target, caption, {
|
||||
token: params.token,
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
});
|
||||
if (threadTs && !hasReplied) hasReplied = true;
|
||||
}
|
||||
}
|
||||
params.runtime.log?.(`delivered reply to ${params.target}`);
|
||||
|
||||
59
src/slack/probe.ts
Normal file
59
src/slack/probe.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export type SlackProbe = {
|
||||
ok: boolean;
|
||||
status?: number | null;
|
||||
error?: string | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: { id?: string; name?: string };
|
||||
team?: { id?: string; name?: string };
|
||||
};
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
|
||||
export async function probeSlack(
|
||||
token: string,
|
||||
timeoutMs = 2500,
|
||||
): Promise<SlackProbe> {
|
||||
const client = new WebClient(token);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await withTimeout(client.auth.test(), timeoutMs);
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 200,
|
||||
error: result.error ?? "unknown",
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
elapsedMs: Date.now() - start,
|
||||
bot: { id: result.user_id ?? undefined, name: result.user ?? undefined },
|
||||
team: { id: result.team_id ?? undefined, name: result.team ?? undefined },
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const status =
|
||||
typeof (err as { status?: number }).status === "number"
|
||||
? (err as { status?: number }).status
|
||||
: null;
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
error: message,
|
||||
elapsedMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
187
src/slack/send.ts
Normal file
187
src/slack/send.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { type FilesUploadV2Arguments, WebClient } from "@slack/web-api";
|
||||
|
||||
import { chunkText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveSlackBotToken } from "./token.js";
|
||||
|
||||
const SLACK_TEXT_LIMIT = 4000;
|
||||
|
||||
type SlackRecipient =
|
||||
| {
|
||||
kind: "user";
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
kind: "channel";
|
||||
id: string;
|
||||
};
|
||||
|
||||
type SlackSendOpts = {
|
||||
token?: string;
|
||||
mediaUrl?: string;
|
||||
client?: WebClient;
|
||||
threadTs?: string;
|
||||
};
|
||||
|
||||
export type SlackSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
function resolveToken(explicit?: string) {
|
||||
const cfgToken = loadConfig().slack?.botToken;
|
||||
const token = resolveSlackBotToken(
|
||||
explicit ?? process.env.SLACK_BOT_TOKEN ?? cfgToken ?? undefined,
|
||||
);
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
"SLACK_BOT_TOKEN or slack.botToken is required for Slack sends",
|
||||
);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
function parseRecipient(raw: string): SlackRecipient {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("Recipient is required for Slack sends");
|
||||
}
|
||||
const mentionMatch = trimmed.match(/^<@([A-Z0-9]+)>$/i);
|
||||
if (mentionMatch) {
|
||||
return { kind: "user", id: mentionMatch[1] };
|
||||
}
|
||||
if (trimmed.startsWith("user:")) {
|
||||
return { kind: "user", id: trimmed.slice("user:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("channel:")) {
|
||||
return { kind: "channel", id: trimmed.slice("channel:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("slack:")) {
|
||||
return { kind: "user", id: trimmed.slice("slack:".length) };
|
||||
}
|
||||
if (trimmed.startsWith("@")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^[A-Z0-9]+$/i.test(candidate)) {
|
||||
throw new Error("Slack DMs require a user id (use user:<id> or <@id>)");
|
||||
}
|
||||
return { kind: "user", id: candidate };
|
||||
}
|
||||
if (trimmed.startsWith("#")) {
|
||||
const candidate = trimmed.slice(1);
|
||||
if (!/^[A-Z0-9]+$/i.test(candidate)) {
|
||||
throw new Error("Slack channels require a channel id (use channel:<id>)");
|
||||
}
|
||||
return { kind: "channel", id: candidate };
|
||||
}
|
||||
return { kind: "channel", id: trimmed };
|
||||
}
|
||||
|
||||
async function resolveChannelId(
|
||||
client: WebClient,
|
||||
recipient: SlackRecipient,
|
||||
): Promise<{ channelId: string; isDm?: boolean }> {
|
||||
if (recipient.kind === "channel") {
|
||||
return { channelId: recipient.id };
|
||||
}
|
||||
const response = await client.conversations.open({ users: recipient.id });
|
||||
const channelId = response.channel?.id;
|
||||
if (!channelId) {
|
||||
throw new Error("Failed to open Slack DM channel");
|
||||
}
|
||||
return { channelId, isDm: true };
|
||||
}
|
||||
|
||||
async function uploadSlackFile(params: {
|
||||
client: WebClient;
|
||||
channelId: string;
|
||||
mediaUrl: string;
|
||||
caption?: string;
|
||||
threadTs?: string;
|
||||
maxBytes?: number;
|
||||
}): Promise<string> {
|
||||
const { buffer, contentType, fileName } = await loadWebMedia(
|
||||
params.mediaUrl,
|
||||
params.maxBytes,
|
||||
);
|
||||
const basePayload = {
|
||||
channel_id: params.channelId,
|
||||
file: buffer,
|
||||
filename: fileName,
|
||||
...(params.caption ? { initial_comment: params.caption } : {}),
|
||||
...(contentType ? { filetype: contentType } : {}),
|
||||
};
|
||||
const payload: FilesUploadV2Arguments = params.threadTs
|
||||
? { ...basePayload, thread_ts: params.threadTs }
|
||||
: basePayload;
|
||||
const response = await params.client.files.uploadV2(payload);
|
||||
const parsed = response as {
|
||||
files?: Array<{ id?: string; name?: string }>;
|
||||
file?: { id?: string; name?: string };
|
||||
};
|
||||
const fileId =
|
||||
parsed.files?.[0]?.id ??
|
||||
parsed.file?.id ??
|
||||
parsed.files?.[0]?.name ??
|
||||
parsed.file?.name ??
|
||||
"unknown";
|
||||
return fileId;
|
||||
}
|
||||
|
||||
export async function sendMessageSlack(
|
||||
to: string,
|
||||
message: string,
|
||||
opts: SlackSendOpts = {},
|
||||
): Promise<SlackSendResult> {
|
||||
const trimmedMessage = message?.trim() ?? "";
|
||||
if (!trimmedMessage && !opts.mediaUrl) {
|
||||
throw new Error("Slack send requires text or media");
|
||||
}
|
||||
const token = resolveToken(opts.token);
|
||||
const client = opts.client ?? new WebClient(token);
|
||||
const recipient = parseRecipient(to);
|
||||
const { channelId } = await resolveChannelId(client, recipient);
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "slack");
|
||||
const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT);
|
||||
const chunks = chunkText(trimmedMessage, chunkLimit);
|
||||
const mediaMaxBytes =
|
||||
typeof cfg.slack?.mediaMaxMb === "number"
|
||||
? cfg.slack.mediaMaxMb * 1024 * 1024
|
||||
: undefined;
|
||||
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const [firstChunk, ...rest] = chunks;
|
||||
lastMessageId = await uploadSlackFile({
|
||||
client,
|
||||
channelId,
|
||||
mediaUrl: opts.mediaUrl,
|
||||
caption: firstChunk,
|
||||
threadTs: opts.threadTs,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
for (const chunk of rest) {
|
||||
const response = await client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: chunk,
|
||||
thread_ts: opts.threadTs,
|
||||
});
|
||||
lastMessageId = response.ts ?? lastMessageId;
|
||||
}
|
||||
} else {
|
||||
for (const chunk of chunks.length ? chunks : [""]) {
|
||||
const response = await client.chat.postMessage({
|
||||
channel: channelId,
|
||||
text: chunk,
|
||||
thread_ts: opts.threadTs,
|
||||
});
|
||||
lastMessageId = response.ts ?? lastMessageId;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messageId: lastMessageId || "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
12
src/slack/token.ts
Normal file
12
src/slack/token.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function normalizeSlackToken(raw?: string): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveSlackBotToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
|
||||
export function resolveSlackAppToken(raw?: string): string | undefined {
|
||||
return normalizeSlackToken(raw);
|
||||
}
|
||||
Reference in New Issue
Block a user