wolli 0.0.4 → 0.0.6
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 +1 -8
- package/built-in/plugins/github/README.md +148 -0
- package/built-in/plugins/github/github-api.test.ts +260 -0
- package/built-in/plugins/github/github-api.ts +491 -0
- package/built-in/plugins/github/github-chat.ts +216 -0
- package/built-in/plugins/github/github-review.ts +102 -0
- package/built-in/plugins/github/github-workspace.ts +84 -0
- package/built-in/plugins/github/index.ts +795 -0
- package/built-in/plugins/github/package.json +14 -0
- package/built-in/plugins/slack/README.md +144 -0
- package/built-in/plugins/slack/index.ts +360 -0
- package/built-in/plugins/slack/package.json +13 -0
- package/built-in/plugins/slack/slack-chat.ts +59 -0
- package/dist/cli.js +5439 -5441
- package/package.json +1 -1
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wolli-integration-github",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"wolli": {
|
|
7
|
+
"integrations": ["./index.ts"],
|
|
8
|
+
"workflows": ["./github-chat.ts"],
|
|
9
|
+
"tools": ["./github-review.ts"]
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"wolli": "*"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Slack
|
|
2
|
+
|
|
3
|
+
Connect a wolli agent to Slack over **Socket Mode** — an outbound WebSocket, so no
|
|
4
|
+
public URL, TLS certificate, or webhook signing is needed (the app's signing secret
|
|
5
|
+
is never used or stored). Setup is a few clicks at api.slack.com plus two token
|
|
6
|
+
prompts.
|
|
7
|
+
|
|
8
|
+
## How the bot behaves
|
|
9
|
+
|
|
10
|
+
The bot is **mention-gated**: it answers when @-mentioned, then holds a conversation
|
|
11
|
+
in the mention's thread.
|
|
12
|
+
|
|
13
|
+
| Context | Behavior |
|
|
14
|
+
|---------|----------|
|
|
15
|
+
| **@-mention in a channel** | Starts (or continues) a session bound to that thread and replies in-thread. A mention outside a thread makes that message the thread root. |
|
|
16
|
+
| **Replies inside a tracked thread** | Routed to the thread's session as follow-ups — no further mention needed. |
|
|
17
|
+
| **Channel chatter outside threads** | Ignored. No mention, no routing. |
|
|
18
|
+
| **DMs** | Not routed in v1. |
|
|
19
|
+
| **Other bots / itself** | Ignored (loop prevention). |
|
|
20
|
+
|
|
21
|
+
## Session model
|
|
22
|
+
|
|
23
|
+
Each mention thread gets its **own wolli session**, bound by a `slack:thread` tag
|
|
24
|
+
holding `channelId:threadTs`:
|
|
25
|
+
|
|
26
|
+
- **Inbound** — a mention or tracked-thread reply routes to the session tagged for its
|
|
27
|
+
thread; a mention in an untracked thread creates and tags one on the spot.
|
|
28
|
+
- **Outbound** — the reply rides the producing session's tag, so answers always land
|
|
29
|
+
in the thread that started the turn.
|
|
30
|
+
|
|
31
|
+
## Events and actions
|
|
32
|
+
|
|
33
|
+
| Surface | Shape |
|
|
34
|
+
|---------|-------|
|
|
35
|
+
| `message` event | A plain user message the bot can see: `{ channelId, ts, threadTs?, text, user: { id }, channelType }`. Edits, deletes, joins, bot posts (including the bot's own), and messages that @-mention the bot are dropped. |
|
|
36
|
+
| `app_mention` event | The bot was @-mentioned: `{ channelId, ts, threadTs?, text, user: { id } }`. Mentions arrive **only** as this event, never as `message`, so the two are disjoint. |
|
|
37
|
+
| `sendMessage` action | `{ channelId, text, threadTs? }` → `chat.postMessage`, chunked at 40,000 characters, returns `{ ts: string[] }`. Pass `threadTs` to reply in a thread; Slack renders `mrkdwn`, not standard markdown. |
|
|
38
|
+
|
|
39
|
+
`ts` is Slack's message id and doubles as the key for threading: reply to a message
|
|
40
|
+
by passing its `ts` (or its `threadTs` if it was already in a thread) as `threadTs`.
|
|
41
|
+
|
|
42
|
+
## Setup
|
|
43
|
+
|
|
44
|
+
### 1. Create a Slack app
|
|
45
|
+
|
|
46
|
+
Open [api.slack.com/apps](https://api.slack.com/apps), **Create New App → From
|
|
47
|
+
scratch**, pick a name and workspace.
|
|
48
|
+
|
|
49
|
+
### 2. Enable Socket Mode
|
|
50
|
+
|
|
51
|
+
Open **Socket Mode** in the sidebar and enable it. Slack prompts you to create an
|
|
52
|
+
**app-level token** with the `connections:write` scope — copy the `xapp-...` token.
|
|
53
|
+
|
|
54
|
+
### 3. Add bot token scopes
|
|
55
|
+
|
|
56
|
+
Under **OAuth & Permissions → Scopes → Bot Token Scopes** add:
|
|
57
|
+
|
|
58
|
+
- `chat:write` — send messages
|
|
59
|
+
- `app_mentions:read` — receive @-mentions
|
|
60
|
+
- `channels:history`, `groups:history`, `im:history`, `mpim:history` — receive
|
|
61
|
+
messages in public channels, private channels, DMs, and group DMs
|
|
62
|
+
|
|
63
|
+
### 4. Subscribe to events
|
|
64
|
+
|
|
65
|
+
Under **Event Subscriptions**, toggle **Enable Events** and add the bot events
|
|
66
|
+
`app_mention`, `message.channels`, `message.groups`, `message.im`, `message.mpim`.
|
|
67
|
+
(With Socket Mode on, no Request URL is asked for.)
|
|
68
|
+
|
|
69
|
+
### 5. Install and copy the bot token
|
|
70
|
+
|
|
71
|
+
Under **OAuth & Permissions**, click **Install to Workspace** and authorize. Copy the
|
|
72
|
+
**Bot User OAuth Token** (`xoxb-...`).
|
|
73
|
+
|
|
74
|
+
### 6. Install and onboard in wolli
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
wolli <agent> plugins install ./built-in/plugins/slack
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Onboarding prompts for the bot token, then the app-level token, verifies them with
|
|
81
|
+
live `auth.test` and `apps.connections.open` calls, and stores them. If you installed
|
|
82
|
+
non-interactively, onboard later with `wolli <agent> plugins configure slack`.
|
|
83
|
+
|
|
84
|
+
### 7. Invite the bot and restart
|
|
85
|
+
|
|
86
|
+
The bot only receives channel messages where it is a member — run `/invite @yourbot`
|
|
87
|
+
in the channels it should hear. Then:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
wolli restart <agent>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Configuration reference
|
|
94
|
+
|
|
95
|
+
Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json`:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"slack": {
|
|
100
|
+
"botToken": "xoxb-...",
|
|
101
|
+
"appToken": "xapp-...",
|
|
102
|
+
"allowedChannelIds": ["C0123456789"]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Field | Required | Default | Purpose |
|
|
108
|
+
|-------|----------|---------|---------|
|
|
109
|
+
| `botToken` | Yes | — | Bot token for all Web API calls. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
|
|
110
|
+
| `appToken` | Yes | — | App-level token, used only to open the Socket Mode connection. |
|
|
111
|
+
| `allowedChannelIds` | No | *(any channel)* | Allowlist of channel ids the integration emits events for. Empty or absent accepts **any** channel the bot can see (logged as a warning at startup). |
|
|
112
|
+
|
|
113
|
+
`allowedChannelIds` is not asked during onboarding — edit `integrations.json` to set
|
|
114
|
+
it. Channel ids look like `C0123456789`; grab one from a channel's **View channel
|
|
115
|
+
details → About** panel.
|
|
116
|
+
|
|
117
|
+
## Not supported
|
|
118
|
+
|
|
119
|
+
- DM routing — DM `message` events are emitted by the integration (with the `im`
|
|
120
|
+
scopes and event subscriptions) but the chat workflow does not route them.
|
|
121
|
+
- Inbound media, file uploads, edits, deletions, or reactions.
|
|
122
|
+
- Slash commands and interactive payloads (buttons, modals) — their envelopes are
|
|
123
|
+
acked and dropped.
|
|
124
|
+
- Durable event dedupe: envelopes are acked on receipt, so a Slack retry after a
|
|
125
|
+
crash can re-emit an event.
|
|
126
|
+
- HTTP Events API mode — Socket Mode only.
|
|
127
|
+
|
|
128
|
+
## Troubleshooting
|
|
129
|
+
|
|
130
|
+
| Symptom | Fix |
|
|
131
|
+
|---------|-----|
|
|
132
|
+
| Connects but no `message` events | Subscribe to the `message.*` bot events (step 4) and reinstall the app; then check the bot is a member of the channel (`/invite @yourbot`). |
|
|
133
|
+
| `invalid_auth` / `not_authed` | Wrong token in the wrong field — `botToken` is `xoxb-...`, `appToken` is `xapp-...`. |
|
|
134
|
+
| `not_in_channel` on sendMessage | Invite the bot to the channel first. |
|
|
135
|
+
| Bot never answers a mention | Check the `app_mention` bot event is subscribed (step 4) and the agent was restarted after install. |
|
|
136
|
+
|
|
137
|
+
## Security
|
|
138
|
+
|
|
139
|
+
- `allowedChannelIds` is the only access gate today. With it empty, **any** channel
|
|
140
|
+
the bot can see is accepted — set it to lock the integration to known channels.
|
|
141
|
+
- There is no per-user gate. Anyone who can post where the bot listens can drive the
|
|
142
|
+
agent, so keep the bot in trusted channels.
|
|
143
|
+
- The tokens grant control of the bot — never commit them. Wolli writes
|
|
144
|
+
`integrations.json` with mode `0600`.
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack transport — the integration half only (no routing workflows yet).
|
|
3
|
+
*
|
|
4
|
+
* Faces Slack over Socket Mode: `apps.connections.open` (app-level `xapp-` token)
|
|
5
|
+
* returns a one-time WebSocket URL, envelopes arrive over that socket and are
|
|
6
|
+
* acked immediately, and normalized `message` / `app_mention` events are emitted.
|
|
7
|
+
* Outbound goes through the Web API (`chat.postMessage`, bot `xoxb-` token). No
|
|
8
|
+
* public URL, TLS, or webhook signing is involved — Socket Mode has no inbound
|
|
9
|
+
* HTTP surface, so the app's signing secret is NOT needed or stored.
|
|
10
|
+
*
|
|
11
|
+
* Zero dependencies: plain `fetch` plus Node's global `WebSocket` (Node >= 22).
|
|
12
|
+
*
|
|
13
|
+
* ## Slack app requirements
|
|
14
|
+
* - Socket Mode enabled (this is what mints the `xapp-` token, scope `connections:write`).
|
|
15
|
+
* - Bot token scopes: `chat:write`, `app_mentions:read`, plus the history scope for
|
|
16
|
+
* each surface it should hear (`channels:history`, `groups:history`, `im:history`,
|
|
17
|
+
* `mpim:history`).
|
|
18
|
+
* - Event Subscriptions enabled with the matching bot events (`app_mention`,
|
|
19
|
+
* `message.channels`, `message.groups`, `message.im`, `message.mpim`).
|
|
20
|
+
* - The bot only receives channel messages where it is a member (`/invite @bot`).
|
|
21
|
+
*
|
|
22
|
+
* ## Config (`~/.wolli/agents/<name>/integrations.json`)
|
|
23
|
+
*
|
|
24
|
+
* { "slack": { "botToken": "xoxb-...", "appToken": "xapp-..." } }
|
|
25
|
+
*
|
|
26
|
+
* Both tokens resolve `$ENV` / `!cmd` references on read. `allowedChannelIds` is an
|
|
27
|
+
* allowlist — empty/absent means "allow any channel" (logged as a warning).
|
|
28
|
+
*
|
|
29
|
+
* ## Known v1 limitations
|
|
30
|
+
* - Slack sends a bot mention as BOTH `app_mention` and `message`; this transport
|
|
31
|
+
* emits only `app_mention` for it, keeping the two events disjoint. The suppression
|
|
32
|
+
* needs the bot's user id from `auth.test`, so a mention landing in the first
|
|
33
|
+
* moments after connect can still emit both.
|
|
34
|
+
* - Envelopes are acked on receipt with no durable event-id dedupe, so a Slack
|
|
35
|
+
* retry after a crash can re-emit an event.
|
|
36
|
+
* - On a Slack-requested refresh the socket is closed and reopened, so events in
|
|
37
|
+
* that gap are seen only if Slack retries them.
|
|
38
|
+
* - Text only: no media, edits, deletions, reactions, slash commands, or interactive
|
|
39
|
+
* payloads (non-`events_api` envelopes are acked and dropped).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// The integration surface is host-provided via the loader's VIRTUAL_MODULES / aliases, so
|
|
43
|
+
// wolli is a peerDependency, not a dependency.
|
|
44
|
+
import { defineIntegration, type IntegrationOnboardContext } from "wolli";
|
|
45
|
+
import { Type } from "typebox";
|
|
46
|
+
|
|
47
|
+
/** Slack cuts off a single chat.postMessage at 40,000 characters. */
|
|
48
|
+
const SLACK_MAX_LENGTH = 40000;
|
|
49
|
+
|
|
50
|
+
const ONBOARD_GUIDE = [
|
|
51
|
+
"## Connect Slack",
|
|
52
|
+
"",
|
|
53
|
+
"1. Open [api.slack.com/apps](https://api.slack.com/apps) and **Create New App**",
|
|
54
|
+
" (from scratch).",
|
|
55
|
+
"2. Under **Socket Mode**, enable it — this creates an **app-level token**",
|
|
56
|
+
" (`xapp-...`) with the `connections:write` scope. Copy it.",
|
|
57
|
+
"3. Under **OAuth & Permissions → Bot Token Scopes**, add `chat:write`,",
|
|
58
|
+
" `app_mentions:read`, `channels:history`, `groups:history`, `im:history`,",
|
|
59
|
+
" `mpim:history`.",
|
|
60
|
+
"4. Under **Event Subscriptions**, enable events and subscribe to the bot events",
|
|
61
|
+
" `app_mention`, `message.channels`, `message.groups`, `message.im`,",
|
|
62
|
+
" `message.mpim`.",
|
|
63
|
+
"5. **Install to Workspace** (OAuth & Permissions) and copy the **bot token**",
|
|
64
|
+
" (`xoxb-...`).",
|
|
65
|
+
"6. Invite the bot to a channel with `/invite @yourbot`, then paste both tokens",
|
|
66
|
+
" on the next screens. The signing secret is not needed — Socket Mode has no",
|
|
67
|
+
" inbound webhook to verify.",
|
|
68
|
+
].join("\n");
|
|
69
|
+
|
|
70
|
+
interface SlackAccount {
|
|
71
|
+
botToken: string;
|
|
72
|
+
appToken: string;
|
|
73
|
+
allowedChannelIds?: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Successful envelope of every Web API response; `ok: false` carries `error`. */
|
|
77
|
+
interface SlackApiOk {
|
|
78
|
+
ok: boolean;
|
|
79
|
+
error?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One inbound Socket Mode frame. Only `events_api` envelopes carry an event. */
|
|
83
|
+
interface SocketEnvelope {
|
|
84
|
+
type: string;
|
|
85
|
+
envelope_id?: string;
|
|
86
|
+
payload?: { event?: SlackEvent };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The subset of Slack event fields this transport reads. */
|
|
90
|
+
interface SlackEvent {
|
|
91
|
+
type: string;
|
|
92
|
+
subtype?: string;
|
|
93
|
+
user?: string;
|
|
94
|
+
bot_id?: string;
|
|
95
|
+
channel?: string;
|
|
96
|
+
channel_type?: string;
|
|
97
|
+
text?: string;
|
|
98
|
+
ts?: string;
|
|
99
|
+
thread_ts?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Call a Slack Web API method; throws on transport errors and `ok: false`. */
|
|
103
|
+
async function slackApi<T>(token: string, method: string, body?: Record<string, unknown>): Promise<T & SlackApiOk> {
|
|
104
|
+
const res = await fetch(`https://slack.com/api/${method}`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: {
|
|
107
|
+
authorization: `Bearer ${token}`,
|
|
108
|
+
...(body ? { "content-type": "application/json; charset=utf-8" } : {}),
|
|
109
|
+
},
|
|
110
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
111
|
+
});
|
|
112
|
+
const data = (await res.json()) as T & SlackApiOk;
|
|
113
|
+
if (!data.ok) throw new Error(`Slack ${method} failed: ${data.error ?? `HTTP ${res.status}`}`);
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Split into ≤40,000-code-unit chunks without cutting a surrogate pair. */
|
|
118
|
+
function chunkText(text: string, max = SLACK_MAX_LENGTH): string[] {
|
|
119
|
+
if (text.length <= max) return [text];
|
|
120
|
+
const chunks: string[] = [];
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < text.length) {
|
|
123
|
+
let end = Math.min(i + max, text.length);
|
|
124
|
+
if (end < text.length) {
|
|
125
|
+
const code = text.charCodeAt(end - 1);
|
|
126
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
127
|
+
}
|
|
128
|
+
chunks.push(text.slice(i, end));
|
|
129
|
+
i = end;
|
|
130
|
+
}
|
|
131
|
+
return chunks;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Guided setup: collect both tokens, verify the bot token with `auth.test` and the
|
|
136
|
+
* app token with `apps.connections.open`, return them to store.
|
|
137
|
+
*/
|
|
138
|
+
async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string; appToken: string } | undefined> {
|
|
139
|
+
ctx.ui.notify(ONBOARD_GUIDE, "info");
|
|
140
|
+
|
|
141
|
+
const botEntered = await ctx.ui.input("Paste the bot token (xoxb-...)");
|
|
142
|
+
if (botEntered === undefined) return undefined; // cancelled
|
|
143
|
+
const botToken = botEntered.trim();
|
|
144
|
+
if (!botToken) {
|
|
145
|
+
ctx.ui.notify("No bot token entered.", "error");
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const appEntered = await ctx.ui.input("Paste the app-level token (xapp-...)");
|
|
150
|
+
if (appEntered === undefined) return undefined; // cancelled
|
|
151
|
+
const appToken = appEntered.trim();
|
|
152
|
+
if (!appToken) {
|
|
153
|
+
ctx.ui.notify("No app-level token entered.", "error");
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const me = await slackApi<{ user: string; team: string }>(botToken, "auth.test");
|
|
159
|
+
await slackApi<{ url: string }>(appToken, "apps.connections.open");
|
|
160
|
+
ctx.ui.notify(`Verified bot @${me.user} in workspace ${me.team}.`, "info");
|
|
161
|
+
} catch (err) {
|
|
162
|
+
ctx.ui.notify(`Could not verify the tokens: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { botToken, appToken };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export default defineIntegration({
|
|
170
|
+
account: Type.Object({
|
|
171
|
+
/** Bot token (`xoxb-...`), used for all Web API calls. */
|
|
172
|
+
botToken: Type.String(),
|
|
173
|
+
/** App-level token (`xapp-...`, scope `connections:write`), used only to open the socket. */
|
|
174
|
+
appToken: Type.String(),
|
|
175
|
+
/** Allowlist of channel ids. Empty/absent = allow all (logged as a warning). */
|
|
176
|
+
allowedChannelIds: Type.Optional(Type.Array(Type.String())),
|
|
177
|
+
}),
|
|
178
|
+
events: {
|
|
179
|
+
/**
|
|
180
|
+
* A plain user message the bot can see (no subtype — edits, joins, bot posts are
|
|
181
|
+
* dropped). Messages that @-mention the bot arrive only as `app_mention`.
|
|
182
|
+
*/
|
|
183
|
+
message: Type.Object({
|
|
184
|
+
channelId: Type.String(),
|
|
185
|
+
/** Message timestamp — Slack's message id AND the key for replying in-thread. */
|
|
186
|
+
ts: Type.String(),
|
|
187
|
+
/** Present when the message is inside a thread. */
|
|
188
|
+
threadTs: Type.Optional(Type.String()),
|
|
189
|
+
text: Type.String(),
|
|
190
|
+
user: Type.Object({ id: Type.String() }),
|
|
191
|
+
/** "channel" | "group" | "im" | "mpim". */
|
|
192
|
+
channelType: Type.String(),
|
|
193
|
+
}),
|
|
194
|
+
/** The bot was @-mentioned. Mentions are NOT also emitted as `message`. */
|
|
195
|
+
app_mention: Type.Object({
|
|
196
|
+
channelId: Type.String(),
|
|
197
|
+
ts: Type.String(),
|
|
198
|
+
threadTs: Type.Optional(Type.String()),
|
|
199
|
+
text: Type.String(),
|
|
200
|
+
user: Type.Object({ id: Type.String() }),
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
onboard,
|
|
204
|
+
actions: {
|
|
205
|
+
sendMessage: {
|
|
206
|
+
description:
|
|
207
|
+
"Send a text message to a channel via chat.postMessage (chunked at 40000; Slack renders mrkdwn). Pass threadTs to reply in a thread.",
|
|
208
|
+
parameters: Type.Object({
|
|
209
|
+
channelId: Type.String(),
|
|
210
|
+
text: Type.String(),
|
|
211
|
+
threadTs: Type.Optional(Type.String()),
|
|
212
|
+
}),
|
|
213
|
+
execute: async (params, ctx) => {
|
|
214
|
+
const { channelId, text, threadTs } = params as {
|
|
215
|
+
channelId: string;
|
|
216
|
+
text: string;
|
|
217
|
+
threadTs?: string;
|
|
218
|
+
};
|
|
219
|
+
const account = ctx.account as SlackAccount;
|
|
220
|
+
|
|
221
|
+
const ts: string[] = [];
|
|
222
|
+
for (const chunk of chunkText(text)) {
|
|
223
|
+
const sent = await slackApi<{ ts: string }>(account.botToken, "chat.postMessage", {
|
|
224
|
+
channel: channelId,
|
|
225
|
+
text: chunk,
|
|
226
|
+
...(threadTs ? { thread_ts: threadTs } : {}),
|
|
227
|
+
});
|
|
228
|
+
ts.push(sent.ts);
|
|
229
|
+
}
|
|
230
|
+
return { ts };
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
run(ctx) {
|
|
235
|
+
const account = ctx.account as SlackAccount;
|
|
236
|
+
const { botToken, appToken, allowedChannelIds } = account;
|
|
237
|
+
const allowAll = !allowedChannelIds || allowedChannelIds.length === 0;
|
|
238
|
+
if (allowAll) {
|
|
239
|
+
console.warn("[slack] no allowedChannelIds configured — accepting messages from ANY channel.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
let ws: WebSocket | undefined;
|
|
243
|
+
let stopped = false;
|
|
244
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
245
|
+
let attempt = 0;
|
|
246
|
+
|
|
247
|
+
// Self-filter id. Bot posts always carry bot_id, which is filtered regardless;
|
|
248
|
+
// this covers the bot's non-bot-authored surfaces while auth.test is in flight.
|
|
249
|
+
let botUserId: string | undefined;
|
|
250
|
+
void slackApi<{ user_id: string }>(botToken, "auth.test")
|
|
251
|
+
.then((r) => {
|
|
252
|
+
botUserId = r.user_id;
|
|
253
|
+
})
|
|
254
|
+
.catch((err) => {
|
|
255
|
+
console.error("[slack] auth.test failed:", err instanceof Error ? err.message : err);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const handleEvent = (event: SlackEvent) => {
|
|
259
|
+
const channelId = event.channel;
|
|
260
|
+
if (!channelId || !event.ts) return;
|
|
261
|
+
if (!allowAll && !allowedChannelIds?.includes(channelId)) return;
|
|
262
|
+
if (event.bot_id) return; // skip our own posts and other bots (loop prevention)
|
|
263
|
+
if (!event.user || botUserId === event.user) return;
|
|
264
|
+
const text = event.text;
|
|
265
|
+
if (!text) return; // media-only or empty
|
|
266
|
+
|
|
267
|
+
if (event.type === "message") {
|
|
268
|
+
if (event.subtype) return; // edits, deletes, joins, bot_message, ...
|
|
269
|
+
// Slack sends a bot mention as both `message` and `app_mention` — emit only the
|
|
270
|
+
// latter so the events stay disjoint and consumers never double-handle a mention.
|
|
271
|
+
if (botUserId && text.includes(`<@${botUserId}>`)) return;
|
|
272
|
+
ctx.emit("message", {
|
|
273
|
+
channelId,
|
|
274
|
+
ts: event.ts,
|
|
275
|
+
threadTs: event.thread_ts,
|
|
276
|
+
text,
|
|
277
|
+
user: { id: event.user },
|
|
278
|
+
channelType: event.channel_type ?? "channel",
|
|
279
|
+
});
|
|
280
|
+
} else if (event.type === "app_mention") {
|
|
281
|
+
ctx.emit("app_mention", {
|
|
282
|
+
channelId,
|
|
283
|
+
ts: event.ts,
|
|
284
|
+
threadTs: event.thread_ts,
|
|
285
|
+
text,
|
|
286
|
+
user: { id: event.user },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const scheduleReconnect = () => {
|
|
292
|
+
if (stopped || reconnectTimer) return;
|
|
293
|
+
attempt += 1;
|
|
294
|
+
const delay = Math.min(30_000, 1000 * 2 ** attempt);
|
|
295
|
+
reconnectTimer = setTimeout(() => {
|
|
296
|
+
reconnectTimer = undefined;
|
|
297
|
+
void connect().catch((err) => {
|
|
298
|
+
console.error("[slack] reconnect failed:", err instanceof Error ? err.message : err);
|
|
299
|
+
scheduleReconnect();
|
|
300
|
+
});
|
|
301
|
+
}, delay);
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const handleFrame = (socket: WebSocket, raw: string) => {
|
|
305
|
+
let envelope: SocketEnvelope;
|
|
306
|
+
try {
|
|
307
|
+
envelope = JSON.parse(raw) as SocketEnvelope;
|
|
308
|
+
} catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Slack refreshes connections periodically; close and let the reconnect loop
|
|
312
|
+
// fetch a fresh one-time URL.
|
|
313
|
+
if (envelope.type === "disconnect") {
|
|
314
|
+
socket.close();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
// Ack within 3s or Slack redelivers. Ack everything (non-events_api envelopes
|
|
318
|
+
// like slash_commands are acked and dropped).
|
|
319
|
+
if (envelope.envelope_id) {
|
|
320
|
+
socket.send(JSON.stringify({ envelope_id: envelope.envelope_id }));
|
|
321
|
+
}
|
|
322
|
+
if (envelope.type === "events_api" && envelope.payload?.event) {
|
|
323
|
+
handleEvent(envelope.payload.event);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
const connect = async () => {
|
|
328
|
+
if (stopped) return;
|
|
329
|
+
const { url } = await slackApi<{ url: string }>(appToken, "apps.connections.open");
|
|
330
|
+
if (stopped) return;
|
|
331
|
+
const socket = new WebSocket(url);
|
|
332
|
+
ws = socket;
|
|
333
|
+
socket.addEventListener("open", () => {
|
|
334
|
+
attempt = 0;
|
|
335
|
+
});
|
|
336
|
+
socket.addEventListener("message", (e) => {
|
|
337
|
+
if (typeof e.data === "string") handleFrame(socket, e.data);
|
|
338
|
+
});
|
|
339
|
+
socket.addEventListener("error", () => {
|
|
340
|
+
// A close event always follows; reconnect is scheduled there.
|
|
341
|
+
});
|
|
342
|
+
socket.addEventListener("close", () => {
|
|
343
|
+
if (ws === socket) scheduleReconnect();
|
|
344
|
+
});
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
void connect().catch((err) => {
|
|
348
|
+
console.error("[slack] failed to connect:", err instanceof Error ? err.message : err);
|
|
349
|
+
scheduleReconnect();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const dispose = () => {
|
|
353
|
+
stopped = true;
|
|
354
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
355
|
+
ws?.close();
|
|
356
|
+
};
|
|
357
|
+
ctx.signal.addEventListener("abort", dispose);
|
|
358
|
+
return dispose;
|
|
359
|
+
},
|
|
360
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack chat routing, as one workflows file:
|
|
3
|
+
* - `mention` binds each mention thread to its own session by a `slack:thread` tag and
|
|
4
|
+
* delivers the mention text as a followUp. A mention posted outside a thread makes its
|
|
5
|
+
* own `ts` the thread root, so the reply opens the thread the session lives in.
|
|
6
|
+
* - `thread` continues tracked threads: replies inside a thread whose session exists are
|
|
7
|
+
* delivered as followUps without needing another mention. Untracked threads and
|
|
8
|
+
* non-thread channel chatter never route — the bot is mention-gated.
|
|
9
|
+
* - `reply` ships the turn's final assistant text back on `agent_end`, riding the
|
|
10
|
+
* producing session's `slack:thread` tag, threaded under the originating message.
|
|
11
|
+
* Slack has no typing indicator for classic bots, so there is no typing workflow.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { wolli } from "wolli";
|
|
15
|
+
import slack from "./index.ts";
|
|
16
|
+
|
|
17
|
+
/** Tag value `${channelId}:${threadTs}` — channel ids never contain ":". */
|
|
18
|
+
function threadTag(channelId: string, threadTs: string): Record<string, string> {
|
|
19
|
+
return { "slack:thread": `${channelId}:${threadTs}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// m is typed from the event schema
|
|
23
|
+
export const mention = slack.on("app_mention", async (m, ctx) => {
|
|
24
|
+
// The mention is the thread root unless it was posted inside an existing thread.
|
|
25
|
+
const tag = threadTag(m.channelId, m.threadTs ?? m.ts);
|
|
26
|
+
const [match] = await ctx.agent.findSessions(tag);
|
|
27
|
+
const session = match
|
|
28
|
+
? await ctx.agent.openSession(match.id)
|
|
29
|
+
: await ctx.agent.createSession({
|
|
30
|
+
setup: (s) => s.appendTags(tag),
|
|
31
|
+
});
|
|
32
|
+
// followUp queues behind a running turn instead of interrupting it.
|
|
33
|
+
await session.sendUserMessage(m.text, { deliverAs: "followUp" });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const thread = slack.on("message", async (m, ctx) => {
|
|
37
|
+
if (!m.threadTs) return; // channel chatter outside threads never routes (mention-gated)
|
|
38
|
+
const [match] = await ctx.agent.findSessions(threadTag(m.channelId, m.threadTs));
|
|
39
|
+
if (!match) return; // untracked thread
|
|
40
|
+
const session = await ctx.agent.openSession(match.id);
|
|
41
|
+
await session.sendUserMessage(m.text, { deliverAs: "followUp" });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const reply = wolli.on("agent_end", async (evt, ctx) => {
|
|
45
|
+
const bound = ctx.session.getTags()["slack:thread"];
|
|
46
|
+
if (!bound) return; // not a slack-bound session
|
|
47
|
+
const sep = bound.indexOf(":");
|
|
48
|
+
const channelId = bound.slice(0, sep);
|
|
49
|
+
const threadTs = bound.slice(sep + 1);
|
|
50
|
+
const text = evt.messages
|
|
51
|
+
.filter((m) => m.role === "assistant")
|
|
52
|
+
.at(-1)
|
|
53
|
+
?.content.filter((c) => c.type === "text")
|
|
54
|
+
.map((c) => c.text)
|
|
55
|
+
.join("")
|
|
56
|
+
.trim();
|
|
57
|
+
if (!text) return; // a pure tool-call turn sends nothing
|
|
58
|
+
await ctx.integration(slack).sendMessage({ channelId, text, threadTs });
|
|
59
|
+
});
|