openclaw/extensions/slack/src/resolve-users.ts

191 lines
5.4 KiB
TypeScript

import type { WebClient } from "@slack/web-api";
import { createSlackWebClient } from "./client.js";
import {
collectSlackCursorItems,
resolveSlackAllowlistEntries,
} from "./resolve-allowlist-common.js";
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[]> {
return collectSlackCursorItems({
fetchPage: async (cursor) =>
(await client.users.list({
limit: 200,
cursor,
})) as SlackListUsersResponse,
collectPageItems: (res) =>
(res.members ?? [])
.map((member) => {
const id = member.id?.trim();
const name = member.name?.trim();
if (!id || !name) {
return null;
}
const profile = member.profile ?? {};
return {
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),
} satisfies SlackUserLookup;
})
.filter(Boolean) as SlackUserLookup[],
});
}
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;
}
function resolveSlackUserFromMatches(
input: string,
matches: SlackUserLookup[],
parsed: { name?: string; email?: string },
): SlackUserResolution {
const scored = matches
.map((user) => ({ user, score: scoreSlackUser(user, parsed) }))
.toSorted((a, b) => b.score - a.score);
const best = scored[0]?.user ?? matches[0];
return {
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,
};
}
export async function resolveSlackUserAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
}): Promise<SlackUserResolution[]> {
const client = params.client ?? createSlackWebClient(params.token);
const users = await listSlackUsers(client);
return resolveSlackAllowlistEntries<
{ id?: string; name?: string; email?: string },
SlackUserLookup,
SlackUserResolution
>({
entries: params.entries,
lookup: users,
parseInput: parseSlackUserInput,
findById: (lookup, id) => lookup.find((user) => user.id === id),
buildIdResolved: ({ input, parsed, match }) => ({
input,
resolved: true,
id: parsed.id,
name: match?.displayName ?? match?.realName ?? match?.name,
email: match?.email,
deleted: match?.deleted,
isBot: match?.isBot,
}),
resolveNonId: ({ input, parsed, lookup }) => {
if (parsed.email) {
const matches = lookup.filter((user) => user.email === parsed.email);
if (matches.length > 0) {
return resolveSlackUserFromMatches(input, matches, parsed);
}
}
if (parsed.name) {
const target = parsed.name.toLowerCase();
const matches = lookup.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) {
return resolveSlackUserFromMatches(input, matches, parsed);
}
}
return undefined;
},
buildUnresolved: (input) => ({ input, resolved: false }),
});
}