@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/package.json
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-channel-slack",
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "Slack bot channel for moxxy. Ingests Slack Events API over the proxy relay, drives the Session, and streams threaded replies. Autonomous allow-list permission model (no human in the loop).",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"channel",
|
|
9
|
+
"slack",
|
|
10
|
+
"bot"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-channel-slack"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "cli"
|
|
43
|
+
},
|
|
44
|
+
"requirements": [
|
|
45
|
+
{
|
|
46
|
+
"kind": "plugin",
|
|
47
|
+
"name": "@moxxy/plugin-vault",
|
|
48
|
+
"hint": "slack resolves the vault from the service registry for its bot token + signing secret + pairing; @moxxy/plugin-vault must load first"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"setup": {
|
|
52
|
+
"title": "Slack bot",
|
|
53
|
+
"description": "Create an app at https://api.slack.com/apps → install it to your workspace and copy the Bot User OAuth Token (xoxb-…) plus the Signing Secret (Basic Information). Events arrive over an HTTP ingest URL — after setup, `moxxy slack pair` prints the Request URL to paste into Event Subscriptions; pairing is trust-on-first-@mention.",
|
|
54
|
+
"fields": [
|
|
55
|
+
{
|
|
56
|
+
"key": "botToken",
|
|
57
|
+
"label": "Bot User OAuth Token (xoxb-…)",
|
|
58
|
+
"kind": "secret",
|
|
59
|
+
"vaultKey": "slack_bot_token",
|
|
60
|
+
"required": true,
|
|
61
|
+
"placeholder": "xoxb-…"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"key": "signingSecret",
|
|
65
|
+
"label": "Signing Secret",
|
|
66
|
+
"kind": "secret",
|
|
67
|
+
"vaultKey": "slack_signing_secret",
|
|
68
|
+
"required": true
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"dependencies": {
|
|
74
|
+
"@clack/prompts": "^1.4.0",
|
|
75
|
+
"zod": "^3.24.0",
|
|
76
|
+
"@moxxy/channel-kit": "0.27.0",
|
|
77
|
+
"@moxxy/core": "0.27.0",
|
|
78
|
+
"@moxxy/plugin-tunnel-proxy": "0.27.0",
|
|
79
|
+
"@moxxy/config": "0.27.0",
|
|
80
|
+
"@moxxy/plugin-vault": "0.27.0",
|
|
81
|
+
"@moxxy/sdk": "0.27.0"
|
|
82
|
+
},
|
|
83
|
+
"devDependencies": {
|
|
84
|
+
"@types/node": "^22.10.0",
|
|
85
|
+
"typescript": "^5.7.3",
|
|
86
|
+
"vitest": "^2.1.8",
|
|
87
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
88
|
+
"@moxxy/testing": "0.0.45",
|
|
89
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
90
|
+
},
|
|
91
|
+
"scripts": {
|
|
92
|
+
"build": "tsc -p tsconfig.json",
|
|
93
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
94
|
+
"test": "vitest run",
|
|
95
|
+
"clean": "rm -rf dist .turbo"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal hand-rolled Slack Web API client. The repo norm is hand-rolled
|
|
3
|
+
* `fetch` over a vendor SDK (only Telegram pulls in grammy), so there is NO
|
|
4
|
+
* `@slack/web-api` dependency: each method POSTs JSON to
|
|
5
|
+
* `https://slack.com/api/<method>` with `Authorization: Bearer <token>` and
|
|
6
|
+
* throws on a non-`ok` Slack response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SLACK_API_BASE = 'https://slack.com/api';
|
|
10
|
+
|
|
11
|
+
export interface SlackClientOptions {
|
|
12
|
+
readonly token: string;
|
|
13
|
+
/** Override the API base (tests). */
|
|
14
|
+
readonly baseUrl?: string;
|
|
15
|
+
/** Injectable fetch (tests). Defaults to the global `fetch`. */
|
|
16
|
+
readonly fetchImpl?: typeof fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuthTestResult {
|
|
20
|
+
/** The bot's own user id — used to drop the bot's own messages. */
|
|
21
|
+
readonly botUserId: string;
|
|
22
|
+
readonly teamId?: string;
|
|
23
|
+
readonly team?: string;
|
|
24
|
+
readonly url?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PostMessageResult {
|
|
28
|
+
readonly channel: string;
|
|
29
|
+
/** The new message ts — used as the edit target for streaming. */
|
|
30
|
+
readonly ts: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Shape of every Slack API JSON response: a boolean `ok` plus method fields. */
|
|
34
|
+
interface SlackApiResponse {
|
|
35
|
+
readonly ok: boolean;
|
|
36
|
+
readonly error?: string;
|
|
37
|
+
readonly [k: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class SlackClient {
|
|
41
|
+
private readonly token: string;
|
|
42
|
+
private readonly baseUrl: string;
|
|
43
|
+
private readonly fetchImpl: typeof fetch;
|
|
44
|
+
|
|
45
|
+
constructor(opts: SlackClientOptions) {
|
|
46
|
+
this.token = opts.token;
|
|
47
|
+
this.baseUrl = (opts.baseUrl ?? SLACK_API_BASE).replace(/\/+$/, '');
|
|
48
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private async call(method: string, body: Record<string, unknown>): Promise<SlackApiResponse> {
|
|
52
|
+
const res = await this.fetchImpl(`${this.baseUrl}/${method}`, {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: {
|
|
55
|
+
'content-type': 'application/json; charset=utf-8',
|
|
56
|
+
authorization: `Bearer ${this.token}`,
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify(body),
|
|
59
|
+
});
|
|
60
|
+
// Slack always returns 200 with `{ ok: false, error }` for app-level errors;
|
|
61
|
+
// a non-2xx is a transport/auth problem worth surfacing distinctly.
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new Error(`Slack ${method} HTTP ${res.status}`);
|
|
64
|
+
}
|
|
65
|
+
const json = (await res.json()) as SlackApiResponse;
|
|
66
|
+
if (!json.ok) {
|
|
67
|
+
throw new Error(`Slack ${method} failed: ${json.error ?? 'unknown_error'}`);
|
|
68
|
+
}
|
|
69
|
+
return json;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Validate the token and capture the bot's own user id. */
|
|
73
|
+
async authTest(): Promise<AuthTestResult> {
|
|
74
|
+
const json = await this.call('auth.test', {});
|
|
75
|
+
const botUserId = typeof json['user_id'] === 'string' ? json['user_id'] : '';
|
|
76
|
+
if (!botUserId) throw new Error('Slack auth.test returned no user_id');
|
|
77
|
+
return {
|
|
78
|
+
botUserId,
|
|
79
|
+
...(typeof json['team_id'] === 'string' ? { teamId: json['team_id'] } : {}),
|
|
80
|
+
...(typeof json['team'] === 'string' ? { team: json['team'] } : {}),
|
|
81
|
+
...(typeof json['url'] === 'string' ? { url: json['url'] } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Post a message into a channel/thread. Returns the channel + new ts. */
|
|
86
|
+
async postMessage(args: {
|
|
87
|
+
channel: string;
|
|
88
|
+
text: string;
|
|
89
|
+
threadTs?: string;
|
|
90
|
+
}): Promise<PostMessageResult> {
|
|
91
|
+
const json = await this.call('chat.postMessage', {
|
|
92
|
+
channel: args.channel,
|
|
93
|
+
text: args.text,
|
|
94
|
+
...(args.threadTs ? { thread_ts: args.threadTs } : {}),
|
|
95
|
+
});
|
|
96
|
+
const channel = typeof json['channel'] === 'string' ? json['channel'] : args.channel;
|
|
97
|
+
const ts = typeof json['ts'] === 'string' ? json['ts'] : '';
|
|
98
|
+
if (!ts) throw new Error('Slack chat.postMessage returned no ts');
|
|
99
|
+
return { channel, ts };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Edit an existing message (the streaming-update path). */
|
|
103
|
+
async updateMessage(args: { channel: string; ts: string; text: string }): Promise<void> {
|
|
104
|
+
await this.call('chat.update', {
|
|
105
|
+
channel: args.channel,
|
|
106
|
+
ts: args.ts,
|
|
107
|
+
text: args.text,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Optional: read a thread's replies (unused by v1 streaming, exposed for tools). */
|
|
112
|
+
async conversationsReplies(args: {
|
|
113
|
+
channel: string;
|
|
114
|
+
ts: string;
|
|
115
|
+
}): Promise<ReadonlyArray<Record<string, unknown>>> {
|
|
116
|
+
const json = await this.call('conversations.replies', {
|
|
117
|
+
channel: args.channel,
|
|
118
|
+
ts: args.ts,
|
|
119
|
+
});
|
|
120
|
+
const messages = json['messages'];
|
|
121
|
+
return Array.isArray(messages) ? (messages as Array<Record<string, unknown>>) : [];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { MoxxyEvent } from '@moxxy/sdk';
|
|
3
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
+
import { runSlackTurn } from './turn-runner.js';
|
|
5
|
+
import type { SlackClient } from './slack-client.js';
|
|
6
|
+
|
|
7
|
+
/** A fake event log that fans out to subscribers, like the real session.log. */
|
|
8
|
+
class FakeLog {
|
|
9
|
+
private readonly subs = new Set<(e: MoxxyEvent) => void>();
|
|
10
|
+
subscribe(fn: (e: MoxxyEvent) => void): () => void {
|
|
11
|
+
this.subs.add(fn);
|
|
12
|
+
return () => this.subs.delete(fn);
|
|
13
|
+
}
|
|
14
|
+
emit(e: MoxxyEvent): void {
|
|
15
|
+
for (const s of this.subs) s(e);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function chunk(turnId: string, delta: string): MoxxyEvent {
|
|
20
|
+
return {
|
|
21
|
+
id: `e_${Math.random()}`,
|
|
22
|
+
seq: 0,
|
|
23
|
+
ts: 0,
|
|
24
|
+
sessionId: 's1',
|
|
25
|
+
turnId,
|
|
26
|
+
source: 'model',
|
|
27
|
+
type: 'assistant_chunk',
|
|
28
|
+
delta,
|
|
29
|
+
} as MoxxyEvent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function message(turnId: string, content: string): MoxxyEvent {
|
|
33
|
+
return {
|
|
34
|
+
id: `e_${Math.random()}`,
|
|
35
|
+
seq: 0,
|
|
36
|
+
ts: 0,
|
|
37
|
+
sessionId: 's1',
|
|
38
|
+
turnId,
|
|
39
|
+
source: 'model',
|
|
40
|
+
type: 'assistant_message',
|
|
41
|
+
content,
|
|
42
|
+
stopReason: 'end_turn',
|
|
43
|
+
} as MoxxyEvent;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Records every chat.postMessage / chat.update so the test can assert on Slack output. */
|
|
47
|
+
function fakeClient(): {
|
|
48
|
+
client: SlackClient;
|
|
49
|
+
posts: Array<{ channel: string; text: string; threadTs?: string }>;
|
|
50
|
+
edits: Array<{ channel: string; ts: string; text: string }>;
|
|
51
|
+
} {
|
|
52
|
+
const posts: Array<{ channel: string; text: string; threadTs?: string }> = [];
|
|
53
|
+
const edits: Array<{ channel: string; ts: string; text: string }> = [];
|
|
54
|
+
let n = 0;
|
|
55
|
+
const client = {
|
|
56
|
+
async postMessage(args: { channel: string; text: string; threadTs?: string }) {
|
|
57
|
+
posts.push(args);
|
|
58
|
+
return { channel: args.channel, ts: `ts_${++n}` };
|
|
59
|
+
},
|
|
60
|
+
async updateMessage(args: { channel: string; ts: string; text: string }) {
|
|
61
|
+
edits.push(args);
|
|
62
|
+
},
|
|
63
|
+
} as unknown as SlackClient;
|
|
64
|
+
return { client, posts, edits };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('runSlackTurn (turnId-filtered streaming)', () => {
|
|
68
|
+
it('streams only the matching turnId into the thread', async () => {
|
|
69
|
+
vi.useFakeTimers();
|
|
70
|
+
const log = new FakeLog();
|
|
71
|
+
const { client, posts, edits } = fakeClient();
|
|
72
|
+
|
|
73
|
+
// runTurn emits onto the shared log: our turn's chunks PLUS a foreign turn's
|
|
74
|
+
// chunk that must never reach Slack. Resolves after emitting.
|
|
75
|
+
const session = {
|
|
76
|
+
log,
|
|
77
|
+
runTurn(_prompt: string, opts: { turnId: string }) {
|
|
78
|
+
const ownTurn = opts.turnId;
|
|
79
|
+
return (async function* () {
|
|
80
|
+
log.emit(chunk('foreign-turn', 'SHOULD NOT APPEAR'));
|
|
81
|
+
log.emit(chunk(ownTurn, 'Hello'));
|
|
82
|
+
log.emit(chunk(ownTurn, ', world'));
|
|
83
|
+
log.emit(message(ownTurn, 'Hello, world!'));
|
|
84
|
+
log.emit(chunk('foreign-turn', 'ALSO NOT THIS'));
|
|
85
|
+
yield message(ownTurn, 'Hello, world!');
|
|
86
|
+
})();
|
|
87
|
+
},
|
|
88
|
+
} as unknown as Session;
|
|
89
|
+
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const promise = runSlackTurn(
|
|
92
|
+
{ session, client, editFrameMs: 1000 },
|
|
93
|
+
{
|
|
94
|
+
channel: 'C1',
|
|
95
|
+
threadTs: '111.222',
|
|
96
|
+
text: '<@UBOT> hi',
|
|
97
|
+
controller,
|
|
98
|
+
turnId: 'own-turn',
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
// Drain timers (the debounced flush) and the turn.
|
|
102
|
+
await vi.runAllTimersAsync();
|
|
103
|
+
await promise;
|
|
104
|
+
vi.useRealTimers();
|
|
105
|
+
|
|
106
|
+
// Exactly one message posted into the right thread, carrying the final text.
|
|
107
|
+
expect(posts).toHaveLength(1);
|
|
108
|
+
expect(posts[0]).toMatchObject({ channel: 'C1', threadTs: '111.222' });
|
|
109
|
+
// The final flush carries the complete assistant_message content.
|
|
110
|
+
const finalText = edits.length > 0 ? edits[edits.length - 1]?.text : posts[0]?.text;
|
|
111
|
+
expect(finalText).toBe('Hello, world!');
|
|
112
|
+
|
|
113
|
+
// No foreign-turn text ever reached Slack.
|
|
114
|
+
const allText = [...posts.map((p) => p.text), ...edits.map((e) => e.text)].join('\n');
|
|
115
|
+
expect(allText).not.toContain('SHOULD NOT APPEAR');
|
|
116
|
+
expect(allText).not.toContain('ALSO NOT THIS');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('posts a "(no output)" placeholder when a turn produces nothing', async () => {
|
|
120
|
+
vi.useFakeTimers();
|
|
121
|
+
const log = new FakeLog();
|
|
122
|
+
const { client, posts } = fakeClient();
|
|
123
|
+
const session = {
|
|
124
|
+
log,
|
|
125
|
+
runTurn() {
|
|
126
|
+
return (async function* () {
|
|
127
|
+
// no events
|
|
128
|
+
})();
|
|
129
|
+
},
|
|
130
|
+
} as unknown as Session;
|
|
131
|
+
|
|
132
|
+
const promise = runSlackTurn(
|
|
133
|
+
{ session, client, editFrameMs: 1000 },
|
|
134
|
+
{ channel: 'C1', threadTs: '1.2', text: 'hi', controller: new AbortController(), turnId: 't' },
|
|
135
|
+
);
|
|
136
|
+
await vi.runAllTimersAsync();
|
|
137
|
+
await promise;
|
|
138
|
+
vi.useRealTimers();
|
|
139
|
+
|
|
140
|
+
expect(posts).toHaveLength(1);
|
|
141
|
+
expect(posts[0]?.text).toContain('no output');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { newTurnId } from '@moxxy/core';
|
|
2
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
3
|
+
import { FramePump, PlainTurnRenderer, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
|
|
4
|
+
import type { SlackClient } from './slack-client.js';
|
|
5
|
+
|
|
6
|
+
export interface TurnRunnerLogger {
|
|
7
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RunSlackTurnDeps {
|
|
11
|
+
readonly session: Session;
|
|
12
|
+
readonly client: SlackClient;
|
|
13
|
+
readonly editFrameMs: number;
|
|
14
|
+
readonly logger?: TurnRunnerLogger;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RunSlackTurnOptions {
|
|
18
|
+
/** Channel the triggering event arrived in. */
|
|
19
|
+
readonly channel: string;
|
|
20
|
+
/** Thread root for the reply: `event.thread_ts ?? event.ts`. */
|
|
21
|
+
readonly threadTs: string;
|
|
22
|
+
readonly text: string;
|
|
23
|
+
readonly model?: string;
|
|
24
|
+
readonly controller: AbortController;
|
|
25
|
+
/** Pre-minted turn id; the channel records it as an own-turn id. */
|
|
26
|
+
readonly turnId: ReturnType<typeof newTurnId>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drive a single Slack turn end-to-end: subscribe the frame pump to THIS turn's
|
|
31
|
+
* events (filtered by turnId — `session.log` fans out to every listener, so a
|
|
32
|
+
* concurrent turn on the same Session would otherwise stream into this thread,
|
|
33
|
+
* AGENTS.md invariant #8), run the turn through `runTurn`, flush the final
|
|
34
|
+
* frame, and unwind in `finally`.
|
|
35
|
+
*
|
|
36
|
+
* The streaming loop is `@moxxy/channel-kit`'s {@link FramePump} ("post once
|
|
37
|
+
* via `chat.postMessage`, then edit THAT message via `chat.update`, throttled
|
|
38
|
+
* to `editFrameMs`") over a {@link PlainTurnRenderer} snapshot; only the Slack
|
|
39
|
+
* Web-API calls live here. The turnId is minted by the caller so the channel
|
|
40
|
+
* can also record it as an own-turn id (it filters foreign-turn mirroring on
|
|
41
|
+
* those).
|
|
42
|
+
*/
|
|
43
|
+
export async function runSlackTurn(
|
|
44
|
+
deps: RunSlackTurnDeps,
|
|
45
|
+
opts: RunSlackTurnOptions,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
const { session, client, editFrameMs, logger } = deps;
|
|
48
|
+
const { channel, threadTs, text, model, controller, turnId } = opts;
|
|
49
|
+
|
|
50
|
+
const renderer = new PlainTurnRenderer();
|
|
51
|
+
const pump = new FramePump<string>({
|
|
52
|
+
editFrameMs,
|
|
53
|
+
frame: () => renderer.snapshot(),
|
|
54
|
+
// Guarantee at least one message even when the turn produced no text.
|
|
55
|
+
emptyFinalText: '_(no output)_',
|
|
56
|
+
sink: {
|
|
57
|
+
send: async (t) => {
|
|
58
|
+
try {
|
|
59
|
+
const res = await client.postMessage({ channel, text: t, threadTs });
|
|
60
|
+
return res.ts;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger?.warn?.('slack chat.postMessage failed', { err: String(err) });
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
edit: async (ts, t) => {
|
|
67
|
+
try {
|
|
68
|
+
await client.updateMessage({ channel, ts, text: t });
|
|
69
|
+
} catch (err) {
|
|
70
|
+
logger?.warn?.('slack chat.update failed', { err: String(err) });
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const unsubscribe = subscribeTurn(session, turnId, (event) => {
|
|
77
|
+
if (renderer.accept(event)) pump.scheduleEdit();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
await driveTurn(session, {
|
|
82
|
+
turnId,
|
|
83
|
+
prompt: text,
|
|
84
|
+
...(model ? { model } : {}),
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
});
|
|
87
|
+
await pump.flush(true);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
logger?.warn?.('slack turn failed', {
|
|
90
|
+
err: err instanceof Error ? err.message : String(err),
|
|
91
|
+
});
|
|
92
|
+
// Surface the failure into the thread rather than leaving a dangling
|
|
93
|
+
// placeholder. Errors from this send are swallowed (best-effort).
|
|
94
|
+
try {
|
|
95
|
+
await client.postMessage({
|
|
96
|
+
channel,
|
|
97
|
+
threadTs,
|
|
98
|
+
text: `Turn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
99
|
+
});
|
|
100
|
+
} catch {
|
|
101
|
+
/* ignore */
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
unsubscribe();
|
|
105
|
+
pump.dispose();
|
|
106
|
+
}
|
|
107
|
+
}
|