@dreki-gg/pi-slack 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,7 @@
1
+ # @dreki-gg/pi-slack
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
@@ -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,43 @@
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
+ // ---------------------------------------------------------------------------
16
+ // Effects
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export function postMessage(params: {
20
+ channel: string;
21
+ text: string;
22
+ threadTs?: string;
23
+ replyBroadcast?: boolean;
24
+ }) {
25
+ return Effect.gen(function* () {
26
+ const body: Record<string, unknown> = {
27
+ channel: params.channel,
28
+ text: params.text,
29
+ };
30
+ if (params.threadTs !== undefined) body.thread_ts = params.threadTs;
31
+ if (params.replyBroadcast !== undefined) body.reply_broadcast = params.replyBroadcast;
32
+
33
+ const resp = yield* slackPost<{ ok: true; ts: string; channel: string }>(
34
+ 'chat.postMessage',
35
+ body,
36
+ );
37
+
38
+ return {
39
+ ts: resp.ts,
40
+ channel: resp.channel,
41
+ } satisfies PostMessageResult;
42
+ });
43
+ }
@@ -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 } from './client/messages.js';
4
5
 
5
6
  // ---------------------------------------------------------------------------
6
7
  // Channels
@@ -80,6 +81,20 @@ 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(
89
+ result: PostMessageResult,
90
+ threadTs?: string,
91
+ ): string {
92
+ const where = threadTs
93
+ ? `thread ${threadTs} in ${result.channel}`
94
+ : `${result.channel}`;
95
+ return `✅ Message posted to ${where} (ts: ${result.ts})`;
96
+ }
97
+
83
98
  // ---------------------------------------------------------------------------
84
99
  // Files
85
100
  // ---------------------------------------------------------------------------
@@ -14,12 +14,14 @@ 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 } from './client/messages.js';
17
18
  import {
18
19
  formatChannelList,
19
20
  formatMessages,
20
21
  formatThread,
21
22
  formatSearchResults,
22
23
  formatDownloadedFile,
24
+ formatPostedMessage,
23
25
  } from './format.js';
24
26
  import {
25
27
  TOOL_GUIDELINES,
@@ -28,6 +30,7 @@ import {
28
30
  readThreadParams,
29
31
  searchParams,
30
32
  downloadFileParams,
33
+ postMessageParams,
31
34
  } from './tools.js';
32
35
 
33
36
  // ---------------------------------------------------------------------------
@@ -286,6 +289,51 @@ export default function slackExtension(pi: ExtensionAPI) {
286
289
  },
287
290
  });
288
291
 
292
+ // -------------------------------------------------------------------------
293
+ // slack_post_message
294
+ // -------------------------------------------------------------------------
295
+
296
+ pi.registerTool({
297
+ name: 'slack_post_message',
298
+ label: 'Slack Post Message',
299
+ description:
300
+ '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.',
301
+ promptSnippet: 'Post a message to a Slack channel or thread',
302
+ promptGuidelines: TOOL_GUIDELINES,
303
+ parameters: postMessageParams,
304
+
305
+ async execute(
306
+ _toolCallId: string,
307
+ params: {
308
+ channel: string;
309
+ text: string;
310
+ thread_ts?: string;
311
+ reply_broadcast?: boolean;
312
+ },
313
+ ) {
314
+ const creds = getCredentials();
315
+ if (!creds) return missingCredentials('bot');
316
+
317
+ try {
318
+ const result = await runSlack(
319
+ creds,
320
+ postMessage({
321
+ channel: params.channel,
322
+ text: params.text,
323
+ threadTs: params.thread_ts,
324
+ replyBroadcast: params.reply_broadcast,
325
+ }),
326
+ );
327
+ return textResult(formatPostedMessage(result, params.thread_ts), {
328
+ channel: result.channel,
329
+ ts: result.ts,
330
+ });
331
+ } catch (err) {
332
+ return errorResult(`❌ ${(err as Error).message}`);
333
+ }
334
+ },
335
+ });
336
+
289
337
  // -------------------------------------------------------------------------
290
338
  // /slack command — status check
291
339
  // -------------------------------------------------------------------------
@@ -11,6 +11,7 @@ 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.',
14
15
  'When messages reference files or images (shown with 📎), download them with `slack_download_file` using the file ID to get visual context.',
15
16
  'Channel IDs look like C0123ABC456. Thread timestamps look like 1512085950.000216.',
16
17
  ];
@@ -113,3 +114,23 @@ export const downloadFileParams = Type.Object({
113
114
  description: 'Slack file ID to download (e.g. F0123ABC456).',
114
115
  }),
115
116
  });
117
+
118
+ export const postMessageParams = Type.Object({
119
+ channel: Type.String({
120
+ description: 'Channel ID (e.g. C0123ABC456) or #channel-name to post to.',
121
+ }),
122
+ text: Type.String({
123
+ description: 'Message text. Supports Slack mrkdwn formatting.',
124
+ }),
125
+ thread_ts: Type.Optional(
126
+ Type.String({
127
+ description: 'Reply within this thread (the parent message ts).',
128
+ }),
129
+ ),
130
+ reply_broadcast: Type.Optional(
131
+ Type.Boolean({
132
+ description:
133
+ 'When replying in a thread, also broadcast the reply to the channel. Default false.',
134
+ }),
135
+ ),
136
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-slack",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Slack read tools for pi — messages, threads, channels, search, and file/image downloads with Effect-powered client",
5
5
  "keywords": [
6
6
  "pi-package",