@dreki-gg/pi-slack 0.1.0 → 0.3.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/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/extensions/slack/client/http.ts +51 -1
- package/extensions/slack/client/messages.ts +65 -0
- package/extensions/slack/format.ts +14 -0
- package/extensions/slack/index.ts +93 -0
- package/extensions/slack/tools.ts +34 -0
- package/package.json +1 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @dreki-gg/pi-slack
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `slack_edit_message` tool so the agent can edit messages it previously posted. Pass the `channel` ID and the message `ts` (returned by `slack_post_message`) along with the new `text` to rewrite a message via Slack's `chat.update`. Reuses the existing `chat:write` scope — bots can only edit their own messages.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- Add `slack_post_message` tool to post messages to a channel or reply in a thread. Posting is gated by the Slack app's `chat:write` OAuth scope (the bot must also be a member of the target channel); scope/permission errors from Slack are surfaced clearly.
|
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ Uses [Effect](https://effect.website) for the Slack client layer (typed errors,
|
|
|
13
13
|
| `slack_read_thread` | Read replies in a thread |
|
|
14
14
|
| `slack_search` | Full-text search across workspace (requires user token) |
|
|
15
15
|
| `slack_download_file` | Download a shared file/image to temp directory |
|
|
16
|
+
| `slack_post_message` | Post a message to a channel or reply in a thread (requires `chat:write`) |
|
|
17
|
+
| `slack_edit_message` | Edit a message the bot previously posted (requires `chat:write`) |
|
|
16
18
|
|
|
17
19
|
## Setup
|
|
18
20
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FetchHttpClient, HttpClient } from '@effect/platform';
|
|
1
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest } from '@effect/platform';
|
|
2
2
|
import { Context, Effect, Layer, Schedule } from 'effect';
|
|
3
3
|
import type { SlackCredentials } from '../config.js';
|
|
4
4
|
import { SlackApiError, SlackAuthError, SlackRateLimitError } from './errors.js';
|
|
@@ -84,6 +84,56 @@ export function slackGet<T extends SlackOkResponse>(
|
|
|
84
84
|
);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Make a POST request to a Slack Web API method with a JSON body.
|
|
89
|
+
* Handles auth injection, rate limiting (with retry), and error mapping.
|
|
90
|
+
*/
|
|
91
|
+
export function slackPost<T extends SlackOkResponse>(
|
|
92
|
+
method: string,
|
|
93
|
+
body: Record<string, unknown>,
|
|
94
|
+
options?: { useUserToken?: boolean },
|
|
95
|
+
) {
|
|
96
|
+
return Effect.scoped(
|
|
97
|
+
Effect.gen(function* () {
|
|
98
|
+
const config = yield* SlackConfig;
|
|
99
|
+
const client = yield* HttpClient.HttpClient;
|
|
100
|
+
|
|
101
|
+
const token = options?.useUserToken ? config.userToken : config.botToken;
|
|
102
|
+
if (!token) {
|
|
103
|
+
return yield* new SlackAuthError({
|
|
104
|
+
reason: options?.useUserToken
|
|
105
|
+
? 'SLACK_USER_TOKEN is required for this operation but not set'
|
|
106
|
+
: 'SLACK_BOT_TOKEN is required but not set',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const request = HttpClientRequest.post(`${SLACK_BASE_URL}/${method}`).pipe(
|
|
111
|
+
HttpClientRequest.setHeaders({ Authorization: `Bearer ${token}` }),
|
|
112
|
+
HttpClientRequest.bodyUnsafeJson(body),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const response = yield* client.execute(request);
|
|
116
|
+
|
|
117
|
+
// Handle rate limiting
|
|
118
|
+
if (response.status === 429) {
|
|
119
|
+
const retryAfter = Number(response.headers['retry-after']) || undefined;
|
|
120
|
+
return yield* new SlackRateLimitError({ method, retryAfter });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const json = (yield* response.json) as SlackResponse;
|
|
124
|
+
|
|
125
|
+
if (!json.ok) {
|
|
126
|
+
return yield* new SlackApiError({
|
|
127
|
+
method,
|
|
128
|
+
code: (json as SlackErrorResponse).error,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return json as T;
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
87
137
|
/**
|
|
88
138
|
* Download a file from Slack using bot token authentication.
|
|
89
139
|
* Returns the raw ArrayBuffer.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { slackPost } from './http.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface PostMessageResult {
|
|
9
|
+
/** Timestamp of the posted message (also used as thread_ts for replies). */
|
|
10
|
+
ts: string;
|
|
11
|
+
/** Channel the message was posted to. */
|
|
12
|
+
channel: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface EditMessageResult {
|
|
16
|
+
/** Timestamp of the edited message. */
|
|
17
|
+
ts: string;
|
|
18
|
+
/** Channel the message lives in. */
|
|
19
|
+
channel: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Effects
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export function postMessage(params: {
|
|
27
|
+
channel: string;
|
|
28
|
+
text: string;
|
|
29
|
+
threadTs?: string;
|
|
30
|
+
replyBroadcast?: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
return Effect.gen(function* () {
|
|
33
|
+
const body: Record<string, unknown> = {
|
|
34
|
+
channel: params.channel,
|
|
35
|
+
text: params.text,
|
|
36
|
+
};
|
|
37
|
+
if (params.threadTs !== undefined) body.thread_ts = params.threadTs;
|
|
38
|
+
if (params.replyBroadcast !== undefined) body.reply_broadcast = params.replyBroadcast;
|
|
39
|
+
|
|
40
|
+
const resp = yield* slackPost<{ ok: true; ts: string; channel: string }>(
|
|
41
|
+
'chat.postMessage',
|
|
42
|
+
body,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
ts: resp.ts,
|
|
47
|
+
channel: resp.channel,
|
|
48
|
+
} satisfies PostMessageResult;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function editMessage(params: { channel: string; ts: string; text: string }) {
|
|
53
|
+
return Effect.gen(function* () {
|
|
54
|
+
const resp = yield* slackPost<{ ok: true; ts: string; channel: string }>('chat.update', {
|
|
55
|
+
channel: params.channel,
|
|
56
|
+
ts: params.ts,
|
|
57
|
+
text: params.text,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
ts: resp.ts,
|
|
62
|
+
channel: resp.channel,
|
|
63
|
+
} satisfies EditMessageResult;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SlackChannel, SlackMessage, ReadMessagesResult } from './client/channels.js';
|
|
2
2
|
import type { SearchResult } from './client/search.js';
|
|
3
3
|
import type { DownloadedFile, SlackFileInfo } from './client/files.js';
|
|
4
|
+
import type { PostMessageResult, EditMessageResult } from './client/messages.js';
|
|
4
5
|
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Channels
|
|
@@ -80,6 +81,19 @@ export function formatSearchResults(result: SearchResult, query: string): string
|
|
|
80
81
|
return `${header}\n\n${matches.join('\n\n')}`;
|
|
81
82
|
}
|
|
82
83
|
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Posting
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export function formatPostedMessage(result: PostMessageResult, threadTs?: string): string {
|
|
89
|
+
const where = threadTs ? `thread ${threadTs} in ${result.channel}` : `${result.channel}`;
|
|
90
|
+
return `✅ Message posted to ${where} (ts: ${result.ts})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatEditedMessage(result: EditMessageResult): string {
|
|
94
|
+
return `✏️ Message edited in ${result.channel} (ts: ${result.ts})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
83
97
|
// ---------------------------------------------------------------------------
|
|
84
98
|
// Files
|
|
85
99
|
// ---------------------------------------------------------------------------
|
|
@@ -14,12 +14,15 @@ import { listChannels } from './client/channels.js';
|
|
|
14
14
|
import { readMessages, readThread } from './client/channels.js';
|
|
15
15
|
import { searchMessages } from './client/search.js';
|
|
16
16
|
import { downloadFile } from './client/files.js';
|
|
17
|
+
import { postMessage, editMessage } from './client/messages.js';
|
|
17
18
|
import {
|
|
18
19
|
formatChannelList,
|
|
19
20
|
formatMessages,
|
|
20
21
|
formatThread,
|
|
21
22
|
formatSearchResults,
|
|
22
23
|
formatDownloadedFile,
|
|
24
|
+
formatPostedMessage,
|
|
25
|
+
formatEditedMessage,
|
|
23
26
|
} from './format.js';
|
|
24
27
|
import {
|
|
25
28
|
TOOL_GUIDELINES,
|
|
@@ -28,6 +31,8 @@ import {
|
|
|
28
31
|
readThreadParams,
|
|
29
32
|
searchParams,
|
|
30
33
|
downloadFileParams,
|
|
34
|
+
postMessageParams,
|
|
35
|
+
editMessageParams,
|
|
31
36
|
} from './tools.js';
|
|
32
37
|
|
|
33
38
|
// ---------------------------------------------------------------------------
|
|
@@ -286,6 +291,94 @@ export default function slackExtension(pi: ExtensionAPI) {
|
|
|
286
291
|
},
|
|
287
292
|
});
|
|
288
293
|
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
// slack_post_message
|
|
296
|
+
// -------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
pi.registerTool({
|
|
299
|
+
name: 'slack_post_message',
|
|
300
|
+
label: 'Slack Post Message',
|
|
301
|
+
description:
|
|
302
|
+
'Post a message to a Slack channel or reply in a thread. Requires the bot token to have the `chat:write` scope and the bot to be a member of the channel.',
|
|
303
|
+
promptSnippet: 'Post a message to a Slack channel or thread',
|
|
304
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
305
|
+
parameters: postMessageParams,
|
|
306
|
+
|
|
307
|
+
async execute(
|
|
308
|
+
_toolCallId: string,
|
|
309
|
+
params: {
|
|
310
|
+
channel: string;
|
|
311
|
+
text: string;
|
|
312
|
+
thread_ts?: string;
|
|
313
|
+
reply_broadcast?: boolean;
|
|
314
|
+
},
|
|
315
|
+
) {
|
|
316
|
+
const creds = getCredentials();
|
|
317
|
+
if (!creds) return missingCredentials('bot');
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const result = await runSlack(
|
|
321
|
+
creds,
|
|
322
|
+
postMessage({
|
|
323
|
+
channel: params.channel,
|
|
324
|
+
text: params.text,
|
|
325
|
+
threadTs: params.thread_ts,
|
|
326
|
+
replyBroadcast: params.reply_broadcast,
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
return textResult(formatPostedMessage(result, params.thread_ts), {
|
|
330
|
+
channel: result.channel,
|
|
331
|
+
ts: result.ts,
|
|
332
|
+
});
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
// slack_edit_message
|
|
341
|
+
// -------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
pi.registerTool({
|
|
344
|
+
name: 'slack_edit_message',
|
|
345
|
+
label: 'Slack Edit Message',
|
|
346
|
+
description:
|
|
347
|
+
'Edit a message the bot previously posted. Requires the bot token to have the `chat:write` scope. Bots can only edit their own messages.',
|
|
348
|
+
promptSnippet: 'Edit a message the bot previously posted',
|
|
349
|
+
promptGuidelines: TOOL_GUIDELINES,
|
|
350
|
+
parameters: editMessageParams,
|
|
351
|
+
|
|
352
|
+
async execute(
|
|
353
|
+
_toolCallId: string,
|
|
354
|
+
params: {
|
|
355
|
+
channel: string;
|
|
356
|
+
ts: string;
|
|
357
|
+
text: string;
|
|
358
|
+
},
|
|
359
|
+
) {
|
|
360
|
+
const creds = getCredentials();
|
|
361
|
+
if (!creds) return missingCredentials('bot');
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
const result = await runSlack(
|
|
365
|
+
creds,
|
|
366
|
+
editMessage({
|
|
367
|
+
channel: params.channel,
|
|
368
|
+
ts: params.ts,
|
|
369
|
+
text: params.text,
|
|
370
|
+
}),
|
|
371
|
+
);
|
|
372
|
+
return textResult(formatEditedMessage(result), {
|
|
373
|
+
channel: result.channel,
|
|
374
|
+
ts: result.ts,
|
|
375
|
+
});
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return errorResult(`❌ ${(err as Error).message}`);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
289
382
|
// -------------------------------------------------------------------------
|
|
290
383
|
// /slack command — status check
|
|
291
384
|
// -------------------------------------------------------------------------
|
|
@@ -11,6 +11,8 @@ export const TOOL_GUIDELINES = [
|
|
|
11
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
12
|
'Use `slack_search` for full-text search across the workspace. Requires SLACK_USER_TOKEN to be set.',
|
|
13
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
|
+
'Use `slack_post_message` to send a message to a channel or reply in a thread. The bot token must have the `chat:write` scope and the bot must be a member of the target channel.',
|
|
15
|
+
'Use `slack_edit_message` to edit a message the bot previously posted. Pass the channel ID and the message `ts` (returned by `slack_post_message`). Bots can only edit their own messages.',
|
|
14
16
|
'When messages reference files or images (shown with 📎), download them with `slack_download_file` using the file ID to get visual context.',
|
|
15
17
|
'Channel IDs look like C0123ABC456. Thread timestamps look like 1512085950.000216.',
|
|
16
18
|
];
|
|
@@ -113,3 +115,35 @@ export const downloadFileParams = Type.Object({
|
|
|
113
115
|
description: 'Slack file ID to download (e.g. F0123ABC456).',
|
|
114
116
|
}),
|
|
115
117
|
});
|
|
118
|
+
|
|
119
|
+
export const postMessageParams = Type.Object({
|
|
120
|
+
channel: Type.String({
|
|
121
|
+
description: 'Channel ID (e.g. C0123ABC456) or #channel-name to post to.',
|
|
122
|
+
}),
|
|
123
|
+
text: Type.String({
|
|
124
|
+
description: 'Message text. Supports Slack mrkdwn formatting.',
|
|
125
|
+
}),
|
|
126
|
+
thread_ts: Type.Optional(
|
|
127
|
+
Type.String({
|
|
128
|
+
description: 'Reply within this thread (the parent message ts).',
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
131
|
+
reply_broadcast: Type.Optional(
|
|
132
|
+
Type.Boolean({
|
|
133
|
+
description:
|
|
134
|
+
'When replying in a thread, also broadcast the reply to the channel. Default false.',
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
export const editMessageParams = Type.Object({
|
|
140
|
+
channel: Type.String({
|
|
141
|
+
description: 'Channel ID (e.g. C0123ABC456) where the message lives.',
|
|
142
|
+
}),
|
|
143
|
+
ts: Type.String({
|
|
144
|
+
description: 'Timestamp of the message to edit (the `ts` returned by slack_post_message).',
|
|
145
|
+
}),
|
|
146
|
+
text: Type.String({
|
|
147
|
+
description: 'New message text. Supports Slack mrkdwn formatting.',
|
|
148
|
+
}),
|
|
149
|
+
});
|
package/package.json
CHANGED