feat(channels): add resolve command + defaults
This commit is contained in:
163
src/slack/directory-live.ts
Normal file
163
src/slack/directory-live.ts
Normal 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;
|
||||
}
|
||||
@@ -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 ?? [];
|
||||
|
||||
43
src/slack/resolve-channels.test.ts
Normal file
43
src/slack/resolve-channels.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
121
src/slack/resolve-channels.ts
Normal file
121
src/slack/resolve-channels.ts
Normal 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
182
src/slack/resolve-users.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user