@moxxy/plugin-channel-slack 0.27.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/LICENSE +21 -0
- package/dist/channel/slack-client.d.ts +53 -0
- package/dist/channel/slack-client.d.ts.map +1 -0
- package/dist/channel/slack-client.js +82 -0
- package/dist/channel/slack-client.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +39 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +81 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel.d.ts +100 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +286 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +207 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +50 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +78 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +106 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +27 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +33 -0
- package/dist/permission.js.map +1 -0
- package/dist/server/dedupe.d.ts +8 -0
- package/dist/server/dedupe.d.ts.map +1 -0
- package/dist/server/dedupe.js +8 -0
- package/dist/server/dedupe.js.map +1 -0
- package/dist/server/ingest-server.d.ts +80 -0
- package/dist/server/ingest-server.d.ts.map +1 -0
- package/dist/server/ingest-server.js +142 -0
- package/dist/server/ingest-server.js.map +1 -0
- package/dist/server/schema.d.ts +257 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +69 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/verify.d.ts +39 -0
- package/dist/server/verify.d.ts.map +1 -0
- package/dist/server/verify.js +73 -0
- package/dist/server/verify.js.map +1 -0
- package/dist/setup-wizard.d.ts +17 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +92 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +97 -0
- package/src/channel/slack-client.ts +123 -0
- package/src/channel/turn-runner.test.ts +143 -0
- package/src/channel/turn-runner.ts +107 -0
- package/src/channel.ts +378 -0
- package/src/index.ts +254 -0
- package/src/keys.ts +96 -0
- package/src/pair-flow.ts +119 -0
- package/src/permission.test.ts +65 -0
- package/src/permission.ts +42 -0
- package/src/server/dedupe.ts +7 -0
- package/src/server/ingest-server.test.ts +280 -0
- package/src/server/ingest-server.ts +205 -0
- package/src/server/schema.test.ts +67 -0
- package/src/server/schema.ts +77 -0
- package/src/server/verify.test.ts +132 -0
- package/src/server/verify.ts +88 -0
- package/src/setup-wizard.ts +121 -0
- package/src/subcommands.test.ts +189 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { newTurnId } from '@moxxy/core';
|
|
2
|
+
import { TofuPairingWindow, TurnCoordinator } from '@moxxy/channel-kit';
|
|
3
|
+
import { proxyTunnel } from '@moxxy/plugin-tunnel-proxy';
|
|
4
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
5
|
+
import type {
|
|
6
|
+
Channel,
|
|
7
|
+
ChannelHandle,
|
|
8
|
+
ChannelStartOptsBase,
|
|
9
|
+
MoxxyEvent,
|
|
10
|
+
PermissionResolver,
|
|
11
|
+
TunnelHandle,
|
|
12
|
+
TunnelProviderDef,
|
|
13
|
+
} from '@moxxy/sdk';
|
|
14
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
15
|
+
import {
|
|
16
|
+
SLACK_AUTHORIZED_KEY,
|
|
17
|
+
authorizationMatches,
|
|
18
|
+
parseAuthorization,
|
|
19
|
+
resolveBotToken,
|
|
20
|
+
resolveSigningSecret,
|
|
21
|
+
type SlackAuthorization,
|
|
22
|
+
} from './keys.js';
|
|
23
|
+
import { buildSlackPermissionResolver } from './permission.js';
|
|
24
|
+
import { SlackClient } from './channel/slack-client.js';
|
|
25
|
+
import { runSlackTurn } from './channel/turn-runner.js';
|
|
26
|
+
import {
|
|
27
|
+
IngestServer,
|
|
28
|
+
SLACK_EVENTS_PATH,
|
|
29
|
+
type DispatchContext,
|
|
30
|
+
} from './server/ingest-server.js';
|
|
31
|
+
import type { SlackEventCallback } from './server/schema.js';
|
|
32
|
+
|
|
33
|
+
/** Routing label the proxy relay exposes this channel under. */
|
|
34
|
+
const TUNNEL_LABEL = 'slack';
|
|
35
|
+
|
|
36
|
+
export interface SlackChannelLogger {
|
|
37
|
+
debug?(msg: string, meta?: Record<string, unknown>): void;
|
|
38
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
39
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
40
|
+
error?(msg: string, meta?: Record<string, unknown>): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface SlackChannelOptions {
|
|
44
|
+
readonly vault: VaultStore;
|
|
45
|
+
/**
|
|
46
|
+
* Tunnel provider used to expose the local ingest server publicly. Defaults to
|
|
47
|
+
* the self-hosted `proxyTunnel` (imported directly, like the webhooks tunnel);
|
|
48
|
+
* tests pass a fake provider so they never hit the network.
|
|
49
|
+
*/
|
|
50
|
+
readonly tunnelProvider?: TunnelProviderDef;
|
|
51
|
+
/** Tools the model may call autonomously. `['*']` allows every registered tool. */
|
|
52
|
+
readonly allowedTools?: ReadonlyArray<string>;
|
|
53
|
+
/** Debounce window for streaming `chat.update` edits (ms). Default 1000. */
|
|
54
|
+
readonly editFrameMs?: number;
|
|
55
|
+
/** Bind host for the local ingest server. Default 127.0.0.1. */
|
|
56
|
+
readonly host?: string;
|
|
57
|
+
readonly logger?: SlackChannelLogger;
|
|
58
|
+
/** Injectable Slack client factory (tests). */
|
|
59
|
+
readonly makeClient?: (token: string) => SlackClient;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SlackStartOpts extends ChannelStartOptsBase {
|
|
63
|
+
readonly session: Session;
|
|
64
|
+
/**
|
|
65
|
+
* If true, arm a TOFU pairing window: the first verified inbound event from a
|
|
66
|
+
* team/channel is captured and (via `onPairConfirm`) persisted to the vault.
|
|
67
|
+
*/
|
|
68
|
+
readonly pair?: boolean;
|
|
69
|
+
/** Override allowedTools at start (the CLI forwards channel config / flags). */
|
|
70
|
+
readonly allowedTools?: ReadonlyArray<string>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Fires when a verified event lands during a pairing window. */
|
|
74
|
+
export interface PairCandidate {
|
|
75
|
+
readonly teamId: string;
|
|
76
|
+
readonly channelId: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class SlackChannel implements Channel<SlackStartOpts> {
|
|
80
|
+
readonly name = 'slack';
|
|
81
|
+
/**
|
|
82
|
+
* Installed on the session by the CLI dispatcher. Replaced in `start()` once
|
|
83
|
+
* we can expand a `['*']` allow-list against the live tool registry; until
|
|
84
|
+
* then it denies everything (safe default before start).
|
|
85
|
+
*/
|
|
86
|
+
permissionResolver: PermissionResolver;
|
|
87
|
+
|
|
88
|
+
private readonly opts: SlackChannelOptions;
|
|
89
|
+
private session: Session | null = null;
|
|
90
|
+
private client: SlackClient | null = null;
|
|
91
|
+
private ingest: IngestServer | null = null;
|
|
92
|
+
private tunnel: TunnelHandle | null = null;
|
|
93
|
+
private handle: ChannelHandle | null = null;
|
|
94
|
+
private resolveRunning: (() => void) | null = null;
|
|
95
|
+
|
|
96
|
+
private botUserId = '';
|
|
97
|
+
private authorization: SlackAuthorization | null = null;
|
|
98
|
+
private model: string | undefined;
|
|
99
|
+
|
|
100
|
+
// Single-flight turn state (v1: one turn at a time across all threads): the
|
|
101
|
+
// `busy` guard, the per-turn AbortController, and the bounded own-turn-id set
|
|
102
|
+
// mirrorForeignTurn filters on (#8).
|
|
103
|
+
private readonly turns = new TurnCoordinator();
|
|
104
|
+
// Per-turn target so foreign-turn mirroring + permission prompts know the thread.
|
|
105
|
+
private currentChannelId: string | null = null;
|
|
106
|
+
private currentThreadTs: string | null = null;
|
|
107
|
+
private lastChannelId: string | null = null;
|
|
108
|
+
private lastThreadTs: string | null = null;
|
|
109
|
+
private logUnsub: (() => void) | null = null;
|
|
110
|
+
|
|
111
|
+
// Pairing (TOFU) window: armed by `start({ pair: true })`, captures the first
|
|
112
|
+
// verified team/channel for the pair flow to confirm.
|
|
113
|
+
private readonly tofu: TofuPairingWindow<PairCandidate>;
|
|
114
|
+
private publicUrl: string | null = null;
|
|
115
|
+
|
|
116
|
+
constructor(opts: SlackChannelOptions) {
|
|
117
|
+
this.opts = opts;
|
|
118
|
+
this.tofu = new TofuPairingWindow<PairCandidate>({
|
|
119
|
+
onListenerError: (err) =>
|
|
120
|
+
this.opts.logger?.warn?.('slack: pair listener threw', { err: String(err) }),
|
|
121
|
+
});
|
|
122
|
+
// Pre-start: deny-all. Replaced with the real allow-list resolver in start()
|
|
123
|
+
// once the tool registry is available (for `['*']` expansion).
|
|
124
|
+
this.permissionResolver = buildSlackPermissionResolver({
|
|
125
|
+
allowedTools: [],
|
|
126
|
+
allToolNames: [],
|
|
127
|
+
...(opts.logger ? { logger: opts.logger } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The public Request URL to paste into Slack's Event Subscriptions. */
|
|
132
|
+
get requestUrl(): string | null {
|
|
133
|
+
return this.publicUrl;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Subscribe to pairing candidates (the setup/pair flows use this). */
|
|
137
|
+
onPairCandidate(listener: (c: PairCandidate) => void): () => void {
|
|
138
|
+
return this.tofu.onCandidate(listener);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Persist a team/channel as authorized (called by the pair flow on confirm). */
|
|
142
|
+
async confirmPairing(candidate: PairCandidate): Promise<void> {
|
|
143
|
+
const auth: SlackAuthorization = {
|
|
144
|
+
teamId: candidate.teamId,
|
|
145
|
+
channelId: candidate.channelId,
|
|
146
|
+
};
|
|
147
|
+
await this.opts.vault.set(SLACK_AUTHORIZED_KEY, JSON.stringify(auth), ['slack']);
|
|
148
|
+
this.authorization = auth;
|
|
149
|
+
this.tofu.disarm();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async start(startOpts: SlackStartOpts): Promise<ChannelHandle> {
|
|
153
|
+
if (this.handle) return this.handle;
|
|
154
|
+
this.session = startOpts.session;
|
|
155
|
+
this.model = startOpts.model;
|
|
156
|
+
if (startOpts.pair === true) this.tofu.arm();
|
|
157
|
+
|
|
158
|
+
const token = await resolveBotToken(this.opts.vault);
|
|
159
|
+
if (!token) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'Slack bot token not found. Run `moxxy channels slack setup`, set MOXXY_SLACK_BOT_TOKEN, ' +
|
|
162
|
+
'or store one in the vault under slack_bot_token.',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const signingSecret = await resolveSigningSecret(this.opts.vault);
|
|
166
|
+
if (!signingSecret) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'Slack signing secret not found. Run `moxxy channels slack setup`, set ' +
|
|
169
|
+
'MOXXY_SLACK_SIGNING_SECRET, or store one in the vault under slack_signing_secret.',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Load any persisted pairing.
|
|
174
|
+
this.authorization = parseAuthorization(await this.opts.vault.get(SLACK_AUTHORIZED_KEY));
|
|
175
|
+
if (!this.tofu.isArmed && !this.authorization) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
'No Slack team/channel is paired yet. Run `moxxy channels slack pair` first.',
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Validate the token + capture the bot's own user id (for self-message drops).
|
|
182
|
+
this.client = this.opts.makeClient
|
|
183
|
+
? this.opts.makeClient(token)
|
|
184
|
+
: new SlackClient({ token });
|
|
185
|
+
const auth = await this.client.authTest();
|
|
186
|
+
this.botUserId = auth.botUserId;
|
|
187
|
+
this.opts.logger?.info?.('slack: authenticated', {
|
|
188
|
+
botUserId: this.botUserId,
|
|
189
|
+
team: auth.team,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Build the real allow-list resolver now that the tool registry is live, so
|
|
193
|
+
// `['*']` can expand to every registered tool name (mirror prompt.ts).
|
|
194
|
+
const allowedTools = startOpts.allowedTools ?? this.opts.allowedTools ?? [];
|
|
195
|
+
const allToolNames = this.session.tools.list().map((t) => t.name);
|
|
196
|
+
this.permissionResolver = buildSlackPermissionResolver({
|
|
197
|
+
allowedTools,
|
|
198
|
+
allToolNames,
|
|
199
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
200
|
+
});
|
|
201
|
+
// Swap the (now-final) resolver in — start() was called AFTER the CLI
|
|
202
|
+
// installed the pre-start one, so re-install via the documented seam.
|
|
203
|
+
this.session.setPermissionResolver(this.permissionResolver);
|
|
204
|
+
|
|
205
|
+
// Mirror-to-thread: post the assistant's prose for a turn this channel did
|
|
206
|
+
// NOT initiate (e.g. a co-attached surface ran one) into the last thread we
|
|
207
|
+
// served. Our OWN turns stream via the frame pump (filtered by turnId).
|
|
208
|
+
this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
|
|
209
|
+
|
|
210
|
+
// Stand up the local ingest server, then expose it via the proxy tunnel.
|
|
211
|
+
this.ingest = new IngestServer({
|
|
212
|
+
...(this.opts.host ? { host: this.opts.host } : {}),
|
|
213
|
+
signingSecret,
|
|
214
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
215
|
+
hooks: {
|
|
216
|
+
botUserId: this.botUserId,
|
|
217
|
+
isAuthorized: (teamId, channel) =>
|
|
218
|
+
authorizationMatches(this.authorization, teamId, channel),
|
|
219
|
+
onVerifiedEvent: (ev) => this.handlePairingCandidate(ev),
|
|
220
|
+
dispatch: (ctx) => this.dispatchTurn(ctx),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const bound = await this.ingest.start();
|
|
224
|
+
|
|
225
|
+
await this.openTunnel(bound.host, bound.port);
|
|
226
|
+
|
|
227
|
+
const running = new Promise<void>((resolve) => {
|
|
228
|
+
this.resolveRunning = resolve;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
this.handle = {
|
|
232
|
+
running,
|
|
233
|
+
stop: async (reason = 'shutdown') => {
|
|
234
|
+
// Abort the in-flight turn FIRST so the model loop stops the moment the
|
|
235
|
+
// operator asks to shut down (shared/remote Session: spend continues
|
|
236
|
+
// otherwise and only its output is discarded).
|
|
237
|
+
this.turns.abort(reason);
|
|
238
|
+
this.logUnsub?.();
|
|
239
|
+
this.logUnsub = null;
|
|
240
|
+
if (this.tunnel) {
|
|
241
|
+
try { await this.tunnel.close(); } catch { /* ignore */ }
|
|
242
|
+
this.tunnel = null;
|
|
243
|
+
}
|
|
244
|
+
if (this.ingest) {
|
|
245
|
+
await this.ingest.stop();
|
|
246
|
+
this.ingest = null;
|
|
247
|
+
}
|
|
248
|
+
this.publicUrl = null;
|
|
249
|
+
this.resolveRunning?.();
|
|
250
|
+
this.resolveRunning = null;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
this.opts.logger?.info?.('slack: channel started', {
|
|
254
|
+
requestUrl: this.publicUrl,
|
|
255
|
+
paired: this.authorization != null,
|
|
256
|
+
pairing: this.tofu.isArmed,
|
|
257
|
+
});
|
|
258
|
+
return this.handle;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async openTunnel(host: string, port: number): Promise<void> {
|
|
262
|
+
// Default to the self-hosted proxy relay (imported directly, like the
|
|
263
|
+
// webhooks tunnel); tests inject a fake provider.
|
|
264
|
+
const provider = this.opts.tunnelProvider ?? proxyTunnel;
|
|
265
|
+
if (provider.name === 'localhost') {
|
|
266
|
+
// A no-op provider can't reach a loopback port from Slack. Surface the
|
|
267
|
+
// local URL so a manual reverse proxy can still be wired.
|
|
268
|
+
this.publicUrl = `http://${host}:${port}${SLACK_EVENTS_PATH}`;
|
|
269
|
+
this.opts.logger?.warn?.(
|
|
270
|
+
'slack: localhost tunnel provider — Slack cannot reach a loopback port',
|
|
271
|
+
{ localUrl: this.publicUrl },
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
this.tunnel = await provider.open({ port, host, label: TUNNEL_LABEL });
|
|
277
|
+
this.publicUrl = `${this.tunnel.url.replace(/\/+$/, '')}${SLACK_EVENTS_PATH}`;
|
|
278
|
+
this.opts.logger?.info?.('slack: tunnel open', {
|
|
279
|
+
provider: provider.name,
|
|
280
|
+
requestUrl: this.publicUrl,
|
|
281
|
+
});
|
|
282
|
+
} catch (err) {
|
|
283
|
+
this.publicUrl = `http://${host}:${port}${SLACK_EVENTS_PATH}`;
|
|
284
|
+
this.opts.logger?.warn?.('slack: tunnel failed; using local URL', {
|
|
285
|
+
provider: provider.name,
|
|
286
|
+
err: String(err),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* TOFU pairing hook: while a pairing window is armed, the first verified event
|
|
293
|
+
* captures the team/channel and notifies listeners. It's "consumed" (no turn)
|
|
294
|
+
* so the very first message just establishes trust. Returns true when consumed.
|
|
295
|
+
*/
|
|
296
|
+
private handlePairingCandidate(ev: SlackEventCallback): boolean {
|
|
297
|
+
if (!this.tofu.isArmed) return false;
|
|
298
|
+
const teamId = ev.team_id;
|
|
299
|
+
const channelId = ev.event.channel;
|
|
300
|
+
if (!teamId || !channelId) return false;
|
|
301
|
+
return this.tofu.offer({ teamId, channelId });
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Fire-and-forget a turn for an authorized event. Single-flight (v1): if a
|
|
306
|
+
* turn is already running we drop the new event with a thread reply rather
|
|
307
|
+
* than corrupting the per-turn state. The ingest handler has already ACKed.
|
|
308
|
+
*/
|
|
309
|
+
private dispatchTurn(ctx: DispatchContext): void {
|
|
310
|
+
void this.runTurn(ctx).catch((err) => {
|
|
311
|
+
this.opts.logger?.warn?.('slack: turn dispatch failed', { err: String(err) });
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async runTurn(ctx: DispatchContext): Promise<void> {
|
|
316
|
+
if (!this.session || !this.client) return;
|
|
317
|
+
// The turnId is minted here so the coordinator records it as an own-turn id
|
|
318
|
+
// — that's what mirrorForeignTurn filters on (#8).
|
|
319
|
+
const lease = this.turns.begin(newTurnId());
|
|
320
|
+
if (!lease) {
|
|
321
|
+
// Politely decline; v1 has no per-thread concurrency (see TECH_DEBT).
|
|
322
|
+
try {
|
|
323
|
+
await this.client.postMessage({
|
|
324
|
+
channel: ctx.channel,
|
|
325
|
+
threadTs: ctx.threadTs,
|
|
326
|
+
text: 'I am still working on a previous request. One moment…',
|
|
327
|
+
});
|
|
328
|
+
} catch { /* ignore */ }
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this.currentChannelId = ctx.channel;
|
|
332
|
+
this.currentThreadTs = ctx.threadTs;
|
|
333
|
+
this.lastChannelId = ctx.channel;
|
|
334
|
+
this.lastThreadTs = ctx.threadTs;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await runSlackTurn(
|
|
338
|
+
{
|
|
339
|
+
session: this.session,
|
|
340
|
+
client: this.client,
|
|
341
|
+
editFrameMs: this.opts.editFrameMs ?? 1000,
|
|
342
|
+
...(this.opts.logger ? { logger: this.opts.logger } : {}),
|
|
343
|
+
},
|
|
344
|
+
{
|
|
345
|
+
channel: ctx.channel,
|
|
346
|
+
threadTs: ctx.threadTs,
|
|
347
|
+
text: ctx.text,
|
|
348
|
+
...(this.model ? { model: this.model } : {}),
|
|
349
|
+
controller: lease.controller,
|
|
350
|
+
turnId: lease.turnId,
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
} finally {
|
|
354
|
+
lease.end();
|
|
355
|
+
this.currentChannelId = null;
|
|
356
|
+
this.currentThreadTs = null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Post the assistant's prose for a turn this channel did not initiate. Gated by
|
|
362
|
+
* `!busy` (our own turns stream via the frame pump) and by having served a
|
|
363
|
+
* thread at least once. Skipped for our own turnIds (robust to async ordering /
|
|
364
|
+
* replay, invariant #8).
|
|
365
|
+
*/
|
|
366
|
+
private mirrorForeignTurn(event: MoxxyEvent): void {
|
|
367
|
+
// Skipped for our own turnIds (robust to async ordering / replay, #8) and
|
|
368
|
+
// while a turn of ours is streaming via the frame pump.
|
|
369
|
+
const text = this.turns.mirrorText(event);
|
|
370
|
+
if (text == null) return;
|
|
371
|
+
if (!this.client || this.lastChannelId == null || this.lastThreadTs == null) return;
|
|
372
|
+
void this.client
|
|
373
|
+
.postMessage({ channel: this.lastChannelId, threadTs: this.lastThreadTs, text })
|
|
374
|
+
.catch((err) => {
|
|
375
|
+
this.opts.logger?.warn?.('slack: mirror failed', { err: String(err) });
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineChannel,
|
|
3
|
+
definePlugin,
|
|
4
|
+
type LifecycleHooks,
|
|
5
|
+
type Plugin,
|
|
6
|
+
} from '@moxxy/sdk';
|
|
7
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
8
|
+
import { SlackChannel, type SlackChannelOptions } from './channel.js';
|
|
9
|
+
import {
|
|
10
|
+
SLACK_AUTHORIZED_KEY,
|
|
11
|
+
SLACK_BOT_TOKEN_ENV,
|
|
12
|
+
SLACK_BOT_TOKEN_KEY,
|
|
13
|
+
SLACK_SIGNING_SECRET_ENV,
|
|
14
|
+
SLACK_SIGNING_SECRET_KEY,
|
|
15
|
+
parseAuthorization,
|
|
16
|
+
resolveBotToken,
|
|
17
|
+
resolveSigningSecret,
|
|
18
|
+
} from './keys.js';
|
|
19
|
+
import { runSlackWizard } from './setup-wizard.js';
|
|
20
|
+
import { runSlackPairFlow } from './pair-flow.js';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
SlackChannel,
|
|
24
|
+
type SlackChannelOptions,
|
|
25
|
+
type SlackStartOpts,
|
|
26
|
+
type PairCandidate,
|
|
27
|
+
} from './channel.js';
|
|
28
|
+
export { buildSlackPermissionResolver } from './permission.js';
|
|
29
|
+
export { SlackClient } from './channel/slack-client.js';
|
|
30
|
+
export { verifySlackSignature, SLACK_REPLAY_WINDOW_SEC } from './server/verify.js';
|
|
31
|
+
export { slackEnvelopeSchema, eventCallbackSchema, urlVerificationSchema } from './server/schema.js';
|
|
32
|
+
export { DeliveryDedupeCache } from './server/dedupe.js';
|
|
33
|
+
export { IngestServer, SLACK_EVENTS_PATH } from './server/ingest-server.js';
|
|
34
|
+
export {
|
|
35
|
+
SLACK_BOT_TOKEN_KEY,
|
|
36
|
+
SLACK_SIGNING_SECRET_KEY,
|
|
37
|
+
SLACK_AUTHORIZED_KEY,
|
|
38
|
+
SLACK_BOT_TOKEN_ENV,
|
|
39
|
+
SLACK_SIGNING_SECRET_ENV,
|
|
40
|
+
resolveBotToken,
|
|
41
|
+
resolveSigningSecret,
|
|
42
|
+
parseAuthorization,
|
|
43
|
+
authorizationMatches,
|
|
44
|
+
type SlackAuthorization,
|
|
45
|
+
} from './keys.js';
|
|
46
|
+
|
|
47
|
+
export interface BuildSlackPluginOptions {
|
|
48
|
+
/** Host-injected encrypted secret store (available immediately). */
|
|
49
|
+
readonly vault: VaultStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the Slack channel plugin. The CLI passes the concrete vault (mirroring
|
|
54
|
+
* the Telegram plugin's vault injection). The channel exposes its own ingest
|
|
55
|
+
* server publicly via the self-hosted `proxyTunnel` (imported directly, like
|
|
56
|
+
* the webhooks tunnel).
|
|
57
|
+
*/
|
|
58
|
+
export function buildSlackPlugin(opts: BuildSlackPluginOptions): Plugin {
|
|
59
|
+
return makeSlackPlugin(() => opts.vault);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
64
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
65
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
66
|
+
* `moxxy.requirements`). The channel + subcommands read the vault via
|
|
67
|
+
* `getVault()`, so resolution is deferred to call time — after `onInit` wired it.
|
|
68
|
+
*/
|
|
69
|
+
export const slackPlugin: Plugin = (() => {
|
|
70
|
+
let resolved: VaultStore | null = null;
|
|
71
|
+
const getVault = (): VaultStore => {
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
'@moxxy/plugin-channel-slack: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return resolved;
|
|
78
|
+
};
|
|
79
|
+
const hooks: LifecycleHooks = {
|
|
80
|
+
onInit: (ctx) => {
|
|
81
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return makeSlackPlugin(getVault, hooks);
|
|
85
|
+
})();
|
|
86
|
+
|
|
87
|
+
export default slackPlugin;
|
|
88
|
+
|
|
89
|
+
function readAllowedTools(options: Record<string, unknown> | undefined): string[] | undefined {
|
|
90
|
+
const raw = options?.['allowedTools'];
|
|
91
|
+
if (Array.isArray(raw)) return raw.map((x) => String(x));
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeSlackPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
96
|
+
return definePlugin({
|
|
97
|
+
name: '@moxxy/plugin-channel-slack',
|
|
98
|
+
version: '0.0.0',
|
|
99
|
+
...(hooks ? { hooks } : {}),
|
|
100
|
+
// NB: we do NOT register a tunnelProvider here. The channel opens its ingest
|
|
101
|
+
// tunnel by importing `proxyTunnel` directly (see ingest-server), exactly like
|
|
102
|
+
// the webhooks plugin. Registering it would throw "already registered" when the
|
|
103
|
+
// web channel (which does register `proxyTunnel`) is also loaded, silently
|
|
104
|
+
// dropping this whole plugin — the tunnel-providers registry rejects duplicates.
|
|
105
|
+
channels: [
|
|
106
|
+
defineChannel({
|
|
107
|
+
name: 'slack',
|
|
108
|
+
description:
|
|
109
|
+
'Slack bot channel: ingests the Events API over the proxy relay and streams threaded replies. Autonomous allow-list permissions (no human in the loop).',
|
|
110
|
+
// Runs on its own dedicated, isolated runner (separate socket + sticky
|
|
111
|
+
// session) so the bot operates independently of the user's desktop/TUI.
|
|
112
|
+
// The CLI reads these generically — no per-channel name list.
|
|
113
|
+
dedicatedRunner: true,
|
|
114
|
+
sessionSource: 'slack',
|
|
115
|
+
// Self-described config so a control surface (TUI `/channels`, `moxxy
|
|
116
|
+
// channels start`) can configure + run Slack without a hardcoded table.
|
|
117
|
+
// The vault keys named here are the ones the channel reads at boot.
|
|
118
|
+
config: {
|
|
119
|
+
fields: [
|
|
120
|
+
{
|
|
121
|
+
name: 'botToken',
|
|
122
|
+
label: 'Bot token',
|
|
123
|
+
vaultKey: SLACK_BOT_TOKEN_KEY,
|
|
124
|
+
required: true,
|
|
125
|
+
secret: true,
|
|
126
|
+
placeholder: 'xoxb-…',
|
|
127
|
+
help: 'Slack app → OAuth & Permissions → Bot User OAuth Token',
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: 'signingSecret',
|
|
131
|
+
label: 'Signing secret',
|
|
132
|
+
vaultKey: SLACK_SIGNING_SECRET_KEY,
|
|
133
|
+
required: true,
|
|
134
|
+
secret: true,
|
|
135
|
+
help: 'Slack app → Basic Information → App Credentials → Signing Secret',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
hasRequestUrl: true,
|
|
139
|
+
runHint:
|
|
140
|
+
'Paste the Request URL into your Slack app → Event Subscriptions, subscribe to the app_mention bot event, then mention the bot in a channel to pair.',
|
|
141
|
+
connect: {
|
|
142
|
+
kind: 'url',
|
|
143
|
+
title: 'Request URL',
|
|
144
|
+
hint: 'Paste this into your Slack app → Event Subscriptions, subscribe to the app_mention bot event, then mention the bot in a channel to pair.',
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
create: (deps) => {
|
|
148
|
+
const options = deps.options;
|
|
149
|
+
const allowedTools = readAllowedTools(options);
|
|
150
|
+
const editFrameMs = options?.['editFrameMs'];
|
|
151
|
+
const host = options?.['host'];
|
|
152
|
+
const channelOpts: SlackChannelOptions = {
|
|
153
|
+
vault: getVault(),
|
|
154
|
+
...(allowedTools ? { allowedTools } : {}),
|
|
155
|
+
...(typeof editFrameMs === 'number' ? { editFrameMs } : {}),
|
|
156
|
+
...(typeof host === 'string' ? { host } : {}),
|
|
157
|
+
logger: deps.logger as never,
|
|
158
|
+
};
|
|
159
|
+
return new SlackChannel(channelOpts);
|
|
160
|
+
},
|
|
161
|
+
isAvailable: async () => {
|
|
162
|
+
// Env-first: a fully env-configured bot is available even in a probe
|
|
163
|
+
// context (e.g. the `moxxy channels` listing) where onInit has not yet
|
|
164
|
+
// wired the vault service, so `getVault()` would throw.
|
|
165
|
+
let hasToken = (process.env[SLACK_BOT_TOKEN_ENV]?.trim() ?? '') !== '';
|
|
166
|
+
let hasSecret = (process.env[SLACK_SIGNING_SECRET_ENV]?.trim() ?? '') !== '';
|
|
167
|
+
if (hasToken && hasSecret) return { ok: true };
|
|
168
|
+
try {
|
|
169
|
+
const vault = getVault();
|
|
170
|
+
if (!hasToken) hasToken = (await resolveBotToken(vault)) != null;
|
|
171
|
+
if (!hasSecret) hasSecret = (await resolveSigningSecret(vault)) != null;
|
|
172
|
+
} catch {
|
|
173
|
+
// Vault unavailable in a probe/listing context — fall through to the
|
|
174
|
+
// "missing secrets" message below (env was already checked above).
|
|
175
|
+
}
|
|
176
|
+
if (hasToken && hasSecret) return { ok: true };
|
|
177
|
+
const missing: string[] = [];
|
|
178
|
+
if (!hasToken) missing.push('bot token (slack_bot_token / MOXXY_SLACK_BOT_TOKEN)');
|
|
179
|
+
if (!hasSecret) missing.push('signing secret (slack_signing_secret / MOXXY_SLACK_SIGNING_SECRET)');
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason: `Missing ${missing.join(' and ')}. Run \`moxxy channels slack setup\`.`,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
interactiveCommand: 'setup',
|
|
186
|
+
subcommands: {
|
|
187
|
+
setup: {
|
|
188
|
+
description:
|
|
189
|
+
'Interactive setup: store the bot token + signing secret, validate via auth.test, pick the allow-list, open the tunnel, and print the Slack Request URL. Shown by default for `moxxy slack` on a TTY.',
|
|
190
|
+
run: async (ctx) => {
|
|
191
|
+
if (process.stdin.isTTY !== true) {
|
|
192
|
+
// Headless: just start the channel (secrets must already be set).
|
|
193
|
+
return ctx.startChannel();
|
|
194
|
+
}
|
|
195
|
+
return runSlackWizard(ctx);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
pair: {
|
|
199
|
+
description:
|
|
200
|
+
'TOFU pairing: arm a window, then authorize the first team/channel that @mentions the bot.',
|
|
201
|
+
run: async (ctx) => {
|
|
202
|
+
if (process.stdin.isTTY !== true) {
|
|
203
|
+
process.stderr.write(
|
|
204
|
+
'Pairing needs a TTY (you confirm the team/channel). Run `moxxy slack pair` interactively.\n',
|
|
205
|
+
);
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
return runSlackPairFlow(ctx);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
status: {
|
|
212
|
+
description: 'Report token/secret/authorization state as JSON.',
|
|
213
|
+
run: async (ctx) => {
|
|
214
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
215
|
+
if (!vault) {
|
|
216
|
+
process.stderr.write('vault unavailable\n');
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
const botTokenConfigured = (await resolveBotToken(vault)) != null;
|
|
220
|
+
const signingSecretConfigured = (await resolveSigningSecret(vault)) != null;
|
|
221
|
+
const authorized = parseAuthorization(await vault.get(SLACK_AUTHORIZED_KEY));
|
|
222
|
+
process.stdout.write(
|
|
223
|
+
JSON.stringify(
|
|
224
|
+
{
|
|
225
|
+
botTokenConfigured,
|
|
226
|
+
signingSecretConfigured,
|
|
227
|
+
authorized,
|
|
228
|
+
tunnelUrl: null,
|
|
229
|
+
},
|
|
230
|
+
null,
|
|
231
|
+
2,
|
|
232
|
+
) + '\n',
|
|
233
|
+
);
|
|
234
|
+
return 0;
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
unpair: {
|
|
238
|
+
description: 'Forget the authorized Slack team/channel.',
|
|
239
|
+
run: async (ctx) => {
|
|
240
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
241
|
+
if (!vault) {
|
|
242
|
+
process.stderr.write('vault unavailable\n');
|
|
243
|
+
return 1;
|
|
244
|
+
}
|
|
245
|
+
const removed = await vault.delete(SLACK_AUTHORIZED_KEY);
|
|
246
|
+
process.stdout.write(removed ? 'unpaired\n' : 'no pairing was active\n');
|
|
247
|
+
return 0;
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
],
|
|
253
|
+
});
|
|
254
|
+
}
|