feat(channels): add resolve command + defaults

This commit is contained in:
Peter Steinberger
2026-01-18 00:41:57 +00:00
parent b543339373
commit c7ea47e886
60 changed files with 4418 additions and 101 deletions

163
src/slack/directory-live.ts Normal file
View File

@@ -0,0 +1,163 @@
import { WebClient } from "@slack/web-api";
import type { ChannelDirectoryEntry } from "../channels/plugins/types.js";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import { resolveSlackAccount } from "./accounts.js";
type SlackUser = {
id?: string;
name?: string;
real_name?: string;
is_bot?: boolean;
is_app_user?: boolean;
deleted?: boolean;
profile?: {
display_name?: string;
real_name?: string;
email?: string;
};
};
type SlackChannel = {
id?: string;
name?: string;
is_archived?: boolean;
is_private?: boolean;
};
type SlackListUsersResponse = {
members?: SlackUser[];
response_metadata?: { next_cursor?: string };
};
type SlackListChannelsResponse = {
channels?: SlackChannel[];
response_metadata?: { next_cursor?: string };
};
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const userToken = account.config.userToken?.trim() || undefined;
return userToken ?? account.botToken?.trim();
}
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function buildUserRank(user: SlackUser): number {
let rank = 0;
if (!user.deleted) rank += 2;
if (!user.is_bot && !user.is_app_user) rank += 1;
return rank;
}
function buildChannelRank(channel: SlackChannel): number {
return channel.is_archived ? 0 : 1;
}
export async function listSlackDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const query = normalizeQuery(params.query);
const members: SlackUser[] = [];
let cursor: string | undefined;
do {
const res = (await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse;
if (Array.isArray(res.members)) members.push(...res.members);
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = members.filter((member) => {
const name = member.profile?.display_name || member.profile?.real_name || member.real_name;
const handle = member.name;
const email = member.profile?.email;
const candidates = [name, handle, email].map((item) => item?.trim().toLowerCase()).filter(Boolean);
if (!query) return true;
return candidates.some((candidate) => candidate?.includes(query));
});
const rows = filtered
.map((member) => {
const id = member.id?.trim();
if (!id) return null;
const handle = member.name?.trim();
const display =
member.profile?.display_name?.trim() ||
member.profile?.real_name?.trim() ||
member.real_name?.trim() ||
handle;
return {
kind: "user",
id: `user:${id}`,
name: display || undefined,
handle: handle ? `@${handle}` : undefined,
rank: buildUserRank(member),
raw: member,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}
export async function listSlackDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const token = resolveReadToken(params);
if (!token) return [];
const client = new WebClient(token);
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListChannelsResponse;
if (Array.isArray(res.channels)) channels.push(...res.channels);
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
const filtered = channels.filter((channel) => {
const name = channel.name?.trim().toLowerCase();
if (!query) return true;
return Boolean(name && name.includes(query));
});
const rows = filtered
.map((channel) => {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) return null;
return {
kind: "group",
id: `channel:${id}`,
name,
handle: `#${name}`,
rank: buildChannelRank(channel),
raw: channel,
} satisfies ChannelDirectoryEntry;
})
.filter(Boolean) as ChannelDirectoryEntry[];
if (typeof params.limit === "number" && params.limit > 0) {
return rows.slice(0, params.limit);
}
return rows;
}

View File

@@ -5,10 +5,13 @@ import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
import { loadConfig } from "../../config/config.js";
import type { SessionScope } from "../../config/sessions.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import { warn } from "../../globals.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
@@ -25,10 +28,56 @@ function parseApiAppIdFromAppToken(raw?: string) {
return match?.[1]?.toUpperCase();
}
function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
}): string[] {
const seen = new Set<string>();
const merged: string[] = [];
const push = (value: string) => {
const normalized = value.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
merged.push(normalized);
};
for (const entry of params.existing ?? []) {
push(String(entry));
}
for (const entry of params.additions) {
push(entry);
}
return merged;
}
function summarizeMapping(
label: string,
mapping: string[],
unresolved: string[],
runtime: RuntimeEnv,
) {
const lines: string[] = [];
if (mapping.length > 0) {
const sample = mapping.slice(0, 6);
const suffix = mapping.length > sample.length ? ` (+${mapping.length - sample.length})` : "";
lines.push(`${label} resolved: ${sample.join(", ")}${suffix}`);
}
if (unresolved.length > 0) {
const sample = unresolved.slice(0, 6);
const suffix =
unresolved.length > sample.length ? ` (+${unresolved.length - sample.length})` : "";
lines.push(`${label} unresolved: ${sample.join(", ")}${suffix}`);
}
if (lines.length > 0) {
runtime.log?.(lines.join("\n"));
}
}
export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveSlackAccount({
let account = resolveSlackAccount({
cfg,
accountId: opts.accountId,
});
@@ -65,11 +114,128 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = (dmConfig?.policy ?? "pairing") as DmPolicy;
const allowFrom = dmConfig?.allowFrom;
let allowFrom = dmConfig?.allowFrom;
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const channelsConfig = slackCfg.channels;
const groupPolicy = (slackCfg.groupPolicy ?? "open") as GroupPolicy;
let channelsConfig = slackCfg.channels;
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = (slackCfg.groupPolicy ?? defaultGroupPolicy ?? "open") as GroupPolicy;
if (
slackCfg.groupPolicy === undefined &&
slackCfg.channels === undefined &&
defaultGroupPolicy === undefined &&
groupPolicy === "open"
) {
runtime.log?.(
warn(
'slack: groupPolicy defaults to "open" when channels.slack is missing; set channels.slack.groupPolicy (or channels.defaults.groupPolicy) or add channels.slack.channels to restrict access.',
),
);
}
const resolveToken = slackCfg.userToken?.trim() || botToken;
if (resolveToken) {
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
try {
const entries = Object.keys(channelsConfig);
const resolved = await resolveSlackChannelAllowlist({
token: resolveToken,
entries,
});
const resolvedMap: string[] = [];
const unresolved: string[] = [];
const nextChannels = { ...channelsConfig };
for (const entry of resolved) {
if (entry.resolved && entry.id) {
resolvedMap.push(`${entry.input}${entry.id}`);
if (!nextChannels[entry.id] && channelsConfig[entry.input]) {
nextChannels[entry.id] = channelsConfig[entry.input];
}
} else {
unresolved.push(entry.input);
}
}
channelsConfig = nextChannels;
summarizeMapping("slack channels", resolvedMap, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack channel resolve failed; using config entries. ${String(err)}`);
}
}
const allowEntries =
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
if (allowEntries.length > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: allowEntries.map((entry) => String(entry)),
});
const resolvedMap: string[] = [];
const unresolved: string[] = [];
const additions: string[] = [];
for (const entry of resolvedUsers) {
if (entry.resolved && entry.id) {
resolvedMap.push(`${entry.input}${entry.id}`);
additions.push(entry.id);
} else {
unresolved.push(entry.input);
}
}
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
summarizeMapping("slack users", resolvedMap, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack user resolve failed; using config entries. ${String(err)}`);
}
}
if (channelsConfig && Object.keys(channelsConfig).length > 0) {
const userEntries = new Set<string>();
for (const channel of Object.values(channelsConfig)) {
if (!channel || typeof channel !== "object") continue;
const users = (channel as { users?: Array<string | number> }).users;
if (!Array.isArray(users)) continue;
for (const entry of users) {
const trimmed = String(entry).trim();
if (trimmed && trimmed !== "*") userEntries.add(trimmed);
}
}
if (userEntries.size > 0) {
try {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: Array.from(userEntries),
});
const resolvedMap = new Map(resolvedUsers.map((entry) => [entry.input, entry]));
const mapping = resolvedUsers
.filter((entry) => entry.resolved && entry.id)
.map((entry) => `${entry.input}${entry.id}`);
const unresolved = resolvedUsers
.filter((entry) => !entry.resolved)
.map((entry) => entry.input);
const nextChannels = { ...channelsConfig };
for (const [channelId, channelConfig] of Object.entries(channelsConfig)) {
if (!channelConfig || typeof channelConfig !== "object") continue;
const users = (channelConfig as { users?: Array<string | number> }).users;
if (!Array.isArray(users) || users.length === 0) continue;
const additions: string[] = [];
for (const entry of users) {
const trimmed = String(entry).trim();
const resolved = resolvedMap.get(trimmed);
if (resolved?.resolved && resolved.id) additions.push(resolved.id);
}
nextChannels[channelId] = {
...channelConfig,
users: mergeAllowlist({ existing: users, additions }),
};
}
channelsConfig = nextChannels;
summarizeMapping("slack channel users", mapping, unresolved, runtime);
} catch (err) {
runtime.log?.(`slack channel user resolve failed; using config entries. ${String(err)}`);
}
}
}
}
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const reactionMode = slackCfg.reactionNotifications ?? "own";
const reactionAllowlist = slackCfg.reactionAllowlist ?? [];

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from "vitest";
import { resolveSlackChannelAllowlist } from "./resolve-channels.js";
describe("resolveSlackChannelAllowlist", () => {
it("resolves by name and prefers active channels", async () => {
const client = {
conversations: {
list: vi.fn().mockResolvedValue({
channels: [
{ id: "C1", name: "general", is_archived: true },
{ id: "C2", name: "general", is_archived: false },
],
}),
},
};
const res = await resolveSlackChannelAllowlist({
token: "xoxb-test",
entries: ["#general"],
client: client as never,
});
expect(res[0]?.resolved).toBe(true);
expect(res[0]?.id).toBe("C2");
});
it("keeps unresolved entries", async () => {
const client = {
conversations: {
list: vi.fn().mockResolvedValue({ channels: [] }),
},
};
const res = await resolveSlackChannelAllowlist({
token: "xoxb-test",
entries: ["#does-not-exist"],
client: client as never,
});
expect(res[0]?.resolved).toBe(false);
});
});

