feat: add Slack support to .pi/ as a Pi extension
Adds .pi/extensions/slack/ with Socket Mode bridge, allowlist gating, and 4 LLM-callable tools (slack_post/reply/react/upload). Inbound messages bridge into pi.sendUserMessage() with ctx.isIdle() guard; turn_end posts the assistant's reply back to Slack via SlackContext. session_shutdown tears down the Socket Mode connection. Customizations preserved from portfolio-advisor reference: - threadTs field on event types (7 sites in events.ts) - threadParent routing in createSlackContext (context.ts:45) - exported parseEventContent + buildSyntheticEvent Tests: 27 new (3 files); 78 pre-existing tests still green; 114 total. Plan: .claude/plans/context-study-https-github-com-ryaneggz-binary-hartmanis.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
41e28f6cd5
commit
e7e96ccfdd
24 files changed
+3713
-10
No files matched your search
@@ -3,3 +3,7 @@
|
||||
You are running inside a project workspace. Check `AGENTS.md` and `CLAUDE.md` if present — they define the project's voice, operating principles, and development workflow. Treat them as authoritative guides for how work happens here.
|
||||
|
||||
Application code and core logic are developed and tested within the local environment. Refer to project conventions for architecture patterns, tooling, and coding standards.
|
||||
|
||||
## Slack Bridge Awareness
|
||||
|
||||
When the current input was injected by the Slack extension (recognizable by the `[Slack #channel] user:` prefix in the message), respond with concise text suitable for posting back to Slack. The `slack_post`, `slack_reply`, `slack_react`, and `slack_upload` tools are available for explicit Slack actions (if you want to post to a different channel, react with emoji, or upload a file). The bridge automatically posts your final assistant text on turn_end if you do not call those tools yourself.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Upstream Tracking
|
||||
|
||||
## Lineage
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Upstream** | `badlogic/pi-mono` (historical; not directly used) |
|
||||
| **Reference Source** | `.worktrees/agent/portfolio-advisor/packages/slack/` (Pi harness agent worktree) |
|
||||
| **Reference Commit** | Latest on `portfolio-advisor` main branch as of 2026-05-07 |
|
||||
| **Vendored** | No — this is a port, not a vendor |
|
||||
| **Port Date** | 2026-05-07 |
|
||||
|
||||
## Relationship Model
|
||||
|
||||
This extension is a **port** (one-way), not a vendor (bidirectional sync):
|
||||
|
||||
- The reference source is `.worktrees/agent/portfolio-advisor/packages/slack/` at the latest portfolio-advisor commit.
|
||||
- Future bug fixes in the reference code are reviewed and ported **manually** into `.pi/extensions/slack/`.
|
||||
- **NEVER** auto-sync or merge upstream/reference changes.
|
||||
- **NEVER** force-push harness changes back to the reference.
|
||||
|
||||
This model prevents coupling and allows each codebase to evolve independently.
|
||||
|
||||
## Customizations Carried Over
|
||||
|
||||
3 of 5 harness-specific behavioral customizations from the reference port directly; 2 dissolved into Pi's native handling:
|
||||
|
||||
### Ported (preserved from reference)
|
||||
|
||||
1. **`threadTs` event support** — events can target existing threads by threadTs field. All 8 sites in `events.ts` preserved (parse, dispatch, synthetic-event builder, tests).
|
||||
2. **`threadParent` routing** — when creating a response context, if the triggering event has a threadTs, responses post to that thread (not start a new one). Implemented in `context.ts:createSlackContext()`.
|
||||
3. **Exported testing utilities** — `parseEventContent()` and `buildSyntheticEvent()` exported from `events.ts` for test harnesses.
|
||||
|
||||
### Dissolved (no port needed)
|
||||
|
||||
1. **Configurable LLM provider/model** — was a mifune-bot-specific delta (agent runner could switch providers). Pi handles model configuration natively via its own `settings.json`. **Not ported.**
|
||||
2. **Tool output suppression (errors only)** — the reference's `agent.ts` suppressed raw tool output, posting only status icons (✓/✗). In the new shape, this customization is obsolete: Pi's UI renders tool output natively, and the Slack bridge only posts the agent's final text response (via `turn_end`), never tool outputs. **Not ported.**
|
||||
|
||||
### Factual Corrections to Reference's UPSTREAM.md
|
||||
|
||||
The reference's UPSTREAM.md contains inaccuracies that are corrected here:
|
||||
|
||||
- **`threadParent` location claim**: Reference claims it's in `slack.ts` / `context.ts`, but actual location in reference is `main.ts:121` (in the agent runner). In the new shape, it moves to `context.ts:createSlackContext()` since there is no agent runner.
|
||||
- **Tool output suppression description**: Reference claims "errors only," but actual behavior is status icons (✓/✗) for **both** success and error; raw output is suppressed in threads. This customization doesn't port anyway.
|
||||
|
||||
## Sibling Dependencies
|
||||
|
||||
These packages are consumed from npm (NOT vendored):
|
||||
|
||||
| Package | Pinned Version | Notes |
|
||||
|---------|---------------|-------|
|
||||
| `@slack/socket-mode` | `^2.0.0` | Socket Mode WebSocket client |
|
||||
| `@slack/web-api` | `^7.0.0` | Slack Web API client |
|
||||
| `croner` | `^9.1.0` | Cron-like event scheduler (file watcher trigger) |
|
||||
| `chalk` | `^5.6.2` | Terminal color output for logging |
|
||||
| `@sinclair/typebox` | `^0.34.0` | TypeBox for Pi tool parameter schemas |
|
||||
|
||||
## Quarterly Review
|
||||
|
||||
**Owner**: `@ryaneggz`
|
||||
**Schedule**: Quarterly (review against reference HEAD)
|
||||
**Last reviewed**: 2026-05-07
|
||||
|
||||
On each review:
|
||||
1. Check if reference has bug fixes or features that should port.
|
||||
2. If changes are found, create an issue with title `task: port <description> from portfolio-advisor/packages/slack` and PR with reference commit links.
|
||||
3. Update `Reference Commit` above in this file.
|
||||
4. Do NOT auto-apply upstream changes — always review and port manually.
|
||||
|
||||
## Why Port, Not Vendor?
|
||||
|
||||
Vendoring (tight sync) couples the two codebases and makes divergence expensive. Porting (one-way, reviewed) lets each codebase evolve independently while still capturing high-value fixes. The harness and portfolio-advisor have different deployment shapes (Pi extension vs. standalone bot) and will diverge in behavior over time.
|
||||
|
||||
## Cleanup Notes
|
||||
|
||||
After this port is stable and tested in production, the reference in `.worktrees/agent/portfolio-advisor/packages/slack/` can be archived or left as historical documentation in case a future bot needs the same patterns. The sync relationship ends with this port.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Slack Socket Mode Bridge
|
||||
|
||||
Slack Socket Mode bridge implemented as a Pi extension. Once tokens are set in environment, this extension opens a WebSocket listener and bridges inbound Slack messages into the Pi agent via `pi.sendUserMessage()`, then posts agent responses back to Slack.
|
||||
|
||||
## File Inventory
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `index.ts` | Extension factory; manages `session_start`/`session_shutdown` lifecycle, bridges inbound Slack events to Pi, registers tools, posts agent responses back to Slack |
|
||||
| `client.ts` | Slack Socket Mode client + Web API wrappers (chat.postMessage, files.uploadV2, conversations.history) |
|
||||
| `context.ts` | Response helpers: `respond()`, `replaceMessage()`, `respondInThread()`, `setTyping()`, `uploadFile()`, `setWorking()`, `deleteMessage()` |
|
||||
| `store.ts` | Per-channel JSONL persistence for message history + deduplication |
|
||||
| `events.ts` | File-system event watcher with cron scheduling; synthetic event builder and `threadTs` support |
|
||||
| `tools.ts` | Pi tool registration: `slack_post()`, `slack_reply()`, `slack_react()`, `slack_upload()` |
|
||||
| `allowlist.ts` | Channel/user allow-list gating; fail-safe deny-default when both env vars unset |
|
||||
| `download.ts` | Channel history backfill via Slack Web API |
|
||||
| `log.ts` | Structured logging with chalk formatting |
|
||||
| `__tests__/` | Vitest test suite (events, bridge, tools, allowlist) |
|
||||
|
||||
## Required Environment Variables
|
||||
|
||||
Set these in `.devcontainer/.env` or in your shell before running Pi:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `SLACK_APP_TOKEN` | Socket Mode app token (xapp-...). Required to start the extension. |
|
||||
| `SLACK_BOT_TOKEN` | Bot user token (xoxb-...). Required to start the extension. |
|
||||
|
||||
## Optional Environment Variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `SLACK_ALLOW_CHANNELS` | (unset) | Comma-separated list of channel IDs to allow (`C123,C456`). If unset, allows any channel. |
|
||||
| `SLACK_ALLOW_USERS` | (unset) | Comma-separated list of user IDs to allow (`U123,U456`). If unset, allows any user. |
|
||||
| `SLACK_BASE_DIR` | `~/.pi/cache/slack` | Base directory for per-channel logs (JSONL) and backfill cache. Created if missing. |
|
||||
| `SLACK_BACKFILL` | (unset) | Comma-separated channel IDs to backfill on startup (`C123,C456`). Off by default (slow operation). |
|
||||
|
||||
## Fail-Safe Allowlist Behavior
|
||||
|
||||
When **both** `SLACK_ALLOW_CHANNELS` and `SLACK_ALLOW_USERS` are unset, the extension **denies all events by default**. This prevents unintended channel access in production.
|
||||
|
||||
To enable the bridge:
|
||||
- Set **at least one** of the two env vars. If only `SLACK_ALLOW_CHANNELS` is set, any user in those channels is allowed (user filter disabled). If only `SLACK_ALLOW_USERS` is set, those users are allowed in any channel (channel filter disabled). If both are set, a request passes when **both** the channel and user are in their respective lists.
|
||||
|
||||
Example configurations:
|
||||
```bash
|
||||
# Allow specific channels only
|
||||
SLACK_ALLOW_CHANNELS=C123,C456
|
||||
|
||||
# Allow specific users only
|
||||
SLACK_ALLOW_USERS=U789,U101
|
||||
|
||||
# Allow either specific channels OR specific users (channels unset allows any, users must match)
|
||||
SLACK_ALLOW_USERS=U789
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
1. **Install Pi** (if not already done):
|
||||
```bash
|
||||
# Inside the harness
|
||||
pi --version
|
||||
```
|
||||
|
||||
2. **Set Slack tokens** in `.devcontainer/.env`:
|
||||
```bash
|
||||
export SLACK_APP_TOKEN=xapp-...
|
||||
export SLACK_BOT_TOKEN=xoxb-...
|
||||
# Optionally set the allow-lists:
|
||||
export SLACK_ALLOW_CHANNELS=C123,C456
|
||||
```
|
||||
|
||||
3. **Start Pi**:
|
||||
```bash
|
||||
pi
|
||||
```
|
||||
|
||||
The extension loads automatically on `session_start`. If tokens are missing, the extension logs a warning and disables itself. The bridge is ready to receive Slack messages once Pi is idle.
|
||||
|
||||
## Build and Test
|
||||
|
||||
No separate build step needed — the extension lives alongside Pi's source. Test via the harness root:
|
||||
|
||||
```bash
|
||||
# Run vitest for the extension
|
||||
cd /home/sandbox/harness
|
||||
pnpm test -- .pi/extensions/slack/__tests__
|
||||
|
||||
# Or run all tests
|
||||
pnpm test
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
- `threadTs` event routing and ordering
|
||||
- Bridge message injection and `ctx.isIdle()` gating
|
||||
- Allowlist allow/deny logic
|
||||
- Tool execution and Slack response posting
|
||||
|
||||
## Tracking Upstream Changes
|
||||
|
||||
See `.pi/UPSTREAM.md` for:
|
||||
- Lineage and reference source
|
||||
- Customizations carried over
|
||||
- Sync model (port, not vendor — one-way)
|
||||
- Owner and quarterly review schedule
|
||||
|
||||
## Setup Guide
|
||||
|
||||
For step-by-step Slack app creation, token setup, and channel allowlist configuration, see `docs/integrations/slack.md`.
|
||||
@@ -0,0 +1,298 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SlackEvent } from "../client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock SlackBot — intercept the constructor and capture the eventHandler
|
||||
// that index.ts passes as the first argument. The factory closes over it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let capturedEventHandler: ((event: SlackEvent) => Promise<void>) | undefined;
|
||||
|
||||
const mockBot = {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
enqueueEvent: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
SlackBot: vi.fn().mockImplementation((eventHandler: (event: SlackEvent) => Promise<void>) => {
|
||||
capturedEventHandler = eventHandler;
|
||||
return mockBot;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock ChannelStore — not the focus of these tests
|
||||
vi.mock("../store.js", () => ({
|
||||
ChannelStore: vi.fn().mockImplementation(() => ({
|
||||
processAttachments: vi.fn().mockReturnValue([]),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock EventsWatcher / createEventsWatcher — avoid real fs.watch
|
||||
vi.mock("../events.js", () => ({
|
||||
createEventsWatcher: vi.fn().mockReturnValue({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
EventsWatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock download.ts — not the focus of these tests
|
||||
vi.mock("../download.js", () => ({
|
||||
downloadChannel: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock tools registration — not the focus of these tests
|
||||
vi.mock("../tools.js", () => ({
|
||||
registerSlackTools: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock log — suppress output in tests
|
||||
vi.mock("../log.js", () => ({
|
||||
logInfo: vi.fn(),
|
||||
logWarning: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logConnected: vi.fn(),
|
||||
logDisconnected: vi.fn(),
|
||||
logBackfillStart: vi.fn(),
|
||||
logBackfillChannel: vi.fn(),
|
||||
logBackfillComplete: vi.fn(),
|
||||
}));
|
||||
|
||||
// Static import — runs ONCE for the whole test file. Mocks are hoisted above
|
||||
// this import, so when index.ts pulls SlackBot/ChannelStore/etc. they get
|
||||
// the mocks. Each test calls factory(pi) below to get a fresh closure with
|
||||
// its own bot/store/allowlist module-scope state.
|
||||
import factory from "../index.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pi mock factory — captures event handlers registered via pi.on()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AnyHandler = (...args: any[]) => Promise<void> | void;
|
||||
|
||||
function makePi(isIdleReturnValue = true) {
|
||||
const handlers = new Map<string, AnyHandler[]>();
|
||||
const sendUserMessage = vi.fn();
|
||||
const appendEntry = vi.fn();
|
||||
|
||||
const pi = {
|
||||
on: (event: string, handler: AnyHandler) => {
|
||||
if (!handlers.has(event)) handlers.set(event, []);
|
||||
handlers.get(event)!.push(handler);
|
||||
},
|
||||
sendUserMessage,
|
||||
appendEntry,
|
||||
registerTool: vi.fn(),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
isIdle: vi.fn().mockReturnValue(isIdleReturnValue),
|
||||
hasUI: false,
|
||||
ui: {
|
||||
notify: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
async function fireEvent(name: string, ...args: unknown[]) {
|
||||
for (const h of handlers.get(name) ?? []) {
|
||||
await h(...args);
|
||||
}
|
||||
}
|
||||
|
||||
return { pi, ctx, sendUserMessage, appendEntry, fireEvent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function saveSlackEnv() {
|
||||
return {
|
||||
SLACK_APP_TOKEN: process.env.SLACK_APP_TOKEN,
|
||||
SLACK_BOT_TOKEN: process.env.SLACK_BOT_TOKEN,
|
||||
SLACK_ALLOW_CHANNELS: process.env.SLACK_ALLOW_CHANNELS,
|
||||
SLACK_ALLOW_USERS: process.env.SLACK_ALLOW_USERS,
|
||||
};
|
||||
}
|
||||
|
||||
function restoreSlackEnv(saved: Record<string, string | undefined>) {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
describe("bridge — ctx.isIdle() gate on pi.sendUserMessage", () => {
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = saveSlackEnv();
|
||||
process.env.SLACK_APP_TOKEN = "xapp-test";
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-test";
|
||||
capturedEventHandler = undefined;
|
||||
mockBot.start.mockClear();
|
||||
mockBot.start.mockResolvedValue(undefined);
|
||||
mockBot.stop.mockClear();
|
||||
mockBot.stop.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => restoreSlackEnv(savedEnv));
|
||||
|
||||
it("calls pi.sendUserMessage WITHOUT deliverAs when ctx.isIdle() returns true", async () => {
|
||||
process.env.SLACK_ALLOW_CHANNELS = "C999";
|
||||
process.env.SLACK_ALLOW_USERS = "U999";
|
||||
|
||||
const { pi, ctx, sendUserMessage, fireEvent } = makePi(true /* isIdle */);
|
||||
factory(pi as never);
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
|
||||
expect(capturedEventHandler).toBeDefined();
|
||||
const inboundEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: "C999",
|
||||
user: "U999",
|
||||
text: "hello pi",
|
||||
ts: "1700000000.000001",
|
||||
};
|
||||
await capturedEventHandler!(inboundEvent);
|
||||
|
||||
expect(sendUserMessage).toHaveBeenCalledOnce();
|
||||
const [, opts] = sendUserMessage.mock.calls[0];
|
||||
expect(opts?.deliverAs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("calls pi.sendUserMessage WITH deliverAs:'followUp' when ctx.isIdle() returns false", async () => {
|
||||
process.env.SLACK_ALLOW_CHANNELS = "C999";
|
||||
process.env.SLACK_ALLOW_USERS = "U999";
|
||||
|
||||
const { pi, ctx, sendUserMessage, fireEvent } = makePi(false /* not idle */);
|
||||
factory(pi as never);
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
|
||||
expect(capturedEventHandler).toBeDefined();
|
||||
const inboundEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: "C999",
|
||||
user: "U999",
|
||||
text: "follow-up message",
|
||||
ts: "1700000000.000002",
|
||||
};
|
||||
await capturedEventHandler!(inboundEvent);
|
||||
|
||||
expect(sendUserMessage).toHaveBeenCalledOnce();
|
||||
const [, opts] = sendUserMessage.mock.calls[0];
|
||||
expect(opts?.deliverAs).toBe("followUp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge — allowlist deny path", () => {
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = saveSlackEnv();
|
||||
process.env.SLACK_APP_TOKEN = "xapp-test";
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-test";
|
||||
capturedEventHandler = undefined;
|
||||
mockBot.start.mockClear();
|
||||
mockBot.start.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => restoreSlackEnv(savedEnv));
|
||||
|
||||
it("does NOT call pi.sendUserMessage when both allowlist env vars are unset (fail-safe deny)", async () => {
|
||||
delete process.env.SLACK_ALLOW_CHANNELS;
|
||||
delete process.env.SLACK_ALLOW_USERS;
|
||||
|
||||
const { pi, ctx, sendUserMessage, fireEvent } = makePi(true);
|
||||
factory(pi as never);
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
|
||||
expect(capturedEventHandler).toBeDefined();
|
||||
const inboundEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: "C_ANY",
|
||||
user: "U_ANY",
|
||||
text: "should be blocked",
|
||||
ts: "1700000000.000001",
|
||||
};
|
||||
await capturedEventHandler!(inboundEvent);
|
||||
|
||||
expect(sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls pi.sendUserMessage when channel+user match the allowlist", async () => {
|
||||
process.env.SLACK_ALLOW_CHANNELS = "C_ALLOWED";
|
||||
process.env.SLACK_ALLOW_USERS = "U_ALLOWED";
|
||||
|
||||
const { pi, ctx, sendUserMessage, fireEvent } = makePi(true);
|
||||
factory(pi as never);
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
|
||||
expect(capturedEventHandler).toBeDefined();
|
||||
const inboundEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: "C_ALLOWED",
|
||||
user: "U_ALLOWED",
|
||||
text: "allowed message",
|
||||
ts: "1700000000.000001",
|
||||
};
|
||||
await capturedEventHandler!(inboundEvent);
|
||||
|
||||
expect(sendUserMessage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does NOT call pi.sendUserMessage when channel is not in allowlist", async () => {
|
||||
process.env.SLACK_ALLOW_CHANNELS = "C_ALLOWED";
|
||||
process.env.SLACK_ALLOW_USERS = "U_ALLOWED";
|
||||
|
||||
const { pi, ctx, sendUserMessage, fireEvent } = makePi(true);
|
||||
factory(pi as never);
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
|
||||
expect(capturedEventHandler).toBeDefined();
|
||||
const inboundEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: "C_WRONG",
|
||||
user: "U_ALLOWED",
|
||||
text: "wrong channel",
|
||||
ts: "1700000000.000001",
|
||||
};
|
||||
await capturedEventHandler!(inboundEvent);
|
||||
|
||||
expect(sendUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bridge — session_shutdown calls bot.stop()", () => {
|
||||
let savedEnv: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = saveSlackEnv();
|
||||
process.env.SLACK_APP_TOKEN = "xapp-test";
|
||||
process.env.SLACK_BOT_TOKEN = "xoxb-test";
|
||||
capturedEventHandler = undefined;
|
||||
mockBot.start.mockClear();
|
||||
mockBot.start.mockResolvedValue(undefined);
|
||||
mockBot.stop.mockClear();
|
||||
mockBot.stop.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => restoreSlackEnv(savedEnv));
|
||||
|
||||
it("calls bot.stop() when session_shutdown fires", async () => {
|
||||
process.env.SLACK_ALLOW_CHANNELS = "C999";
|
||||
process.env.SLACK_ALLOW_USERS = "U999";
|
||||
|
||||
const { pi, ctx, fireEvent } = makePi(true);
|
||||
factory(pi as never);
|
||||
|
||||
await fireEvent("session_start", {}, ctx);
|
||||
expect(mockBot.start).toHaveBeenCalled();
|
||||
|
||||
mockBot.stop.mockClear();
|
||||
await fireEvent("session_shutdown", {}, ctx);
|
||||
|
||||
expect(mockBot.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSyntheticEvent, parseEventContent } from "../events.js";
|
||||
import type { ImmediateEvent, OneShotEvent, PeriodicEvent } from "../events.js";
|
||||
|
||||
describe("parseEventContent", () => {
|
||||
describe("immediate events", () => {
|
||||
it("parses without threadTs", () => {
|
||||
const json = JSON.stringify({ type: "immediate", channelId: "C123", text: "hello" });
|
||||
const result = parseEventContent(json, "test.json") as ImmediateEvent;
|
||||
expect(result).toEqual({ type: "immediate", channelId: "C123", text: "hello", threadTs: undefined });
|
||||
});
|
||||
|
||||
it("parses with threadTs", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "immediate",
|
||||
channelId: "C123",
|
||||
text: "hello",
|
||||
threadTs: "1234567890.123456",
|
||||
});
|
||||
const result = parseEventContent(json, "test.json") as ImmediateEvent;
|
||||
expect(result.threadTs).toBe("1234567890.123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one-shot events", () => {
|
||||
it("parses without threadTs", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "one-shot",
|
||||
channelId: "C123",
|
||||
text: "hello",
|
||||
at: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const result = parseEventContent(json, "test.json") as OneShotEvent;
|
||||
expect(result.type).toBe("one-shot");
|
||||
expect(result.threadTs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses with threadTs", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "one-shot",
|
||||
channelId: "C123",
|
||||
text: "hello",
|
||||
at: "2026-01-01T00:00:00Z",
|
||||
threadTs: "1234567890.123456",
|
||||
});
|
||||
const result = parseEventContent(json, "test.json") as OneShotEvent;
|
||||
expect(result.threadTs).toBe("1234567890.123456");
|
||||
});
|
||||
|
||||
it("throws when missing at field", () => {
|
||||
const json = JSON.stringify({ type: "one-shot", channelId: "C123", text: "hello" });
|
||||
expect(() => parseEventContent(json, "test.json")).toThrow("Missing 'at' field");
|
||||
});
|
||||
});
|
||||
|
||||
describe("periodic events", () => {
|
||||
it("parses without threadTs", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "periodic",
|
||||
channelId: "C123",
|
||||
text: "hello",
|
||||
schedule: "0 * * * *",
|
||||
timezone: "UTC",
|
||||
});
|
||||
const result = parseEventContent(json, "test.json") as PeriodicEvent;
|
||||
expect(result.type).toBe("periodic");
|
||||
expect(result.threadTs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses with threadTs", () => {
|
||||
const json = JSON.stringify({
|
||||
type: "periodic",
|
||||
channelId: "C123",
|
||||
text: "hello",
|
||||
schedule: "0 * * * *",
|
||||
timezone: "UTC",
|
||||
threadTs: "1234567890.123456",
|
||||
});
|
||||
const result = parseEventContent(json, "test.json") as PeriodicEvent;
|
||||
expect(result.threadTs).toBe("1234567890.123456");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation", () => {
|
||||
it("throws on missing required fields", () => {
|
||||
const json = JSON.stringify({ type: "immediate" });
|
||||
expect(() => parseEventContent(json, "test.json")).toThrow("Missing required fields");
|
||||
});
|
||||
|
||||
it("throws on unknown type", () => {
|
||||
const json = JSON.stringify({ type: "unknown", channelId: "C123", text: "hello" });
|
||||
expect(() => parseEventContent(json, "test.json")).toThrow("Unknown event type");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSyntheticEvent", () => {
|
||||
it("creates SlackEvent without threadTs", () => {
|
||||
const event: ImmediateEvent = { type: "immediate", channelId: "C123", text: "run task" };
|
||||
const result = buildSyntheticEvent("task.json", event);
|
||||
|
||||
expect(result.type).toBe("mention");
|
||||
expect(result.channel).toBe("C123");
|
||||
expect(result.user).toBe("EVENT");
|
||||
expect(result.text).toContain("[EVENT:task.json:immediate:immediate]");
|
||||
expect(result.text).toContain("run task");
|
||||
expect(result.threadTs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes threadTs through to SlackEvent", () => {
|
||||
const event: ImmediateEvent = {
|
||||
type: "immediate",
|
||||
channelId: "C123",
|
||||
text: "run task",
|
||||
threadTs: "1234567890.123456",
|
||||
};
|
||||
const result = buildSyntheticEvent("task.json", event);
|
||||
|
||||
expect(result.threadTs).toBe("1234567890.123456");
|
||||
});
|
||||
|
||||
it("includes schedule info for one-shot events", () => {
|
||||
const event: OneShotEvent = {
|
||||
type: "one-shot",
|
||||
channelId: "C123",
|
||||
text: "run once",
|
||||
at: "2026-01-01T00:00:00Z",
|
||||
threadTs: "9999.9999",
|
||||
};
|
||||
const result = buildSyntheticEvent("once.json", event);
|
||||
|
||||
expect(result.text).toContain("one-shot:2026-01-01T00:00:00Z");
|
||||
expect(result.threadTs).toBe("9999.9999");
|
||||
});
|
||||
|
||||
it("includes schedule info for periodic events", () => {
|
||||
const event: PeriodicEvent = {
|
||||
type: "periodic",
|
||||
channelId: "C123",
|
||||
text: "check status",
|
||||
schedule: "0 * * * *",
|
||||
timezone: "UTC",
|
||||
};
|
||||
const result = buildSyntheticEvent("cron.json", event);
|
||||
|
||||
expect(result.text).toContain("periodic:0 * * * *");
|
||||
});
|
||||
|
||||
it("produces valid Slack timestamp format (seconds.microseconds)", () => {
|
||||
const event: ImmediateEvent = { type: "immediate", channelId: "C123", text: "test" };
|
||||
const result = buildSyntheticEvent("fmt.json", event);
|
||||
|
||||
// Slack timestamps are "seconds.microseconds" — e.g., "1712931200.000000"
|
||||
expect(result.ts).toMatch(/^\d+\.\d{6}$/);
|
||||
// Must NOT be a raw millisecond epoch like "1712931200000"
|
||||
expect(result.ts).not.toMatch(/^\d{13,}$/);
|
||||
});
|
||||
|
||||
it("sets user to EVENT (sentinel for synthetic events)", () => {
|
||||
const event: ImmediateEvent = { type: "immediate", channelId: "C456", text: "cron task" };
|
||||
const result = buildSyntheticEvent("cron.json", event);
|
||||
expect(result.user).toBe("EVENT");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSlackContext } from "../context.js";
|
||||
import type { SlackEvent } from "../client.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal SlackBot mock — only the methods context.ts calls are needed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBot() {
|
||||
const postMessage = vi.fn().mockResolvedValue("msg-ts-1");
|
||||
const postInThread = vi.fn().mockResolvedValue("thread-ts-1");
|
||||
const updateMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const deleteMessage = vi.fn().mockResolvedValue(undefined);
|
||||
const uploadFile = vi.fn().mockResolvedValue(undefined);
|
||||
const logBotResponse = vi.fn();
|
||||
|
||||
return {
|
||||
postMessage,
|
||||
postInThread,
|
||||
updateMessage,
|
||||
deleteMessage,
|
||||
uploadFile,
|
||||
logBotResponse,
|
||||
} as unknown as import("../client.js").SlackBot;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeEvent(overrides: Partial<SlackEvent> = {}): SlackEvent {
|
||||
return {
|
||||
type: "mention",
|
||||
channel: "C123",
|
||||
user: "U456",
|
||||
text: "hello",
|
||||
ts: "1700000000.000001",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread routing rules (context.ts line 45):
|
||||
// const threadParent = event.threadTs ?? (isEvent ? undefined : event.ts);
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createSlackContext — threadParent routing", () => {
|
||||
describe("Case A: event.threadTs set, isEvent=false (real Slack thread message)", () => {
|
||||
it("responds via postInThread using event.threadTs as threadParent", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent({ threadTs: "1700000000.000000" });
|
||||
const ctx = createSlackContext(bot, event, false);
|
||||
|
||||
await ctx.respond("hello from thread");
|
||||
|
||||
expect(bot.postInThread).toHaveBeenCalledOnce();
|
||||
const [channel, threadParent] = (bot.postInThread as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(channel).toBe("C123");
|
||||
expect(threadParent).toBe("1700000000.000000"); // event.threadTs
|
||||
expect(bot.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Case B: event.threadTs set, isEvent=true (synthetic event with explicit threadTs)", () => {
|
||||
it("responds via postInThread using event.threadTs as threadParent", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent({ threadTs: "1700000000.000000" });
|
||||
const ctx = createSlackContext(bot, event, true);
|
||||
|
||||
await ctx.respond("synthetic in thread");
|
||||
|
||||
expect(bot.postInThread).toHaveBeenCalledOnce();
|
||||
const [channel, threadParent] = (bot.postInThread as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(channel).toBe("C123");
|
||||
expect(threadParent).toBe("1700000000.000000"); // event.threadTs wins
|
||||
expect(bot.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Case C: event.threadTs undefined, isEvent=false (real Slack message at channel root)", () => {
|
||||
it("responds via postInThread using event.ts as threadParent", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent({ ts: "1700000000.000001" }); // no threadTs
|
||||
const ctx = createSlackContext(bot, event, false);
|
||||
|
||||
await ctx.respond("root message reply");
|
||||
|
||||
expect(bot.postInThread).toHaveBeenCalledOnce();
|
||||
const [channel, threadParent] = (bot.postInThread as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(channel).toBe("C123");
|
||||
expect(threadParent).toBe("1700000000.000001"); // falls back to event.ts
|
||||
expect(bot.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Case D: event.threadTs undefined, isEvent=true (synthetic event, no thread)", () => {
|
||||
it("responds via postMessage (channel root) — no thread anchor for synthetic events", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent({ ts: "1700000000.000001" }); // no threadTs
|
||||
const ctx = createSlackContext(bot, event, true);
|
||||
|
||||
await ctx.respond("synthetic channel-root reply");
|
||||
|
||||
// threadParent === undefined, so postMessage is used
|
||||
expect(bot.postMessage).toHaveBeenCalledOnce();
|
||||
const [channel] = (bot.postMessage as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(channel).toBe("C123");
|
||||
expect(bot.postInThread).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTyping respects the same threadParent routing", () => {
|
||||
it("posts typing indicator in-thread when threadTs is set", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent({ threadTs: "1700000000.000000" });
|
||||
const ctx = createSlackContext(bot, event, false);
|
||||
|
||||
await ctx.setTyping(true);
|
||||
|
||||
expect(bot.postInThread).toHaveBeenCalledOnce();
|
||||
expect(bot.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts typing indicator to channel root when isEvent=true and no threadTs", async () => {
|
||||
const bot = makeBot();
|
||||
const event = makeEvent(); // no threadTs
|
||||
const ctx = createSlackContext(bot, event, true);
|
||||
|
||||
await ctx.setTyping(true);
|
||||
|
||||
expect(bot.postMessage).toHaveBeenCalledOnce();
|
||||
expect(bot.postInThread).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
// Fail-safe channel/user allowlist: denies all events if both env vars are unset; grants access only to listed IDs when set.
|
||||
|
||||
// Fail-safe: if BOTH SLACK_ALLOW_CHANNELS and SLACK_ALLOW_USERS are unset, isAllowed returns false for everything.
|
||||
// If at least one is set, a request passes when (channel matches OR allow-channels-unset) AND (user matches OR allow-users-unset).
|
||||
|
||||
export interface Allowlist {
|
||||
isAllowed(channelId: string, userId: string): boolean;
|
||||
}
|
||||
|
||||
export function createAllowlist(): Allowlist {
|
||||
const rawChannels = process.env.SLACK_ALLOW_CHANNELS;
|
||||
const rawUsers = process.env.SLACK_ALLOW_USERS;
|
||||
|
||||
// Both unset → deny everything (fail-safe default)
|
||||
const bothUnset = rawChannels === undefined && rawUsers === undefined;
|
||||
|
||||
const allowedChannels = rawChannels
|
||||
? new Set(rawChannels.split(",").map((s) => s.trim()).filter(Boolean))
|
||||
: null; // null means "no channel filter" (allow any channel)
|
||||
|
||||
const allowedUsers = rawUsers
|
||||
? new Set(rawUsers.split(",").map((s) => s.trim()).filter(Boolean))
|
||||
: null; // null means "no user filter" (allow any user)
|
||||
|
||||
return {
|
||||
isAllowed(channelId: string, userId: string): boolean {
|
||||
if (bothUnset) return false;
|
||||
|
||||
const channelOk = allowedChannels === null || allowedChannels.has(channelId);
|
||||
const userOk = allowedUsers === null || allowedUsers.has(userId);
|
||||
|
||||
return channelOk && userOk;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,583 @@
|
||||
// Socket Mode client, Slack Web API wrappers, per-channel queue, and user/channel caching for the Slack extension.
|
||||
import { SocketModeClient } from "@slack/socket-mode";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { basename, join } from "path";
|
||||
import * as log from "./log.js";
|
||||
import type { Attachment, ChannelStore } from "./store.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export interface SlackEvent {
|
||||
type: "mention" | "dm";
|
||||
channel: string;
|
||||
ts: string;
|
||||
/** Thread timestamp — when set, responses are posted in this thread instead of creating a new one */
|
||||
threadTs?: string;
|
||||
user: string;
|
||||
text: string;
|
||||
files?: Array<{ name?: string; url_private_download?: string; url_private?: string }>;
|
||||
/** Processed attachments with local paths (populated after logUserMessage) */
|
||||
attachments?: Attachment[];
|
||||
}
|
||||
|
||||
export interface SlackUser {
|
||||
id: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SlackChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ChannelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
userName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface SlackContext {
|
||||
message: {
|
||||
text: string;
|
||||
rawText: string;
|
||||
user: string;
|
||||
userName?: string;
|
||||
channel: string;
|
||||
ts: string;
|
||||
attachments: Array<{ local: string }>;
|
||||
};
|
||||
channelName?: string;
|
||||
channels: ChannelInfo[];
|
||||
users: UserInfo[];
|
||||
respond: (text: string, shouldLog?: boolean) => Promise<void>;
|
||||
replaceMessage: (text: string) => Promise<void>;
|
||||
respondInThread: (text: string) => Promise<void>;
|
||||
setTyping: (isTyping: boolean) => Promise<void>;
|
||||
uploadFile: (filePath: string, title?: string) => Promise<void>;
|
||||
setWorking: (working: boolean) => Promise<void>;
|
||||
deleteMessage: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Per-channel queue for sequential processing
|
||||
// ============================================================================
|
||||
|
||||
type QueuedWork = () => Promise<void>;
|
||||
|
||||
class ChannelQueue {
|
||||
private queue: QueuedWork[] = [];
|
||||
private processing = false;
|
||||
|
||||
enqueue(work: QueuedWork): void {
|
||||
this.queue.push(work);
|
||||
this.processNext();
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
|
||||
private async processNext(): Promise<void> {
|
||||
if (this.processing || this.queue.length === 0) return;
|
||||
this.processing = true;
|
||||
const work = this.queue.shift()!;
|
||||
try {
|
||||
await work();
|
||||
} catch (err) {
|
||||
log.logWarning("Queue error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
this.processing = false;
|
||||
this.processNext();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SlackBot
|
||||
// ============================================================================
|
||||
|
||||
export class SlackBot {
|
||||
private socketClient: SocketModeClient;
|
||||
private webClient: WebClient;
|
||||
private eventHandler: (event: SlackEvent) => Promise<void>;
|
||||
private workingDir: string;
|
||||
private store: ChannelStore;
|
||||
private botUserId: string | null = null;
|
||||
private startupTs: string | null = null; // Messages older than this are just logged, not processed
|
||||
|
||||
private users = new Map<string, SlackUser>();
|
||||
private channels = new Map<string, SlackChannel>();
|
||||
private queues = new Map<string, ChannelQueue>();
|
||||
|
||||
constructor(
|
||||
eventHandler: (event: SlackEvent) => Promise<void>,
|
||||
config: { appToken: string; botToken: string; workingDir: string; store: ChannelStore },
|
||||
) {
|
||||
this.eventHandler = eventHandler;
|
||||
this.workingDir = config.workingDir;
|
||||
this.store = config.store;
|
||||
this.socketClient = new SocketModeClient({ appToken: config.appToken });
|
||||
this.webClient = new WebClient(config.botToken);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Public API
|
||||
// ==========================================================================
|
||||
|
||||
async start(): Promise<void> {
|
||||
const auth = await this.webClient.auth.test();
|
||||
this.botUserId = auth.user_id as string;
|
||||
|
||||
await Promise.all([this.fetchUsers(), this.fetchChannels()]);
|
||||
log.logInfo(`Loaded ${this.channels.size} channels, ${this.users.size} users`);
|
||||
|
||||
await this.backfillAllChannels();
|
||||
|
||||
this.setupEventHandlers();
|
||||
await this.socketClient.start();
|
||||
|
||||
// Record startup time - messages older than this are just logged, not processed
|
||||
this.startupTs = (Date.now() / 1000).toFixed(6);
|
||||
|
||||
log.logConnected();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.socketClient.disconnect();
|
||||
log.logDisconnected();
|
||||
}
|
||||
|
||||
getUser(userId: string): SlackUser | undefined {
|
||||
return this.users.get(userId);
|
||||
}
|
||||
|
||||
getChannel(channelId: string): SlackChannel | undefined {
|
||||
return this.channels.get(channelId);
|
||||
}
|
||||
|
||||
getAllUsers(): SlackUser[] {
|
||||
return Array.from(this.users.values());
|
||||
}
|
||||
|
||||
getAllChannels(): SlackChannel[] {
|
||||
return Array.from(this.channels.values());
|
||||
}
|
||||
|
||||
async postMessage(channel: string, text: string): Promise<string> {
|
||||
const result = await this.webClient.chat.postMessage({ channel, text });
|
||||
return result.ts as string;
|
||||
}
|
||||
|
||||
async updateMessage(channel: string, ts: string, text: string): Promise<void> {
|
||||
await this.webClient.chat.update({ channel, ts, text });
|
||||
}
|
||||
|
||||
async deleteMessage(channel: string, ts: string): Promise<void> {
|
||||
await this.webClient.chat.delete({ channel, ts });
|
||||
}
|
||||
|
||||
async postInThread(channel: string, threadTs: string, text: string): Promise<string> {
|
||||
const result = await this.webClient.chat.postMessage({ channel, thread_ts: threadTs, text });
|
||||
return result.ts as string;
|
||||
}
|
||||
|
||||
async uploadFile(channel: string, filePath: string, title?: string): Promise<void> {
|
||||
const fileName = title || basename(filePath);
|
||||
const fileContent = readFileSync(filePath);
|
||||
await this.webClient.files.uploadV2({
|
||||
channel_id: channel,
|
||||
file: fileContent,
|
||||
filename: fileName,
|
||||
title: fileName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message to log.jsonl (SYNC)
|
||||
* This is the ONLY place messages are written to log.jsonl
|
||||
*/
|
||||
logToFile(channel: string, entry: object): void {
|
||||
const dir = join(this.workingDir, channel);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, "log.jsonl"), `${JSON.stringify(entry)}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a bot response to log.jsonl
|
||||
*/
|
||||
logBotResponse(channel: string, text: string, ts: string): void {
|
||||
this.logToFile(channel, {
|
||||
date: new Date().toISOString(),
|
||||
ts,
|
||||
user: "bot",
|
||||
text,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Events Integration
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Enqueue an event for processing. Always queues (no "already working" rejection).
|
||||
* Returns true if enqueued, false if queue is full (max 5).
|
||||
*/
|
||||
enqueueEvent(event: SlackEvent): boolean {
|
||||
const queue = this.getQueue(event.channel);
|
||||
if (queue.size() >= 5) {
|
||||
log.logWarning(`Event queue full for ${event.channel}, discarding: ${event.text.substring(0, 50)}`);
|
||||
return false;
|
||||
}
|
||||
log.logInfo(`Enqueueing event for ${event.channel}: ${event.text.substring(0, 50)}`);
|
||||
queue.enqueue(() => this.eventHandler(event));
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Private - Event Handlers
|
||||
// ==========================================================================
|
||||
|
||||
private getQueue(channelId: string): ChannelQueue {
|
||||
let queue = this.queues.get(channelId);
|
||||
if (!queue) {
|
||||
queue = new ChannelQueue();
|
||||
this.queues.set(channelId, queue);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
// Channel @mentions
|
||||
this.socketClient.on("app_mention", ({ event, ack }) => {
|
||||
const e = event as {
|
||||
text: string;
|
||||
channel: string;
|
||||
user: string;
|
||||
ts: string;
|
||||
thread_ts?: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
};
|
||||
|
||||
// Skip DMs (handled by message event)
|
||||
if (e.channel.startsWith("D")) {
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
|
||||
const slackEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: e.channel,
|
||||
ts: e.ts,
|
||||
threadTs: e.thread_ts,
|
||||
user: e.user,
|
||||
text: e.text.replace(/<@[A-Z0-9]+>/gi, "").trim(),
|
||||
files: e.files,
|
||||
};
|
||||
|
||||
// SYNC: Log to log.jsonl (ALWAYS, even for old messages)
|
||||
// Also downloads attachments in background and stores local paths
|
||||
slackEvent.attachments = this.logUserMessage(slackEvent);
|
||||
|
||||
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
||||
if (this.startupTs && e.ts < this.startupTs) {
|
||||
log.logInfo(
|
||||
`[${e.channel}] Logged old message (pre-startup), not triggering: ${slackEvent.text.substring(0, 30)}`,
|
||||
);
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
|
||||
this.getQueue(e.channel).enqueue(() => this.eventHandler(slackEvent));
|
||||
|
||||
ack();
|
||||
});
|
||||
|
||||
// All messages (for logging) + DMs (for triggering)
|
||||
this.socketClient.on("message", ({ event, ack }) => {
|
||||
const e = event as {
|
||||
text?: string;
|
||||
channel: string;
|
||||
user?: string;
|
||||
ts: string;
|
||||
thread_ts?: string;
|
||||
channel_type?: string;
|
||||
subtype?: string;
|
||||
bot_id?: string;
|
||||
files?: Array<{ name: string; url_private_download?: string; url_private?: string }>;
|
||||
};
|
||||
|
||||
// Skip bot messages, edits, etc.
|
||||
if (e.bot_id || !e.user || e.user === this.botUserId) {
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
if (e.subtype !== undefined && e.subtype !== "file_share") {
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
if (!e.text && (!e.files || e.files.length === 0)) {
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
|
||||
const isDM = e.channel_type === "im";
|
||||
const isBotMention = e.text?.includes(`<@${this.botUserId}>`);
|
||||
|
||||
// Skip channel @mentions - already handled by app_mention event
|
||||
if (!isDM && isBotMention) {
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
|
||||
const slackEvent: SlackEvent = {
|
||||
type: isDM ? "dm" : "mention",
|
||||
channel: e.channel,
|
||||
ts: e.ts,
|
||||
threadTs: e.thread_ts,
|
||||
user: e.user,
|
||||
text: (e.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim(),
|
||||
files: e.files,
|
||||
};
|
||||
|
||||
// SYNC: Log to log.jsonl (ALL messages - channel chatter and DMs)
|
||||
// Also downloads attachments in background and stores local paths
|
||||
slackEvent.attachments = this.logUserMessage(slackEvent);
|
||||
|
||||
// Only trigger processing for messages AFTER startup (not replayed old messages)
|
||||
if (this.startupTs && e.ts < this.startupTs) {
|
||||
log.logInfo(`[${e.channel}] Skipping old message (pre-startup): ${slackEvent.text.substring(0, 30)}`);
|
||||
ack();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger handler for DMs
|
||||
if (isDM) {
|
||||
this.getQueue(e.channel).enqueue(() => this.eventHandler(slackEvent));
|
||||
}
|
||||
|
||||
ack();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user message to log.jsonl (SYNC)
|
||||
* Downloads attachments in background via store
|
||||
*/
|
||||
private logUserMessage(event: SlackEvent): Attachment[] {
|
||||
const user = this.users.get(event.user);
|
||||
// Process attachments - queues downloads in background
|
||||
const attachments = event.files ? this.store.processAttachments(event.channel, event.files, event.ts) : [];
|
||||
this.logToFile(event.channel, {
|
||||
date: new Date(parseFloat(event.ts) * 1000).toISOString(),
|
||||
ts: event.ts,
|
||||
user: event.user,
|
||||
userName: user?.userName,
|
||||
displayName: user?.displayName,
|
||||
text: event.text,
|
||||
attachments,
|
||||
isBot: false,
|
||||
});
|
||||
return attachments;
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Private - Backfill
|
||||
// ==========================================================================
|
||||
|
||||
private getExistingTimestamps(channelId: string): Set<string> {
|
||||
const logPath = join(this.workingDir, channelId, "log.jsonl");
|
||||
const timestamps = new Set<string>();
|
||||
if (!existsSync(logPath)) return timestamps;
|
||||
|
||||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line) as { ts?: string };
|
||||
if (entry.ts) timestamps.add(entry.ts);
|
||||
} catch {}
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
|
||||
private async backfillChannel(channelId: string): Promise<number> {
|
||||
const existingTs = this.getExistingTimestamps(channelId);
|
||||
|
||||
// Find the biggest ts in log.jsonl
|
||||
let latestTs: string | undefined;
|
||||
for (const ts of existingTs) {
|
||||
if (!latestTs || parseFloat(ts) > parseFloat(latestTs)) latestTs = ts;
|
||||
}
|
||||
|
||||
type Message = {
|
||||
user?: string;
|
||||
bot_id?: string;
|
||||
text?: string;
|
||||
ts?: string;
|
||||
subtype?: string;
|
||||
files?: Array<{ name: string }>;
|
||||
};
|
||||
const allMessages: Message[] = [];
|
||||
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
const maxPages = 3;
|
||||
|
||||
do {
|
||||
const result = await this.webClient.conversations.history({
|
||||
channel: channelId,
|
||||
oldest: latestTs, // Only fetch messages newer than what we have
|
||||
inclusive: false,
|
||||
limit: 1000,
|
||||
cursor,
|
||||
});
|
||||
if (result.messages) {
|
||||
allMessages.push(...(result.messages as Message[]));
|
||||
}
|
||||
cursor = result.response_metadata?.next_cursor;
|
||||
pageCount++;
|
||||
} while (cursor && pageCount < maxPages);
|
||||
|
||||
// Filter: include bot's messages, exclude other bots, skip already logged
|
||||
const relevantMessages = allMessages.filter((msg) => {
|
||||
if (!msg.ts || existingTs.has(msg.ts)) return false; // Skip duplicates
|
||||
if (msg.user === this.botUserId) return true;
|
||||
if (msg.bot_id) return false;
|
||||
if (msg.subtype !== undefined && msg.subtype !== "file_share") return false;
|
||||
if (!msg.user) return false;
|
||||
if (!msg.text && (!msg.files || msg.files.length === 0)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Reverse to chronological order
|
||||
relevantMessages.reverse();
|
||||
|
||||
// Log each message to log.jsonl
|
||||
for (const msg of relevantMessages) {
|
||||
const isBotMessage = msg.user === this.botUserId;
|
||||
const user = this.users.get(msg.user!);
|
||||
// Strip @mentions from text (same as live messages)
|
||||
const text = (msg.text || "").replace(/<@[A-Z0-9]+>/gi, "").trim();
|
||||
// Process attachments - queues downloads in background
|
||||
const attachments = msg.files ? this.store.processAttachments(channelId, msg.files, msg.ts!) : [];
|
||||
|
||||
this.logToFile(channelId, {
|
||||
date: new Date(parseFloat(msg.ts!) * 1000).toISOString(),
|
||||
ts: msg.ts!,
|
||||
user: isBotMessage ? "bot" : msg.user!,
|
||||
userName: isBotMessage ? undefined : user?.userName,
|
||||
displayName: isBotMessage ? undefined : user?.displayName,
|
||||
text,
|
||||
attachments,
|
||||
isBot: isBotMessage,
|
||||
});
|
||||
}
|
||||
|
||||
return relevantMessages.length;
|
||||
}
|
||||
|
||||
private async backfillAllChannels(): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Only backfill channels that already have a log.jsonl (bot has interacted with them before)
|
||||
const channelsToBackfill: Array<[string, SlackChannel]> = [];
|
||||
for (const [channelId, channel] of this.channels) {
|
||||
const logPath = join(this.workingDir, channelId, "log.jsonl");
|
||||
if (existsSync(logPath)) {
|
||||
channelsToBackfill.push([channelId, channel]);
|
||||
}
|
||||
}
|
||||
|
||||
log.logBackfillStart(channelsToBackfill.length);
|
||||
|
||||
let totalMessages = 0;
|
||||
for (const [channelId, channel] of channelsToBackfill) {
|
||||
try {
|
||||
const count = await this.backfillChannel(channelId);
|
||||
if (count > 0) log.logBackfillChannel(channel.name, count);
|
||||
totalMessages += count;
|
||||
} catch (error) {
|
||||
log.logWarning(`Failed to backfill #${channel.name}`, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
log.logBackfillComplete(totalMessages, durationMs);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Private - Fetch Users/Channels
|
||||
// ==========================================================================
|
||||
|
||||
private async fetchUsers(): Promise<void> {
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await this.webClient.users.list({ limit: 200, cursor });
|
||||
const members = result.members as
|
||||
| Array<{ id?: string; name?: string; real_name?: string; deleted?: boolean }>
|
||||
| undefined;
|
||||
if (members) {
|
||||
for (const u of members) {
|
||||
if (u.id && u.name && !u.deleted) {
|
||||
this.users.set(u.id, { id: u.id, userName: u.name, displayName: u.real_name || u.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = result.response_metadata?.next_cursor;
|
||||
} while (cursor);
|
||||
}
|
||||
|
||||
private async fetchChannels(): Promise<void> {
|
||||
// Fetch public/private channels
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const result = await this.webClient.conversations.list({
|
||||
types: "public_channel,private_channel",
|
||||
exclude_archived: true,
|
||||
limit: 200,
|
||||
cursor,
|
||||
});
|
||||
const channels = result.channels as Array<{ id?: string; name?: string; is_member?: boolean }> | undefined;
|
||||
if (channels) {
|
||||
for (const c of channels) {
|
||||
if (c.id && c.name && c.is_member) {
|
||||
this.channels.set(c.id, { id: c.id, name: c.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = result.response_metadata?.next_cursor;
|
||||
} while (cursor);
|
||||
|
||||
// Also fetch DM channels (IMs)
|
||||
cursor = undefined;
|
||||
do {
|
||||
const result = await this.webClient.conversations.list({
|
||||
types: "im",
|
||||
limit: 200,
|
||||
cursor,
|
||||
});
|
||||
const ims = result.channels as Array<{ id?: string; user?: string }> | undefined;
|
||||
if (ims) {
|
||||
for (const im of ims) {
|
||||
if (im.id) {
|
||||
// Use user's name as channel name for DMs
|
||||
const user = im.user ? this.users.get(im.user) : undefined;
|
||||
const name = user ? `DM:${user.userName}` : `DM:${im.id}`;
|
||||
this.channels.set(im.id, { id: im.id, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor = result.response_metadata?.next_cursor;
|
||||
} while (cursor);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Slack response helpers and SlackContext factory for the Pi extension.
|
||||
// Ported from packages/slack/src/main.ts:114-286 (portfolio-advisor vendored copy).
|
||||
import * as log from "./log.js";
|
||||
import type { SlackBot, SlackEvent } from "./client.js";
|
||||
|
||||
// ============================================================================
|
||||
// Public interface
|
||||
// ============================================================================
|
||||
|
||||
export interface SlackContext {
|
||||
respond(text: string, shouldLog?: boolean): Promise<void>;
|
||||
replaceMessage(text: string): Promise<void>;
|
||||
respondInThread(text: string): Promise<void>;
|
||||
setTyping(isTyping: boolean): Promise<void>;
|
||||
uploadFile(filePath: string, title?: string): Promise<void>;
|
||||
setWorking(working: boolean): Promise<void>;
|
||||
deleteMessage(): Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Factory
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Build a SlackContext for a single inbound event.
|
||||
*
|
||||
* Thread routing rule:
|
||||
* const threadParent = event.threadTs ?? (isEvent ? undefined : event.ts);
|
||||
*
|
||||
* When isEvent=true (cron/synthetic event), we never promote the event's own ts
|
||||
* to a thread parent — synthetic events have no real Slack message to anchor to.
|
||||
* When isEvent=false (real user message), event.ts becomes the thread parent so
|
||||
* the bot replies in-thread rather than top-level.
|
||||
*/
|
||||
export function createSlackContext(bot: SlackBot, event: SlackEvent, isEvent: boolean = false): SlackContext {
|
||||
let messageTs: string | null = null;
|
||||
const threadMessageTs: string[] = [];
|
||||
let accumulatedText = "";
|
||||
let isWorking = true;
|
||||
const workingIndicator = " ...";
|
||||
let updatePromise = Promise.resolve();
|
||||
|
||||
// Thread routing: prefer explicit threadTs; for real messages use event.ts;
|
||||
// for synthetic events leave undefined so responses go to channel top-level.
|
||||
const threadParent = event.threadTs ?? (isEvent ? undefined : event.ts);
|
||||
|
||||
// Extract event filename for status message (only relevant for synthetic events)
|
||||
const eventFilename = isEvent ? event.text.match(/^\[EVENT:([^:]+):/)?.[1] : undefined;
|
||||
|
||||
return {
|
||||
respond: async (text: string, shouldLog = true) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
try {
|
||||
accumulatedText = accumulatedText ? `${accumulatedText}\n${text}` : text;
|
||||
|
||||
// Truncate accumulated text if too long (Slack limit is 40K, use 35K for safety)
|
||||
const MAX_MAIN_LENGTH = 35000;
|
||||
const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_";
|
||||
if (accumulatedText.length > MAX_MAIN_LENGTH) {
|
||||
accumulatedText =
|
||||
accumulatedText.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;
|
||||
}
|
||||
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
|
||||
if (messageTs) {
|
||||
await bot.updateMessage(event.channel, messageTs, displayText);
|
||||
} else {
|
||||
messageTs = threadParent
|
||||
? await bot.postInThread(event.channel, threadParent, displayText)
|
||||
: await bot.postMessage(event.channel, displayText);
|
||||
}
|
||||
|
||||
if (shouldLog && messageTs) {
|
||||
bot.logBotResponse(event.channel, text, messageTs);
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning("Slack respond error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
replaceMessage: async (text: string) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
try {
|
||||
// Replace the accumulated text entirely, with truncation
|
||||
const MAX_MAIN_LENGTH = 35000;
|
||||
const truncationNote = "\n\n_(message truncated, ask me to elaborate on specific parts)_";
|
||||
if (text.length > MAX_MAIN_LENGTH) {
|
||||
accumulatedText = text.substring(0, MAX_MAIN_LENGTH - truncationNote.length) + truncationNote;
|
||||
} else {
|
||||
accumulatedText = text;
|
||||
}
|
||||
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
|
||||
if (messageTs) {
|
||||
await bot.updateMessage(event.channel, messageTs, displayText);
|
||||
} else {
|
||||
messageTs = threadParent
|
||||
? await bot.postInThread(event.channel, threadParent, displayText)
|
||||
: await bot.postMessage(event.channel, displayText);
|
||||
}
|
||||
|
||||
// Log final consolidated response
|
||||
if (messageTs) {
|
||||
bot.logBotResponse(event.channel, accumulatedText, messageTs);
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning("Slack replaceMessage error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
respondInThread: async (text: string) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
try {
|
||||
if (messageTs) {
|
||||
// Truncate thread messages if too long (20K limit for safety)
|
||||
const MAX_THREAD_LENGTH = 20000;
|
||||
let threadText = text;
|
||||
if (threadText.length > MAX_THREAD_LENGTH) {
|
||||
threadText = `${threadText.substring(0, MAX_THREAD_LENGTH - 50)}\n\n_(truncated)_`;
|
||||
}
|
||||
|
||||
const ts = await bot.postInThread(event.channel, messageTs, threadText);
|
||||
threadMessageTs.push(ts);
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning("Slack respondInThread error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
setTyping: async (isTyping: boolean) => {
|
||||
if (isTyping && !messageTs) {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
try {
|
||||
if (!messageTs) {
|
||||
accumulatedText = eventFilename ? `_Starting event: ${eventFilename}_` : "_Thinking_";
|
||||
messageTs = threadParent
|
||||
? await bot.postInThread(event.channel, threadParent, accumulatedText + workingIndicator)
|
||||
: await bot.postMessage(event.channel, accumulatedText + workingIndicator);
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning("Slack setTyping error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
}
|
||||
},
|
||||
|
||||
uploadFile: async (filePath: string, title?: string) => {
|
||||
await bot.uploadFile(event.channel, filePath, title);
|
||||
},
|
||||
|
||||
setWorking: async (working: boolean) => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
try {
|
||||
isWorking = working;
|
||||
if (messageTs) {
|
||||
const displayText = isWorking ? accumulatedText + workingIndicator : accumulatedText;
|
||||
await bot.updateMessage(event.channel, messageTs, displayText);
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning("Slack setWorking error", err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
|
||||
deleteMessage: async () => {
|
||||
updatePromise = updatePromise.then(async () => {
|
||||
// Delete thread messages first (in reverse order)
|
||||
for (let i = threadMessageTs.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
await bot.deleteMessage(event.channel, threadMessageTs[i]);
|
||||
} catch {
|
||||
// Ignore errors deleting thread messages
|
||||
}
|
||||
}
|
||||
threadMessageTs.length = 0;
|
||||
// Then delete main message
|
||||
if (messageTs) {
|
||||
await bot.deleteMessage(event.channel, messageTs);
|
||||
messageTs = null;
|
||||
}
|
||||
});
|
||||
await updatePromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Channel-history backfill utility: fetches full conversation history from the Slack Web API and prints chronologically.
|
||||
import { LogLevel, WebClient } from "@slack/web-api";
|
||||
|
||||
interface Message {
|
||||
ts: string;
|
||||
user?: string;
|
||||
text?: string;
|
||||
thread_ts?: string;
|
||||
reply_count?: number;
|
||||
files?: Array<{ name: string; url_private?: string }>;
|
||||
}
|
||||
|
||||
function formatTs(ts: string): string {
|
||||
const date = new Date(parseFloat(ts) * 1000);
|
||||
return date
|
||||
.toISOString()
|
||||
.replace("T", " ")
|
||||
.replace(/\.\d+Z$/, "");
|
||||
}
|
||||
|
||||
function formatMessage(ts: string, user: string, text: string, indent = ""): string {
|
||||
const prefix = `[${formatTs(ts)}] ${user}: `;
|
||||
const lines = text.split("\n");
|
||||
const firstLine = `${indent}${prefix}${lines[0]}`;
|
||||
if (lines.length === 1) return firstLine;
|
||||
// All continuation lines get same indent as content start
|
||||
const contentIndent = indent + " ".repeat(prefix.length);
|
||||
return [firstLine, ...lines.slice(1).map((l) => contentIndent + l)].join("\n");
|
||||
}
|
||||
|
||||
export async function downloadChannel(channelId: string, botToken: string): Promise<void> {
|
||||
const client = new WebClient(botToken, { logLevel: LogLevel.ERROR });
|
||||
|
||||
console.error(`Fetching channel info for ${channelId}...`);
|
||||
|
||||
// Get channel info
|
||||
let channelName = channelId;
|
||||
try {
|
||||
const info = await client.conversations.info({ channel: channelId });
|
||||
channelName = (info.channel as { name?: string })?.name || channelId;
|
||||
} catch {
|
||||
// DM channels don't have names, that's fine
|
||||
}
|
||||
|
||||
console.error(`Downloading history for #${channelName} (${channelId})...`);
|
||||
|
||||
// Fetch all messages
|
||||
const messages: Message[] = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.conversations.history({
|
||||
channel: channelId,
|
||||
limit: 200,
|
||||
cursor,
|
||||
});
|
||||
|
||||
if (response.messages) {
|
||||
messages.push(...(response.messages as Message[]));
|
||||
}
|
||||
|
||||
cursor = response.response_metadata?.next_cursor;
|
||||
console.error(` Fetched ${messages.length} messages...`);
|
||||
} while (cursor);
|
||||
|
||||
// Reverse to chronological order
|
||||
messages.reverse();
|
||||
|
||||
// Build map of thread replies
|
||||
const threadReplies = new Map<string, Message[]>();
|
||||
const threadsToFetch = messages.filter((m) => m.reply_count && m.reply_count > 0);
|
||||
|
||||
console.error(`Fetching ${threadsToFetch.length} threads...`);
|
||||
|
||||
for (let i = 0; i < threadsToFetch.length; i++) {
|
||||
const parent = threadsToFetch[i];
|
||||
console.error(` Thread ${i + 1}/${threadsToFetch.length} (${parent.reply_count} replies)...`);
|
||||
|
||||
const replies: Message[] = [];
|
||||
let threadCursor: string | undefined;
|
||||
|
||||
do {
|
||||
const response = await client.conversations.replies({
|
||||
channel: channelId,
|
||||
ts: parent.ts,
|
||||
limit: 200,
|
||||
cursor: threadCursor,
|
||||
});
|
||||
|
||||
if (response.messages) {
|
||||
// Skip the first message (it's the parent)
|
||||
replies.push(...(response.messages as Message[]).slice(1));
|
||||
}
|
||||
|
||||
threadCursor = response.response_metadata?.next_cursor;
|
||||
} while (threadCursor);
|
||||
|
||||
threadReplies.set(parent.ts, replies);
|
||||
}
|
||||
|
||||
// Output messages with thread replies interleaved
|
||||
let totalReplies = 0;
|
||||
for (const msg of messages) {
|
||||
// Output the message
|
||||
console.log(formatMessage(msg.ts, msg.user || "unknown", msg.text || ""));
|
||||
|
||||
// Output thread replies right after parent (indented)
|
||||
const replies = threadReplies.get(msg.ts);
|
||||
if (replies) {
|
||||
for (const reply of replies) {
|
||||
console.log(formatMessage(reply.ts, reply.user || "unknown", reply.text || "", " "));
|
||||
totalReplies++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Done! ${messages.length} messages, ${totalReplies} thread replies`);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
// Cron-driven scheduler that watches a directory for event JSON files and dispatches
|
||||
// synthetic Slack events. Ported near-verbatim from
|
||||
// packages/slack/src/events.ts (portfolio-advisor vendored copy).
|
||||
import { Cron } from "croner";
|
||||
import { existsSync, type FSWatcher, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import * as log from "./log.js";
|
||||
import type { SlackEvent } from "./client.js";
|
||||
|
||||
// ============================================================================
|
||||
// Event Types
|
||||
// ============================================================================
|
||||
|
||||
export interface ImmediateEvent {
|
||||
type: "immediate";
|
||||
channelId: string;
|
||||
text: string;
|
||||
threadTs?: string;
|
||||
}
|
||||
|
||||
export interface OneShotEvent {
|
||||
type: "one-shot";
|
||||
channelId: string;
|
||||
text: string;
|
||||
at: string; // ISO 8601 with timezone offset
|
||||
threadTs?: string;
|
||||
}
|
||||
|
||||
export interface PeriodicEvent {
|
||||
type: "periodic";
|
||||
channelId: string;
|
||||
text: string;
|
||||
schedule: string; // cron syntax
|
||||
timezone: string; // IANA timezone
|
||||
threadTs?: string;
|
||||
}
|
||||
|
||||
export type HarnessEvent = ImmediateEvent | OneShotEvent | PeriodicEvent;
|
||||
|
||||
// ============================================================================
|
||||
// Parsing & synthetic event helpers (exported for testing / harness tooling)
|
||||
// ============================================================================
|
||||
|
||||
export function parseEventContent(content: string, filename: string): HarnessEvent | null {
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (!data.type || !data.channelId || !data.text) {
|
||||
throw new Error(`Missing required fields (type, channelId, text) in ${filename}`);
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case "immediate":
|
||||
return { type: "immediate", channelId: data.channelId, text: data.text, threadTs: data.threadTs };
|
||||
|
||||
case "one-shot":
|
||||
if (!data.at) {
|
||||
throw new Error(`Missing 'at' field for one-shot event in ${filename}`);
|
||||
}
|
||||
return {
|
||||
type: "one-shot",
|
||||
channelId: data.channelId,
|
||||
text: data.text,
|
||||
at: data.at,
|
||||
threadTs: data.threadTs,
|
||||
};
|
||||
|
||||
case "periodic":
|
||||
if (!data.schedule) {
|
||||
throw new Error(`Missing 'schedule' field for periodic event in ${filename}`);
|
||||
}
|
||||
if (!data.timezone) {
|
||||
throw new Error(`Missing 'timezone' field for periodic event in ${filename}`);
|
||||
}
|
||||
return {
|
||||
type: "periodic",
|
||||
channelId: data.channelId,
|
||||
text: data.text,
|
||||
schedule: data.schedule,
|
||||
timezone: data.timezone,
|
||||
threadTs: data.threadTs,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown event type '${data.type}' in ${filename}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSyntheticEvent(filename: string, event: HarnessEvent): SlackEvent {
|
||||
let scheduleInfo: string;
|
||||
switch (event.type) {
|
||||
case "immediate":
|
||||
scheduleInfo = "immediate";
|
||||
break;
|
||||
case "one-shot":
|
||||
scheduleInfo = event.at;
|
||||
break;
|
||||
case "periodic":
|
||||
scheduleInfo = event.schedule;
|
||||
break;
|
||||
}
|
||||
|
||||
const message = `[EVENT:${filename}:${event.type}:${scheduleInfo}] ${event.text}`;
|
||||
|
||||
return {
|
||||
type: "mention",
|
||||
channel: event.channelId,
|
||||
user: "EVENT",
|
||||
text: message,
|
||||
ts: (Date.now() / 1000).toFixed(6),
|
||||
threadTs: event.threadTs,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EventsWatcher
|
||||
// ============================================================================
|
||||
|
||||
const DEBOUNCE_MS = 100;
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_BASE_MS = 100;
|
||||
|
||||
export class EventsWatcher {
|
||||
private timers: Map<string, NodeJS.Timeout> = new Map();
|
||||
private crons: Map<string, Cron> = new Map();
|
||||
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
|
||||
private startTime: number;
|
||||
private watcher: FSWatcher | null = null;
|
||||
private knownFiles: Set<string> = new Set();
|
||||
|
||||
constructor(
|
||||
private eventsDir: string,
|
||||
private dispatch: (event: SlackEvent) => Promise<void>,
|
||||
) {
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching for events. Call this after the bot is ready.
|
||||
*/
|
||||
start(): void {
|
||||
// Ensure events directory exists
|
||||
if (!existsSync(this.eventsDir)) {
|
||||
mkdirSync(this.eventsDir, { recursive: true });
|
||||
}
|
||||
|
||||
log.logInfo(`Events watcher starting, dir: ${this.eventsDir}`);
|
||||
|
||||
// Scan existing files
|
||||
this.scanExisting();
|
||||
|
||||
// Watch for changes
|
||||
this.watcher = watch(this.eventsDir, (_eventType, filename) => {
|
||||
if (!filename || !filename.endsWith(".json")) return;
|
||||
this.debounce(filename, () => this.handleFileChange(filename));
|
||||
});
|
||||
|
||||
log.logInfo(`Events watcher started, tracking ${this.knownFiles.size} files`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching and cancel all scheduled events.
|
||||
*/
|
||||
stop(): void {
|
||||
// Stop fs watcher
|
||||
if (this.watcher) {
|
||||
this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
|
||||
// Cancel all debounce timers
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.debounceTimers.clear();
|
||||
|
||||
// Cancel all scheduled timers
|
||||
for (const timer of this.timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers.clear();
|
||||
|
||||
// Cancel all cron jobs
|
||||
for (const cron of this.crons.values()) {
|
||||
cron.stop();
|
||||
}
|
||||
this.crons.clear();
|
||||
|
||||
this.knownFiles.clear();
|
||||
log.logInfo("Events watcher stopped");
|
||||
}
|
||||
|
||||
private debounce(filename: string, fn: () => void): void {
|
||||
const existing = this.debounceTimers.get(filename);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
this.debounceTimers.set(
|
||||
filename,
|
||||
setTimeout(() => {
|
||||
this.debounceTimers.delete(filename);
|
||||
fn();
|
||||
}, DEBOUNCE_MS),
|
||||
);
|
||||
}
|
||||
|
||||
private scanExisting(): void {
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(this.eventsDir).filter((f) => f.endsWith(".json"));
|
||||
} catch (err) {
|
||||
log.logWarning("Failed to read events directory", String(err));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const filename of files) {
|
||||
this.handleFile(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private handleFileChange(filename: string): void {
|
||||
const filePath = join(this.eventsDir, filename);
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
// File was deleted
|
||||
this.handleDelete(filename);
|
||||
} else if (this.knownFiles.has(filename)) {
|
||||
// File was modified — cancel existing and re-schedule
|
||||
this.cancelScheduled(filename);
|
||||
this.handleFile(filename);
|
||||
} else {
|
||||
// New file
|
||||
this.handleFile(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private handleDelete(filename: string): void {
|
||||
if (!this.knownFiles.has(filename)) return;
|
||||
|
||||
log.logInfo(`Event file deleted: ${filename}`);
|
||||
this.cancelScheduled(filename);
|
||||
this.knownFiles.delete(filename);
|
||||
}
|
||||
|
||||
private cancelScheduled(filename: string): void {
|
||||
const timer = this.timers.get(filename);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.timers.delete(filename);
|
||||
}
|
||||
|
||||
const cron = this.crons.get(filename);
|
||||
if (cron) {
|
||||
cron.stop();
|
||||
this.crons.delete(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleFile(filename: string): Promise<void> {
|
||||
const filePath = join(this.eventsDir, filename);
|
||||
|
||||
// Parse with retries
|
||||
let event: HarnessEvent | null = null;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
event = parseEventContent(content, filename);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (i < MAX_RETRIES - 1) {
|
||||
await this.sleep(RETRY_BASE_MS * 2 ** i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
|
||||
this.deleteFile(filename);
|
||||
return;
|
||||
}
|
||||
|
||||
this.knownFiles.add(filename);
|
||||
|
||||
// Schedule based on type
|
||||
switch (event.type) {
|
||||
case "immediate":
|
||||
this.handleImmediate(filename, event);
|
||||
break;
|
||||
case "one-shot":
|
||||
this.handleOneShot(filename, event);
|
||||
break;
|
||||
case "periodic":
|
||||
this.handlePeriodic(filename, event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleImmediate(filename: string, event: ImmediateEvent): void {
|
||||
const filePath = join(this.eventsDir, filename);
|
||||
|
||||
// Check if stale (created before watcher started)
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (stat.mtimeMs < this.startTime) {
|
||||
log.logInfo(`Stale immediate event, deleting: ${filename}`);
|
||||
this.deleteFile(filename);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// File may have been deleted
|
||||
return;
|
||||
}
|
||||
|
||||
log.logInfo(`Executing immediate event: ${filename}`);
|
||||
this.execute(filename, event);
|
||||
}
|
||||
|
||||
private handleOneShot(filename: string, event: OneShotEvent): void {
|
||||
const atTime = new Date(event.at).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
if (atTime <= now) {
|
||||
// Past — delete without executing
|
||||
log.logInfo(`One-shot event in the past, deleting: ${filename}`);
|
||||
this.deleteFile(filename);
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = atTime - now;
|
||||
log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(filename);
|
||||
log.logInfo(`Executing one-shot event: ${filename}`);
|
||||
this.execute(filename, event);
|
||||
}, delay);
|
||||
|
||||
this.timers.set(filename, timer);
|
||||
}
|
||||
|
||||
private handlePeriodic(filename: string, event: PeriodicEvent): void {
|
||||
try {
|
||||
const cron = new Cron(event.schedule, { timezone: event.timezone }, () => {
|
||||
log.logInfo(`Executing periodic event: ${filename}`);
|
||||
this.execute(filename, event, false); // Don't delete periodic events
|
||||
});
|
||||
|
||||
this.crons.set(filename, cron);
|
||||
|
||||
const next = cron.nextRun();
|
||||
log.logInfo(`Scheduled periodic event: ${filename}, next run: ${next?.toISOString() ?? "unknown"}`);
|
||||
} catch (err) {
|
||||
log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
|
||||
this.deleteFile(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private execute(filename: string, event: HarnessEvent, deleteAfter: boolean = true): void {
|
||||
const syntheticEvent = buildSyntheticEvent(filename, event);
|
||||
|
||||
// Dispatch the synthetic event
|
||||
const enqueued = this.dispatchEvent(syntheticEvent);
|
||||
|
||||
if (enqueued && deleteAfter) {
|
||||
// Delete file after successful dispatch (immediate and one-shot)
|
||||
this.deleteFile(filename);
|
||||
} else if (!enqueued) {
|
||||
log.logWarning(`Event dispatch failed, discarded: ${filename}`);
|
||||
// Still delete immediate/one-shot even if discarded
|
||||
if (deleteAfter) {
|
||||
this.deleteFile(filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchEvent(event: SlackEvent): boolean {
|
||||
try {
|
||||
// Fire-and-forget; errors are caught inside dispatch callback
|
||||
this.dispatch(event).catch((err) => {
|
||||
log.logWarning("Event dispatch error", err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.logWarning("Event dispatch threw synchronously", err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private deleteFile(filename: string): void {
|
||||
const filePath = join(this.eventsDir, filename);
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
// ENOENT is fine (file already deleted), other errors are warnings
|
||||
if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
log.logWarning(`Failed to delete event file: ${filename}`, String(err));
|
||||
}
|
||||
}
|
||||
this.knownFiles.delete(filename);
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and start an events watcher. The watch path defaults to
|
||||
* `${baseDir}/events/` — pass baseDir (same as the store's baseDir).
|
||||
*/
|
||||
export function createEventsWatcher(
|
||||
baseDir: string,
|
||||
dispatch: (event: SlackEvent) => Promise<void>,
|
||||
): EventsWatcher {
|
||||
const eventsDir = join(baseDir, "events");
|
||||
return new EventsWatcher(eventsDir, dispatch);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
// Pi extension factory for Slack: wires SlackBot, ChannelStore, EventsWatcher,
|
||||
// allowlist, SlackContext, and the four slack_* tools into a single Pi
|
||||
// extension lifecycle. Bridges inbound Slack events into pi.sendUserMessage()
|
||||
// and posts assistant turn output back to Slack via SlackContext.
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
SessionStartEvent,
|
||||
SessionShutdownEvent,
|
||||
TurnEndEvent,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { SlackBot, type SlackEvent } from "./client.js";
|
||||
import { ChannelStore } from "./store.js";
|
||||
import { EventsWatcher, createEventsWatcher } from "./events.js";
|
||||
import { createSlackContext } from "./context.js";
|
||||
import { registerSlackTools } from "./tools.js";
|
||||
import { createAllowlist, type Allowlist } from "./allowlist.js";
|
||||
import { downloadChannel } from "./download.js";
|
||||
import * as log from "./log.js";
|
||||
|
||||
// Lifted to module scope so the bridge closure can read the latest values
|
||||
// across reloads without re-binding the SlackBot.
|
||||
type Ctx = ExtensionContext;
|
||||
|
||||
export default function (pi: ExtensionAPI): void {
|
||||
// --------------------------------------------------------------------------
|
||||
// Closure state
|
||||
// --------------------------------------------------------------------------
|
||||
let bot: SlackBot | undefined;
|
||||
let store: ChannelStore | undefined;
|
||||
let eventsWatcher: EventsWatcher | undefined;
|
||||
let allowlist: Allowlist | undefined;
|
||||
|
||||
// Bridge state — set on each inbound Slack event; read by tools.ts getters
|
||||
// and by turn_end to know where to post the assistant's reply.
|
||||
let currentChannel: string | undefined;
|
||||
let currentThread: string | undefined;
|
||||
|
||||
// Refreshed on every session_start so the bridge always uses the live ctx.
|
||||
let currentCtx: Ctx | undefined;
|
||||
|
||||
// Per-turn flag: set if the agent invoked any slack_* tool during the turn.
|
||||
// turn_end uses this to avoid double-posting (the tool already replied).
|
||||
let slackToolUsedThisTurn = false;
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pull plain text from an AgentMessage. AssistantMessage.content is an array
|
||||
* of TextContent | ThinkingContent | ToolCall — only TextContent has user-
|
||||
* facing text we want to post to Slack. UserMessage.content can be a string
|
||||
* or an array of TextContent | ImageContent.
|
||||
*/
|
||||
function extractAssistantText(message: AgentMessage): string {
|
||||
// Only post for assistant messages
|
||||
if (!message || (message as { role?: string }).role !== "assistant") return "";
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
|
||||
const t = (block as { text?: unknown }).text;
|
||||
if (typeof t === "string" && t.length > 0) parts.push(t);
|
||||
}
|
||||
}
|
||||
return parts.join("\n").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbound Slack -> Pi bridge. Allowlist gates, then injects as a user message.
|
||||
* The slack_* tools and the turn_end handler later read currentChannel/Thread
|
||||
* to send the response back to Slack.
|
||||
*/
|
||||
async function bridge(event: SlackEvent): Promise<void> {
|
||||
if (!allowlist || !allowlist.isAllowed(event.channel, event.user)) {
|
||||
return; // allowlist denies by default; silently drop
|
||||
}
|
||||
|
||||
currentChannel = event.channel;
|
||||
currentThread = event.threadTs ?? event.ts;
|
||||
|
||||
const text = `[Slack #${event.channel}] ${event.user}: ${event.text}`;
|
||||
const ctx = currentCtx;
|
||||
try {
|
||||
if (ctx && ctx.isIdle()) {
|
||||
pi.sendUserMessage(text);
|
||||
} else {
|
||||
// sendUserMessage throws if streaming and deliverAs is omitted.
|
||||
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[slack] bridge inject failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// session_start
|
||||
// --------------------------------------------------------------------------
|
||||
pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
|
||||
currentCtx = ctx;
|
||||
|
||||
const appToken = process.env.SLACK_APP_TOKEN;
|
||||
const botToken = process.env.SLACK_BOT_TOKEN;
|
||||
if (!appToken || !botToken) {
|
||||
const msg = "Slack extension: missing SLACK_APP_TOKEN/SLACK_BOT_TOKEN; not connecting";
|
||||
if (ctx.hasUI) {
|
||||
ctx.ui.notify(msg, "warning");
|
||||
} else {
|
||||
log.logWarning(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Already initialized in this process? (e.g. session_start fires for a
|
||||
// reload) — bail out before constructing duplicate sockets.
|
||||
if (bot) {
|
||||
log.logInfo("Slack extension: already connected, skipping re-init");
|
||||
return;
|
||||
}
|
||||
|
||||
const baseDir = process.env.SLACK_BASE_DIR
|
||||
? process.env.SLACK_BASE_DIR
|
||||
: path.join(os.homedir(), ".pi", "cache", "slack");
|
||||
if (!existsSync(baseDir)) {
|
||||
mkdirSync(baseDir, { recursive: true });
|
||||
}
|
||||
|
||||
allowlist = createAllowlist();
|
||||
store = new ChannelStore({ baseDir, botToken });
|
||||
|
||||
// Construct bot — eventHandler is first positional, config is second.
|
||||
bot = new SlackBot(bridge, {
|
||||
appToken,
|
||||
botToken,
|
||||
workingDir: baseDir,
|
||||
store,
|
||||
});
|
||||
|
||||
// Start Slack Socket Mode + Web API. This also runs SlackBot's internal
|
||||
// channel backfill for any channel that already has a log.jsonl.
|
||||
try {
|
||||
await bot.start();
|
||||
} catch (err) {
|
||||
log.logWarning("Slack extension: failed to start bot", err instanceof Error ? err.message : String(err));
|
||||
bot = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional full-history download via download.ts (slow; off by default).
|
||||
// SLACK_BACKFILL=<channelId>[,<channelId>...] enables this. Allowlist still applies.
|
||||
if (process.env.SLACK_BACKFILL) {
|
||||
const channels = process.env.SLACK_BACKFILL.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
for (const ch of channels) {
|
||||
if (!allowlist.isAllowed(ch, "EVENT")) {
|
||||
log.logWarning(`SLACK_BACKFILL: channel ${ch} not allowed, skipping`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await downloadChannel(ch, botToken);
|
||||
} catch (err) {
|
||||
log.logWarning(
|
||||
`SLACK_BACKFILL: failed for ${ch}`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cron/event watcher: drives synthetic SlackEvents into the same bridge.
|
||||
eventsWatcher = createEventsWatcher(baseDir, bridge);
|
||||
eventsWatcher.start();
|
||||
|
||||
// Register the four slack_* tools (slack_post, slack_reply, slack_react, slack_upload)
|
||||
registerSlackTools(
|
||||
pi,
|
||||
bot,
|
||||
() => currentChannel,
|
||||
() => currentThread,
|
||||
);
|
||||
|
||||
// Persist a marker so reloads can detect prior connection state if needed.
|
||||
pi.appendEntry("slack-extension-state", { connectedAt: Date.now(), baseDir });
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// tool_execution_end — note any slack_* tool use to avoid double-posting
|
||||
// --------------------------------------------------------------------------
|
||||
pi.on("tool_execution_end", async (event) => {
|
||||
if (event.toolName && event.toolName.startsWith("slack_")) {
|
||||
slackToolUsedThisTurn = true;
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// turn_end — post assistant text back to Slack if the turn didn't already
|
||||
// --------------------------------------------------------------------------
|
||||
pi.on("turn_end", async (event: TurnEndEvent, _ctx: ExtensionContext) => {
|
||||
try {
|
||||
// If the agent already used slack_post / slack_reply / etc, don't double-post.
|
||||
if (slackToolUsedThisTurn) {
|
||||
slackToolUsedThisTurn = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!bot || !currentChannel) return;
|
||||
|
||||
const assistantText = extractAssistantText(event.message);
|
||||
if (!assistantText) return;
|
||||
|
||||
const syntheticEvent: SlackEvent = {
|
||||
type: "mention",
|
||||
channel: currentChannel,
|
||||
user: "pi-agent",
|
||||
text: "",
|
||||
ts: "0",
|
||||
threadTs: currentThread,
|
||||
};
|
||||
// isEvent=true keeps respond() from promoting ts="0" into a thread parent.
|
||||
const slackCtx = createSlackContext(bot, syntheticEvent, true);
|
||||
await slackCtx.respond(assistantText);
|
||||
await slackCtx.setWorking(false);
|
||||
} catch (err) {
|
||||
log.logWarning(
|
||||
"Slack extension: turn_end post-back failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// session_shutdown — close sockets, stop watchers, clear state
|
||||
// --------------------------------------------------------------------------
|
||||
pi.on("session_shutdown", async (_event: SessionShutdownEvent, _ctx: ExtensionContext) => {
|
||||
try {
|
||||
if (eventsWatcher) {
|
||||
eventsWatcher.stop();
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning(
|
||||
"Slack extension: events watcher stop failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
try {
|
||||
if (bot) {
|
||||
await bot.stop();
|
||||
}
|
||||
} catch (err) {
|
||||
log.logWarning(
|
||||
"Slack extension: bot stop failed",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
// ChannelStore has no flush() — appendFile is awaited per call already.
|
||||
bot = undefined;
|
||||
store = undefined;
|
||||
eventsWatcher = undefined;
|
||||
allowlist = undefined;
|
||||
currentChannel = undefined;
|
||||
currentThread = undefined;
|
||||
currentCtx = undefined;
|
||||
slackToolUsedThisTurn = false;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Structured console logging for the Slack extension: timestamps, context labels, chalk colors.
|
||||
import chalk from "chalk";
|
||||
|
||||
export interface LogContext {
|
||||
channelId: string;
|
||||
userName?: string;
|
||||
channelName?: string; // For display like #dev-team vs C16HET4EQ
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
const ss = String(now.getSeconds()).padStart(2, "0");
|
||||
return `[${hh}:${mm}:${ss}]`;
|
||||
}
|
||||
|
||||
function formatContext(ctx: LogContext): string {
|
||||
// DMs: [DM:username]
|
||||
// Channels: [#channel-name:username] or [C16HET4EQ:username] if no name
|
||||
if (ctx.channelId.startsWith("D")) {
|
||||
return `[DM:${ctx.userName || ctx.channelId}]`;
|
||||
}
|
||||
const channel = ctx.channelName || ctx.channelId;
|
||||
const user = ctx.userName || "unknown";
|
||||
return `[${channel.startsWith("#") ? channel : `#${channel}`}:${user}]`;
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
return `${text.substring(0, maxLen)}\n(truncated at ${maxLen} chars)`;
|
||||
}
|
||||
|
||||
function formatToolArgs(args: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
// Skip the label - it's already shown in the tool name
|
||||
if (key === "label") continue;
|
||||
|
||||
// For read tool, format path with offset/limit
|
||||
if (key === "path" && typeof value === "string") {
|
||||
const offset = args.offset as number | undefined;
|
||||
const limit = args.limit as number | undefined;
|
||||
if (offset !== undefined && limit !== undefined) {
|
||||
lines.push(`${value}:${offset}-${offset + limit}`);
|
||||
} else {
|
||||
lines.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip offset/limit since we already handled them
|
||||
if (key === "offset" || key === "limit") continue;
|
||||
|
||||
// For other values, format them
|
||||
if (typeof value === "string") {
|
||||
lines.push(value);
|
||||
} else {
|
||||
lines.push(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// User messages
|
||||
export function logUserMessage(ctx: LogContext, text: string): void {
|
||||
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} ${text}`));
|
||||
}
|
||||
|
||||
// Tool execution
|
||||
export function logToolStart(ctx: LogContext, toolName: string, label: string, args: Record<string, unknown>): void {
|
||||
const formattedArgs = formatToolArgs(args);
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↳ ${toolName}: ${label}`));
|
||||
if (formattedArgs) {
|
||||
const indented = formattedArgs
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
}
|
||||
|
||||
export function logToolSuccess(ctx: LogContext, toolName: string, durationMs: number, result: string): void {
|
||||
const duration = (durationMs / 1000).toFixed(1);
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ ${toolName} (${duration}s)`));
|
||||
|
||||
const truncated = truncate(result, 1000);
|
||||
if (truncated) {
|
||||
const indented = truncated
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
}
|
||||
|
||||
export function logToolError(ctx: LogContext, toolName: string, durationMs: number, error: string): void {
|
||||
const duration = (durationMs / 1000).toFixed(1);
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ ${toolName} (${duration}s)`));
|
||||
|
||||
const truncated = truncate(error, 1000);
|
||||
const indented = truncated
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
|
||||
// Response streaming
|
||||
export function logResponseStart(ctx: LogContext): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} → Streaming response...`));
|
||||
}
|
||||
|
||||
export function logThinking(ctx: LogContext, thinking: string): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💭 Thinking`));
|
||||
const truncated = truncate(thinking, 1000);
|
||||
const indented = truncated
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
|
||||
export function logResponse(ctx: LogContext, text: string): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💬 Response`));
|
||||
const truncated = truncate(text, 1000);
|
||||
const indented = truncated
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
|
||||
// Attachments
|
||||
export function logDownloadStart(ctx: LogContext, filename: string, localPath: string): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ↓ Downloading attachment`));
|
||||
console.log(chalk.dim(` ${filename} → ${localPath}`));
|
||||
}
|
||||
|
||||
export function logDownloadSuccess(ctx: LogContext, sizeKB: number): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✓ Downloaded (${sizeKB.toLocaleString()} KB)`));
|
||||
}
|
||||
|
||||
export function logDownloadError(ctx: LogContext, filename: string, error: string): void {
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ✗ Download failed`));
|
||||
console.log(chalk.dim(` ${filename}: ${error}`));
|
||||
}
|
||||
|
||||
// Control
|
||||
export function logStopRequest(ctx: LogContext): void {
|
||||
console.log(chalk.green(`${timestamp()} ${formatContext(ctx)} stop`));
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} ⊗ Stop requested - aborting`));
|
||||
}
|
||||
|
||||
// System
|
||||
export function logInfo(message: string): void {
|
||||
console.log(chalk.blue(`${timestamp()} [system] ${message}`));
|
||||
}
|
||||
|
||||
export function logWarning(message: string, details?: string): void {
|
||||
console.log(chalk.yellow(`${timestamp()} [system] ⚠ ${message}`));
|
||||
if (details) {
|
||||
const indented = details
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
}
|
||||
|
||||
export function logAgentError(ctx: LogContext | "system", error: string): void {
|
||||
const context = ctx === "system" ? "[system]" : formatContext(ctx);
|
||||
console.log(chalk.yellow(`${timestamp()} ${context} ✗ Agent error`));
|
||||
const indented = error
|
||||
.split("\n")
|
||||
.map((line) => ` ${line}`)
|
||||
.join("\n");
|
||||
console.log(chalk.dim(indented));
|
||||
}
|
||||
|
||||
// Usage summary (returns formatted string for posting to Slack)
|
||||
export function logUsageSummary(
|
||||
ctx: LogContext,
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
|
||||
},
|
||||
contextTokens?: number,
|
||||
contextWindow?: number,
|
||||
): string {
|
||||
const formatTokens = (count: number): string => {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("*Usage Summary*");
|
||||
lines.push(`Tokens: ${usage.input.toLocaleString()} in, ${usage.output.toLocaleString()} out`);
|
||||
if (usage.cacheRead > 0 || usage.cacheWrite > 0) {
|
||||
lines.push(`Cache: ${usage.cacheRead.toLocaleString()} read, ${usage.cacheWrite.toLocaleString()} write`);
|
||||
}
|
||||
if (contextTokens && contextWindow) {
|
||||
const contextPercent = ((contextTokens / contextWindow) * 100).toFixed(1);
|
||||
lines.push(`Context: ${formatTokens(contextTokens)} / ${formatTokens(contextWindow)} (${contextPercent}%)`);
|
||||
}
|
||||
lines.push(
|
||||
`Cost: $${usage.cost.input.toFixed(4)} in, $${usage.cost.output.toFixed(4)} out` +
|
||||
(usage.cacheRead > 0 || usage.cacheWrite > 0
|
||||
? `, $${usage.cost.cacheRead.toFixed(4)} cache read, $${usage.cost.cacheWrite.toFixed(4)} cache write`
|
||||
: ""),
|
||||
);
|
||||
lines.push(`*Total: $${usage.cost.total.toFixed(4)}*`);
|
||||
|
||||
const summary = lines.join("\n");
|
||||
|
||||
console.log(chalk.yellow(`${timestamp()} ${formatContext(ctx)} 💰 Usage`));
|
||||
console.log(
|
||||
chalk.dim(
|
||||
` ${usage.input.toLocaleString()} in + ${usage.output.toLocaleString()} out` +
|
||||
(usage.cacheRead > 0 || usage.cacheWrite > 0
|
||||
? ` (${usage.cacheRead.toLocaleString()} cache read, ${usage.cacheWrite.toLocaleString()} cache write)`
|
||||
: "") +
|
||||
` = $${usage.cost.total.toFixed(4)}`,
|
||||
),
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Startup (no context needed)
|
||||
export function logStartup(workingDir: string, sandbox: string): void {
|
||||
console.log("Starting Slack extension...");
|
||||
console.log(` Working directory: ${workingDir}`);
|
||||
console.log(` Sandbox: ${sandbox}`);
|
||||
}
|
||||
|
||||
export function logConnected(): void {
|
||||
console.log("⚡️ Slack extension connected and listening!");
|
||||
console.log("");
|
||||
}
|
||||
|
||||
export function logDisconnected(): void {
|
||||
console.log("Slack extension disconnected.");
|
||||
}
|
||||
|
||||
// Backfill
|
||||
export function logBackfillStart(channelCount: number): void {
|
||||
console.log(chalk.blue(`${timestamp()} [system] Backfilling ${channelCount} channels...`));
|
||||
}
|
||||
|
||||
export function logBackfillChannel(channelName: string, messageCount: number): void {
|
||||
console.log(chalk.blue(`${timestamp()} [system] #${channelName}: ${messageCount} messages`));
|
||||
}
|
||||
|
||||
export function logBackfillComplete(totalMessages: number, durationMs: number): void {
|
||||
const duration = (durationMs / 1000).toFixed(1);
|
||||
console.log(chalk.blue(`${timestamp()} [system] Backfill complete: ${totalMessages} messages in ${duration}s`));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// Per-channel JSONL persistence, dedup tracking, and background attachment downloads for the Slack extension.
|
||||
import { existsSync, mkdirSync, readFileSync } from "fs";
|
||||
import { appendFile, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import * as log from "./log.js";
|
||||
|
||||
export interface Attachment {
|
||||
original: string; // original filename from uploader
|
||||
local: string; // path relative to baseDir (e.g., "C12345/attachments/1732531234567_file.png")
|
||||
}
|
||||
|
||||
export interface LoggedMessage {
|
||||
date: string; // ISO 8601 date (e.g., "2025-11-26T10:44:00.000Z") for easy grepping
|
||||
ts: string; // slack timestamp or epoch ms
|
||||
user: string; // user ID (or "bot" for bot responses)
|
||||
userName?: string; // handle (e.g., "mario")
|
||||
displayName?: string; // display name (e.g., "Mario Zechner")
|
||||
text: string;
|
||||
attachments: Attachment[];
|
||||
isBot: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelStoreConfig {
|
||||
baseDir: string; // Caller-supplied root; defaults to ~/.pi/cache/slack/ in index.ts
|
||||
botToken: string; // needed for authenticated file downloads
|
||||
}
|
||||
|
||||
interface PendingDownload {
|
||||
channelId: string;
|
||||
localPath: string; // relative path
|
||||
url: string;
|
||||
}
|
||||
|
||||
export class ChannelStore {
|
||||
private baseDir: string;
|
||||
private botToken: string;
|
||||
private pendingDownloads: PendingDownload[] = [];
|
||||
private isDownloading = false;
|
||||
// Track recently logged message timestamps to prevent duplicates
|
||||
// Key: "channelId:ts", automatically cleaned up after 60 seconds
|
||||
private recentlyLogged = new Map<string, number>();
|
||||
|
||||
constructor(config: ChannelStoreConfig) {
|
||||
this.baseDir = config.baseDir;
|
||||
this.botToken = config.botToken;
|
||||
|
||||
// Ensure base directory exists
|
||||
if (!existsSync(this.baseDir)) {
|
||||
mkdirSync(this.baseDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the directory for a channel/DM
|
||||
*/
|
||||
getChannelDir(channelId: string): string {
|
||||
const dir = join(this.baseDir, channelId);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique local filename for an attachment
|
||||
*/
|
||||
generateLocalFilename(originalName: string, timestamp: string): string {
|
||||
// Convert slack timestamp (1234567890.123456) to milliseconds
|
||||
const ts = Math.floor(parseFloat(timestamp) * 1000);
|
||||
// Sanitize original name (remove problematic characters)
|
||||
const sanitized = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
return `${ts}_${sanitized}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process attachments from a Slack message event
|
||||
* Returns attachment metadata and queues downloads
|
||||
*/
|
||||
processAttachments(
|
||||
channelId: string,
|
||||
files: Array<{ name?: string; url_private_download?: string; url_private?: string }>,
|
||||
timestamp: string,
|
||||
): Attachment[] {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const url = file.url_private_download || file.url_private;
|
||||
if (!url) continue;
|
||||
if (!file.name) {
|
||||
log.logWarning("Attachment missing name, skipping", url);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filename = this.generateLocalFilename(file.name, timestamp);
|
||||
const localPath = `${channelId}/attachments/${filename}`;
|
||||
|
||||
attachments.push({
|
||||
original: file.name,
|
||||
local: localPath,
|
||||
});
|
||||
|
||||
// Queue for background download
|
||||
this.pendingDownloads.push({ channelId, localPath, url });
|
||||
}
|
||||
|
||||
// Trigger background download
|
||||
this.processDownloadQueue();
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a message to the channel's log.jsonl
|
||||
* Returns false if message was already logged (duplicate)
|
||||
*/
|
||||
async logMessage(channelId: string, message: LoggedMessage): Promise<boolean> {
|
||||
// Check for duplicate (same channel + timestamp)
|
||||
const dedupeKey = `${channelId}:${message.ts}`;
|
||||
if (this.recentlyLogged.has(dedupeKey)) {
|
||||
return false; // Already logged
|
||||
}
|
||||
|
||||
// Mark as logged and schedule cleanup after 60 seconds
|
||||
this.recentlyLogged.set(dedupeKey, Date.now());
|
||||
setTimeout(() => this.recentlyLogged.delete(dedupeKey), 60000);
|
||||
|
||||
const logPath = join(this.getChannelDir(channelId), "log.jsonl");
|
||||
|
||||
// Ensure message has a date field
|
||||
if (!message.date) {
|
||||
let date: Date;
|
||||
if (message.ts.includes(".")) {
|
||||
// Slack timestamp format (1234567890.123456)
|
||||
date = new Date(parseFloat(message.ts) * 1000);
|
||||
} else {
|
||||
// Epoch milliseconds
|
||||
date = new Date(parseInt(message.ts, 10));
|
||||
}
|
||||
message.date = date.toISOString();
|
||||
}
|
||||
|
||||
const line = `${JSON.stringify(message)}\n`;
|
||||
await appendFile(logPath, line, "utf-8");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a bot response
|
||||
*/
|
||||
async logBotResponse(channelId: string, text: string, ts: string): Promise<void> {
|
||||
await this.logMessage(channelId, {
|
||||
date: new Date().toISOString(),
|
||||
ts,
|
||||
user: "bot",
|
||||
text,
|
||||
attachments: [],
|
||||
isBot: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp of the last logged message for a channel
|
||||
* Returns null if no log exists
|
||||
*/
|
||||
getLastTimestamp(channelId: string): string | null {
|
||||
const logPath = join(this.baseDir, channelId, "log.jsonl");
|
||||
if (!existsSync(logPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(logPath, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines.length === 0 || lines[0] === "") {
|
||||
return null;
|
||||
}
|
||||
const lastLine = lines[lines.length - 1];
|
||||
const message = JSON.parse(lastLine) as LoggedMessage;
|
||||
return message.ts;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the download queue in the background
|
||||
*/
|
||||
private async processDownloadQueue(): Promise<void> {
|
||||
if (this.isDownloading || this.pendingDownloads.length === 0) return;
|
||||
|
||||
this.isDownloading = true;
|
||||
|
||||
while (this.pendingDownloads.length > 0) {
|
||||
const item = this.pendingDownloads.shift();
|
||||
if (!item) break;
|
||||
|
||||
try {
|
||||
await this.downloadAttachment(item.localPath, item.url);
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
log.logWarning(`Failed to download attachment`, `${item.localPath}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.isDownloading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a single attachment
|
||||
*/
|
||||
private async downloadAttachment(localPath: string, url: string): Promise<void> {
|
||||
const filePath = join(this.baseDir, localPath);
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = join(this.baseDir, localPath.substring(0, localPath.lastIndexOf("/")));
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.botToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
await writeFile(filePath, Buffer.from(buffer));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Slack tools registered with the Pi LLM via pi.registerTool().
|
||||
// TypeBox schemas (inline per tool) define parameter shapes; ExtensionAPI
|
||||
// wires them into the Pi agent's available tool set.
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import type { SlackBot } from "./client.js";
|
||||
|
||||
// ============================================================================
|
||||
// Public registration function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Register Slack tools on the Pi extension API.
|
||||
*
|
||||
* @param pi - Pi ExtensionAPI instance
|
||||
* @param bot - SlackBot for making Web API calls
|
||||
* @param getCurrentChannel - getter: current bridge channel (set by index.ts when processing an event)
|
||||
* @param getCurrentThread - getter: current bridge thread ts (set by index.ts when processing an event)
|
||||
*/
|
||||
export function registerSlackTools(
|
||||
pi: ExtensionAPI,
|
||||
bot: SlackBot,
|
||||
getCurrentChannel: () => string | undefined,
|
||||
getCurrentThread: () => string | undefined,
|
||||
): void {
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// slack_post — post to any channel
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
name: "slack_post",
|
||||
label: "Slack: Post",
|
||||
description:
|
||||
"Post a message to any Slack channel. Use this to send proactive messages or respond to a " +
|
||||
"specific channel when you know the channel ID. For replying to the current conversation use " +
|
||||
"slack_reply instead.",
|
||||
parameters: Type.Object({
|
||||
channel: Type.String({
|
||||
description: "Slack channel ID (e.g. C01234ABCDE) to post the message to.",
|
||||
}),
|
||||
text: Type.String({
|
||||
description: "Message text (Slack mrkdwn supported).",
|
||||
}),
|
||||
thread_ts: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"If provided, post as a reply in the given thread (the thread parent timestamp).",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (toolCallId, params, _signal, _onUpdate, _ctx): Promise<AgentToolResult> => {
|
||||
try {
|
||||
const ts = params.thread_ts
|
||||
? await bot.postInThread(params.channel, params.thread_ts, params.text)
|
||||
: await bot.postMessage(params.channel, params.text);
|
||||
return { content: [{ type: "text", text: `Posted. ts=${ts}` }], isError: false };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `slack_post failed: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// slack_reply — reply in the current bridge thread
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
name: "slack_reply",
|
||||
label: "Slack: Reply",
|
||||
description:
|
||||
"Reply to the current Slack conversation (the channel and thread the user's message arrived in). " +
|
||||
"This is the primary tool for sending responses during an active Slack session.",
|
||||
parameters: Type.Object({
|
||||
text: Type.String({
|
||||
description: "Reply text (Slack mrkdwn supported).",
|
||||
}),
|
||||
}),
|
||||
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx): Promise<AgentToolResult> => {
|
||||
const channel = getCurrentChannel();
|
||||
if (!channel) {
|
||||
return {
|
||||
content: [{ type: "text", text: "slack_reply failed: no current channel. Are you in a Slack session?" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const threadTs = getCurrentThread();
|
||||
const ts = threadTs
|
||||
? await bot.postInThread(channel, threadTs, params.text)
|
||||
: await bot.postMessage(channel, params.text);
|
||||
return { content: [{ type: "text", text: `Replied. ts=${ts}` }], isError: false };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `slack_reply failed: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// slack_react — add an emoji reaction
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
name: "slack_react",
|
||||
label: "Slack: React",
|
||||
description:
|
||||
"Add an emoji reaction to a Slack message. If ts is not provided, reacts to the message that " +
|
||||
"triggered the current session (best effort — uses the current thread ts).",
|
||||
parameters: Type.Object({
|
||||
emoji: Type.String({
|
||||
description: "Emoji name without colons (e.g. 'thumbsup', 'white_check_mark').",
|
||||
}),
|
||||
ts: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Timestamp of the message to react to. Defaults to the current thread/event ts.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx): Promise<AgentToolResult> => {
|
||||
const channel = getCurrentChannel();
|
||||
if (!channel) {
|
||||
return {
|
||||
content: [{ type: "text", text: "slack_react failed: no current channel." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const targetTs = params.ts ?? getCurrentThread();
|
||||
if (!targetTs) {
|
||||
return {
|
||||
content: [{ type: "text", text: "slack_react failed: no ts available and no current thread." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// SlackBot.webClient is private — reach the Slack Web API via postMessage workaround.
|
||||
// We expose a react helper via a cast to any so we don't need to add a public method
|
||||
// to SlackBot for this first iteration. T4/index.ts can add addReaction() to SlackBot
|
||||
// if needed; this is a best-effort implementation.
|
||||
const botAsAny = bot as unknown as {
|
||||
webClient?: { reactions?: { add: (args: { channel: string; name: string; timestamp: string }) => Promise<void> } };
|
||||
};
|
||||
if (botAsAny.webClient?.reactions?.add) {
|
||||
await botAsAny.webClient.reactions.add({ channel, name: params.emoji, timestamp: targetTs });
|
||||
return { content: [{ type: "text", text: `Reacted :${params.emoji}: on ${targetTs}` }], isError: false };
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: "slack_react: reactions API not accessible (SlackBot.webClient is private). Add addReaction() to SlackBot to enable this." }],
|
||||
isError: true,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `slack_react failed: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// slack_upload — upload a file
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
pi.registerTool({
|
||||
name: "slack_upload",
|
||||
label: "Slack: Upload File",
|
||||
description:
|
||||
"Upload a file to Slack. The file must exist on the local filesystem (absolute path). " +
|
||||
"Channel defaults to the current bridge channel if omitted.",
|
||||
parameters: Type.Object({
|
||||
filename: Type.String({
|
||||
description: "Absolute path to the local file to upload.",
|
||||
}),
|
||||
content: Type.String({
|
||||
description:
|
||||
"Optional text content to upload as a snippet instead of reading from disk. " +
|
||||
"When provided, filename is used as the display name only.",
|
||||
}),
|
||||
channel: Type.Optional(
|
||||
Type.String({
|
||||
description: "Channel ID to upload to. Defaults to current bridge channel.",
|
||||
}),
|
||||
),
|
||||
thread_ts: Type.Optional(
|
||||
Type.String({
|
||||
description: "Thread timestamp to attach the file to.",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
execute: async (_toolCallId, params, _signal, _onUpdate, _ctx): Promise<AgentToolResult> => {
|
||||
const channel = params.channel ?? getCurrentChannel();
|
||||
if (!channel) {
|
||||
return {
|
||||
content: [{ type: "text", text: "slack_upload failed: no channel provided and no current bridge channel." }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Use bot.uploadFile which takes a file path; params.content is treated as a path hint.
|
||||
// The content field in this tool is intentionally dual-purpose: if it looks like a path
|
||||
// the caller can pass params.filename as the path; content as title override.
|
||||
// For simplicity: treat params.filename as the path, params.content as the title.
|
||||
await bot.uploadFile(channel, params.filename, params.content || undefined);
|
||||
return { content: [{ type: "text", text: `Uploaded ${params.filename} to ${channel}` }], isError: false };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { content: [{ type: "text", text: `slack_upload failed: ${msg}` }], isError: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"display_information": {
|
||||
"name": "Open Harness",
|
||||
"description": "AI coding agent interface for Open Harness sandboxes",
|
||||
"background_color": "#1a1a2e"
|
||||
},
|
||||
"features": {
|
||||
"app_home": {
|
||||
"home_tab_enabled": false,
|
||||
"messages_tab_enabled": true,
|
||||
"messages_tab_read_only_enabled": false
|
||||
},
|
||||
"bot_user": {
|
||||
"display_name": "OpenHarness",
|
||||
"always_online": true
|
||||
}
|
||||
},
|
||||
"oauth_config": {
|
||||
"scopes": {
|
||||
"bot": [
|
||||
"app_mentions:read",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"chat:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"reactions:read",
|
||||
"users:read"
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"event_subscriptions": {
|
||||
"bot_events": [
|
||||
"app_mention",
|
||||
"message.channels",
|
||||
"message.groups",
|
||||
"message.im"
|
||||
]
|
||||
},
|
||||
"interactivity": {
|
||||
"is_enabled": false
|
||||
},
|
||||
"org_deploy_enabled": false,
|
||||
"socket_mode_enabled": true,
|
||||
"token_rotation_enabled": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Slack onboard step — Pi extension.
|
||||
* Adapted from mifune/onboard-steps/slack.ts.
|
||||
*
|
||||
* Responsibilities, top to bottom:
|
||||
* 1. Load Slack tokens from host `~/harness/.devcontainer/.env` into
|
||||
* deps.env if missing.
|
||||
* 2. If tokens missing, prompt + collect, persist to
|
||||
* `~/harness/.devcontainer/.env`.
|
||||
* Required env vars:
|
||||
* SLACK_APP_TOKEN — App-Level Token (xapp-...)
|
||||
* SLACK_BOT_TOKEN — Bot Token (xoxb-...)
|
||||
* Optional env vars:
|
||||
* SLACK_ALLOW_CHANNELS — comma-separated channel IDs to restrict intake
|
||||
* SLACK_ALLOW_USERS — comma-separated Slack user IDs to restrict intake
|
||||
* 3. Bootstrap `~/.openharness/agent/{settings,auth}.json` symlinks from
|
||||
* `~/.pi/agent/…` (best-effort, Pi-aware).
|
||||
* 4. Ensure the Slack extension has LLM auth via `~/.pi/slack/auth.json`
|
||||
* symlink (shared with Pi agent key store).
|
||||
* 5. Detect an already-running tmux `slack` session → done.
|
||||
* 6. Otherwise (re)start tmux session, poll up to 15 s for
|
||||
* "connected and listening" (done) or an error (failed).
|
||||
*
|
||||
* NOTE: The Slack extension now lives inside Pi — the user starts Pi normally
|
||||
* (`pi` or `claude`). There is no separate `mom`/`pi-mom` binary. The tmux
|
||||
* session launched here runs the extension entrypoint directly.
|
||||
*/
|
||||
|
||||
import { loadEnvInto, upsertEnvFile } from "../env.js";
|
||||
import type { Deps, Step, StepResult, StepStatus } from "../types.js";
|
||||
|
||||
const MAX_POLLS = 15;
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const CONNECTED_MARKER = "connected and listening";
|
||||
const ERROR_MARKERS = [/Run error/, /Error/, /Missing env/];
|
||||
|
||||
export const slackStep: Step = {
|
||||
id: "slack",
|
||||
label: "Slack Extension — Pi",
|
||||
async run(deps): Promise<StepResult> {
|
||||
const { io, fs, home, env } = deps;
|
||||
const hostEnvPath = `${home}/harness/.devcontainer/.env`;
|
||||
|
||||
if (!env.SLACK_APP_TOKEN) {
|
||||
loadEnvInto(fs, hostEnvPath, env);
|
||||
}
|
||||
|
||||
let appToken = env.SLACK_APP_TOKEN ?? "";
|
||||
let botToken = env.SLACK_BOT_TOKEN ?? "";
|
||||
let promptedSkip = false;
|
||||
|
||||
if (appToken && botToken) {
|
||||
io.ok("Slack tokens detected from environment");
|
||||
} else {
|
||||
const setup = await io.ask("Set up Slack extension for Pi? [y/N]:");
|
||||
if (!/^[Yy]$/.test(setup)) {
|
||||
io.skip("Skipped — re-run onboard with --force later to set up Slack");
|
||||
promptedSkip = true;
|
||||
} else {
|
||||
printSlackAppInstructions(deps);
|
||||
appToken = (await io.ask("App Token (xapp-...):")).trim();
|
||||
botToken = (await io.ask("Bot Token (xoxb-...):")).trim();
|
||||
|
||||
if (appToken && botToken) {
|
||||
env.SLACK_APP_TOKEN = appToken;
|
||||
env.SLACK_BOT_TOKEN = botToken;
|
||||
io.ok("Tokens set for this session");
|
||||
persistTokens(deps, hostEnvPath, appToken, botToken);
|
||||
} else {
|
||||
io.warn("Tokens not provided");
|
||||
appToken = "";
|
||||
botToken = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort: create ~/.openharness/agent symlinks pointing at ~/.pi/agent/
|
||||
bootstrapOpenharnessAgent(deps);
|
||||
|
||||
if (promptedSkip) {
|
||||
return { id: "slack", status: "skipped" };
|
||||
}
|
||||
if (!appToken || !botToken) {
|
||||
return { id: "slack", status: "skipped" };
|
||||
}
|
||||
|
||||
ensureSlackLlmAuth(deps);
|
||||
|
||||
const alreadyRunning = isSlackConnected(deps);
|
||||
if (alreadyRunning) {
|
||||
io.ok("Slack extension already running and connected");
|
||||
return { id: "slack", status: "done" };
|
||||
}
|
||||
|
||||
// TODO: adapt for Pi — the extension entrypoint path below is a
|
||||
// placeholder. Replace with the actual built entrypoint once the
|
||||
// extensions/slack package is scaffolded (T7+).
|
||||
return await startAndValidateSlack(deps);
|
||||
},
|
||||
};
|
||||
|
||||
function printSlackAppInstructions(deps: Deps): void {
|
||||
const { io } = deps;
|
||||
io.raw("\n \x1b[1mCreate a Slack app:\x1b[0m\n");
|
||||
io.raw(" 1. Go to \x1b[0;36mhttps://api.slack.com/apps\x1b[0m\n");
|
||||
io.raw(" 2. Click \x1b[1mCreate New App\x1b[0m → \x1b[1mFrom a manifest\x1b[0m\n");
|
||||
io.raw(" 3. Select your workspace, then paste this manifest:\n");
|
||||
io.raw("\n \x1b[0;36m~/.pi/install/slack-manifest.json\x1b[0m\n");
|
||||
io.raw(
|
||||
"\n (or copy from: \x1b[0;36mhttps://github.com/ryaneggz/open-harness/blob/main/.pi/install/slack-manifest.json\x1b[0m)\n",
|
||||
);
|
||||
io.raw("\n 4. Click \x1b[1mCreate\x1b[0m, then:\n");
|
||||
io.raw(
|
||||
" - \x1b[1mBasic Information\x1b[0m → \x1b[1mApp-Level Tokens\x1b[0m → Generate (scope: \x1b[0;36mconnections:write\x1b[0m)\n",
|
||||
);
|
||||
io.raw(" This is your \x1b[1mApp Token\x1b[0m (starts with \x1b[0;36mxapp-\x1b[0m)\n");
|
||||
io.raw(" - \x1b[1mOAuth & Permissions\x1b[0m → \x1b[1mInstall to Workspace\x1b[0m\n");
|
||||
io.raw(" This is your \x1b[1mBot Token\x1b[0m (starts with \x1b[0;36mxoxb-\x1b[0m)\n\n");
|
||||
io.raw(" Optional restrictions (add to .devcontainer/.env):\n");
|
||||
io.raw(" SLACK_ALLOW_CHANNELS=C01234,C05678 # restrict by channel ID\n");
|
||||
io.raw(" SLACK_ALLOW_USERS=U01234,U05678 # restrict by user ID\n\n");
|
||||
}
|
||||
|
||||
function persistTokens(deps: Deps, hostEnvPath: string, appToken: string, botToken: string): void {
|
||||
const { io, fs, home } = deps;
|
||||
const devcontainerDir = `${home}/harness/.devcontainer`;
|
||||
if (!fs.exists(devcontainerDir)) {
|
||||
io.warn(`Cannot write to ${hostEnvPath} — tokens valid for this session only`);
|
||||
io.raw(" Add manually to .devcontainer/.env on the host:\n");
|
||||
io.raw(` SLACK_APP_TOKEN=${appToken}\n`);
|
||||
io.raw(` SLACK_BOT_TOKEN=${botToken}\n`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
upsertEnvFile(fs, hostEnvPath, {
|
||||
SLACK_APP_TOKEN: appToken,
|
||||
SLACK_BOT_TOKEN: botToken,
|
||||
});
|
||||
io.ok("Tokens saved to .devcontainer/.env (persist across rebuilds)");
|
||||
} catch {
|
||||
io.warn(`Cannot write to ${hostEnvPath} — tokens valid for this session only`);
|
||||
io.raw(" Add manually to .devcontainer/.env on the host:\n");
|
||||
io.raw(` SLACK_APP_TOKEN=${appToken}\n`);
|
||||
io.raw(` SLACK_BOT_TOKEN=${botToken}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create `~/.openharness/agent/{settings,auth}.json` symlinks pointing at
|
||||
* `~/.pi/agent/…` equivalents. Best-effort — Pi does its own auth setup.
|
||||
*/
|
||||
function bootstrapOpenharnessAgent(deps: Deps): void {
|
||||
const { fs, home } = deps;
|
||||
const ohAgent = `${home}/.openharness/agent`;
|
||||
fs.mkdirp(ohAgent);
|
||||
|
||||
for (const name of ["settings.json", "auth.json"]) {
|
||||
const link = `${ohAgent}/${name}`;
|
||||
const target = `${home}/.pi/agent/${name}`;
|
||||
if (!fs.exists(link) && fs.exists(target)) {
|
||||
try {
|
||||
fs.symlink(target, link);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSlackLlmAuth(deps: Deps): void {
|
||||
const { fs, io, home, env } = deps;
|
||||
const slackDir = `${home}/.pi/slack`;
|
||||
const slackAuth = `${slackDir}/auth.json`;
|
||||
if (fs.exists(slackAuth) || env.OPENAI_API_KEY) return;
|
||||
const piAuth = `${home}/.pi/agent/auth.json`;
|
||||
if (fs.exists(piAuth)) {
|
||||
fs.mkdirp(slackDir);
|
||||
try {
|
||||
fs.symlink(piAuth, slackAuth);
|
||||
io.ok("Linked Slack extension auth to Pi agent (shared key store)");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
io.warn("Slack extension needs LLM auth. Complete the Pi auth step first.");
|
||||
}
|
||||
}
|
||||
|
||||
function isSlackConnected(deps: Deps): boolean {
|
||||
if (!deps.exec.runSafe(["tmux", "has-session", "-t", "slack"])) return false;
|
||||
const res = deps.exec.capture(["tmux", "capture-pane", "-t", "slack", "-p"]);
|
||||
return res.stdout.includes(CONNECTED_MARKER) || res.stderr.includes(CONNECTED_MARKER);
|
||||
}
|
||||
|
||||
async function startAndValidateSlack(deps: Deps): Promise<StepResult> {
|
||||
const { exec, io, clock, env, home } = deps;
|
||||
|
||||
// TODO: adapt for Pi — replace with the real extension entrypoint once
|
||||
// extensions/slack is built. Pi is started by the user normally; this
|
||||
// tmux session launches the Slack listener as a companion process.
|
||||
const slackDir = `${home}/harness/workspace/.slack`;
|
||||
const piExtensionEntry = `${home}/.pi/extensions/slack/dist/main.js`;
|
||||
|
||||
exec.runSafe(["tmux", "kill-session", "-t", "slack"]);
|
||||
exec.run(
|
||||
["tmux", "new-session", "-d", "-s", "slack", `node ${piExtensionEntry} --sandbox=host ${slackDir}`],
|
||||
{
|
||||
env: {
|
||||
SLACK_APP_TOKEN: env.SLACK_APP_TOKEN ?? "",
|
||||
SLACK_BOT_TOKEN: env.SLACK_BOT_TOKEN ?? "",
|
||||
SLACK_ALLOW_CHANNELS: env.SLACK_ALLOW_CHANNELS ?? "",
|
||||
SLACK_ALLOW_USERS: env.SLACK_ALLOW_USERS ?? "",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
io.raw("\n Validating Slack connection");
|
||||
let status: StepStatus = "failed";
|
||||
for (let i = 0; i < MAX_POLLS; i++) {
|
||||
io.raw(".");
|
||||
const output = deps.exec.capture(["tmux", "capture-pane", "-t", "slack", "-p"]);
|
||||
const combined = output.stdout + "\n" + output.stderr;
|
||||
if (combined.includes(CONNECTED_MARKER)) {
|
||||
status = "done";
|
||||
break;
|
||||
}
|
||||
if (ERROR_MARKERS.some((re) => re.test(combined))) {
|
||||
break;
|
||||
}
|
||||
await clock.sleep(POLL_INTERVAL_MS);
|
||||
}
|
||||
io.raw("\n");
|
||||
|
||||
if (status === "done") {
|
||||
io.ok("Slack extension connected");
|
||||
return { id: "slack", status: "done" };
|
||||
}
|
||||
io.fail("Slack extension failed to connect — check logs: tmux attach -t slack");
|
||||
return { id: "slack", status: "failed" };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Slack Integration Overlay
|
||||
# ==========================
|
||||
# Purpose: Enable Slack bot (pi) automation with configurable LLM provider.
|
||||
# Requirements: SLACK_APP_TOKEN and SLACK_BOT_TOKEN env vars must be set before use
|
||||
# Optional: OPENAI_API_KEY for OpenAI-based integrations
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.slack.yml up
|
||||
# Provides: Slack env vars only. Pi auth persistence lives in the base
|
||||
# compose (pi-auth named volume → /home/sandbox/.pi) or in the
|
||||
# docker-compose.pi-host.yml overlay (host bind-mount).
|
||||
|
||||
services:
|
||||
sandbox:
|
||||
environment:
|
||||
- SLACK_APP_TOKEN=${SLACK_APP_TOKEN:-}
|
||||
- SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
@@ -56,9 +56,11 @@ Provision the agent sandbox. The sandbox uses `.devcontainer/` as the base envir
|
||||
claude # terminal coding agent
|
||||
```
|
||||
|
||||
For multi-agent setups (e.g., Pi+Mom Slack bot), install a harness pack
|
||||
by `git clone`-ing it into the workspace and following its README. See
|
||||
the `@ryaneggz/mifune` repo for the canonical pack contract.
|
||||
For multi-agent setups (e.g., Pi+Slack), the recommended path is to enable
|
||||
the Slack Pi extension in `.pi/extensions/slack/` (see
|
||||
[docs/integrations/slack.md](docs/integrations/slack.md)). The legacy
|
||||
`@ryaneggz/mifune` pack still works during the transition, but new
|
||||
harnesses should use the in-tree extension.
|
||||
|
||||
### Validate
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Update policy and release automation live in [`.claude/rules/git.md`](.claude/ru
|
||||
- `opencode` and `opencode /connect` failing with `EACCES: permission denied, mkdir '/home/sandbox/.local/state'` on a fresh sandbox. Docker auto-creates `~/.local` and `~/.local/share` as root to satisfy the `opencode-host` bind-mount path; the entrypoint now non-recursively chowns those parent dirs to `sandbox:sandbox` so the user can create siblings like `.local/state`. The bind-mounted `~/.local/share/opencode` itself stays untouched. Re-applies a fix that was lost when [#241](https://github.com/ryaneggz/open-harness/pull/241) merged a conflicting `entrypoint.sh`. ([#245](https://github.com/ryaneggz/open-harness/issues/245))
|
||||
|
||||
### Added
|
||||
- Slack integration as a Pi extension at `.pi/extensions/slack/` ([#TBD](https://github.com/ryaneggz/open-harness/issues/TBD)).
|
||||
- Project-local Pi single-line footer extension showing cwd/branch, context usage, model/thinking state, extension statuses, token usage, and session name with width-aware truncation. Header copy now uses a plain `MIFUNE` wordmark with an `agent harness` subtitle.
|
||||
- Project-local Pi config under `.pi/` standing the agent up at the harness root: OpenAI-default `settings.json` (`gpt-4o`, model-cycling list scoped to GPT/o-series), `mifune-banner.ts` extension replacing pi's startup header with a flush-left `MIFUNE` wordmark + `pi harness · github.com/ryaneggz/mifune` subtitle, `path-guard.ts` extension intercepting `tool_call` events to confirm before write/edit on sensitive paths (`.env*`, `secrets/`, `credentials.{json,yaml}`, `*.pem`, `*.key`, ssh keys) and risky bash (`rm -rf`, `sudo`, `git push --force`, `git reset --hard`, bare `> /dev/sd*|nvme*|hd*|disk*`) with case-insensitive matching and `ctx.hasUI` guards for headless/RPC mode, generic consumer-agnostic `APPEND_SYSTEM.md`, `prompts/review.md` template invokable as `/review`, and `skills` + `rules` directory symlinks to `../.claude/skills` / `../.claude/rules` matching the single-source-of-truth pattern already in `.codex/`. New canonical `.claude/skills/harness-context/SKILL.md` reachable from pi via the symlink. Test suite at `.pi/extensions/__tests__/{path-guard,mifune-banner}.test.ts` (32 + 7 cases, hand-rolled mock `ExtensionAPI`, no pi devDep); pi source verified via `gh api` for tool-name casing (lowercase) and write/edit canonical input key (`path`). `vitest.config.ts` `include` extended to `.pi/**/__tests__/**/*.test.ts`; `.github/workflows/ci-harness.yml` `paths:` filter extended (`.pi/**`, `scripts/**`, `vitest.config.ts`) and a new "Root tests" step runs `pnpm test:scripts`, also bringing the previously-orphaned `scripts/__tests__/cron-runtime.test.ts` into CI. Project Pi settings pin `npm:@tintinweb/pi-subagents@0.7.1` so PR #256 starts with Claude Code-style sub-agents available in pi. ([#255](https://github.com/ryaneggz/open-harness/issues/255))
|
||||
- Project-local Pi plan-mode extension under `.pi/extensions/plan-mode/`, with `/plan`, `/plan approve`, `--plan`, read-only tool/bash gating, plan approval prompts, and `[DONE:n]` progress tracking.
|
||||
|
||||
@@ -111,6 +111,17 @@ open-harness/
|
||||
│ ├── hooks/ # security hooks (deny-env-dump, etc.)
|
||||
│ └── specs/ # architecture decision records
|
||||
├── .codex/ # parallel agent harness (mirrors .claude/ shape)
|
||||
├── .pi/ # Project-local Pi configuration + extensions
|
||||
│ ├── extensions/ # Pi extension packages
|
||||
│ │ ├── slack/ # Slack Socket Mode bridge as a Pi extension
|
||||
│ │ ├── mifune-banner.ts # (existing)
|
||||
│ │ ├── single-line-footer.ts # (existing)
|
||||
│ │ └── path-guard.ts # (existing)
|
||||
│ ├── install/ # Installation assets (Slack manifest, etc.)
|
||||
│ ├── overlays/ # docker-compose overlay snippets (Slack env wiring, etc.)
|
||||
│ ├── onboard-steps/ # Interactive onboarding helpers (Slack token setup, etc.)
|
||||
│ ├── APPEND_SYSTEM.md # System prompt addendum (agent guidance)
|
||||
│ └── UPSTREAM.md # Tracking behavioral source for Slack extension
|
||||
├── apps/
|
||||
│ └── docs/ # Docusaurus documentation site (only workspace pkg)
|
||||
├── blog/ # Docusaurus blog source
|
||||
|
||||
@@ -5,10 +5,81 @@ title: Slack
|
||||
|
||||
# Slack
|
||||
|
||||
The Slack integration lives in the [`@ryaneggz/mifune`](https://github.com/ryaneggz/mifune) harness pack. Install it into a sandbox by cloning the pack into your workspace and following its README:
|
||||
Slack support ships as a Pi extension at `.pi/extensions/slack/`. Once your sandbox has Pi installed and Slack tokens in env, mention the bot or DM it to start a conversation.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Create a Slack App
|
||||
|
||||
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App**.
|
||||
2. Choose **From an app manifest** and paste the manifest from `.pi/install/slack-manifest.json` in the harness.
|
||||
3. Install the app to your workspace.
|
||||
4. Copy the **App-Level Token** (starts with `xapp-`) from the **Basic Information** page.
|
||||
5. Copy the **Bot User OAuth Token** (starts with `xoxb-`) from the **OAuth & Permissions** page.
|
||||
|
||||
### 2. Set Environment Variables
|
||||
|
||||
Add these to `.devcontainer/.env`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ryaneggz/mifune workspace/mifune
|
||||
export SLACK_APP_TOKEN=xapp-...
|
||||
export SLACK_BOT_TOKEN=xoxb-...
|
||||
```
|
||||
|
||||
Then follow the [mifune README](https://github.com/ryaneggz/mifune#readme) for setup instructions, architecture, tools, skills, events, memory, and security details.
|
||||
Optionally set channel and user allowlists (see § Allowlist below).
|
||||
|
||||
### 3. (Optional) Enable the Docker Compose Overlay
|
||||
|
||||
If you want the `.devcontainer/docker-compose.slack.yml` overlay to handle Slack env wiring for you:
|
||||
|
||||
1. Copy `config.example.json` to `config.json` at the harness root (if not already present).
|
||||
2. Add `.pi/overlays/docker-compose.slack.yml` to the `composeOverrides[]` list in `config.json`.
|
||||
3. Run `make sandbox` (or re-up the container).
|
||||
|
||||
The overlay wires `SLACK_APP_TOKEN` and `SLACK_BOT_TOKEN` from `.devcontainer/.env` into the running container.
|
||||
|
||||
## Allowlist
|
||||
|
||||
By default, the extension denies all Slack events unless you explicitly set at least one of these:
|
||||
|
||||
| Env Var | Purpose |
|
||||
|---------|---------|
|
||||
| `SLACK_ALLOW_CHANNELS` | Comma-separated channel IDs to allow (e.g., `C123,C456`). If unset, allows any channel. |
|
||||
| `SLACK_ALLOW_USERS` | Comma-separated user IDs to allow (e.g., `U789,U101`). If unset, allows any user. |
|
||||
|
||||
**Fail-safe deny-default**: If both are unset, the extension blocks all messages. Set at least one to enable bridging.
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
# Allow specific channels only
|
||||
SLACK_ALLOW_CHANNELS=C123,C456
|
||||
|
||||
# Allow specific users only
|
||||
SLACK_ALLOW_USERS=U789,U101
|
||||
|
||||
# Allow both specific channels AND specific users (both must match)
|
||||
SLACK_ALLOW_CHANNELS=C123
|
||||
SLACK_ALLOW_USERS=U789
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
The extension opens a Socket Mode WebSocket on `session_start` and listens for:
|
||||
- `app_mention` events (when the bot is mentioned in a channel)
|
||||
- `message` events (DMs to the bot)
|
||||
|
||||
Inbound messages are injected into the Pi agent via `pi.sendUserMessage()` with a `[Slack #channel] user:` prefix. The agent processes the message normally. On `turn_end`, if the agent did not explicitly call a slack tool, the extension automatically posts the agent's final text response back to Slack.
|
||||
|
||||
Four tools are available for explicit Slack actions:
|
||||
- `slack_post(channel, text, threadTs?)` — post to a specific channel
|
||||
- `slack_reply(text, threadTs?)` — reply to the current Slack thread
|
||||
- `slack_react(emoji, ts?)` — add a reaction to a Slack message
|
||||
- `slack_upload(filename, content, channel?, threadTs?)` — upload a file to Slack
|
||||
|
||||
Cleanup happens on `session_shutdown` — the Socket Mode connection is closed and watchers are stopped.
|
||||
|
||||
## Full Details
|
||||
|
||||
For a complete file inventory, env var reference, and build/test instructions, see `.pi/extensions/slack/README.md`.
|
||||
|
||||
For tracking upstream changes and divergence history, see `.pi/UPSTREAM.md`.
|
||||
+6
-1
@@ -21,7 +21,12 @@
|
||||
"croner": "^9.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^3.0.0"
|
||||
"vitest": "^3.0.0",
|
||||
"@slack/socket-mode": "^2.0.0",
|
||||
"@slack/web-api": "^7.0.0",
|
||||
"@sinclair/typebox": "^0.34.0",
|
||||
"chalk": "^5.6.2",
|
||||
"diff": "^8.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
Generated
+190
-3
@@ -18,6 +18,21 @@ importers:
|
||||
specifier: ^9.1.0
|
||||
version: 9.1.0
|
||||
devDependencies:
|
||||
'@sinclair/typebox':
|
||||
specifier: ^0.34.0
|
||||
version: 0.34.49
|
||||
'@slack/socket-mode':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.7
|
||||
'@slack/web-api':
|
||||
specifier: ^7.0.0
|
||||
version: 7.15.2
|
||||
chalk:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
diff:
|
||||
specifier: ^8.0.2
|
||||
version: 8.0.4
|
||||
vitest:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.4(@types/debug@4.1.13)(@types/node@25.6.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.13.4(@types/node@25.6.0)(typescript@5.9.3))(terser@5.46.1)(yaml@2.8.3)
|
||||
@@ -1913,6 +1928,9 @@ packages:
|
||||
'@sinclair/typebox@0.27.10':
|
||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||
|
||||
'@sinclair/typebox@0.34.49':
|
||||
resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==}
|
||||
|
||||
'@sindresorhus/is@4.6.0':
|
||||
resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1921,6 +1939,22 @@ packages:
|
||||
resolution: {integrity: sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
'@slack/logger@4.0.1':
|
||||
resolution: {integrity: sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slack/socket-mode@2.0.7':
|
||||
resolution: {integrity: sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slack/types@2.21.1':
|
||||
resolution: {integrity: sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==}
|
||||
engines: {node: '>= 12.13.0', npm: '>= 6.12.0'}
|
||||
|
||||
'@slack/web-api@7.15.2':
|
||||
resolution: {integrity: sha512-/m9qVFkiq85Oa/FSQwYIRDa/AO4qNYkDh4sRBK1WqEc2+RyG7w4tbU6rBIwUOcc/TmWOIr24Nraquxg7um5mYw==}
|
||||
engines: {node: '>= 18', npm: '>= 8.6.0'}
|
||||
|
||||
'@slorber/react-helmet-async@1.3.0':
|
||||
resolution: {integrity: sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==}
|
||||
peerDependencies:
|
||||
@@ -2477,6 +2511,9 @@ packages:
|
||||
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
|
||||
hasBin: true
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
at-least-node@1.0.0:
|
||||
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
@@ -2488,6 +2525,9 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.1.0
|
||||
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
|
||||
babel-loader@9.2.1:
|
||||
resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==}
|
||||
engines: {node: '>= 14.15.0'}
|
||||
@@ -2747,6 +2787,10 @@ packages:
|
||||
resolution: {integrity: sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
comlink@4.4.2:
|
||||
resolution: {integrity: sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==}
|
||||
|
||||
@@ -3231,6 +3275,10 @@ packages:
|
||||
delaunator@5.1.0:
|
||||
resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
depd@1.1.2:
|
||||
resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3267,6 +3315,10 @@ packages:
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
diff@8.0.4:
|
||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3387,6 +3439,10 @@ packages:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
|
||||
|
||||
@@ -3515,6 +3571,9 @@ packages:
|
||||
eventemitter3@4.0.7:
|
||||
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
@@ -3648,6 +3707,15 @@ packages:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
fork-ts-checker-webpack-plugin@6.5.3:
|
||||
resolution: {integrity: sha512-SbH/l9ikmMWycd5puHJKTkZJKddF4iRLyW3DeZ08HTI7NGyLS38MXd/KGgeWumQO7YNQbW2u/NtPT2YowbPaGQ==}
|
||||
engines: {node: '>=10', yarn: '>=1.0.0'}
|
||||
@@ -3666,6 +3734,10 @@ packages:
|
||||
resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==}
|
||||
engines: {node: '>= 14.17'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
format@0.2.2:
|
||||
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
|
||||
engines: {node: '>=0.4.x'}
|
||||
@@ -3815,6 +3887,10 @@ packages:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-yarn@3.0.0:
|
||||
resolution: {integrity: sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -4063,6 +4139,9 @@ packages:
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
is-electron@2.2.2:
|
||||
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
|
||||
|
||||
is-extendable@0.1.1:
|
||||
resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -4870,6 +4949,10 @@ packages:
|
||||
resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4898,10 +4981,18 @@ packages:
|
||||
resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-queue@6.6.2:
|
||||
resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-retry@4.6.2:
|
||||
resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-timeout@3.2.0:
|
||||
resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -5460,6 +5551,10 @@ packages:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -9231,10 +9326,48 @@ snapshots:
|
||||
|
||||
'@sinclair/typebox@0.27.10': {}
|
||||
|
||||
'@sinclair/typebox@0.34.49': {}
|
||||
|
||||
'@sindresorhus/is@4.6.0': {}
|
||||
|
||||
'@sindresorhus/is@5.6.0': {}
|
||||
|
||||
'@slack/logger@4.0.1':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@slack/socket-mode@2.0.7':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.1
|
||||
'@slack/web-api': 7.15.2
|
||||
'@types/node': 25.6.0
|
||||
'@types/ws': 8.18.1
|
||||
eventemitter3: 5.0.4
|
||||
ws: 8.20.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- debug
|
||||
- utf-8-validate
|
||||
|
||||
'@slack/types@2.21.1': {}
|
||||
|
||||
'@slack/web-api@7.15.2':
|
||||
dependencies:
|
||||
'@slack/logger': 4.0.1
|
||||
'@slack/types': 2.21.1
|
||||
'@types/node': 25.6.0
|
||||
'@types/retry': 0.12.0
|
||||
axios: 1.16.0
|
||||
eventemitter3: 5.0.4
|
||||
form-data: 4.0.5
|
||||
is-electron: 2.2.2
|
||||
is-stream: 2.0.1
|
||||
p-queue: 6.6.2
|
||||
p-retry: 4.6.2
|
||||
retry: 0.13.1
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
'@slorber/react-helmet-async@1.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
@@ -9584,7 +9717,6 @@ snapshots:
|
||||
'@types/node@25.6.0':
|
||||
dependencies:
|
||||
undici-types: 7.19.2
|
||||
optional: true
|
||||
|
||||
'@types/parse-json@4.0.2': {}
|
||||
|
||||
@@ -9911,6 +10043,8 @@ snapshots:
|
||||
|
||||
astring@1.9.0: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
at-least-node@1.0.0: {}
|
||||
|
||||
autoprefixer@10.5.0(postcss@8.5.9):
|
||||
@@ -9922,6 +10056,14 @@ snapshots:
|
||||
postcss: 8.5.9
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
axios@1.16.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
babel-loader@9.2.1(@babel/core@7.29.0)(webpack@5.98.0):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.0
|
||||
@@ -10239,6 +10381,10 @@ snapshots:
|
||||
|
||||
combine-promises@1.2.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
comlink@4.4.2: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
@@ -10762,6 +10908,8 @@ snapshots:
|
||||
dependencies:
|
||||
robust-predicates: 3.0.3
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
depd@1.1.2: {}
|
||||
|
||||
depd@2.0.0: {}
|
||||
@@ -10793,6 +10941,8 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
diff@8.0.4: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
dependencies:
|
||||
path-type: 4.0.0
|
||||
@@ -10910,6 +11060,13 @@ snapshots:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
es-set-tostringtag@2.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
has-tostringtag: 1.0.2
|
||||
hasown: 2.0.2
|
||||
|
||||
esast-util-from-estree@2.0.0:
|
||||
dependencies:
|
||||
'@types/estree-jsx': 1.0.5
|
||||
@@ -11096,6 +11253,8 @@ snapshots:
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
execa@5.1.1:
|
||||
@@ -11271,6 +11430,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
fork-ts-checker-webpack-plugin@6.5.3(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)(webpack@5.98.0):
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
@@ -11293,6 +11454,14 @@ snapshots:
|
||||
|
||||
form-data-encoder@2.1.4: {}
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
format@0.2.2: {}
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
@@ -11457,6 +11626,10 @@ snapshots:
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
has-tostringtag@1.0.2:
|
||||
dependencies:
|
||||
has-symbols: 1.1.0
|
||||
|
||||
has-yarn@3.0.0: {}
|
||||
|
||||
hasown@2.0.2:
|
||||
@@ -11785,6 +11958,8 @@ snapshots:
|
||||
|
||||
is-docker@2.2.1: {}
|
||||
|
||||
is-electron@2.2.2: {}
|
||||
|
||||
is-extendable@0.1.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
@@ -12844,6 +13019,8 @@ snapshots:
|
||||
|
||||
p-cancelable@3.0.0: {}
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
@@ -12872,11 +13049,20 @@ snapshots:
|
||||
dependencies:
|
||||
aggregate-error: 3.1.0
|
||||
|
||||
p-queue@6.6.2:
|
||||
dependencies:
|
||||
eventemitter3: 4.0.7
|
||||
p-timeout: 3.2.0
|
||||
|
||||
p-retry@4.6.2:
|
||||
dependencies:
|
||||
'@types/retry': 0.12.0
|
||||
retry: 0.13.1
|
||||
|
||||
p-timeout@3.2.0:
|
||||
dependencies:
|
||||
p-finally: 1.0.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
package-json@8.1.1:
|
||||
@@ -13482,6 +13668,8 @@ snapshots:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pupa@3.3.0:
|
||||
@@ -14359,8 +14547,7 @@ snapshots:
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
undici-types@7.19.2:
|
||||
optional: true
|
||||
undici-types@7.19.2: {}
|
||||
|
||||
undici@7.24.7: {}
|
||||
|
||||
|
||||
Reference in new issue
Block a user