@eliya-oss/agent-slack 0.1.10 → 0.1.12
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.
- package/.agents/cli-api.md +42 -8
- package/CHANGELOG.md +21 -0
- package/README.md +2 -2
- package/dist/main.js +26937 -18
- package/package.json +22 -20
- package/dist/adapters/catalog/BundledMethodCatalog.js +0 -53
- package/dist/adapters/http-file-downloader/HttpFileDownloader.js +0 -20
- package/dist/adapters/keychain/KeychainTokenStore.js +0 -152
- package/dist/adapters/live.js +0 -17
- package/dist/adapters/localhost-oauth/NodeLocalhostOAuthFlow.js +0 -232
- package/dist/adapters/profile-file/FileTokenStore.js +0 -67
- package/dist/adapters/slack-sdk/SlackSdkWebApi.js +0 -48
- package/dist/application/auth.js +0 -35
- package/dist/application/commands.js +0 -599
- package/dist/application/execute.js +0 -35
- package/dist/application/payload.js +0 -40
- package/dist/application/services.js +0 -1
- package/dist/application/slack-call.js +0 -62
- package/dist/cli/args.js +0 -72
- package/dist/cli/metadata.js +0 -374
- package/dist/cli/types.js +0 -1
- package/dist/domain/errors.js +0 -68
- package/dist/domain/ids.js +0 -31
- package/dist/domain/slack.js +0 -1
- package/dist/output/envelope.js +0 -62
- package/dist/output/human.js +0 -302
- package/dist/output/projection.js +0 -55
- package/dist/ports/FileDownloader.js +0 -1
- package/dist/ports/MethodCatalog.js +0 -1
- package/dist/ports/OAuthFlow.js +0 -1
- package/dist/ports/SlackWebApi.js +0 -1
- package/dist/ports/TokenStore.js +0 -1
package/package.json
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eliya-oss/agent-slack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12",
|
|
4
4
|
"description": "Slack context CLI for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"packageManager": "pnpm@11.9.0",
|
|
7
6
|
"bin": {
|
|
8
7
|
"agent-slack": "dist/main.js",
|
|
9
8
|
"aslk": "dist/main.js"
|
|
@@ -17,22 +16,6 @@
|
|
|
17
16
|
"LICENSE",
|
|
18
17
|
".agents/cli-api.md"
|
|
19
18
|
],
|
|
20
|
-
"scripts": {
|
|
21
|
-
"build": "tsc -p tsconfig.build.json",
|
|
22
|
-
"check": "pnpm typecheck && pnpm test:coverage && pnpm build",
|
|
23
|
-
"changeset": "changeset",
|
|
24
|
-
"clean": "rm -rf dist coverage",
|
|
25
|
-
"dev": "tsx src/main.ts",
|
|
26
|
-
"prepack": "pnpm build",
|
|
27
|
-
"release:check": "pnpm check && npm pack --dry-run",
|
|
28
|
-
"agent-slack": "tsx src/main.ts",
|
|
29
|
-
"aslk": "tsx src/main.ts",
|
|
30
|
-
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
-
"test": "vitest run",
|
|
32
|
-
"test:coverage": "vitest run --coverage",
|
|
33
|
-
"test:emulate": "vitest run tests/emulate",
|
|
34
|
-
"version-packages": "changeset version"
|
|
35
|
-
},
|
|
36
19
|
"keywords": [
|
|
37
20
|
"slack",
|
|
38
21
|
"cli",
|
|
@@ -55,11 +38,30 @@
|
|
|
55
38
|
"@types/node": "latest",
|
|
56
39
|
"@vitest/coverage-v8": "^4.1.9",
|
|
57
40
|
"emulate": "^0.8.0",
|
|
58
|
-
"
|
|
41
|
+
"rolldown": "^1.1.4",
|
|
59
42
|
"typescript": "latest",
|
|
60
43
|
"vitest": "latest"
|
|
61
44
|
},
|
|
62
45
|
"engines": {
|
|
63
46
|
"node": ">=22"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "rolldown -c rolldown.config.mjs",
|
|
50
|
+
"check": "pnpm typecheck && pnpm test:coverage && pnpm build",
|
|
51
|
+
"changeset": "changeset",
|
|
52
|
+
"clean": "rm -rf dist coverage",
|
|
53
|
+
"dev": "rolldown -c rolldown.config.mjs && node dist/main.js",
|
|
54
|
+
"docs:build": "pnpm --filter docs build",
|
|
55
|
+
"docs:dev": "pnpm --filter docs dev",
|
|
56
|
+
"docs:types": "pnpm --filter docs types:check",
|
|
57
|
+
"release:check": "pnpm check && npm pack --dry-run",
|
|
58
|
+
"release:publish": "pnpm build && changeset publish",
|
|
59
|
+
"agent-slack": "rolldown -c rolldown.config.mjs && node dist/main.js",
|
|
60
|
+
"aslk": "rolldown -c rolldown.config.mjs && node dist/main.js",
|
|
61
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:coverage": "vitest run --coverage",
|
|
64
|
+
"test:emulate": "vitest run tests/emulate",
|
|
65
|
+
"version-packages": "changeset version"
|
|
64
66
|
}
|
|
65
|
-
}
|
|
67
|
+
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
const methods = [
|
|
2
|
-
{ method: "api.test", family: "api", summary: "Test Slack API reachability.", safety: "read", scopes: [], cursorPaginated: false },
|
|
3
|
-
{ method: "auth.test", family: "auth", summary: "Test authentication.", safety: "read", scopes: [], cursorPaginated: false },
|
|
4
|
-
{ method: "team.info", family: "team", summary: "Workspace info.", safety: "read", scopes: ["team:read"], cursorPaginated: false },
|
|
5
|
-
{ method: "team.profile.get", family: "team", summary: "Workspace profile fields.", safety: "read", scopes: ["users.profile:read"], cursorPaginated: false },
|
|
6
|
-
{ method: "conversations.list", family: "conversations", summary: "List conversations.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], itemKey: "channels", cursorPaginated: true },
|
|
7
|
-
{ method: "conversations.info", family: "conversations", summary: "Conversation info.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], cursorPaginated: false },
|
|
8
|
-
{ method: "conversations.members", family: "conversations", summary: "Conversation members.", safety: "read", scopes: ["channels:read", "groups:read", "im:read", "mpim:read"], itemKey: "members", cursorPaginated: true },
|
|
9
|
-
{ method: "conversations.history", family: "conversations", summary: "Conversation history.", safety: "read", scopes: ["channels:history", "groups:history", "im:history", "mpim:history"], itemKey: "messages", cursorPaginated: true },
|
|
10
|
-
{ method: "conversations.replies", family: "conversations", summary: "Thread replies.", safety: "read", scopes: ["channels:history", "groups:history", "im:history", "mpim:history"], itemKey: "messages", cursorPaginated: true },
|
|
11
|
-
{ method: "chat.getPermalink", family: "chat", summary: "Message permalink.", safety: "read", scopes: [], cursorPaginated: false },
|
|
12
|
-
{ method: "users.list", family: "users", summary: "List users.", safety: "read", scopes: ["users:read"], itemKey: "members", cursorPaginated: true },
|
|
13
|
-
{ method: "users.info", family: "users", summary: "User info.", safety: "read", scopes: ["users:read"], cursorPaginated: false },
|
|
14
|
-
{ method: "users.lookupByEmail", family: "users", summary: "Find user by email.", safety: "read", scopes: ["users:read.email"], cursorPaginated: false },
|
|
15
|
-
{ method: "users.getPresence", family: "users", summary: "User presence.", safety: "read", scopes: ["users:read"], cursorPaginated: false },
|
|
16
|
-
{ method: "usergroups.list", family: "usergroups", summary: "List user groups.", safety: "read", scopes: ["usergroups:read"], itemKey: "usergroups", cursorPaginated: false },
|
|
17
|
-
{ method: "usergroups.users.list", family: "usergroups", summary: "List users in a user group.", safety: "read", scopes: ["usergroups:read"], itemKey: "users", cursorPaginated: false },
|
|
18
|
-
{ method: "files.list", family: "files", summary: "List files.", safety: "read", scopes: ["files:read"], itemKey: "files", cursorPaginated: true },
|
|
19
|
-
{ method: "files.info", family: "files", summary: "File info.", safety: "read", scopes: ["files:read"], cursorPaginated: false },
|
|
20
|
-
{ method: "reactions.get", family: "reactions", summary: "Message reactions.", safety: "read", scopes: ["reactions:read"], cursorPaginated: false },
|
|
21
|
-
{ method: "pins.list", family: "pins", summary: "Pinned items.", safety: "read", scopes: ["pins:read"], itemKey: "items", cursorPaginated: false },
|
|
22
|
-
{ method: "bookmarks.list", family: "bookmarks", summary: "Channel bookmarks.", safety: "read", scopes: ["bookmarks:read"], itemKey: "bookmarks", cursorPaginated: false },
|
|
23
|
-
{ method: "emoji.list", family: "emoji", summary: "Workspace emoji.", safety: "read", scopes: ["emoji:read"], cursorPaginated: false },
|
|
24
|
-
{ method: "dnd.info", family: "dnd", summary: "DND status.", safety: "read", scopes: ["dnd:read"], cursorPaginated: false },
|
|
25
|
-
{ method: "search.messages", family: "search", summary: "Legacy message search.", safety: "read", scopes: ["search:read.public", "search:read.private"], itemKey: "matches", cursorPaginated: false },
|
|
26
|
-
{ method: "search.files", family: "search", summary: "Legacy file search.", safety: "read", scopes: ["search:read.files"], itemKey: "matches", cursorPaginated: false },
|
|
27
|
-
{ method: "assistant.search.context", family: "assistant", summary: "Real-time Search API context.", safety: "read", scopes: ["search:read.public", "search:read.private"], itemKey: "results", cursorPaginated: false },
|
|
28
|
-
{ method: "chat.postMessage", family: "chat", summary: "Post message.", safety: "write", scopes: ["chat:write"], cursorPaginated: false },
|
|
29
|
-
{ method: "chat.delete", family: "chat", summary: "Delete message.", safety: "destructive", scopes: ["chat:write"], cursorPaginated: false },
|
|
30
|
-
{ method: "files.delete", family: "files", summary: "Delete file.", safety: "destructive", scopes: ["files:write"], cursorPaginated: false }
|
|
31
|
-
];
|
|
32
|
-
const unsafePrefixes = ["admin.", "chat.post", "chat.update", "chat.delete", "files.delete", "conversations.archive", "conversations.kick"];
|
|
33
|
-
export class BundledMethodCatalog {
|
|
34
|
-
listMethods(family) {
|
|
35
|
-
return family === undefined ? methods : methods.filter((method) => method.family === family);
|
|
36
|
-
}
|
|
37
|
-
describeMethod(method) {
|
|
38
|
-
return methods.find((item) => item.method === method) ?? null;
|
|
39
|
-
}
|
|
40
|
-
safetyFor(method) {
|
|
41
|
-
const found = this.describeMethod(method);
|
|
42
|
-
if (found !== null) {
|
|
43
|
-
return found.safety;
|
|
44
|
-
}
|
|
45
|
-
if (unsafePrefixes.some((prefix) => method.startsWith(prefix))) {
|
|
46
|
-
return method.startsWith("admin.") ? "admin" : "write";
|
|
47
|
-
}
|
|
48
|
-
return "unknown";
|
|
49
|
-
}
|
|
50
|
-
itemKeyFor(method) {
|
|
51
|
-
return this.describeMethod(method)?.itemKey ?? null;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
-
import { SlackApiFailed } from "../../domain/errors.js";
|
|
4
|
-
export class HttpFileDownloader {
|
|
5
|
-
async download(input) {
|
|
6
|
-
const response = await fetch(input.url, {
|
|
7
|
-
headers: { authorization: `Bearer ${input.token}` }
|
|
8
|
-
});
|
|
9
|
-
if (!response.ok) {
|
|
10
|
-
throw new SlackApiFailed(`Slack file download failed with HTTP ${response.status}`, {
|
|
11
|
-
status: response.status,
|
|
12
|
-
url: input.url
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
const data = Buffer.from(await response.arrayBuffer());
|
|
16
|
-
await mkdir(dirname(input.outPath), { recursive: true });
|
|
17
|
-
await writeFile(input.outPath, data, { mode: 0o600 });
|
|
18
|
-
return { path: input.outPath, bytes: data.byteLength };
|
|
19
|
-
}
|
|
20
|
-
}
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
|
-
import { UsageError } from "../../domain/errors.js";
|
|
6
|
-
import { ProfileName, Scope } from "../../domain/ids.js";
|
|
7
|
-
const execFileAsync = promisify(execFile);
|
|
8
|
-
export class KeychainTokenStore {
|
|
9
|
-
filePath;
|
|
10
|
-
secrets;
|
|
11
|
-
constructor(filePath, secrets = new MacOSSecurityKeychainSecrets()) {
|
|
12
|
-
this.filePath = filePath;
|
|
13
|
-
this.secrets = secrets;
|
|
14
|
-
}
|
|
15
|
-
static fromEnv(env = process.env) {
|
|
16
|
-
const configDir = env.AGENT_SLACK_CONFIG_DIR ?? env.SLK_CONFIG_DIR ?? join(env.HOME ?? process.cwd(), ".config", "agent-slack");
|
|
17
|
-
return new KeychainTokenStore(join(configDir, "profiles.keychain.json"));
|
|
18
|
-
}
|
|
19
|
-
async getProfile(name) {
|
|
20
|
-
const stored = (await this.readProfiles()).find((profile) => profile.name === name);
|
|
21
|
-
return stored === undefined ? null : this.hydrate(stored);
|
|
22
|
-
}
|
|
23
|
-
async setProfile(profile) {
|
|
24
|
-
const profiles = await this.readProfiles();
|
|
25
|
-
const previous = profiles.find((item) => item.name === profile.name);
|
|
26
|
-
const stored = await this.dehydrate(profile);
|
|
27
|
-
await this.deleteRemovedTokenAccounts(previous, stored);
|
|
28
|
-
await this.writeProfiles([stored, ...profiles.filter((item) => item.name !== profile.name)]);
|
|
29
|
-
}
|
|
30
|
-
async listProfiles() {
|
|
31
|
-
const profiles = await this.readProfiles();
|
|
32
|
-
return Promise.all(profiles.map((profile) => this.hydrate(profile)));
|
|
33
|
-
}
|
|
34
|
-
async deleteProfile(name) {
|
|
35
|
-
const profiles = await this.readProfiles();
|
|
36
|
-
const found = profiles.find((profile) => profile.name === name);
|
|
37
|
-
if (found === undefined) {
|
|
38
|
-
return false;
|
|
39
|
-
}
|
|
40
|
-
await Promise.all(Object.values(found.tokenAccounts).map((account) => account === undefined ? Promise.resolve() : this.secrets.delete(account)));
|
|
41
|
-
const next = profiles.filter((profile) => profile.name !== name);
|
|
42
|
-
if (next.length === 0) {
|
|
43
|
-
await rm(this.filePath, { force: true });
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
await this.writeProfiles(next);
|
|
47
|
-
}
|
|
48
|
-
return true;
|
|
49
|
-
}
|
|
50
|
-
async dehydrate(profile) {
|
|
51
|
-
const tokenAccounts = {};
|
|
52
|
-
for (const field of tokenFields) {
|
|
53
|
-
const token = profile[field];
|
|
54
|
-
if (token !== undefined) {
|
|
55
|
-
const account = accountFor(profile.name, field);
|
|
56
|
-
await this.secrets.set(account, token);
|
|
57
|
-
tokenAccounts[field] = account;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
name: profile.name,
|
|
62
|
-
tokenType: profile.tokenType,
|
|
63
|
-
scopes: profile.scopes,
|
|
64
|
-
tokenAccounts,
|
|
65
|
-
...(profile.teamId === undefined ? {} : { teamId: profile.teamId }),
|
|
66
|
-
...(profile.enterpriseId === undefined ? {} : { enterpriseId: profile.enterpriseId }),
|
|
67
|
-
...(profile.userId === undefined ? {} : { userId: profile.userId }),
|
|
68
|
-
...(profile.botId === undefined ? {} : { botId: profile.botId })
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
async hydrate(profile) {
|
|
72
|
-
const tokens = Object.fromEntries(await Promise.all(tokenFields.map(async (field) => {
|
|
73
|
-
const account = profile.tokenAccounts[field];
|
|
74
|
-
return [field, account === undefined ? undefined : await this.secrets.get(account)];
|
|
75
|
-
})));
|
|
76
|
-
return {
|
|
77
|
-
name: ProfileName.make(profile.name),
|
|
78
|
-
tokenType: profile.tokenType,
|
|
79
|
-
scopes: profile.scopes.map(Scope.make),
|
|
80
|
-
...(profile.teamId === undefined ? {} : { teamId: profile.teamId }),
|
|
81
|
-
...(profile.enterpriseId === undefined ? {} : { enterpriseId: profile.enterpriseId }),
|
|
82
|
-
...(profile.userId === undefined ? {} : { userId: profile.userId }),
|
|
83
|
-
...(profile.botId === undefined ? {} : { botId: profile.botId }),
|
|
84
|
-
...(tokens.botToken == null ? {} : { botToken: tokens.botToken }),
|
|
85
|
-
...(tokens.userToken == null ? {} : { userToken: tokens.userToken }),
|
|
86
|
-
...(tokens.adminToken == null ? {} : { adminToken: tokens.adminToken }),
|
|
87
|
-
...(tokens.appToken == null ? {} : { appToken: tokens.appToken })
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
async deleteRemovedTokenAccounts(previous, next) {
|
|
91
|
-
if (previous === undefined) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
const nextAccounts = new Set(Object.values(next.tokenAccounts));
|
|
95
|
-
await Promise.all(Object.values(previous.tokenAccounts).map((account) => {
|
|
96
|
-
if (account === undefined || nextAccounts.has(account)) {
|
|
97
|
-
return Promise.resolve();
|
|
98
|
-
}
|
|
99
|
-
return this.secrets.delete(account);
|
|
100
|
-
}));
|
|
101
|
-
}
|
|
102
|
-
async readProfiles() {
|
|
103
|
-
try {
|
|
104
|
-
const raw = await readFile(this.filePath, "utf8");
|
|
105
|
-
const parsed = JSON.parse(raw);
|
|
106
|
-
return parsed.profiles ?? [];
|
|
107
|
-
}
|
|
108
|
-
catch (error) {
|
|
109
|
-
if (error.code === "ENOENT") {
|
|
110
|
-
return [];
|
|
111
|
-
}
|
|
112
|
-
throw error;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
async writeProfiles(profiles) {
|
|
116
|
-
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
|
|
117
|
-
await writeFile(this.filePath, JSON.stringify({ profiles }, null, 2), { mode: 0o600 });
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
class MacOSSecurityKeychainSecrets {
|
|
121
|
-
service = "agent-slack";
|
|
122
|
-
async get(account) {
|
|
123
|
-
this.assertDarwin();
|
|
124
|
-
try {
|
|
125
|
-
const { stdout } = await execFileAsync("security", ["find-generic-password", "-s", this.service, "-a", account, "-w"]);
|
|
126
|
-
return stdout.trimEnd();
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
async set(account, value) {
|
|
133
|
-
this.assertDarwin();
|
|
134
|
-
await execFileAsync("security", ["add-generic-password", "-s", this.service, "-a", account, "-w", value, "-U"]);
|
|
135
|
-
}
|
|
136
|
-
async delete(account) {
|
|
137
|
-
this.assertDarwin();
|
|
138
|
-
try {
|
|
139
|
-
await execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", account]);
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
// Deleting a missing keychain item is idempotent for TokenStore semantics.
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
assertDarwin() {
|
|
146
|
-
if (process.platform !== "darwin") {
|
|
147
|
-
throw new UsageError("Keychain token storage is only supported on macOS in this adapter");
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
const tokenFields = ["botToken", "userToken", "adminToken", "appToken"];
|
|
152
|
-
const accountFor = (profileName, field) => `profile:${profileName}:${field}`;
|
package/dist/adapters/live.js
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
import { BundledMethodCatalog } from "./catalog/BundledMethodCatalog.js";
|
|
2
|
-
import { HttpFileDownloader } from "./http-file-downloader/HttpFileDownloader.js";
|
|
3
|
-
import { KeychainTokenStore } from "./keychain/KeychainTokenStore.js";
|
|
4
|
-
import { NodeLocalhostOAuthFlow } from "./localhost-oauth/NodeLocalhostOAuthFlow.js";
|
|
5
|
-
import { FileTokenStore } from "./profile-file/FileTokenStore.js";
|
|
6
|
-
import { SlackSdkWebApi } from "./slack-sdk/SlackSdkWebApi.js";
|
|
7
|
-
export const createLiveServices = (env = process.env) => {
|
|
8
|
-
const tokenStore = env.AGENT_SLACK_TOKEN_STORE ?? env.SLK_TOKEN_STORE;
|
|
9
|
-
const slackApiUrl = env.AGENT_SLACK_API_BASE_URL ?? env.SLK_SLACK_API_BASE_URL;
|
|
10
|
-
return {
|
|
11
|
-
tokenStore: tokenStore === "keychain" ? KeychainTokenStore.fromEnv(env) : FileTokenStore.fromEnv(env),
|
|
12
|
-
oauthFlow: NodeLocalhostOAuthFlow.fromEnv(env),
|
|
13
|
-
fileDownloader: new HttpFileDownloader(),
|
|
14
|
-
slackWebApi: new SlackSdkWebApi(slackApiUrl === undefined ? {} : { slackApiUrl }),
|
|
15
|
-
methodCatalog: new BundledMethodCatalog()
|
|
16
|
-
};
|
|
17
|
-
};
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { createServer } from "node:http";
|
|
5
|
-
import { PermissionDenied, SlackApiFailed, UsageError } from "../../domain/errors.js";
|
|
6
|
-
import { ProfileName, Scope } from "../../domain/ids.js";
|
|
7
|
-
export class NodeLocalhostOAuthFlow {
|
|
8
|
-
options;
|
|
9
|
-
constructor(options) {
|
|
10
|
-
this.options = options;
|
|
11
|
-
}
|
|
12
|
-
static fromEnv(env = process.env) {
|
|
13
|
-
const apiBaseUrl = env.AGENT_SLACK_API_BASE_URL ?? env.SLK_SLACK_API_BASE_URL;
|
|
14
|
-
const emulatorBaseUrl = apiBaseUrl?.replace(/\/api\/?$/, "");
|
|
15
|
-
const redirectHost = env.AGENT_SLACK_OAUTH_REDIRECT_HOST ?? env.SLK_OAUTH_REDIRECT_HOST;
|
|
16
|
-
const redirectPort = env.AGENT_SLACK_OAUTH_REDIRECT_PORT ?? env.SLK_OAUTH_REDIRECT_PORT;
|
|
17
|
-
const redirectPath = env.AGENT_SLACK_OAUTH_REDIRECT_PATH ?? env.SLK_OAUTH_REDIRECT_PATH;
|
|
18
|
-
const slackRedirectUri = env.AGENT_SLACK_OAUTH_REDIRECT_URI ?? env.AGENT_SLACK_OAUTH_PUBLIC_REDIRECT_URI;
|
|
19
|
-
const localCallbackUri = env.AGENT_SLACK_OAUTH_LOCAL_CALLBACK_URI;
|
|
20
|
-
const timeoutMs = env.AGENT_SLACK_OAUTH_TIMEOUT_MS ?? env.SLK_OAUTH_TIMEOUT_MS;
|
|
21
|
-
return new NodeLocalhostOAuthFlow({
|
|
22
|
-
authorizeUrl: env.AGENT_SLACK_OAUTH_AUTHORIZE_URL ?? env.SLK_SLACK_OAUTH_AUTHORIZE_URL ?? (emulatorBaseUrl === undefined ? "https://slack.com/oauth/v2/authorize" : `${emulatorBaseUrl}/oauth/v2/authorize`),
|
|
23
|
-
tokenUrl: env.AGENT_SLACK_OAUTH_ACCESS_URL ?? env.SLK_SLACK_OAUTH_ACCESS_URL ?? (apiBaseUrl === undefined ? "https://slack.com/api/oauth.v2.access" : `${apiBaseUrl.replace(/\/?$/, "/")}oauth.v2.access`),
|
|
24
|
-
...(slackRedirectUri === undefined ? {} : { defaultSlackRedirectUri: slackRedirectUri }),
|
|
25
|
-
...(localCallbackUri === undefined ? {} : { defaultLocalCallbackUri: localCallbackUri }),
|
|
26
|
-
...(redirectHost === undefined ? {} : { defaultRedirectHost: redirectHost }),
|
|
27
|
-
...(redirectPort === undefined ? {} : { defaultRedirectPort: Number(redirectPort) }),
|
|
28
|
-
...(redirectPath === undefined ? {} : { defaultRedirectPath: redirectPath }),
|
|
29
|
-
...(timeoutMs === undefined ? {} : { defaultTimeoutMs: Number(timeoutMs) })
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
async login(input) {
|
|
33
|
-
const state = randomBytes(24).toString("base64url");
|
|
34
|
-
const codeVerifier = input.pkce === true ? randomBytes(32).toString("base64url") : undefined;
|
|
35
|
-
const timeoutMs = input.timeoutMs ?? this.options.defaultTimeoutMs ?? 120_000;
|
|
36
|
-
const server = createServer();
|
|
37
|
-
const slackRedirectUri = input.redirectUri ?? this.options.defaultSlackRedirectUri;
|
|
38
|
-
const localCallbackUri = input.localCallbackUri ??
|
|
39
|
-
this.options.defaultLocalCallbackUri ??
|
|
40
|
-
(slackRedirectUri !== undefined && isLocalCallbackUri(slackRedirectUri) ? slackRedirectUri : undefined);
|
|
41
|
-
const started = await listen(server, localCallbackUri, {
|
|
42
|
-
host: this.options.defaultRedirectHost ?? defaultRedirectHost,
|
|
43
|
-
port: this.options.defaultRedirectPort ?? defaultRedirectPort,
|
|
44
|
-
path: this.options.defaultRedirectPath ?? "/oauth/slack/callback"
|
|
45
|
-
});
|
|
46
|
-
const redirectUri = slackRedirectUri ?? started.redirectUri;
|
|
47
|
-
const authorizationUrl = buildAuthorizationUrl({
|
|
48
|
-
authorizeUrl: this.options.authorizeUrl,
|
|
49
|
-
clientId: input.clientId,
|
|
50
|
-
redirectUri,
|
|
51
|
-
scopes: input.scopes,
|
|
52
|
-
userScopes: input.userScopes,
|
|
53
|
-
state,
|
|
54
|
-
...(codeVerifier === undefined ? {} : { codeChallenge: codeChallengeFor(codeVerifier) })
|
|
55
|
-
});
|
|
56
|
-
if (input.authUrlOut !== undefined) {
|
|
57
|
-
await writeFile(input.authUrlOut, authorizationUrl, "utf8");
|
|
58
|
-
}
|
|
59
|
-
else if (input.openBrowser !== false) {
|
|
60
|
-
const opened = await (this.options.openBrowser ?? openSystemBrowser)(authorizationUrl);
|
|
61
|
-
process.stderr.write(opened
|
|
62
|
-
? `Opening Slack OAuth in your browser.\nIf it did not open, visit:\n${authorizationUrl}\n`
|
|
63
|
-
: `Could not open a browser automatically. Visit this Slack OAuth URL:\n${authorizationUrl}\n`);
|
|
64
|
-
}
|
|
65
|
-
else {
|
|
66
|
-
process.stderr.write(`Open this Slack OAuth URL:\n${authorizationUrl}\n`);
|
|
67
|
-
}
|
|
68
|
-
return await new Promise((resolve, reject) => {
|
|
69
|
-
const timer = setTimeout(() => {
|
|
70
|
-
closeServer(server);
|
|
71
|
-
reject(new UsageError("Timed out waiting for Slack OAuth callback", { timeoutMs }));
|
|
72
|
-
}, timeoutMs);
|
|
73
|
-
server.on("request", async (request, response) => {
|
|
74
|
-
try {
|
|
75
|
-
const requestUrl = new URL(request.url ?? "/", started.redirectUri);
|
|
76
|
-
if (requestUrl.pathname !== started.path) {
|
|
77
|
-
response.writeHead(404).end("Not found");
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
if (requestUrl.searchParams.get("state") !== state) {
|
|
81
|
-
throw new PermissionDenied("Slack OAuth state mismatch");
|
|
82
|
-
}
|
|
83
|
-
const code = requestUrl.searchParams.get("code");
|
|
84
|
-
const oauthError = requestUrl.searchParams.get("error");
|
|
85
|
-
if (oauthError !== null) {
|
|
86
|
-
throw new PermissionDenied(`Slack OAuth failed: ${oauthError}`, { slackError: oauthError });
|
|
87
|
-
}
|
|
88
|
-
if (code === null || code === "") {
|
|
89
|
-
throw new UsageError("Slack OAuth callback did not include a code");
|
|
90
|
-
}
|
|
91
|
-
const access = await exchangeCode({
|
|
92
|
-
tokenUrl: this.options.tokenUrl,
|
|
93
|
-
code,
|
|
94
|
-
clientId: input.clientId,
|
|
95
|
-
clientSecret: input.clientSecret,
|
|
96
|
-
codeVerifier,
|
|
97
|
-
redirectUri
|
|
98
|
-
});
|
|
99
|
-
const profile = toAuthProfile(input.profileName, access, input.pkce === true ? "user" : "bot");
|
|
100
|
-
response.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end("<html><body>Slack auth complete. You can close this tab.</body></html>");
|
|
101
|
-
clearTimeout(timer);
|
|
102
|
-
closeServer(server);
|
|
103
|
-
resolve(profile);
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
response.writeHead(400, { "content-type": "text/plain; charset=utf-8" }).end(error instanceof Error ? error.message : String(error));
|
|
107
|
-
clearTimeout(timer);
|
|
108
|
-
closeServer(server);
|
|
109
|
-
reject(error);
|
|
110
|
-
}
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const listen = (server, redirectUri, fallback) => new Promise((resolve, reject) => {
|
|
116
|
-
const parsed = redirectUri === undefined ? null : new URL(redirectUri);
|
|
117
|
-
const host = parsed?.hostname ?? fallback.host;
|
|
118
|
-
const path = parsed?.pathname ?? fallback.path;
|
|
119
|
-
const port = parsed === null || parsed.port === "" ? fallback.port : Number(parsed.port);
|
|
120
|
-
if (!Number.isInteger(port) || port < 0) {
|
|
121
|
-
reject(new UsageError("OAuth redirect URI must include a valid port", { redirectUri }));
|
|
122
|
-
return;
|
|
123
|
-
}
|
|
124
|
-
server.once("error", reject);
|
|
125
|
-
server.listen(port, host, () => {
|
|
126
|
-
const address = server.address();
|
|
127
|
-
const actualPort = parsed === null || parsed.port === "" ? port : address.port;
|
|
128
|
-
resolve({
|
|
129
|
-
redirectUri: redirectUri ?? `http://${host}:${actualPort}${path}`,
|
|
130
|
-
path
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
});
|
|
134
|
-
const defaultRedirectHost = "localhost";
|
|
135
|
-
const defaultRedirectPort = 45454;
|
|
136
|
-
const isLocalCallbackUri = (value) => {
|
|
137
|
-
const parsed = new URL(value);
|
|
138
|
-
return parsed.protocol === "http:" && (parsed.hostname === "localhost" ||
|
|
139
|
-
parsed.hostname === "127.0.0.1" ||
|
|
140
|
-
parsed.hostname === "[::1]" ||
|
|
141
|
-
parsed.hostname === "::1");
|
|
142
|
-
};
|
|
143
|
-
const buildAuthorizationUrl = (input) => {
|
|
144
|
-
const url = new URL(input.authorizeUrl);
|
|
145
|
-
url.searchParams.set("client_id", input.clientId);
|
|
146
|
-
if (input.scopes.length > 0) {
|
|
147
|
-
url.searchParams.set("scope", input.scopes.join(","));
|
|
148
|
-
}
|
|
149
|
-
if (input.userScopes.length > 0) {
|
|
150
|
-
url.searchParams.set("user_scope", input.userScopes.join(","));
|
|
151
|
-
}
|
|
152
|
-
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
153
|
-
url.searchParams.set("state", input.state);
|
|
154
|
-
if (input.codeChallenge !== undefined) {
|
|
155
|
-
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
156
|
-
url.searchParams.set("code_challenge_method", "S256");
|
|
157
|
-
}
|
|
158
|
-
return url.toString();
|
|
159
|
-
};
|
|
160
|
-
const exchangeCode = async (input) => {
|
|
161
|
-
const requestBody = new URLSearchParams({
|
|
162
|
-
code: input.code,
|
|
163
|
-
client_id: input.clientId,
|
|
164
|
-
redirect_uri: input.redirectUri
|
|
165
|
-
});
|
|
166
|
-
if (input.clientSecret !== undefined) {
|
|
167
|
-
requestBody.set("client_secret", input.clientSecret);
|
|
168
|
-
}
|
|
169
|
-
if (input.codeVerifier !== undefined) {
|
|
170
|
-
requestBody.set("code_verifier", input.codeVerifier);
|
|
171
|
-
}
|
|
172
|
-
const response = await fetch(input.tokenUrl, {
|
|
173
|
-
method: "POST",
|
|
174
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
175
|
-
body: requestBody
|
|
176
|
-
});
|
|
177
|
-
const body = await response.json();
|
|
178
|
-
if (body.ok === false) {
|
|
179
|
-
throw new PermissionDenied(`Slack OAuth token exchange failed: ${body.error ?? "unknown_error"}`, {
|
|
180
|
-
slackError: body.error ?? "unknown_error"
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
if (body.access_token === undefined && body.authed_user?.access_token === undefined) {
|
|
184
|
-
throw new SlackApiFailed("Slack OAuth token exchange did not return a token");
|
|
185
|
-
}
|
|
186
|
-
return body;
|
|
187
|
-
};
|
|
188
|
-
const toAuthProfile = (profileName, response, preferredTokenType) => {
|
|
189
|
-
const botScopes = splitScopes(response.scope);
|
|
190
|
-
const userScopes = splitScopes(response.authed_user?.scope);
|
|
191
|
-
const rootTokenIsUser = preferredTokenType === "user" && response.access_token !== undefined && response.token_type !== "bot";
|
|
192
|
-
const userToken = response.authed_user?.access_token ?? (rootTokenIsUser ? response.access_token : undefined);
|
|
193
|
-
const useUserToken = preferredTokenType === "user" && userToken !== undefined;
|
|
194
|
-
const scopes = useUserToken && userScopes.length > 0 ? userScopes : [...botScopes, ...userScopes];
|
|
195
|
-
return {
|
|
196
|
-
name: ProfileName.make(profileName),
|
|
197
|
-
tokenType: useUserToken || response.access_token === undefined ? "user" : "bot",
|
|
198
|
-
enterpriseId: response.enterprise?.id ?? null,
|
|
199
|
-
...(response.team?.id === undefined ? {} : { teamId: response.team.id }),
|
|
200
|
-
...(response.authed_user?.id === undefined ? {} : { userId: response.authed_user.id }),
|
|
201
|
-
...(response.bot_user_id === undefined ? {} : { botId: response.bot_user_id }),
|
|
202
|
-
...(response.access_token === undefined || useUserToken ? {} : { botToken: response.access_token }),
|
|
203
|
-
...(userToken === undefined ? {} : { userToken }),
|
|
204
|
-
scopes: [...new Set(scopes)].map(Scope.make)
|
|
205
|
-
};
|
|
206
|
-
};
|
|
207
|
-
const codeChallengeFor = (codeVerifier) => createHash("sha256").update(codeVerifier).digest("base64url");
|
|
208
|
-
const splitScopes = (value) => value === undefined || value.trim() === "" ? [] : value.split(",").map((scope) => scope.trim()).filter(Boolean);
|
|
209
|
-
const closeServer = (server) => {
|
|
210
|
-
server.close();
|
|
211
|
-
};
|
|
212
|
-
const openSystemBrowser = (url) => new Promise((resolve) => {
|
|
213
|
-
const command = browserCommand(url);
|
|
214
|
-
const child = spawn(command.file, command.args, {
|
|
215
|
-
detached: true,
|
|
216
|
-
stdio: "ignore"
|
|
217
|
-
});
|
|
218
|
-
child.once("error", () => resolve(false));
|
|
219
|
-
child.once("spawn", () => {
|
|
220
|
-
child.unref();
|
|
221
|
-
resolve(true);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
224
|
-
const browserCommand = (url) => {
|
|
225
|
-
if (process.platform === "darwin") {
|
|
226
|
-
return { file: "open", args: [url] };
|
|
227
|
-
}
|
|
228
|
-
if (process.platform === "win32") {
|
|
229
|
-
return { file: "cmd", args: ["/c", "start", "", url] };
|
|
230
|
-
}
|
|
231
|
-
return { file: "xdg-open", args: [url] };
|
|
232
|
-
};
|
|
@@ -1,67 +0,0 @@
|
|
|
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.AGENT_SLACK_CONFIG_DIR ?? env.SLK_CONFIG_DIR ?? join(env.HOME ?? process.cwd(), ".config", "agent-slack");
|
|
13
|
-
const token = env.AGENT_SLACK_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.AGENT_SLACK_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
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
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
|
-
};
|