View File

@@ -0,0 +1,121 @@
import { WebClient } from "@slack/web-api";
export type SlackChannelLookup = {
id: string;
name: string;
archived: boolean;
isPrivate: boolean;
};
export type SlackChannelResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
archived?: boolean;
};
type SlackListResponse = {
channels?: Array<{
id?: string;
name?: string;
is_archived?: boolean;
is_private?: boolean;
}>;
response_metadata?: { next_cursor?: string };
};
function parseSlackChannelMention(raw: string): { id?: string; name?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<#([A-Z0-9]+)(?:\|([^>]+))?>$/i);
if (mention) {
const id = mention[1]?.toUpperCase();
const name = mention[2]?.trim();
return { id, name };
}
const prefixed = trimmed.replace(/^(slack:|channel:)/i, "");
if (/^[CG][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() };
const name = prefixed.replace(/^#/, "").trim();
return name ? { name } : {};
}
async function listSlackChannels(client: WebClient): Promise<SlackChannelLookup[]> {
const channels: SlackChannelLookup[] = [];
let cursor: string | undefined;
do {
const res = (await client.conversations.list({
types: "public_channel,private_channel",
exclude_archived: false,
limit: 1000,
cursor,
})) as SlackListResponse;
for (const channel of res.channels ?? []) {
const id = channel.id?.trim();
const name = channel.name?.trim();
if (!id || !name) continue;
channels.push({
id,
name,
archived: Boolean(channel.is_archived),
isPrivate: Boolean(channel.is_private),
});
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
return channels;
}
function resolveByName(
name: string,
channels: SlackChannelLookup[],
): SlackChannelLookup | undefined {
const target = name.trim().toLowerCase();
if (!target) return undefined;
const matches = channels.filter((channel) => channel.name.toLowerCase() === target);
if (matches.length === 0) return undefined;
const active = matches.find((channel) => !channel.archived);
return active ?? matches[0];
}
export async function resolveSlackChannelAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
}): Promise<SlackChannelResolution[]> {
const client = params.client ?? new WebClient(params.token);
const channels = await listSlackChannels(client);
const results: SlackChannelResolution[] = [];
for (const input of params.entries) {
const parsed = parseSlackChannelMention(input);
if (parsed.id) {
const match = channels.find((channel) => channel.id === parsed.id);
results.push({
input,
resolved: true,
id: parsed.id,
name: match?.name ?? parsed.name,
archived: match?.archived,
});
continue;
}
if (parsed.name) {
const match = resolveByName(parsed.name, channels);
if (match) {
results.push({
input,
resolved: true,
id: match.id,
name: match.name,
archived: match.archived,
});
continue;
}
}
results.push({ input, resolved: false });
}
return results;
}

182
src/slack/resolve-users.ts Normal file
View File

@@ -0,0 +1,182 @@
import { WebClient } from "@slack/web-api";
export type SlackUserLookup = {
id: string;
name: string;
displayName?: string;
realName?: string;
email?: string;
deleted: boolean;
isBot: boolean;
isAppUser: boolean;
};
export type SlackUserResolution = {
input: string;
resolved: boolean;
id?: string;
name?: string;
email?: string;
deleted?: boolean;
isBot?: boolean;
note?: string;
};
type SlackListUsersResponse = {
members?: Array<{
id?: string;
name?: string;
deleted?: boolean;
is_bot?: boolean;
is_app_user?: boolean;
real_name?: string;
profile?: {
display_name?: string;
real_name?: string;
email?: string;
};
}>;
response_metadata?: { next_cursor?: string };
};
function parseSlackUserInput(raw: string): { id?: string; name?: string; email?: string } {
const trimmed = raw.trim();
if (!trimmed) return {};
const mention = trimmed.match(/^<@([A-Z0-9]+)>$/i);
if (mention) return { id: mention[1]?.toUpperCase() };
const prefixed = trimmed.replace(/^(slack:|user:)/i, "");
if (/^[A-Z][A-Z0-9]+$/i.test(prefixed)) return { id: prefixed.toUpperCase() };
if (trimmed.includes("@") && !trimmed.startsWith("@")) return { email: trimmed.toLowerCase() };
const name = trimmed.replace(/^@/, "").trim();
return name ? { name } : {};
}
async function listSlackUsers(client: WebClient): Promise<SlackUserLookup[]> {
const users: SlackUserLookup[] = [];
let cursor: string | undefined;
do {
const res = (await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse;
for (const member of res.members ?? []) {
const id = member.id?.trim();
const name = member.name?.trim();
if (!id || !name) continue;
const profile = member.profile ?? {};
users.push({
id,
name,
displayName: profile.display_name?.trim() || undefined,
realName: profile.real_name?.trim() || member.real_name?.trim() || undefined,
email: profile.email?.trim()?.toLowerCase() || undefined,
deleted: Boolean(member.deleted),
isBot: Boolean(member.is_bot),
isAppUser: Boolean(member.is_app_user),
});
}
const next = res.response_metadata?.next_cursor?.trim();
cursor = next ? next : undefined;
} while (cursor);
return users;
}
function scoreSlackUser(user: SlackUserLookup, match: { name?: string; email?: string }): number {
let score = 0;
if (!user.deleted) score += 3;
if (!user.isBot && !user.isAppUser) score += 2;
if (match.email && user.email === match.email) score += 5;
if (match.name) {
const target = match.name.toLowerCase();
const candidates = [
user.name,
user.displayName,
user.realName,
]
.map((value) => value?.toLowerCase())
.filter(Boolean) as string[];
if (candidates.some((value) => value === target)) score += 2;
}
return score;
}
export async function resolveSlackUserAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
}): Promise<SlackUserResolution[]> {
const client = params.client ?? new WebClient(params.token);
const users = await listSlackUsers(client);
const results: SlackUserResolution[] = [];
for (const input of params.entries) {
const parsed = parseSlackUserInput(input);
if (parsed.id) {
const match = users.find((user) => user.id === parsed.id);
results.push({
input,
resolved: true,
id: parsed.id,
name: match?.displayName ?? match?.realName ?? match?.name,
email: match?.email,
deleted: match?.deleted,
isBot: match?.isBot,
});
continue;
}
if (parsed.email) {
const matches = users.filter((user) => user.email === parsed.email);
if (matches.length > 0) {
const scored = matches
.map((user) => ({ user, score: scoreSlackUser(user, parsed) }))
.sort((a, b) => b.score - a.score);
const best = scored[0]?.user ?? matches[0];
results.push({
input,
resolved: true,
id: best.id,
name: best.displayName ?? best.realName ?? best.name,
email: best.email,
deleted: best.deleted,
isBot: best.isBot,
note: matches.length > 1 ? "multiple matches; chose best" : undefined,
});
continue;
}
}
if (parsed.name) {
const target = parsed.name.toLowerCase();
const matches = users.filter((user) => {
const candidates = [
user.name,
user.displayName,
user.realName,
]
.map((value) => value?.toLowerCase())
.filter(Boolean) as string[];
return candidates.includes(target);
});
if (matches.length > 0) {
const scored = matches
.map((user) => ({ user, score: scoreSlackUser(user, parsed) }))
.sort((a, b) => b.score - a.score);
const best = scored[0]?.user ?? matches[0];
results.push({
input,
resolved: true,
id: best.id,
name: best.displayName ?? best.realName ?? best.name,
email: best.email,
deleted: best.deleted,
isBot: best.isBot,
note: matches.length > 1 ? "multiple matches; chose best" : undefined,
});
continue;
}
}
results.push({ input, resolved: false });
}
return results;
}