@eliya-oss/agent-slack 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { createServer } from "node:http";
4
+ import { PermissionDenied, SlackApiFailed, UsageError } from "../../domain/errors.js";
5
+ import { ProfileName, Scope } from "../../domain/ids.js";
6
+ export class NodeLocalhostOAuthFlow {
7
+ options;
8
+ constructor(options) {
9
+ this.options = options;
10
+ }
11
+ static fromEnv(env = process.env) {
12
+ const apiBaseUrl = env.SLK_SLACK_API_BASE_URL;
13
+ const emulatorBaseUrl = apiBaseUrl?.replace(/\/api\/?$/, "");
14
+ return new NodeLocalhostOAuthFlow({
15
+ authorizeUrl: env.SLK_SLACK_OAUTH_AUTHORIZE_URL ?? (emulatorBaseUrl === undefined ? "https://slack.com/oauth/v2/authorize" : `${emulatorBaseUrl}/oauth/v2/authorize`),
16
+ tokenUrl: env.SLK_SLACK_OAUTH_ACCESS_URL ?? (apiBaseUrl === undefined ? "https://slack.com/api/oauth.v2.access" : `${apiBaseUrl.replace(/\/?$/, "/")}oauth.v2.access`),
17
+ ...(env.SLK_OAUTH_REDIRECT_HOST === undefined ? {} : { defaultRedirectHost: env.SLK_OAUTH_REDIRECT_HOST }),
18
+ ...(env.SLK_OAUTH_REDIRECT_PATH === undefined ? {} : { defaultRedirectPath: env.SLK_OAUTH_REDIRECT_PATH }),
19
+ ...(env.SLK_OAUTH_TIMEOUT_MS === undefined ? {} : { defaultTimeoutMs: Number(env.SLK_OAUTH_TIMEOUT_MS) })
20
+ });
21
+ }
22
+ async login(input) {
23
+ const state = randomBytes(24).toString("base64url");
24
+ const timeoutMs = input.timeoutMs ?? this.options.defaultTimeoutMs ?? 120_000;
25
+ const server = createServer();
26
+ const started = await listen(server, input.redirectUri, {
27
+ host: this.options.defaultRedirectHost ?? "127.0.0.1",
28
+ path: this.options.defaultRedirectPath ?? "/oauth/slack/callback"
29
+ });
30
+ const authorizationUrl = buildAuthorizationUrl({
31
+ authorizeUrl: this.options.authorizeUrl,
32
+ clientId: input.clientId,
33
+ redirectUri: started.redirectUri,
34
+ scopes: input.scopes,
35
+ userScopes: input.userScopes,
36
+ state
37
+ });
38
+ if (input.authUrlOut !== undefined) {
39
+ await writeFile(input.authUrlOut, authorizationUrl, "utf8");
40
+ }
41
+ else {
42
+ process.stderr.write(`Open this Slack OAuth URL:\n${authorizationUrl}\n`);
43
+ }
44
+ return await new Promise((resolve, reject) => {
45
+ const timer = setTimeout(() => {
46
+ closeServer(server);
47
+ reject(new UsageError("Timed out waiting for Slack OAuth callback", { timeoutMs }));
48
+ }, timeoutMs);
49
+ server.on("request", async (request, response) => {
50
+ try {
51
+ const requestUrl = new URL(request.url ?? "/", started.redirectUri);
52
+ if (requestUrl.pathname !== started.path) {
53
+ response.writeHead(404).end("Not found");
54
+ return;
55
+ }
56
+ if (requestUrl.searchParams.get("state") !== state) {
57
+ throw new PermissionDenied("Slack OAuth state mismatch");
58
+ }
59
+ const code = requestUrl.searchParams.get("code");
60
+ const oauthError = requestUrl.searchParams.get("error");
61
+ if (oauthError !== null) {
62
+ throw new PermissionDenied(`Slack OAuth failed: ${oauthError}`, { slackError: oauthError });
63
+ }
64
+ if (code === null || code === "") {
65
+ throw new UsageError("Slack OAuth callback did not include a code");
66
+ }
67
+ const access = await exchangeCode({
68
+ tokenUrl: this.options.tokenUrl,
69
+ code,
70
+ clientId: input.clientId,
71
+ clientSecret: input.clientSecret,
72
+ redirectUri: started.redirectUri
73
+ });
74
+ const profile = toAuthProfile(input.profileName, access);
75
+ response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<html><body>Slack auth complete. You can close this tab.</body></html>");
76
+ clearTimeout(timer);
77
+ closeServer(server);
78
+ resolve(profile);
79
+ }
80
+ catch (error) {
81
+ response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end(error instanceof Error ? error.message : String(error));
82
+ clearTimeout(timer);
83
+ closeServer(server);
84
+ reject(error);
85
+ }
86
+ });
87
+ });
88
+ }
89
+ }
90
+ const listen = (server, redirectUri, fallback) => new Promise((resolve, reject) => {
91
+ const parsed = redirectUri === undefined ? null : new URL(redirectUri);
92
+ const host = parsed?.hostname ?? fallback.host;
93
+ const path = parsed?.pathname ?? fallback.path;
94
+ const port = parsed === null || parsed.port === "" ? 0 : Number(parsed.port);
95
+ if (!Number.isInteger(port) || port < 0) {
96
+ reject(new UsageError("OAuth redirect URI must include a valid port", { redirectUri }));
97
+ return;
98
+ }
99
+ server.once("error", reject);
100
+ server.listen(port, host, () => {
101
+ const address = server.address();
102
+ const actualPort = parsed === null || parsed.port === "" ? address.port : port;
103
+ resolve({
104
+ redirectUri: redirectUri ?? `http://${host}:${actualPort}${path}`,
105
+ path
106
+ });
107
+ });
108
+ });
109
+ const buildAuthorizationUrl = (input) => {
110
+ const url = new URL(input.authorizeUrl);
111
+ url.searchParams.set("client_id", input.clientId);
112
+ url.searchParams.set("scope", input.scopes.join(","));
113
+ if (input.userScopes.length > 0) {
114
+ url.searchParams.set("user_scope", input.userScopes.join(","));
115
+ }
116
+ url.searchParams.set("redirect_uri", input.redirectUri);
117
+ url.searchParams.set("state", input.state);
118
+ return url.toString();
119
+ };
120
+ const exchangeCode = async (input) => {
121
+ const response = await fetch(input.tokenUrl, {
122
+ method: "POST",
123
+ headers: { "content-type": "application/x-www-form-urlencoded" },
124
+ body: new URLSearchParams({
125
+ code: input.code,
126
+ client_id: input.clientId,
127
+ client_secret: input.clientSecret,
128
+ redirect_uri: input.redirectUri
129
+ })
130
+ });
131
+ const body = await response.json();
132
+ if (body.ok === false) {
133
+ throw new PermissionDenied(`Slack OAuth token exchange failed: ${body.error ?? "unknown_error"}`, {
134
+ slackError: body.error ?? "unknown_error"
135
+ });
136
+ }
137
+ if (body.access_token === undefined && body.authed_user?.access_token === undefined) {
138
+ throw new SlackApiFailed("Slack OAuth token exchange did not return a token");
139
+ }
140
+ return body;
141
+ };
142
+ const toAuthProfile = (profileName, response) => {
143
+ const botScopes = splitScopes(response.scope);
144
+ const userScopes = splitScopes(response.authed_user?.scope);
145
+ return {
146
+ name: ProfileName.make(profileName),
147
+ tokenType: response.access_token === undefined ? "user" : "bot",
148
+ enterpriseId: response.enterprise?.id ?? null,
149
+ ...(response.team?.id === undefined ? {} : { teamId: response.team.id }),
150
+ ...(response.authed_user?.id === undefined ? {} : { userId: response.authed_user.id }),
151
+ ...(response.bot_user_id === undefined ? {} : { botId: response.bot_user_id }),
152
+ ...(response.access_token === undefined ? {} : { botToken: response.access_token }),
153
+ ...(response.authed_user?.access_token === undefined ? {} : { userToken: response.authed_user.access_token }),
154
+ scopes: [...new Set([...botScopes, ...userScopes])].map(Scope.make)
155
+ };
156
+ };
157
+ const splitScopes = (value) => value === undefined || value.trim() === "" ? [] : value.split(",").map((scope) => scope.trim()).filter(Boolean);
158
+ const closeServer = (server) => {
159
+ server.close();
160
+ };
@@ -0,0 +1,67 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ProfileName, Scope } from "../../domain/ids.js";
4
+ export class FileTokenStore {
5
+ filePath;
6
+ envProfile;
7
+ constructor(filePath, envProfile = null) {
8
+ this.filePath = filePath;
9
+ this.envProfile = envProfile;
10
+ }
11
+ static fromEnv(env = process.env) {
12
+ const configDir = env.SLK_CONFIG_DIR ?? join(env.HOME ?? process.cwd(), ".config", "slk");
13
+ const token = env.SLK_TOKEN ?? env.SLACK_BOT_TOKEN ?? null;
14
+ const envProfile = token === null
15
+ ? null
16
+ : {
17
+ name: ProfileName.default,
18
+ tokenType: "bot",
19
+ botToken: token,
20
+ scopes: (env.SLK_SCOPES ?? "").split(",").filter(Boolean).map(Scope.make)
21
+ };
22
+ return new FileTokenStore(join(configDir, "profiles.json"), envProfile);
23
+ }
24
+ async getProfile(name) {
25
+ const profiles = await this.readProfiles();
26
+ return profiles.find((profile) => profile.name === name) ?? (name === "default" ? this.envProfile : null);
27
+ }
28
+ async setProfile(profile) {
29
+ const profiles = await this.readProfiles();
30
+ const next = [profile, ...profiles.filter((item) => item.name !== profile.name)];
31
+ await this.writeProfiles(next);
32
+ }
33
+ async listProfiles() {
34
+ const profiles = await this.readProfiles();
35
+ return this.envProfile === null ? profiles : [this.envProfile, ...profiles.filter((item) => item.name !== this.envProfile?.name)];
36
+ }
37
+ async deleteProfile(name) {
38
+ const profiles = await this.readProfiles();
39
+ const next = profiles.filter((profile) => profile.name !== name);
40
+ if (next.length === profiles.length) {
41
+ return false;
42
+ }
43
+ if (next.length === 0) {
44
+ await rm(this.filePath, { force: true });
45
+ return true;
46
+ }
47
+ await this.writeProfiles(next);
48
+ return true;
49
+ }
50
+ async readProfiles() {
51
+ try {
52
+ const raw = await readFile(this.filePath, "utf8");
53
+ const parsed = JSON.parse(raw);
54
+ return parsed.profiles ?? [];
55
+ }
56
+ catch (error) {
57
+ if (error.code === "ENOENT") {
58
+ return [];
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+ async writeProfiles(profiles) {
64
+ await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
65
+ await writeFile(this.filePath, JSON.stringify({ profiles }, null, 2), { mode: 0o600 });
66
+ }
67
+ }
@@ -0,0 +1,48 @@
1
+ import { WebClient } from "@slack/web-api";
2
+ import { MissingScope, PermissionDenied, SlackApiFailed, SlackRateLimited } from "../../domain/errors.js";
3
+ export class SlackSdkWebApi {
4
+ options;
5
+ constructor(options = {}) {
6
+ this.options = options;
7
+ }
8
+ async call(input) {
9
+ const client = new WebClient(input.token, {
10
+ ...(this.options.slackApiUrl === undefined ? {} : { slackApiUrl: this.options.slackApiUrl }),
11
+ allowAbsoluteUrls: false
12
+ });
13
+ try {
14
+ const response = await client.apiCall(input.method, input.payload);
15
+ const record = response;
16
+ if (record.ok === false) {
17
+ throw slackError(input.method, record);
18
+ }
19
+ return { method: input.method, response: record };
20
+ }
21
+ catch (error) {
22
+ if (error instanceof SlackApiFailed || error instanceof MissingScope || error instanceof PermissionDenied || error instanceof SlackRateLimited) {
23
+ throw error;
24
+ }
25
+ const maybe = error;
26
+ if (maybe.code === "slack_webapi_rate_limited") {
27
+ throw new SlackRateLimited("Slack rate limit reached", maybe.retryAfter === undefined ? {} : { retryAfterSeconds: maybe.retryAfter });
28
+ }
29
+ if (maybe.code === "slack_webapi_platform_error" && maybe.data !== undefined) {
30
+ throw slackError(input.method, maybe.data);
31
+ }
32
+ throw new SlackApiFailed(`Slack method failed: ${input.method}`, { method: input.method, cause: error instanceof Error ? error.message : String(error) });
33
+ }
34
+ }
35
+ }
36
+ const slackError = (method, response) => {
37
+ const slackErrorText = typeof response.error === "string" ? response.error : "unknown_error";
38
+ if (slackErrorText === "missing_scope") {
39
+ throw new MissingScope("Slack method is missing required scope", { method, slackError: slackErrorText, response });
40
+ }
41
+ if (slackErrorText === "invalid_auth" || slackErrorText === "not_authed" || slackErrorText === "account_inactive") {
42
+ throw new PermissionDenied("Slack authentication failed", { method, slackError: slackErrorText });
43
+ }
44
+ if (slackErrorText === "ratelimited") {
45
+ throw new SlackRateLimited("Slack rate limit reached", { method, slackError: slackErrorText });
46
+ }
47
+ throw new SlackApiFailed(`Slack method returned ${slackErrorText}`, { method, slackError: slackErrorText, response });
48
+ };
@@ -0,0 +1,35 @@
1
+ import { NotAuthenticated, UnsafeMethodBlocked, UsageError } from "../domain/errors.js";
2
+ export const getProfile = async (services, name) => {
3
+ const profile = await services.tokenStore.getProfile(name);
4
+ if (profile === null) {
5
+ throw new NotAuthenticated(`No auth profile named ${name}`, { profile: name });
6
+ }
7
+ return profile;
8
+ };
9
+ export const tokenFor = (profile, tokenType) => {
10
+ const token = tokenType === "user" ? profile.userToken :
11
+ tokenType === "admin" ? profile.adminToken :
12
+ tokenType === "app" ? profile.appToken :
13
+ profile.botToken ?? profile.userToken ?? profile.adminToken ?? profile.appToken;
14
+ if (token === undefined || token.length === 0) {
15
+ throw new NotAuthenticated(`Profile ${profile.name} does not have a ${tokenType} token`, {
16
+ profile: profile.name,
17
+ tokenType
18
+ });
19
+ }
20
+ return token;
21
+ };
22
+ export const parseTokenType = (value) => {
23
+ if (value === undefined) {
24
+ return "bot";
25
+ }
26
+ if (value === "user" || value === "bot" || value === "admin" || value === "app") {
27
+ return value;
28
+ }
29
+ throw new UsageError("Invalid token type", { tokenType: value });
30
+ };
31
+ export const requireYes = (input) => {
32
+ if (!input.yes) {
33
+ throw new UnsafeMethodBlocked(input.message);
34
+ }
35
+ };