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:
OpenHarness BotandClaude Opus 4.7 committed 2026-05-07 23:03:04 -06:00
1 parent 41e28f6cd5
commit e7e96ccfdd
24 files changed
+3713 -10

No files matched your search

+4
View File
@@ -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.
+76
View File
@@ -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.
+109
View File
@@ -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();
});
});
});
+35
View File
@@ -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;
},
};
}
+583
View File
@@ -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);
}
}
+195
View File
@@ -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;
},
};
}
+118
View File
@@ -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`);
}
+420
View File
@@ -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);
}
+271
View File
@@ -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;
});
}
+265
View File
@@ -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`));
}
+233
View File
@@ -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));
}
}
+211
View File
@@ -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 };
}
},
});
}
+53
View File
@@ -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
}
}
+240
View File
@@ -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" };
}
+16
View File
@@ -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:-}
+5 -3
View File
@@ -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
+1
View File
@@ -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.
+11
View File
@@ -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
+74 -3
View File
@@ -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
View File
@@ -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"
+190 -3
View File
@@ -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: {}