@dreki-gg/pi-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.
- package/README.md +95 -0
- package/extensions/slack/client/SlackClient.ts +15 -0
- package/extensions/slack/client/channels.ts +182 -0
- package/extensions/slack/client/errors.ts +44 -0
- package/extensions/slack/client/files.ts +121 -0
- package/extensions/slack/client/http.ts +133 -0
- package/extensions/slack/client/search.ts +78 -0
- package/extensions/slack/config.ts +96 -0
- package/extensions/slack/format.ts +107 -0
- package/extensions/slack/index.ts +329 -0
- package/extensions/slack/tools.ts +115 -0
- package/package.json +61 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# @dreki-gg/pi-slack
|
|
2
|
+
|
|
3
|
+
Slack read tools for [pi](https://github.com/earendil-works/pi-coding-agent) — messages, threads, channels, search, and file/image downloads.
|
|
4
|
+
|
|
5
|
+
Uses [Effect](https://effect.website) for the Slack client layer (typed errors, retries, rate limit handling).
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Description |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| `slack_list_channels` | List channels the bot has access to |
|
|
12
|
+
| `slack_read_messages` | Read message history from a channel |
|
|
13
|
+
| `slack_read_thread` | Read replies in a thread |
|
|
14
|
+
| `slack_search` | Full-text search across workspace (requires user token) |
|
|
15
|
+
| `slack_download_file` | Download a shared file/image to temp directory |
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
### 1. Create a Slack App
|
|
20
|
+
|
|
21
|
+
Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch**. Give it a name (e.g. `pi-context`) and select your workspace.
|
|
22
|
+
|
|
23
|
+
### 2. Add OAuth scopes
|
|
24
|
+
|
|
25
|
+
In the left sidebar → **OAuth & Permissions** → scroll to **Scopes**.
|
|
26
|
+
|
|
27
|
+
**Bot Token Scopes** (for `SLACK_BOT_TOKEN`):
|
|
28
|
+
|
|
29
|
+
| Scope | Enables |
|
|
30
|
+
|-------|---------|
|
|
31
|
+
| `channels:read` | list public channels |
|
|
32
|
+
| `channels:history` | read public channel messages |
|
|
33
|
+
| `groups:read` | list private channels |
|
|
34
|
+
| `groups:history` | read private channel messages |
|
|
35
|
+
| `im:history` | read DMs |
|
|
36
|
+
| `files:read` | file info + image/file downloads |
|
|
37
|
+
|
|
38
|
+
**User Token Scopes** (for `SLACK_USER_TOKEN` — only needed for `slack_search`):
|
|
39
|
+
|
|
40
|
+
| Scope | Enables |
|
|
41
|
+
|-------|---------|
|
|
42
|
+
| `search:read` | `slack_search` tool |
|
|
43
|
+
|
|
44
|
+
> `search.messages` is only available with a **user** token — bots cannot search. Skip the user token entirely if you don't need search.
|
|
45
|
+
|
|
46
|
+
### 3. Install to workspace
|
|
47
|
+
|
|
48
|
+
Still on **OAuth & Permissions** → click **Install to Workspace** at the top → authorize. (If your workspace requires admin approval, an admin must approve it.)
|
|
49
|
+
|
|
50
|
+
### 4. Copy the tokens
|
|
51
|
+
|
|
52
|
+
After installing, the same page shows:
|
|
53
|
+
|
|
54
|
+
- **Bot User OAuth Token** — starts with `xoxb-` → your `SLACK_BOT_TOKEN`
|
|
55
|
+
- **User OAuth Token** — starts with `xoxp-` → your `SLACK_USER_TOKEN`
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
export SLACK_BOT_TOKEN=xoxb-...
|
|
59
|
+
export SLACK_USER_TOKEN=xoxp-... # Optional, needed for slack_search
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### 5. Invite the bot to channels
|
|
63
|
+
|
|
64
|
+
A bot token can only read channels the bot is **a member of**. In each Slack channel you want the agent to read, type:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
/invite @your-app-name
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Otherwise `slack_read_messages` returns `not_in_channel`.
|
|
71
|
+
|
|
72
|
+
### 6. Optional project config
|
|
73
|
+
|
|
74
|
+
Create `.pi/slack.json` in your project to set a default channel and message limit:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"defaultChannel": "C0123ABC456",
|
|
79
|
+
"messageLimit": 50
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Finding channel IDs
|
|
84
|
+
|
|
85
|
+
Open a channel in Slack → click the channel name → scroll to the bottom of the popup; the ID (`C0123ABC456`) is shown there. Or run `slack_list_channels` once the bot is installed to list channels with their IDs.
|
|
86
|
+
|
|
87
|
+
## Checking status
|
|
88
|
+
|
|
89
|
+
Run `/slack` in pi to see token detection and project config status.
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
pi install @dreki-gg/pi-slack
|
|
95
|
+
```
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Layer } from 'effect';
|
|
2
|
+
import type { SlackCredentials } from '../config.js';
|
|
3
|
+
import { SlackConfig, SlackHttpLive } from './http.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the runtime layer that the Slack Effect programs require:
|
|
7
|
+
* the credentials (SlackConfig) plus the retrying HttpClient (SlackHttpLive).
|
|
8
|
+
*
|
|
9
|
+
* Used at the `Effect.runPromise` boundary in the tool execute handlers.
|
|
10
|
+
* The individual operations (listChannels, readMessages, searchMessages,
|
|
11
|
+
* downloadFile, …) are imported directly from their domain modules.
|
|
12
|
+
*/
|
|
13
|
+
export function makeRuntimeLayer(credentials: SlackCredentials) {
|
|
14
|
+
return Layer.merge(Layer.succeed(SlackConfig, credentials), SlackHttpLive);
|
|
15
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { slackGet } from './http.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface SlackChannel {
|
|
9
|
+
id: string;
|
|
10
|
+
name: string;
|
|
11
|
+
isPrivate: boolean;
|
|
12
|
+
isMember: boolean;
|
|
13
|
+
topic: string;
|
|
14
|
+
purpose: string;
|
|
15
|
+
numMembers: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SlackMessage {
|
|
19
|
+
user: string;
|
|
20
|
+
text: string;
|
|
21
|
+
ts: string;
|
|
22
|
+
threadTs?: string;
|
|
23
|
+
replyCount?: number;
|
|
24
|
+
reactions?: Array<{ name: string; count: number }>;
|
|
25
|
+
files?: Array<{ id: string; name: string; mimetype: string; urlPrivate: string }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ListChannelsResult {
|
|
29
|
+
channels: SlackChannel[];
|
|
30
|
+
cursor?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReadMessagesResult {
|
|
34
|
+
messages: SlackMessage[];
|
|
35
|
+
hasMore: boolean;
|
|
36
|
+
cursor?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// API response shapes (raw from Slack)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
interface RawChannel {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
is_private: boolean;
|
|
47
|
+
is_member: boolean;
|
|
48
|
+
topic?: { value: string };
|
|
49
|
+
purpose?: { value: string };
|
|
50
|
+
num_members: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RawMessage {
|
|
54
|
+
user?: string;
|
|
55
|
+
text?: string;
|
|
56
|
+
ts: string;
|
|
57
|
+
thread_ts?: string;
|
|
58
|
+
reply_count?: number;
|
|
59
|
+
reactions?: Array<{ name: string; count: number }>;
|
|
60
|
+
files?: Array<{
|
|
61
|
+
id: string;
|
|
62
|
+
name: string;
|
|
63
|
+
mimetype: string;
|
|
64
|
+
url_private: string;
|
|
65
|
+
}>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Normalizers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
function normalizeChannel(raw: RawChannel): SlackChannel {
|
|
73
|
+
return {
|
|
74
|
+
id: raw.id,
|
|
75
|
+
name: raw.name,
|
|
76
|
+
isPrivate: raw.is_private,
|
|
77
|
+
isMember: raw.is_member,
|
|
78
|
+
topic: raw.topic?.value ?? '',
|
|
79
|
+
purpose: raw.purpose?.value ?? '',
|
|
80
|
+
numMembers: raw.num_members,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function normalizeMessage(raw: RawMessage): SlackMessage {
|
|
85
|
+
const msg: SlackMessage = {
|
|
86
|
+
user: raw.user ?? 'unknown',
|
|
87
|
+
text: raw.text ?? '',
|
|
88
|
+
ts: raw.ts,
|
|
89
|
+
};
|
|
90
|
+
if (raw.thread_ts) msg.threadTs = raw.thread_ts;
|
|
91
|
+
if (raw.reply_count) msg.replyCount = raw.reply_count;
|
|
92
|
+
if (raw.reactions) msg.reactions = raw.reactions;
|
|
93
|
+
if (raw.files) {
|
|
94
|
+
msg.files = raw.files.map((f) => ({
|
|
95
|
+
id: f.id,
|
|
96
|
+
name: f.name,
|
|
97
|
+
mimetype: f.mimetype,
|
|
98
|
+
urlPrivate: f.url_private,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
return msg;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Effects
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
export function listChannels(params: { limit?: number; cursor?: string; types?: string }) {
|
|
109
|
+
return Effect.gen(function* () {
|
|
110
|
+
const resp = yield* slackGet<{
|
|
111
|
+
ok: true;
|
|
112
|
+
channels: RawChannel[];
|
|
113
|
+
response_metadata?: { next_cursor?: string };
|
|
114
|
+
}>('conversations.list', {
|
|
115
|
+
limit: params.limit ?? 100,
|
|
116
|
+
cursor: params.cursor,
|
|
117
|
+
types: params.types ?? 'public_channel,private_channel',
|
|
118
|
+
exclude_archived: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
channels: resp.channels.map(normalizeChannel),
|
|
123
|
+
cursor: resp.response_metadata?.next_cursor || undefined,
|
|
124
|
+
} satisfies ListChannelsResult;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function readMessages(params: {
|
|
129
|
+
channel: string;
|
|
130
|
+
limit?: number;
|
|
131
|
+
oldest?: string;
|
|
132
|
+
latest?: string;
|
|
133
|
+
cursor?: string;
|
|
134
|
+
}) {
|
|
135
|
+
return Effect.gen(function* () {
|
|
136
|
+
const resp = yield* slackGet<{
|
|
137
|
+
ok: true;
|
|
138
|
+
messages: RawMessage[];
|
|
139
|
+
has_more: boolean;
|
|
140
|
+
response_metadata?: { next_cursor?: string };
|
|
141
|
+
}>('conversations.history', {
|
|
142
|
+
channel: params.channel,
|
|
143
|
+
limit: params.limit ?? 50,
|
|
144
|
+
oldest: params.oldest,
|
|
145
|
+
latest: params.latest,
|
|
146
|
+
cursor: params.cursor,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
messages: resp.messages.map(normalizeMessage),
|
|
151
|
+
hasMore: resp.has_more,
|
|
152
|
+
cursor: resp.response_metadata?.next_cursor || undefined,
|
|
153
|
+
} satisfies ReadMessagesResult;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readThread(params: {
|
|
158
|
+
channel: string;
|
|
159
|
+
threadTs: string;
|
|
160
|
+
limit?: number;
|
|
161
|
+
cursor?: string;
|
|
162
|
+
}) {
|
|
163
|
+
return Effect.gen(function* () {
|
|
164
|
+
const resp = yield* slackGet<{
|
|
165
|
+
ok: true;
|
|
166
|
+
messages: RawMessage[];
|
|
167
|
+
has_more: boolean;
|
|
168
|
+
response_metadata?: { next_cursor?: string };
|
|
169
|
+
}>('conversations.replies', {
|
|
170
|
+
channel: params.channel,
|
|
171
|
+
ts: params.threadTs,
|
|
172
|
+
limit: params.limit ?? 100,
|
|
173
|
+
cursor: params.cursor,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
messages: resp.messages.map(normalizeMessage),
|
|
178
|
+
hasMore: resp.has_more,
|
|
179
|
+
cursor: resp.response_metadata?.next_cursor || undefined,
|
|
180
|
+
} satisfies ReadMessagesResult;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Data } from 'effect';
|
|
2
|
+
|
|
3
|
+
/** Slack API returned an error response (ok: false). */
|
|
4
|
+
export class SlackApiError extends Data.TaggedError('SlackApiError')<{
|
|
5
|
+
readonly method: string;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly detail?: string;
|
|
8
|
+
}> {
|
|
9
|
+
get message(): string {
|
|
10
|
+
return `Slack API error [${this.method}]: ${this.code}${this.detail ? ` — ${this.detail}` : ''}`;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Missing or invalid authentication token. */
|
|
15
|
+
export class SlackAuthError extends Data.TaggedError('SlackAuthError')<{
|
|
16
|
+
readonly reason: string;
|
|
17
|
+
}> {
|
|
18
|
+
get message(): string {
|
|
19
|
+
return `Slack auth error: ${this.reason}`;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Rate limited by Slack — includes retry-after seconds when available. */
|
|
24
|
+
export class SlackRateLimitError extends Data.TaggedError('SlackRateLimitError')<{
|
|
25
|
+
readonly method: string;
|
|
26
|
+
readonly retryAfter?: number;
|
|
27
|
+
}> {
|
|
28
|
+
get message(): string {
|
|
29
|
+
return `Slack rate limited [${this.method}]${this.retryAfter ? ` — retry after ${this.retryAfter}s` : ''}`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** File download or processing failed. */
|
|
34
|
+
export class SlackFileError extends Data.TaggedError('SlackFileError')<{
|
|
35
|
+
readonly fileId: string;
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}> {
|
|
38
|
+
get message(): string {
|
|
39
|
+
return `Slack file error [${this.fileId}]: ${this.reason}`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Union of all Slack client errors. */
|
|
44
|
+
export type SlackError = SlackApiError | SlackAuthError | SlackRateLimitError | SlackFileError;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { Effect } from 'effect';
|
|
5
|
+
import { slackGet, slackDownload } from './http.js';
|
|
6
|
+
import { SlackFileError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export interface SlackFileInfo {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
title: string;
|
|
16
|
+
mimetype: string;
|
|
17
|
+
filetype: string;
|
|
18
|
+
size: number;
|
|
19
|
+
urlPrivate: string;
|
|
20
|
+
permalink: string;
|
|
21
|
+
isImage: boolean;
|
|
22
|
+
imageWidth?: number;
|
|
23
|
+
imageHeight?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DownloadedFile {
|
|
27
|
+
info: SlackFileInfo;
|
|
28
|
+
localPath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Raw Slack shapes
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface RawFile {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
title: string;
|
|
39
|
+
mimetype: string;
|
|
40
|
+
filetype: string;
|
|
41
|
+
size: number;
|
|
42
|
+
url_private: string;
|
|
43
|
+
permalink: string;
|
|
44
|
+
original_w?: number;
|
|
45
|
+
original_h?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const IMAGE_MIMETYPES = new Set([
|
|
49
|
+
'image/png',
|
|
50
|
+
'image/jpeg',
|
|
51
|
+
'image/gif',
|
|
52
|
+
'image/webp',
|
|
53
|
+
'image/svg+xml',
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
function normalizeFileInfo(raw: RawFile): SlackFileInfo {
|
|
57
|
+
const isImage = IMAGE_MIMETYPES.has(raw.mimetype);
|
|
58
|
+
const info: SlackFileInfo = {
|
|
59
|
+
id: raw.id,
|
|
60
|
+
name: raw.name,
|
|
61
|
+
title: raw.title,
|
|
62
|
+
mimetype: raw.mimetype,
|
|
63
|
+
filetype: raw.filetype,
|
|
64
|
+
size: raw.size,
|
|
65
|
+
urlPrivate: raw.url_private,
|
|
66
|
+
permalink: raw.permalink,
|
|
67
|
+
isImage,
|
|
68
|
+
};
|
|
69
|
+
if (isImage && raw.original_w) info.imageWidth = raw.original_w;
|
|
70
|
+
if (isImage && raw.original_h) info.imageHeight = raw.original_h;
|
|
71
|
+
return info;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Temp directory for downloads
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
const DOWNLOAD_DIR = join(tmpdir(), 'pi-slack-files');
|
|
79
|
+
|
|
80
|
+
function ensureDownloadDir() {
|
|
81
|
+
return Effect.tryPromise({
|
|
82
|
+
try: () => mkdir(DOWNLOAD_DIR, { recursive: true }),
|
|
83
|
+
catch: (err) => new SlackFileError({ fileId: 'n/a', reason: `Cannot create temp dir: ${err}` }),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Effects
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
export function getFileInfo(fileId: string) {
|
|
92
|
+
return Effect.gen(function* () {
|
|
93
|
+
const resp = yield* slackGet<{ ok: true; file: RawFile }>('files.info', {
|
|
94
|
+
file: fileId,
|
|
95
|
+
});
|
|
96
|
+
return normalizeFileInfo(resp.file);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function downloadFile(fileId: string) {
|
|
101
|
+
return Effect.gen(function* () {
|
|
102
|
+
yield* ensureDownloadDir();
|
|
103
|
+
|
|
104
|
+
const info = yield* getFileInfo(fileId);
|
|
105
|
+
|
|
106
|
+
const buffer = yield* slackDownload(info.urlPrivate).pipe(
|
|
107
|
+
Effect.mapError((err) => new SlackFileError({ fileId, reason: `Download failed: ${err}` })),
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const ext = info.name.includes('.') ? '' : `.${info.filetype}`;
|
|
111
|
+
const filename = `${info.id}-${info.name}${ext}`;
|
|
112
|
+
const localPath = join(DOWNLOAD_DIR, filename);
|
|
113
|
+
|
|
114
|
+
yield* Effect.tryPromise({
|
|
115
|
+
try: () => writeFile(localPath, Buffer.from(buffer)),
|
|
116
|
+
catch: (err) => new SlackFileError({ fileId, reason: `Write failed: ${err}` }),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return { info, localPath } satisfies DownloadedFile;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { FetchHttpClient, HttpClient } from '@effect/platform';
|
|
2
|
+
import { Context, Effect, Layer, Schedule } from 'effect';
|
|
3
|
+
import type { SlackCredentials } from '../config.js';
|
|
4
|
+
import { SlackApiError, SlackAuthError, SlackRateLimitError } from './errors.js';
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// SlackConfig service — provides credentials to the HTTP layer
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
export class SlackConfig extends Context.Tag('SlackConfig')<SlackConfig, SlackCredentials>() {}
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Slack-specific JSON response shape
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
interface SlackOkResponse {
|
|
17
|
+
ok: true;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface SlackErrorResponse {
|
|
22
|
+
ok: false;
|
|
23
|
+
error: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type SlackResponse = SlackOkResponse | SlackErrorResponse;
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const SLACK_BASE_URL = 'https://slack.com/api';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Make a GET request to a Slack Web API method, returning typed JSON.
|
|
36
|
+
* Handles auth injection, rate limiting (with retry), and error mapping.
|
|
37
|
+
*/
|
|
38
|
+
export function slackGet<T extends SlackOkResponse>(
|
|
39
|
+
method: string,
|
|
40
|
+
params: Record<string, string | number | boolean | undefined>,
|
|
41
|
+
options?: { useUserToken?: boolean },
|
|
42
|
+
) {
|
|
43
|
+
return Effect.scoped(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const config = yield* SlackConfig;
|
|
46
|
+
const client = yield* HttpClient.HttpClient;
|
|
47
|
+
|
|
48
|
+
const token = options?.useUserToken ? config.userToken : config.botToken;
|
|
49
|
+
if (!token) {
|
|
50
|
+
return yield* new SlackAuthError({
|
|
51
|
+
reason: options?.useUserToken
|
|
52
|
+
? 'SLACK_USER_TOKEN is required for this operation but not set'
|
|
53
|
+
: 'SLACK_BOT_TOKEN is required but not set',
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build URL with query params
|
|
58
|
+
const url = new URL(`${SLACK_BASE_URL}/${method}`);
|
|
59
|
+
for (const [key, value] of Object.entries(params)) {
|
|
60
|
+
if (value !== undefined) url.searchParams.set(key, String(value));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const response = yield* client.get(url.toString(), {
|
|
64
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Handle rate limiting
|
|
68
|
+
if (response.status === 429) {
|
|
69
|
+
const retryAfter = Number(response.headers['retry-after']) || undefined;
|
|
70
|
+
return yield* new SlackRateLimitError({ method, retryAfter });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const json = (yield* response.json) as SlackResponse;
|
|
74
|
+
|
|
75
|
+
if (!json.ok) {
|
|
76
|
+
return yield* new SlackApiError({
|
|
77
|
+
method,
|
|
78
|
+
code: (json as SlackErrorResponse).error,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return json as T;
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Download a file from Slack using bot token authentication.
|
|
89
|
+
* Returns the raw ArrayBuffer.
|
|
90
|
+
*/
|
|
91
|
+
export function slackDownload(url: string) {
|
|
92
|
+
return Effect.scoped(
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const config = yield* SlackConfig;
|
|
95
|
+
const client = yield* HttpClient.HttpClient;
|
|
96
|
+
|
|
97
|
+
if (!config.botToken) {
|
|
98
|
+
return yield* new SlackAuthError({
|
|
99
|
+
reason: 'SLACK_BOT_TOKEN is required for file downloads',
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const response = yield* client.get(url, {
|
|
104
|
+
headers: { Authorization: `Bearer ${config.botToken}` },
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (response.status !== 200) {
|
|
108
|
+
return yield* Effect.fail(new Error(`Download failed with status ${response.status}`));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return yield* response.arrayBuffer;
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Retry policy — respects rate limit backoff
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const retryPolicy = Schedule.intersect(Schedule.recurs(3), Schedule.exponential('1 second'));
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Layers
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/** FetchHttpClient with retry policy for Slack rate limits. */
|
|
127
|
+
export const SlackHttpLive = FetchHttpClient.layer.pipe(
|
|
128
|
+
Layer.map((context) => {
|
|
129
|
+
const client = Context.get(context, HttpClient.HttpClient);
|
|
130
|
+
const retried = client.pipe(HttpClient.retry(retryPolicy));
|
|
131
|
+
return Context.make(HttpClient.HttpClient, retried);
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { slackGet } from './http.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface SearchMatch {
|
|
9
|
+
channel: { id: string; name: string };
|
|
10
|
+
user: string;
|
|
11
|
+
text: string;
|
|
12
|
+
ts: string;
|
|
13
|
+
permalink: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SearchResult {
|
|
17
|
+
matches: SearchMatch[];
|
|
18
|
+
total: number;
|
|
19
|
+
cursor?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Raw Slack shapes
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
interface RawSearchMatch {
|
|
27
|
+
channel: { id: string; name: string };
|
|
28
|
+
username?: string;
|
|
29
|
+
user?: string;
|
|
30
|
+
text: string;
|
|
31
|
+
ts: string;
|
|
32
|
+
permalink: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Effect
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
export function searchMessages(params: {
|
|
40
|
+
query: string;
|
|
41
|
+
count?: number;
|
|
42
|
+
sort?: 'score' | 'timestamp';
|
|
43
|
+
sortDir?: 'asc' | 'desc';
|
|
44
|
+
cursor?: string;
|
|
45
|
+
}) {
|
|
46
|
+
return Effect.gen(function* () {
|
|
47
|
+
const resp = yield* slackGet<{
|
|
48
|
+
ok: true;
|
|
49
|
+
messages: {
|
|
50
|
+
matches: RawSearchMatch[];
|
|
51
|
+
total: number;
|
|
52
|
+
pagination?: { next_cursor?: string };
|
|
53
|
+
};
|
|
54
|
+
}>(
|
|
55
|
+
'search.messages',
|
|
56
|
+
{
|
|
57
|
+
query: params.query,
|
|
58
|
+
count: params.count ?? 20,
|
|
59
|
+
sort: params.sort ?? 'timestamp',
|
|
60
|
+
sort_dir: params.sortDir ?? 'desc',
|
|
61
|
+
cursor: params.cursor,
|
|
62
|
+
},
|
|
63
|
+
{ useUserToken: true },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
matches: resp.messages.matches.map((m) => ({
|
|
68
|
+
channel: m.channel,
|
|
69
|
+
user: m.username ?? m.user ?? 'unknown',
|
|
70
|
+
text: m.text,
|
|
71
|
+
ts: m.ts,
|
|
72
|
+
permalink: m.permalink,
|
|
73
|
+
})),
|
|
74
|
+
total: resp.messages.total,
|
|
75
|
+
cursor: resp.messages.pagination?.next_cursor || undefined,
|
|
76
|
+
} satisfies SearchResult;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Project config (.pi/slack.json)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface SlackProjectConfig {
|
|
9
|
+
/** Default channel ID to read from. */
|
|
10
|
+
defaultChannel?: string;
|
|
11
|
+
/** Max messages per request (default 50). */
|
|
12
|
+
messageLimit: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RawConfig {
|
|
16
|
+
defaultChannel?: unknown;
|
|
17
|
+
messageLimit?: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONFIG: SlackProjectConfig = {
|
|
21
|
+
messageLimit: 50,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export async function loadProjectConfig(cwd: string): Promise<SlackProjectConfig> {
|
|
25
|
+
const configPath = join(cwd, '.pi', 'slack.json');
|
|
26
|
+
|
|
27
|
+
let raw: string;
|
|
28
|
+
try {
|
|
29
|
+
raw = await readFile(configPath, 'utf-8');
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
32
|
+
return { ...DEFAULT_CONFIG };
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Failed to read ${configPath}: ${(err as Error).message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let parsed: RawConfig;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(raw) as RawConfig;
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error(`Invalid JSON in ${configPath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
45
|
+
throw new Error(`${configPath} must be a JSON object`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return validateConfig(parsed, configPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateConfig(raw: RawConfig, configPath: string): SlackProjectConfig {
|
|
52
|
+
const config: SlackProjectConfig = { ...DEFAULT_CONFIG };
|
|
53
|
+
|
|
54
|
+
if (raw.defaultChannel !== undefined) {
|
|
55
|
+
if (typeof raw.defaultChannel !== 'string') {
|
|
56
|
+
throw new Error(`${configPath}: "defaultChannel" must be a string`);
|
|
57
|
+
}
|
|
58
|
+
config.defaultChannel = raw.defaultChannel;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (raw.messageLimit !== undefined) {
|
|
62
|
+
if (typeof raw.messageLimit !== 'number' || raw.messageLimit < 1 || raw.messageLimit > 999) {
|
|
63
|
+
throw new Error(`${configPath}: "messageLimit" must be a number between 1 and 999`);
|
|
64
|
+
}
|
|
65
|
+
config.messageLimit = raw.messageLimit;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return config;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Credentials from environment
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
export interface SlackCredentials {
|
|
76
|
+
botToken: string;
|
|
77
|
+
userToken?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function getCredentials(): SlackCredentials | null {
|
|
81
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
82
|
+
if (!botToken) return null;
|
|
83
|
+
|
|
84
|
+
const userToken = process.env.SLACK_USER_TOKEN || undefined;
|
|
85
|
+
return { botToken, userToken };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getCredentialStatus(): {
|
|
89
|
+
hasBotToken: boolean;
|
|
90
|
+
hasUserToken: boolean;
|
|
91
|
+
} {
|
|
92
|
+
return {
|
|
93
|
+
hasBotToken: Boolean(process.env.SLACK_BOT_TOKEN),
|
|
94
|
+
hasUserToken: Boolean(process.env.SLACK_USER_TOKEN),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { SlackChannel, SlackMessage, ReadMessagesResult } from './client/channels.js';
|
|
2
|
+
import type { SearchResult } from './client/search.js';
|
|
3
|
+
import type { DownloadedFile, SlackFileInfo } from './client/files.js';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Channels
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export function formatChannelList(channels: SlackChannel[]): string {
|
|
10
|
+
if (channels.length === 0) return 'No channels found.';
|
|
11
|
+
|
|
12
|
+
const lines = channels.map((ch) => {
|
|
13
|
+
const visibility = ch.isPrivate ? '🔒' : '#';
|
|
14
|
+
const members = `${ch.numMembers} members`;
|
|
15
|
+
const topic = ch.topic ? ` — ${ch.topic}` : '';
|
|
16
|
+
return `${visibility} **${ch.name}** (${ch.id}) · ${members}${topic}`;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
return `**Channels** (${channels.length}):\n\n${lines.join('\n')}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Messages
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function formatTimestamp(ts: string): string {
|
|
27
|
+
const date = new Date(Number(ts) * 1000);
|
|
28
|
+
return date
|
|
29
|
+
.toISOString()
|
|
30
|
+
.replace('T', ' ')
|
|
31
|
+
.replace(/\.\d{3}Z$/, ' UTC');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function formatSingleMessage(msg: SlackMessage): string {
|
|
35
|
+
const time = formatTimestamp(msg.ts);
|
|
36
|
+
const thread = msg.replyCount ? ` [${msg.replyCount} replies]` : '';
|
|
37
|
+
const reactions = msg.reactions
|
|
38
|
+
? ` ${msg.reactions.map((r) => `:${r.name}: ${r.count}`).join(' ')}`
|
|
39
|
+
: '';
|
|
40
|
+
const files = msg.files ? `\n 📎 ${msg.files.map((f) => `${f.name} (${f.id})`).join(', ')}` : '';
|
|
41
|
+
|
|
42
|
+
return `[${time}] **${msg.user}**${thread}: ${msg.text}${reactions}${files}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatMessages(result: ReadMessagesResult, channelId: string): string {
|
|
46
|
+
if (result.messages.length === 0) return `No messages found in channel ${channelId}.`;
|
|
47
|
+
|
|
48
|
+
const header = `**Messages** in ${channelId} (${result.messages.length}${result.hasMore ? ', more available' : ''}):`;
|
|
49
|
+
const msgs = result.messages.map(formatSingleMessage);
|
|
50
|
+
|
|
51
|
+
return `${header}\n\n${msgs.join('\n\n')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function formatThread(
|
|
55
|
+
result: ReadMessagesResult,
|
|
56
|
+
channelId: string,
|
|
57
|
+
threadTs: string,
|
|
58
|
+
): string {
|
|
59
|
+
if (result.messages.length === 0) return `No replies found in thread ${threadTs}.`;
|
|
60
|
+
|
|
61
|
+
const header = `**Thread** ${threadTs} in ${channelId} (${result.messages.length} messages${result.hasMore ? ', more available' : ''}):`;
|
|
62
|
+
const msgs = result.messages.map(formatSingleMessage);
|
|
63
|
+
|
|
64
|
+
return `${header}\n\n${msgs.join('\n\n')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Search
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
export function formatSearchResults(result: SearchResult, query: string): string {
|
|
72
|
+
if (result.matches.length === 0) return `No results found for "${query}".`;
|
|
73
|
+
|
|
74
|
+
const header = `**Search results** for "${query}" (${result.matches.length} of ${result.total} total):`;
|
|
75
|
+
const matches = result.matches.map((m) => {
|
|
76
|
+
const time = formatTimestamp(m.ts);
|
|
77
|
+
return `[${time}] **${m.user}** in #${m.channel.name}: ${m.text}\n 🔗 ${m.permalink}`;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return `${header}\n\n${matches.join('\n\n')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Files
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
export function formatFileInfo(info: SlackFileInfo): string {
|
|
88
|
+
const size = formatFileSize(info.size);
|
|
89
|
+
const dims = info.isImage && info.imageWidth ? ` · ${info.imageWidth}×${info.imageHeight}` : '';
|
|
90
|
+
|
|
91
|
+
return `📎 **${info.title}** (${info.name})\n Type: ${info.mimetype} · Size: ${size}${dims}\n ID: ${info.id}\n 🔗 ${info.permalink}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatDownloadedFile(result: DownloadedFile): string {
|
|
95
|
+
const info = formatFileInfo(result.info);
|
|
96
|
+
const hint = result.info.isImage
|
|
97
|
+
? `\n\n💡 This is an image file. Use the \`read\` tool to view it:\n \`read ${result.localPath}\``
|
|
98
|
+
: `\n\n📁 Downloaded to: ${result.localPath}`;
|
|
99
|
+
|
|
100
|
+
return `${info}${hint}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function formatFileSize(bytes: number): string {
|
|
104
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
105
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
106
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
107
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import type { HttpClient } from '@effect/platform';
|
|
4
|
+
import type { SlackConfig } from './client/http.js';
|
|
5
|
+
import {
|
|
6
|
+
type SlackProjectConfig,
|
|
7
|
+
type SlackCredentials,
|
|
8
|
+
getCredentials,
|
|
9
|
+
getCredentialStatus,
|
|
10
|
+
loadProjectConfig,
|
|
11
|
+
} from './config.js';
|
|
12
|
+
import { makeRuntimeLayer } from './client/SlackClient.js';
|
|
13
|
+
import { listChannels } from './client/channels.js';
|
|
14
|
+
import { readMessages, readThread } from './client/channels.js';
|
|
15
|
+
import { searchMessages } from './client/search.js';
|
|
16
|
+
import { downloadFile } from './client/files.js';
|
|
17
|
+
import {
|
|
18
|
+
formatChannelList,
|
|
19
|
+
formatMessages,
|
|
20
|
+
formatThread,
|
|
21
|
+
formatSearchResults,
|
|
22
|
+
formatDownloadedFile,
|
|
23
|
+
} from './format.js';
|
|
24
|
+
import {
|
|
25
|
+
TOOL_GUIDELINES,
|
|
26
|
+
listChannelsParams,
|
|
27
|
+
readMessagesParams,
|
|
28
|
+
readThreadParams,
|
|
29
|
+
searchParams,
|
|
30
|
+
downloadFileParams,
|
|
31
|
+
} from './tools.js';
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
function textResult(text: string, details?: Record<string, unknown>) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text' as const, text }],
|
|
40
|
+
details: (details ?? {}) as Record<string, unknown>,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function errorResult(text: string, details?: Record<string, unknown>) {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text' as const, text }],
|
|
47
|
+
details: (details ?? {}) as Record<string, unknown>,
|
|
48
|
+
isError: true,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function missingCredentials(which: 'bot' | 'user') {
|
|
53
|
+
const envVar = which === 'bot' ? 'SLACK_BOT_TOKEN' : 'SLACK_USER_TOKEN';
|
|
54
|
+
return errorResult(
|
|
55
|
+
`❌ Missing ${envVar}.\n\nSet this environment variable to enable this Slack tool.`,
|
|
56
|
+
{ error: 'missing_credentials', missing: [envVar] },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs an Effect program with the Slack runtime layer.
|
|
62
|
+
* This is the boundary between Effect and plain-TS tool handlers.
|
|
63
|
+
*/
|
|
64
|
+
type SlackRuntime = SlackConfig | HttpClient.HttpClient;
|
|
65
|
+
|
|
66
|
+
function runSlack<A, E>(
|
|
67
|
+
credentials: SlackCredentials,
|
|
68
|
+
program: Effect.Effect<A, E, SlackRuntime>,
|
|
69
|
+
): Promise<A> {
|
|
70
|
+
const layer = makeRuntimeLayer(credentials);
|
|
71
|
+
return Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Extension
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export default function slackExtension(pi: ExtensionAPI) {
|
|
79
|
+
let projectConfig: SlackProjectConfig | null = null;
|
|
80
|
+
|
|
81
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
82
|
+
try {
|
|
83
|
+
projectConfig = await loadProjectConfig(ctx.cwd);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
ctx.ui.notify(`Slack config error: ${(err as Error).message}`, 'warning');
|
|
86
|
+
projectConfig = null;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// -------------------------------------------------------------------------
|
|
91
|
+
// slack_list_channels
|
|
92
|
+
// -------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
pi.registerTool({
|
|
95
|
+
name: 'slack_list_channels',
|
|
96
|
+
label: 'Slack List Channels',
|
|
97
|
+
description: 'List Slack channels the bot has access to.',
|
|
98
|
+
promptSnippet: 'List available Slack channels',
|
|
99
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
100
|
+
parameters: listChannelsParams,
|
|
101
|
+
|
|
102
|
+
async execute(
|
|
103
|
+
_toolCallId: string,
|
|
104
|
+
params: { limit?: number; cursor?: string; types?: string },
|
|
105
|
+
) {
|
|
106
|
+
const creds = getCredentials();
|
|
107
|
+
if (!creds) return missingCredentials('bot');
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const result = await runSlack(creds, listChannels(params));
|
|
111
|
+
return textResult(formatChannelList(result.channels), {
|
|
112
|
+
count: result.channels.length,
|
|
113
|
+
cursor: result.cursor,
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// -------------------------------------------------------------------------
|
|
122
|
+
// slack_read_messages
|
|
123
|
+
// -------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
pi.registerTool({
|
|
126
|
+
name: 'slack_read_messages',
|
|
127
|
+
label: 'Slack Read Messages',
|
|
128
|
+
description:
|
|
129
|
+
'Read message history from a Slack channel. Returns messages with timestamps, users, reactions, and file attachments.',
|
|
130
|
+
promptSnippet: 'Read messages from a Slack channel',
|
|
131
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
132
|
+
parameters: readMessagesParams,
|
|
133
|
+
|
|
134
|
+
async execute(
|
|
135
|
+
_toolCallId: string,
|
|
136
|
+
params: {
|
|
137
|
+
channel: string;
|
|
138
|
+
limit?: number;
|
|
139
|
+
oldest?: string;
|
|
140
|
+
latest?: string;
|
|
141
|
+
cursor?: string;
|
|
142
|
+
},
|
|
143
|
+
) {
|
|
144
|
+
const creds = getCredentials();
|
|
145
|
+
if (!creds) return missingCredentials('bot');
|
|
146
|
+
|
|
147
|
+
const limit = params.limit ?? projectConfig?.messageLimit ?? 50;
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
const result = await runSlack(creds, readMessages({ ...params, limit }));
|
|
151
|
+
return textResult(formatMessages(result, params.channel), {
|
|
152
|
+
count: result.messages.length,
|
|
153
|
+
hasMore: result.hasMore,
|
|
154
|
+
cursor: result.cursor,
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// slack_read_thread
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
pi.registerTool({
|
|
167
|
+
name: 'slack_read_thread',
|
|
168
|
+
label: 'Slack Read Thread',
|
|
169
|
+
description:
|
|
170
|
+
'Read all replies in a Slack thread. Provide the channel ID and the thread parent timestamp.',
|
|
171
|
+
promptSnippet: 'Read replies in a Slack thread',
|
|
172
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
173
|
+
parameters: readThreadParams,
|
|
174
|
+
|
|
175
|
+
async execute(
|
|
176
|
+
_toolCallId: string,
|
|
177
|
+
params: {
|
|
178
|
+
channel: string;
|
|
179
|
+
thread_ts: string;
|
|
180
|
+
limit?: number;
|
|
181
|
+
cursor?: string;
|
|
182
|
+
},
|
|
183
|
+
) {
|
|
184
|
+
const creds = getCredentials();
|
|
185
|
+
if (!creds) return missingCredentials('bot');
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const result = await runSlack(
|
|
189
|
+
creds,
|
|
190
|
+
readThread({
|
|
191
|
+
channel: params.channel,
|
|
192
|
+
threadTs: params.thread_ts,
|
|
193
|
+
limit: params.limit,
|
|
194
|
+
cursor: params.cursor,
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return textResult(formatThread(result, params.channel, params.thread_ts), {
|
|
198
|
+
count: result.messages.length,
|
|
199
|
+
hasMore: result.hasMore,
|
|
200
|
+
cursor: result.cursor,
|
|
201
|
+
});
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// slack_search
|
|
210
|
+
// -------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
pi.registerTool({
|
|
213
|
+
name: 'slack_search',
|
|
214
|
+
label: 'Slack Search',
|
|
215
|
+
description:
|
|
216
|
+
'Full-text search across Slack messages. Supports Slack search syntax (in:#channel, from:@user, has:link). Requires SLACK_USER_TOKEN.',
|
|
217
|
+
promptSnippet: 'Search Slack messages across the workspace',
|
|
218
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
219
|
+
parameters: searchParams,
|
|
220
|
+
|
|
221
|
+
async execute(
|
|
222
|
+
_toolCallId: string,
|
|
223
|
+
params: {
|
|
224
|
+
query: string;
|
|
225
|
+
count?: number;
|
|
226
|
+
sort?: 'score' | 'timestamp';
|
|
227
|
+
sort_dir?: 'asc' | 'desc';
|
|
228
|
+
cursor?: string;
|
|
229
|
+
},
|
|
230
|
+
) {
|
|
231
|
+
const creds = getCredentials();
|
|
232
|
+
if (!creds) return missingCredentials('bot');
|
|
233
|
+
if (!creds.userToken) return missingCredentials('user');
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const result = await runSlack(
|
|
237
|
+
creds,
|
|
238
|
+
searchMessages({
|
|
239
|
+
query: params.query,
|
|
240
|
+
count: params.count,
|
|
241
|
+
sort: params.sort,
|
|
242
|
+
sortDir: params.sort_dir,
|
|
243
|
+
cursor: params.cursor,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
return textResult(formatSearchResults(result, params.query), {
|
|
247
|
+
total: result.total,
|
|
248
|
+
count: result.matches.length,
|
|
249
|
+
cursor: result.cursor,
|
|
250
|
+
});
|
|
251
|
+
} catch (err) {
|
|
252
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
// slack_download_file
|
|
259
|
+
// -------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
pi.registerTool({
|
|
262
|
+
name: 'slack_download_file',
|
|
263
|
+
label: 'Slack Download File',
|
|
264
|
+
description:
|
|
265
|
+
'Download a file or image shared in Slack. Saves to a temp directory and returns the local path. Use `read` to view downloaded images.',
|
|
266
|
+
promptSnippet: 'Download a Slack file/image to local temp directory',
|
|
267
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
268
|
+
parameters: downloadFileParams,
|
|
269
|
+
|
|
270
|
+
async execute(_toolCallId: string, params: { file_id: string }) {
|
|
271
|
+
const creds = getCredentials();
|
|
272
|
+
if (!creds) return missingCredentials('bot');
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const result = await runSlack(creds, downloadFile(params.file_id));
|
|
276
|
+
return textResult(formatDownloadedFile(result), {
|
|
277
|
+
fileId: result.info.id,
|
|
278
|
+
localPath: result.localPath,
|
|
279
|
+
isImage: result.info.isImage,
|
|
280
|
+
mimetype: result.info.mimetype,
|
|
281
|
+
size: result.info.size,
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// -------------------------------------------------------------------------
|
|
290
|
+
// /slack command — status check
|
|
291
|
+
// -------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
pi.registerCommand('slack', {
|
|
294
|
+
description: 'Show Slack extension configuration and connection status',
|
|
295
|
+
handler: async (
|
|
296
|
+
_args: string,
|
|
297
|
+
ctx: {
|
|
298
|
+
cwd: string;
|
|
299
|
+
hasUI: boolean;
|
|
300
|
+
ui: { notify(message: string, level: 'info' | 'warning' | 'error'): void };
|
|
301
|
+
},
|
|
302
|
+
) => {
|
|
303
|
+
const credStatus = getCredentialStatus();
|
|
304
|
+
const config = projectConfig ?? (await loadProjectConfig(ctx.cwd).catch(() => null));
|
|
305
|
+
|
|
306
|
+
const lines: string[] = ['Slack Extension Status', ''];
|
|
307
|
+
|
|
308
|
+
lines.push(
|
|
309
|
+
`Bot Token: ${credStatus.hasBotToken ? '✅ Set' : '❌ Missing (SLACK_BOT_TOKEN)'}`,
|
|
310
|
+
);
|
|
311
|
+
lines.push(
|
|
312
|
+
`User Token: ${credStatus.hasUserToken ? '✅ Set' : '⚠️ Missing (SLACK_USER_TOKEN) — search disabled'}`,
|
|
313
|
+
);
|
|
314
|
+
lines.push('');
|
|
315
|
+
|
|
316
|
+
if (config) {
|
|
317
|
+
lines.push('Project Config (.pi/slack.json):');
|
|
318
|
+
lines.push(` Default channel: ${config.defaultChannel ?? '(not set)'}`);
|
|
319
|
+
lines.push(` Message limit: ${config.messageLimit}`);
|
|
320
|
+
} else {
|
|
321
|
+
lines.push('No .pi/slack.json found — using defaults.');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (ctx.hasUI) {
|
|
325
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Prompt guidelines injected into the system prompt
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export const TOOL_GUIDELINES = [
|
|
9
|
+
'Use `slack_list_channels` to discover available Slack channels before reading messages.',
|
|
10
|
+
'Use `slack_read_messages` to read recent messages from a specific channel. Provide a channel ID (starts with C).',
|
|
11
|
+
'Use `slack_read_thread` to read all replies in a thread. You need both the channel ID and the thread timestamp (thread_ts).',
|
|
12
|
+
'Use `slack_search` for full-text search across the workspace. Requires SLACK_USER_TOKEN to be set.',
|
|
13
|
+
'Use `slack_download_file` to download images or files shared in Slack. The tool saves files to a temp directory and returns the path — use `read` to view images.',
|
|
14
|
+
'When messages reference files or images (shown with 📎), download them with `slack_download_file` using the file ID to get visual context.',
|
|
15
|
+
'Channel IDs look like C0123ABC456. Thread timestamps look like 1512085950.000216.',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Parameter schemas
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
export const listChannelsParams = Type.Object({
|
|
23
|
+
limit: Type.Optional(
|
|
24
|
+
Type.Number({
|
|
25
|
+
description: 'Max channels to return (1-200). Default 100.',
|
|
26
|
+
minimum: 1,
|
|
27
|
+
maximum: 200,
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
cursor: Type.Optional(
|
|
31
|
+
Type.String({ description: 'Pagination cursor from a previous response.' }),
|
|
32
|
+
),
|
|
33
|
+
types: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
description: 'Comma-separated channel types. Default "public_channel,private_channel".',
|
|
36
|
+
}),
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const readMessagesParams = Type.Object({
|
|
41
|
+
channel: Type.String({
|
|
42
|
+
description: 'Channel ID (e.g. C0123ABC456).',
|
|
43
|
+
}),
|
|
44
|
+
limit: Type.Optional(
|
|
45
|
+
Type.Number({
|
|
46
|
+
description: 'Max messages to return (1-100). Default from config or 50.',
|
|
47
|
+
minimum: 1,
|
|
48
|
+
maximum: 100,
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
oldest: Type.Optional(
|
|
52
|
+
Type.String({
|
|
53
|
+
description: 'Only messages after this Unix timestamp.',
|
|
54
|
+
}),
|
|
55
|
+
),
|
|
56
|
+
latest: Type.Optional(
|
|
57
|
+
Type.String({
|
|
58
|
+
description: 'Only messages before this Unix timestamp.',
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
cursor: Type.Optional(
|
|
62
|
+
Type.String({ description: 'Pagination cursor from a previous response.' }),
|
|
63
|
+
),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const readThreadParams = Type.Object({
|
|
67
|
+
channel: Type.String({
|
|
68
|
+
description: 'Channel ID where the thread lives.',
|
|
69
|
+
}),
|
|
70
|
+
thread_ts: Type.String({
|
|
71
|
+
description: 'Timestamp of the parent message (thread_ts).',
|
|
72
|
+
}),
|
|
73
|
+
limit: Type.Optional(
|
|
74
|
+
Type.Number({
|
|
75
|
+
description: 'Max replies to return (1-200). Default 100.',
|
|
76
|
+
minimum: 1,
|
|
77
|
+
maximum: 200,
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
cursor: Type.Optional(
|
|
81
|
+
Type.String({ description: 'Pagination cursor from a previous response.' }),
|
|
82
|
+
),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const SORT_ENUM = ['score', 'timestamp'] as const;
|
|
86
|
+
const SORT_DIR_ENUM = ['asc', 'desc'] as const;
|
|
87
|
+
|
|
88
|
+
export const searchParams = Type.Object({
|
|
89
|
+
query: Type.String({
|
|
90
|
+
description:
|
|
91
|
+
'Search query. Supports Slack search syntax (in:#channel, from:@user, has:link, etc.).',
|
|
92
|
+
}),
|
|
93
|
+
count: Type.Optional(
|
|
94
|
+
Type.Number({
|
|
95
|
+
description: 'Number of results (1-100). Default 20.',
|
|
96
|
+
minimum: 1,
|
|
97
|
+
maximum: 100,
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
sort: Type.Optional(
|
|
101
|
+
StringEnum(SORT_ENUM, { description: 'Sort by "score" or "timestamp". Default "timestamp".' }),
|
|
102
|
+
),
|
|
103
|
+
sort_dir: Type.Optional(
|
|
104
|
+
StringEnum(SORT_DIR_ENUM, { description: 'Sort direction. Default "desc".' }),
|
|
105
|
+
),
|
|
106
|
+
cursor: Type.Optional(
|
|
107
|
+
Type.String({ description: 'Pagination cursor from a previous response.' }),
|
|
108
|
+
),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const downloadFileParams = Type.Object({
|
|
112
|
+
file_id: Type.String({
|
|
113
|
+
description: 'Slack file ID to download (e.g. F0123ABC456).',
|
|
114
|
+
}),
|
|
115
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dreki-gg/pi-slack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Slack read tools for pi — messages, threads, channels, search, and file/image downloads with Effect-powered client",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"slack",
|
|
9
|
+
"messages",
|
|
10
|
+
"images"
|
|
11
|
+
],
|
|
12
|
+
"author": "Juan Albarran <jalbarrandev@gmail.com>",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/dreki-gg/pi-extensions",
|
|
17
|
+
"directory": "packages/slack"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"files": [
|
|
21
|
+
"extensions",
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"package.json"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "bun test",
|
|
29
|
+
"lint": "oxlint extensions test",
|
|
30
|
+
"format": "oxfmt --write extensions test",
|
|
31
|
+
"format:check": "oxfmt --check extensions test"
|
|
32
|
+
},
|
|
33
|
+
"pi": {
|
|
34
|
+
"extensions": [
|
|
35
|
+
"./extensions/slack"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"effect": "^3.16.0",
|
|
40
|
+
"@effect/platform": "^0.78.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "24",
|
|
44
|
+
"bun-types": "^1.3.14",
|
|
45
|
+
"oxfmt": "^0.43.0",
|
|
46
|
+
"oxlint": "^1.58.0",
|
|
47
|
+
"typescript": "^6.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
51
|
+
"typebox": "*"
|
|
52
|
+
},
|
|
53
|
+
"peerDependenciesMeta": {
|
|
54
|
+
"@earendil-works/pi-coding-agent": {
|
|
55
|
+
"optional": true
|
|
56
|
+
},
|
|
57
|
+
"typebox": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|