From a9d04a573dd2820d9a32a0174488c89435f0c72a Mon Sep 17 00:00:00 2001 From: MK Date: Sun, 1 Mar 2026 19:39:09 -0500 Subject: [PATCH] Rewrite Slack adapter to Socket Mode, add community files and update docs Slack channel adapter: - Replace webhook-based adapter with Socket Mode (WebSocket via apps.connections.open) - Add mention-aware filtering: respond only when @mentioned in channels, always in threads/DMs - Add :eyes: reaction indicator and interim "Researching..." message for long-running queries - Strip bot mention from message text before passing to LLM - Add file upload for large responses via files.getUploadURLExternal API - Unwrap JSON tool outputs (Tavily Research/Search) into readable markdown - Update capability bundle domains: wss-primary.slack.com, files.slack.com Runtime and Telegram: - Track large tool outputs (>8000 chars) as file parts in A2A response - Add Telegram document upload via sendDocument for large responses - Add SplitSummaryAndReport for summary+file splitting CLI and templates: - Update Slack config template: app_token_env replaces signing_secret_env - Update .env template: SLACK_APP_TOKEN replaces SLACK_SIGNING_SECRET - Update channel setup instructions for Socket Mode flow - Scaffold skills to subdirectories (skills/{name}/SKILL.md) - Add Slack /invite reminder after init Community files: - Add CONTRIBUTING.md, CODE_OF_CONDUCT.md - Add GitHub issue templates (bug-report, feature-request, new-skill, config) - Add pull request template with skill-specific checklist - Add skill starter template (_template/SKILL.md) with scripts/.gitkeep - Update README with contributing section, mention filtering, processing indicators --- .github/ISSUE_TEMPLATE/bug-report.yml | 83 ++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature-request.yml | 60 ++ .github/ISSUE_TEMPLATE/new-skill.yml | 78 ++ .github/PULL_REQUEST_TEMPLATE.md | 35 + CODE_OF_CONDUCT.md | 72 ++ CONTRIBUTING.md | 162 ++++ README.md | 63 +- docs/channels.md | 26 + forge-cli/build/egress_stage_test.go | 7 +- forge-cli/channels/integration_test.go | 7 +- forge-cli/cmd/channel.go | 15 +- forge-cli/cmd/channel_test.go | 9 +- forge-cli/cmd/init.go | 8 + forge-cli/cmd/init_test.go | 15 +- forge-cli/internal/tui/steps/channel_step.go | 22 +- forge-cli/internal/tui/steps/egress_step.go | 9 +- forge-cli/templates/init/env-slack.tmpl | 4 +- .../templates/init/slack-config.yaml.tmpl | 4 +- forge-core/runtime/loop.go | 29 +- forge-core/security/capabilities.go | 2 +- forge-core/security/capabilities_test.go | 13 +- forge-core/security/resolver_test.go | 9 +- forge-plugins/channels/markdown/split.go | 2 +- forge-plugins/channels/slack/slack.go | 736 ++++++++++++++---- forge-plugins/channels/slack/slack_test.go | 639 ++++++++++++--- forge-plugins/channels/telegram/telegram.go | 93 ++- forge-plugins/go.mod | 2 + forge-plugins/go.sum | 38 +- .../local/embedded/_template/SKILL.md | 91 +++ .../local/embedded/_template/scripts/.gitkeep | 12 + 31 files changed, 2006 insertions(+), 347 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/new-skill.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 forge-skills/local/embedded/_template/SKILL.md create mode 100644 forge-skills/local/embedded/_template/scripts/.gitkeep diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..2470f3a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,83 @@ +name: Bug Report +description: Report a bug in Forge +title: "[Bug]: " +labels: ["bug"] +body: + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Forge is affected? + options: + - forge-core (registry, tools, security, channels, LLM) + - forge-cli (commands, TUI wizard, runtime) + - forge-plugins (channel plugins, markdown converter) + - forge-skills (skill parser, compiler, analyzer, trust) + - Documentation + - Other + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: A clear description of the bug + placeholder: Describe the bug + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can we reproduce this behavior? + placeholder: | + 1. Run `forge ...` + 2. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + + - type: input + id: os + attributes: + label: Operating system + description: "e.g. macOS 14.2, Ubuntu 22.04, Windows 11" + placeholder: macOS 14.2 + + - type: input + id: go-version + attributes: + label: Go version + description: "Output of `go version`" + placeholder: go1.25.0 + + - type: textarea + id: forge-yaml + attributes: + label: forge.yaml (if relevant) + description: Paste your `forge.yaml` configuration (redact secrets) + render: yaml + + - type: textarea + id: logs + attributes: + label: Logs / error output + description: Paste any relevant log output or error messages + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..002093f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Documentation + url: https://github.com/initializ/forge/tree/main/docs + about: Read the Forge documentation before opening an issue + - name: Discussions + url: https://github.com/initializ/forge/discussions + about: Ask questions and share ideas in GitHub Discussions diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..1d227f5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,60 @@ +name: Feature Request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: dropdown + id: component + attributes: + label: Component + description: Which part of Forge does this feature relate to? + options: + - forge-core (registry, tools, security, channels, LLM) + - forge-cli (commands, TUI wizard, runtime) + - forge-plugins (channel plugins, markdown converter) + - forge-skills (skill parser, compiler, analyzer, trust) + - Documentation + - Other + validations: + required: true + + - type: dropdown + id: scope + attributes: + label: Scope + description: How large is this feature? + options: + - Small (single file / minor change) + - Medium (multiple files / new command) + - Large (new module / architectural change) + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem statement + description: What problem does this feature solve? Is it related to a frustration? + placeholder: I'm always frustrated when ... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the solution you'd like + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Describe any alternative solutions or features you've considered + + - type: textarea + id: context + attributes: + label: Additional context + description: Add any other context, screenshots, or examples diff --git a/.github/ISSUE_TEMPLATE/new-skill.yml b/.github/ISSUE_TEMPLATE/new-skill.yml new file mode 100644 index 0000000..4497ecb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-skill.yml @@ -0,0 +1,78 @@ +name: New Skill Proposal +description: Propose a new Forge skill +title: "[Skill]: " +labels: ["skill", "proposal"] +body: + - type: input + id: skill-name + attributes: + label: Skill name + description: "Kebab-case identifier (e.g. `my-cool-skill`)" + placeholder: my-cool-skill + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: What does this skill do and why is it useful? + placeholder: Describe the skill's purpose and use cases + validations: + required: true + + - type: dropdown + id: execution-type + attributes: + label: Execution type + description: How will the skill's tools run? + options: + - Script-backed (shell scripts in scripts/) + - Binary-backed (compiled binary in PATH) + validations: + required: true + + - type: input + id: category + attributes: + label: Category + description: "Skill category (e.g. sre, research, ops, dev, security)" + placeholder: ops + + - type: textarea + id: requirements + attributes: + label: Requirements + description: List required binaries, environment variables, and egress domains + placeholder: | + Binaries: curl, jq + Env vars: MY_API_KEY (required) + Egress: api.example.com + + - type: textarea + id: use-case + attributes: + label: Example use case + description: Describe a concrete scenario where this skill would be used + placeholder: | + As a developer, I want to ... + validations: + required: true + + - type: textarea + id: security + attributes: + label: Security considerations + description: Describe any security implications (network access, credentials, read-only constraints) + placeholder: | + - Read-only access to ... + - Requires API key for ... + + - type: textarea + id: example-interaction + attributes: + label: Example interaction + description: Show an example input/output or conversation with the skill + placeholder: | + Input: {"query": "example"} + Output: {"results": [...]} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..dfd9fb8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Enhancement / refactor +- [ ] New skill +- [ ] Documentation +- [ ] CI / build + +## Description + + + +## General Checklist + +- [ ] Tests pass for affected modules (`go test ./...`) +- [ ] Code is formatted (`gofmt -w`) +- [ ] Linter passes (`golangci-lint run`) +- [ ] `go vet` reports no issues +- [ ] No new egress domains added without justification + +## Skill Contribution Checklist + + + +- [ ] `forge skills validate` passes with no errors +- [ ] `forge skills audit` reports no policy violations +- [ ] `egress_domains` lists every domain the skill contacts +- [ ] No secrets or credentials are hardcoded +- [ ] SKILL.md includes `## Tool:` sections with input/output tables +- [ ] Skill tested locally with expected input/output + +## Related Issues + + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..82cfb3d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,72 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and maintainers of the Forge project pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Being respectful and considerate in communication +- Giving and gracefully accepting constructive feedback +- Focusing on what is best for the community and the project +- Showing empathy toward other community members +- Being patient with newcomers and helping them get started + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and unwelcome sexual attention or advances +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Sustained disruption of discussions, reviews, or other community activities +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Scope + +This Code of Conduct applies within all project spaces — including the GitHub repository, issues, pull requests, discussions, and any other communication channels associated with Forge. It also applies when an individual is officially representing the project in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project maintainers at: + +**conduct@initializ.ai** + +All complaints will be reviewed and investigated promptly and fairly. The project team is obligated to maintain confidentiality regarding the reporter of an incident. + +## Enforcement Guidelines + +Project maintainers will follow these guidelines in determining consequences for any action deemed in violation of this Code of Conduct: + +### 1. Correction + +**Impact:** Use of inappropriate language or other behavior deemed unprofessional or unwelcome. + +**Consequence:** A private written warning from maintainers, with clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Impact:** A violation through a single incident or series of actions. + +**Consequence:** A warning with consequences for continued behavior. No interaction with the people involved for a specified period. This includes avoiding interactions in project spaces and external channels. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Impact:** A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence:** A temporary ban from any sort of interaction or public communication with the project for a specified period. No public or private interaction with the people involved is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Impact:** Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence:** A permanent ban from any sort of public interaction within the project. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1. + +For answers to common questions about the Contributor Covenant, see the [FAQ](https://www.contributor-covenant.org/faq). \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..31712d1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,162 @@ +# Contributing to Forge + +Thank you for your interest in contributing to Forge! This guide covers general development workflow and skill contribution. + +## Table of Contents + +- [Getting Started](#getting-started) +- [Project Structure](#project-structure) +- [Development Workflow](#development-workflow) +- [Contributing a Skill](#contributing-a-skill) +- [Security Rules](#security-rules) +- [Pull Request Process](#pull-request-process) +- [Code Style](#code-style) + +## Getting Started + +### Prerequisites + +- Go 1.25 or later +- [golangci-lint](https://golangci-lint.run/) v2.10+ +- Git + +### Clone and build + +```bash +git clone https://github.com/initializ/forge.git +cd forge +cd forge-cli && go build ./... && cd .. +``` + +### Run tests + +```bash +cd forge-core && go test ./... && cd .. +cd forge-cli && go test ./... && cd .. +cd forge-plugins && go test ./... && cd .. +cd forge-skills && go test ./... && cd .. +``` + +## Project Structure + +Forge is a multi-module Go workspace: + +| Module | Purpose | +|--------|---------| +| `forge-core/` | Core library — registry, tools, security, channels, LLM | +| `forge-cli/` | CLI commands, TUI wizard, runtime | +| `forge-plugins/` | Channel plugins (Telegram, Slack), markdown converter | +| `forge-skills/` | Skill parser, compiler, analyzer, trust, embedded skills | + +Skills live in two locations: + +- **Embedded skills** — `forge-skills/local/embedded/` (bundled in the binary) +- **Project skills** — `skills/` in the user's working directory + +## Development Workflow + +1. Fork the repository and clone your fork +2. Create a feature branch from `main`: + ```bash + git checkout -b feature/my-feature main + ``` +3. Make your changes in the relevant module(s) +4. Format and lint: + ```bash + gofmt -w forge-core/ forge-cli/ forge-plugins/ forge-skills/ + golangci-lint run ./forge-core/... + golangci-lint run ./forge-cli/... + golangci-lint run ./forge-plugins/... + golangci-lint run ./forge-skills/... + ``` +5. Run tests for affected modules +6. Commit with a clear message and open a pull request against `main` + +## Contributing a Skill + +### Step 1 — Copy the template + +```bash +cp -r forge-skills/local/embedded/_template skills/my-skill +``` + +### Step 2 — Edit SKILL.md + +Open `skills/my-skill/SKILL.md` and fill in: + +- **`name`** — Kebab-case identifier matching the directory name +- **`description`** — One-line summary of what the skill does +- **`category`** (optional) — e.g. `sre`, `research`, `ops`, `dev`, `security` +- **`tags`** (optional) — Discovery keywords +- **`requires.bins`** — Binaries that must be in PATH +- **`requires.env`** — Environment variables (required, one_of, optional) +- **`egress_domains`** — Network domains the skill contacts (supports `$VAR` substitution) +- **`denied_tools`** (optional) — Tools the skill must NOT use +- **`timeout_hint`** (optional) — Suggested timeout in seconds + +Add `## Tool: tool_name` sections documenting each tool with input/output tables. + +If your skill is script-backed, add executable scripts to `scripts/`. Tool name underscores become hyphens in the filename: tool `my_search` maps to `scripts/my-search.sh`. + +If your skill is binary-backed, delete the `scripts/` directory and list the binary in `requires.bins`. + +### Step 3 — Validate + +```bash +forge skills validate +forge skills audit +``` + +Fix any errors or warnings before submitting. + +### Step 4 — Test + +Run your skill locally and verify: + +- Tools execute correctly with expected input +- Output matches the documented format +- Error cases are handled gracefully +- Egress domains are accurate and minimal + +### Step 5 — Open a PR + +Follow the [Pull Request Process](#pull-request-process) below. + +## Security Rules + +All contributions must follow these security requirements: + +1. **Egress allowlist** — Every network domain a skill contacts must be listed in `egress_domains`. No wildcard domains. +2. **Minimal permissions** — Request only the environment variables and binaries actually needed. +3. **No secrets in code** — Never hardcode API keys, tokens, or credentials. Use `requires.env` to declare them. +4. **Read-only by default** — Skills should avoid mutating external state unless explicitly required and documented. +5. **Tool restrictions** — If a skill should not use certain tools (e.g. `http_request` when using `cli_execute`), declare them in `denied_tools`. + +Use `forge skills audit` to check your skill against the default security policy. Use `forge skills trust-report ` to review full metadata. + +## Pull Request Process + +1. Branch from `main` — never push directly to `main` +2. Ensure all tests pass for affected modules +3. Run `gofmt` and `golangci-lint` with no errors +4. For skill contributions, include `forge skills validate` and `forge skills audit` output +5. Fill out the PR template completely +6. Request review from a maintainer + +### Commit messages + +Use clear, descriptive commit messages: + +``` +Add tavily-research skill with async polling + +Fix egress domain validation for env var substitution +``` + +## Code Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- Use `golangci-lint` with the project configuration +- Keep functions focused and testable +- Add tests for new functionality +- Document exported types and functions diff --git a/README.md b/README.md index 595a4ec..a49010d 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,51 @@ forge run --with slack forge run --with slack,telegram ``` +### Slack App Setup + +Before running the Slack adapter, create and configure a Slack App: + +1. **Create a Slack App** at https://api.slack.com/apps → "Create New App" → "From scratch" +2. **Enable Socket Mode** — Settings → Socket Mode → toggle **On** +3. **Generate an App-Level Token** — Basic Information → "App-Level Tokens" → "Generate Token and Scopes" → add the `connections:write` scope → copy the `xapp-...` token +4. **Enable Event Subscriptions** — Features → Event Subscriptions → toggle **On** → Subscribe to bot events: + - `message.channels` — messages in public channels + - `message.im` — direct messages + - `app_mention` — @mentions of your bot +5. **Set Bot Token Scopes** — Features → OAuth & Permissions → Bot Token Scopes → add: + - `app_mentions:read` + - `chat:write` + - `channels:history` + - `im:history` + - `files:write` (for large response file uploads) + - `reactions:write` (for processing indicators) +6. **Install the App** — Settings → Install App → "Install to Workspace" → copy the `xoxb-...` Bot Token +7. **Add tokens to `.env`**: + ``` + SLACK_APP_TOKEN=xapp-1-... + SLACK_BOT_TOKEN=xoxb-... + ``` +8. **Invite the bot** to channels where you want it active: `/invite @YourBot` + +### Mention-Aware Filtering + +The Slack adapter resolves the bot's own user ID at startup via `auth.test` and uses it for intelligent message filtering: + +- **Channel messages** — the bot only responds when explicitly @mentioned (e.g. `@ForgeBot what's the status?`) +- **Thread replies** — the bot responds to all messages in a thread it's participating in, unless the message @mentions a different user +- **Direct messages** — all DMs are processed +- Bot mentions are stripped from the message text before passing to the LLM, so it sees clean input + +### Processing Indicators + +When the Slack adapter receives a message: + +1. An :eyes: reaction is added immediately to acknowledge receipt +2. If the handler takes longer than 15 seconds, an interim message is posted: _"Researching, I'll post the result shortly..."_ +3. The :eyes: reaction is removed when the response is ready + +This gives users visual feedback that their message is being processed, especially for long-running research queries. + Channels can also run standalone as separate services: ```bash @@ -472,7 +517,9 @@ When an agent response exceeds 4096 characters (common with research reports), c 1. A brief summary (first paragraph, up to 600 characters) is sent as a regular message 2. The full report is uploaded as a downloadable Markdown file (`research-report.md`) -This works on both Slack (via `files.upload`) and Telegram (via `sendDocument`). If file upload fails, adapters fall back to chunked messages. Markdown is converted to platform-native formatting (Slack mrkdwn or Telegram HTML). +This works on both Slack (via `files.getUploadURLExternal`) and Telegram (via `sendDocument`). If file upload fails, adapters fall back to chunked messages. Markdown is converted to platform-native formatting (Slack mrkdwn or Telegram HTML). + +Additionally, the runtime tracks large tool outputs (>8000 characters) and attaches them as file parts in the A2A response. This ensures channel adapters receive the complete, untruncated tool output even when the LLM's text summary is truncated by output token limits. JSON tool outputs (e.g. Tavily Research/Search results) are automatically unwrapped into readable markdown before delivery. --- @@ -494,7 +541,7 @@ Key behaviors: - **Localhost always allowed** (`127.0.0.1`, `::1`, `localhost`) in all modes - **Wildcard domains** supported (e.g., `*.github.com` matches `api.github.com`) - **Tool domains auto-inferred** — declaring `web_search` in tools automatically allows `api.tavily.com` and `api.perplexity.ai` -- **Capability bundles** — declaring `slack` capability adds `slack.com`, `hooks.slack.com`, `api.slack.com` +- **Capability bundles** — declaring `slack` capability adds `slack.com`, `wss-primary.slack.com`, `api.slack.com`, `files.slack.com` - Blocked requests return: `egress blocked: domain "X" not in allowlist (mode=allowlist)` ### Subprocess Egress Proxy @@ -1285,7 +1332,17 @@ Forge provides those building blocks. - [Hooks](docs/hooks.md) — Agent loop hook system - [Plugins](docs/plugins.md) — Framework plugin system - [Channels](docs/channels.md) — Channel adapter architecture -- [Contributing](docs/contributing.md) — Development guide and PR process + +## Contributing + +We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide, including: + +- Development setup and multi-module workflow +- How to contribute a new skill (copy the [skill template](forge-skills/local/embedded/_template/), validate, and PR) +- Security rules for egress, secrets, and tool restrictions +- Pull request process and code style + +Please read our [Code of Conduct](CODE_OF_CONDUCT.md) before participating. ## License diff --git a/docs/channels.md b/docs/channels.md index 0fd5995..6ec21ce 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -35,6 +35,32 @@ This command: 3. Adds the channel to `forge.yaml`'s `channels` list 4. Prints setup instructions +## Slack App Setup + +Before running the Slack adapter, create and configure a Slack App: + +1. **Create a Slack App** at https://api.slack.com/apps → "Create New App" → "From scratch" +2. **Enable Socket Mode** — Settings → Socket Mode → toggle **On** +3. **Generate an App-Level Token** — Basic Information → "App-Level Tokens" → "Generate Token and Scopes" → add the `connections:write` scope → copy the `xapp-...` token +4. **Enable Event Subscriptions** — Features → Event Subscriptions → toggle **On** → Subscribe to bot events: + - `message.channels` — messages in public channels + - `message.im` — direct messages + - `app_mention` — @mentions of your bot +5. **Set Bot Token Scopes** — Features → OAuth & Permissions → Bot Token Scopes → add: + - `app_mentions:read` + - `chat:write` + - `channels:history` + - `im:history` + - `files:write` (for large response file uploads) + - `reactions:write` (for processing indicators) +6. **Install the App** — Settings → Install App → "Install to Workspace" → copy the `xoxb-...` Bot Token +7. **Add tokens to `.env`**: + ``` + SLACK_APP_TOKEN=xapp-1-... + SLACK_BOT_TOKEN=xoxb-... + ``` +8. **Invite the bot** to any channel where you want it active: `/invite @YourBot` + ## Configuration ### Slack (`slack-config.yaml`) diff --git a/forge-cli/build/egress_stage_test.go b/forge-cli/build/egress_stage_test.go index 1379917..59dbb32 100644 --- a/forge-cli/build/egress_stage_test.go +++ b/forge-cli/build/egress_stage_test.go @@ -120,9 +120,10 @@ func TestEgressStage_AllowlistWithCapabilities(t *testing.T) { // Check that slack domains are in the resolved AllDomains wantDomains := map[string]bool{ - "slack.com": true, - "hooks.slack.com": true, - "api.slack.com": true, + "slack.com": true, + "wss-primary.slack.com": true, + "api.slack.com": true, + "files.slack.com": true, } for d := range wantDomains { found := false diff --git a/forge-cli/channels/integration_test.go b/forge-cli/channels/integration_test.go index a09dad1..cf4ef0d 100644 --- a/forge-cli/channels/integration_test.go +++ b/forge-cli/channels/integration_test.go @@ -67,11 +67,10 @@ func TestSlackPlugin_MockA2A(t *testing.T) { plugin := slack.New() err := plugin.Init(channels.ChannelConfig{ - Adapter: "slack", - WebhookPort: 0, + Adapter: "slack", Settings: map[string]string{ - "signing_secret": "test-secret", - "bot_token": "xoxb-test-token", + "app_token": "xapp-test-token", + "bot_token": "xoxb-test-token", }, }) if err != nil { diff --git a/forge-cli/cmd/channel.go b/forge-cli/cmd/channel.go index 2641149..adb8371 100644 --- a/forge-cli/cmd/channel.go +++ b/forge-cli/cmd/channel.go @@ -324,12 +324,15 @@ func printSetupInstructions(adapter string) { case "slack": fmt.Println("Slack setup instructions:") fmt.Println(" 1. Create a Slack App at https://api.slack.com/apps") - fmt.Println(" 2. Enable Event Subscriptions and set the Request URL to") - fmt.Println(" https://:3000/slack/events") - fmt.Println(" 3. Subscribe to bot events: message.channels, message.im") - fmt.Println(" 4. Install the app to your workspace") - fmt.Println(" 5. Copy the Signing Secret and Bot Token into .env") - fmt.Println(" 6. Run: forge run --with slack") + fmt.Println(" 2. Enable Socket Mode in your app settings") + fmt.Println(" 3. Generate an app-level token with connections:write scope") + fmt.Println(" 4. Subscribe to bot events: message.channels, message.im") + fmt.Println(" 5. Install the app to your workspace") + fmt.Println(" 6. Add bot scopes: chat:write, app_mentions:read, channels:history,") + fmt.Println(" im:history, files:write, reactions:write") + fmt.Println(" 7. Install the app and copy the Bot Token (xoxb-...) into .env") + fmt.Println(" 8. Copy the App Token (xapp-...) into .env") + fmt.Println(" 9. Run: forge run --with slack") case "telegram": fmt.Println("Telegram setup instructions:") fmt.Println(" 1. Create a bot via @BotFather on Telegram") diff --git a/forge-cli/cmd/channel_test.go b/forge-cli/cmd/channel_test.go index e5fa6d8..934f5cb 100644 --- a/forge-cli/cmd/channel_test.go +++ b/forge-cli/cmd/channel_test.go @@ -49,8 +49,8 @@ channels: if err != nil { t.Fatalf("reading .env: %v", err) } - if !strings.Contains(string(envData), "SLACK_SIGNING_SECRET") { - t.Error(".env missing SLACK_SIGNING_SECRET") + if !strings.Contains(string(envData), "SLACK_APP_TOKEN") { + t.Error(".env missing SLACK_APP_TOKEN") } if !strings.Contains(string(envData), "SLACK_BOT_TOKEN") { t.Error(".env missing SLACK_BOT_TOKEN") @@ -180,10 +180,9 @@ func TestChannelServeNoAgentURL(t *testing.T) { // Write a valid channel config _ = os.WriteFile(filepath.Join(dir, "slack-config.yaml"), []byte(` adapter: slack -webhook_port: 3000 settings: - signing_secret: test - bot_token: test + app_token: xapp-test + bot_token: xoxb-test `), 0644) //nolint:errcheck t.Setenv("AGENT_URL", "") diff --git a/forge-cli/cmd/init.go b/forge-cli/cmd/init.go index 24a6e3a..313fb25 100644 --- a/forge-cli/cmd/init.go +++ b/forge-cli/cmd/init.go @@ -637,6 +637,14 @@ func scaffold(opts *initOptions) error { fmt.Printf("\nCreated agent project in ./%s\n", opts.AgentID) + // Show channel-specific reminders + for _, ch := range opts.Channels { + if ch == "slack" { + fmt.Println() + fmt.Println(" Slack reminder: /invite @YourBot in each channel you want it active in.") + } + } + // In non-interactive mode, just print the command if opts.NonInteractive { fmt.Printf(" cd %s && forge run\n", opts.AgentID) diff --git a/forge-cli/cmd/init_test.go b/forge-cli/cmd/init_test.go index 969f5cb..d59edad 100644 --- a/forge-cli/cmd/init_test.go +++ b/forge-cli/cmd/init_test.go @@ -485,13 +485,14 @@ func TestDeriveEgressDomains(t *testing.T) { domains := deriveEgressDomains(opts, skillInfos) expected := map[string]bool{ - "api.openai.com": true, - "slack.com": true, - "hooks.slack.com": true, - "api.slack.com": true, - "api.tavily.com": true, - "api.github.com": true, - "github.com": true, + "api.openai.com": true, + "slack.com": true, + "wss-primary.slack.com": true, + "api.slack.com": true, + "files.slack.com": true, + "api.tavily.com": true, + "api.github.com": true, + "github.com": true, } for _, d := range domains { if !expected[d] { diff --git a/forge-cli/internal/tui/steps/channel_step.go b/forge-cli/internal/tui/steps/channel_step.go index b96ad3b..402c97c 100644 --- a/forge-cli/internal/tui/steps/channel_step.go +++ b/forge-cli/internal/tui/steps/channel_step.go @@ -207,16 +207,28 @@ func (s *ChannelStep) View(width int) string { s.styles.DimTxt.Render("3. Copy the bot token"), ) case "slack": - instructions = fmt.Sprintf(" %s\n %s\n %s\n %s\n\n", - s.styles.SecondaryTxt.Render("Slack Socket Mode Setup:"), + instructions = fmt.Sprintf(" %s\n %s\n %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("Slack App Setup (Step 1/2 — App-Level Token):"), s.styles.DimTxt.Render("1. Create a Slack App at https://api.slack.com/apps"), - s.styles.DimTxt.Render("2. Enable Socket Mode, generate app-level token"), - s.styles.DimTxt.Render("3. Add bot scopes: chat:write, app_mentions:read"), + s.styles.DimTxt.Render(" → \"Create New App\" → \"From scratch\""), + s.styles.DimTxt.Render("2. Settings → Socket Mode → toggle ON"), + s.styles.DimTxt.Render("3. Basic Information → App-Level Tokens → Generate"), + s.styles.DimTxt.Render(" → add scope: connections:write → copy the xapp-... token"), ) } return instructions + s.keyInput.View(width) case channelSlackBotTokenPhase: - return s.keyInput.View(width) + botInstructions := fmt.Sprintf(" %s\n %s\n %s\n %s\n %s\n %s\n %s\n %s\n\n", + s.styles.SecondaryTxt.Render("Slack App Setup (Step 2/2 — Bot Token):"), + s.styles.DimTxt.Render("4. Event Subscriptions → toggle ON → Subscribe to bot events:"), + s.styles.DimTxt.Render(" → message.channels, message.im, app_mention"), + s.styles.DimTxt.Render("5. OAuth & Permissions → Bot Token Scopes → add:"), + s.styles.DimTxt.Render(" → app_mentions:read, chat:write, channels:history,"), + s.styles.DimTxt.Render(" im:history, files:write, reactions:write"), + s.styles.DimTxt.Render("6. Install App → Install to Workspace"), + s.styles.DimTxt.Render(" → copy the xoxb-... Bot User OAuth Token"), + ) + return botInstructions + s.keyInput.View(width) } return "" } diff --git a/forge-cli/internal/tui/steps/egress_step.go b/forge-cli/internal/tui/steps/egress_step.go index d21101e..93739bd 100644 --- a/forge-cli/internal/tui/steps/egress_step.go +++ b/forge-cli/internal/tui/steps/egress_step.go @@ -138,10 +138,11 @@ func inferSource(domain string, ctx *tui.WizardContext) string { // Channel domains channelDomains := map[string]string{ - "api.telegram.org": "channel", - "slack.com": "channel", - "hooks.slack.com": "channel", - "api.slack.com": "channel", + "api.telegram.org": "channel", + "slack.com": "channel", + "wss-primary.slack.com": "channel", + "api.slack.com": "channel", + "files.slack.com": "channel", } if src, ok := channelDomains[domain]; ok { return src diff --git a/forge-cli/templates/init/env-slack.tmpl b/forge-cli/templates/init/env-slack.tmpl index e9affa4..fcbc620 100644 --- a/forge-cli/templates/init/env-slack.tmpl +++ b/forge-cli/templates/init/env-slack.tmpl @@ -1,4 +1,4 @@ -# Slack channel adapter -SLACK_SIGNING_SECRET= +# Slack channel adapter (Socket Mode) +SLACK_APP_TOKEN= SLACK_BOT_TOKEN= diff --git a/forge-cli/templates/init/slack-config.yaml.tmpl b/forge-cli/templates/init/slack-config.yaml.tmpl index d9f7ac3..620196f 100644 --- a/forge-cli/templates/init/slack-config.yaml.tmpl +++ b/forge-cli/templates/init/slack-config.yaml.tmpl @@ -1,6 +1,4 @@ adapter: slack -webhook_port: 3000 -webhook_path: /slack/events settings: - signing_secret_env: SLACK_SIGNING_SECRET + app_token_env: SLACK_APP_TOKEN bot_token_env: SLACK_BOT_TOKEN diff --git a/forge-core/runtime/loop.go b/forge-core/runtime/loop.go index 6c36e08..8de27a4 100644 --- a/forge-core/runtime/loop.go +++ b/forge-core/runtime/loop.go @@ -135,6 +135,11 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess toolDefs = e.tools.ToolDefinitions() } + // Track large tool outputs so they can be included as file parts + // in the response (the LLM may truncate them due to output token limits). + const largeToolOutputThreshold = 8000 + var largeToolOutputs []a2a.Part + // Agent loop for i := 0; i < e.maxIter; i++ { // Run compaction before LLM call (best-effort). @@ -190,13 +195,13 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess // Check if we're done (no tool calls) if resp.FinishReason == "stop" || len(resp.Message.ToolCalls) == 0 { e.persistSession(task.ID, mem) - return llmMessageToA2A(resp.Message), nil + return llmMessageToA2A(resp.Message, largeToolOutputs...), nil } // Execute tool calls if e.tools == nil { e.persistSession(task.ID, mem) - return llmMessageToA2A(resp.Message), nil + return llmMessageToA2A(resp.Message, largeToolOutputs...), nil } for _, tc := range resp.Message.ToolCalls { @@ -234,6 +239,18 @@ func (e *LLMExecutor) Execute(ctx context.Context, task *a2a.Task, msg *a2a.Mess return nil, fmt.Errorf("after tool exec hook: %w", err) } + // Track large tool outputs for pass-through in the response. + if len(result) > largeToolOutputThreshold { + largeToolOutputs = append(largeToolOutputs, a2a.Part{ + Kind: a2a.PartKindFile, + File: &a2a.FileContent{ + Name: tc.Function.Name + "-output.md", + MimeType: "text/markdown", + Bytes: []byte(result), + }, + }) + } + // Append tool result to memory mem.Append(llm.ChatMessage{ Role: llm.RoleTool, @@ -311,14 +328,18 @@ func a2aMessageToLLM(msg a2a.Message) llm.ChatMessage { } // llmMessageToA2A converts an LLM chat message to an A2A message. -func llmMessageToA2A(msg llm.ChatMessage) *a2a.Message { +// Any extra parts (e.g. large tool output files) are appended after the text part. +func llmMessageToA2A(msg llm.ChatMessage, extraParts ...a2a.Part) *a2a.Message { role := a2a.MessageRoleAgent if msg.Role == llm.RoleUser { role = a2a.MessageRoleUser } + parts := []a2a.Part{a2a.NewTextPart(msg.Content)} + parts = append(parts, extraParts...) + return &a2a.Message{ Role: role, - Parts: []a2a.Part{a2a.NewTextPart(msg.Content)}, + Parts: parts, } } diff --git a/forge-core/security/capabilities.go b/forge-core/security/capabilities.go index 3367856..4f3449c 100644 --- a/forge-core/security/capabilities.go +++ b/forge-core/security/capabilities.go @@ -2,7 +2,7 @@ package security // DefaultCapabilityBundles maps capability names to their required domain sets. var DefaultCapabilityBundles = map[string][]string{ - "slack": {"slack.com", "hooks.slack.com", "api.slack.com"}, + "slack": {"slack.com", "wss-primary.slack.com", "api.slack.com", "files.slack.com"}, "telegram": {"api.telegram.org"}, } diff --git a/forge-core/security/capabilities_test.go b/forge-core/security/capabilities_test.go index 46d9df7..e426183 100644 --- a/forge-core/security/capabilities_test.go +++ b/forge-core/security/capabilities_test.go @@ -7,9 +7,10 @@ import ( func TestResolveCapabilities_Slack(t *testing.T) { domains := ResolveCapabilities([]string{"slack"}) expected := map[string]bool{ - "slack.com": true, - "hooks.slack.com": true, - "api.slack.com": true, + "slack.com": true, + "wss-primary.slack.com": true, + "api.slack.com": true, + "files.slack.com": true, } if len(domains) != len(expected) { t.Fatalf("got %d domains, want %d: %v", len(domains), len(expected), domains) @@ -40,9 +41,9 @@ func TestResolveCapabilities_Unknown(t *testing.T) { func TestResolveCapabilities_Dedup(t *testing.T) { domains := ResolveCapabilities([]string{"slack", "slack"}) - // Should deduplicate: slack.com, hooks.slack.com, api.slack.com - if len(domains) != 3 { - t.Errorf("got %d domains after dedup, want 3: %v", len(domains), domains) + // Should deduplicate: slack.com, wss-primary.slack.com, api.slack.com, files.slack.com + if len(domains) != 4 { + t.Errorf("got %d domains after dedup, want 4: %v", len(domains), domains) } } diff --git a/forge-core/security/resolver_test.go b/forge-core/security/resolver_test.go index 38268c0..72dbc9c 100644 --- a/forge-core/security/resolver_test.go +++ b/forge-core/security/resolver_test.go @@ -122,10 +122,11 @@ func TestResolve_AllowlistWithCapabilities(t *testing.T) { // Check that slack domains are in AllDomains want := map[string]bool{ - "api.example.com": true, - "slack.com": true, - "hooks.slack.com": true, - "api.slack.com": true, + "api.example.com": true, + "slack.com": true, + "wss-primary.slack.com": true, + "api.slack.com": true, + "files.slack.com": true, } for d := range want { found := false diff --git a/forge-plugins/channels/markdown/split.go b/forge-plugins/channels/markdown/split.go index 22e816b..56a4a25 100644 --- a/forge-plugins/channels/markdown/split.go +++ b/forge-plugins/channels/markdown/split.go @@ -21,7 +21,7 @@ func SplitSummaryAndReport(text string) (summary, report string) { summary = truncateAtSentence(text, 500) } - summary = strings.TrimSpace(summary) + "\n\n_Full report attached as file._" + summary = strings.TrimSpace(summary) return summary, report } diff --git a/forge-plugins/channels/slack/slack.go b/forge-plugins/channels/slack/slack.go index 21d8768..a2e983c 100644 --- a/forge-plugins/channels/slack/slack.go +++ b/forge-plugins/channels/slack/slack.go @@ -4,39 +4,39 @@ package slack import ( "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" - "math" "net/http" + "net/url" + "slices" "strconv" + "strings" + "sync" "time" + "github.com/gorilla/websocket" "github.com/initializ/forge/forge-core/a2a" "github.com/initializ/forge/forge-core/channels" "github.com/initializ/forge/forge-plugins/channels/markdown" ) -const ( - defaultWebhookPort = 3000 - defaultWebhookPath = "/slack/events" - replayWindowSec = 300 // 5 minutes -) - const slackAPIBase = "https://slack.com/api" -// Plugin implements channels.ChannelPlugin for Slack. +// longRunningThreshold is how long to wait before sending an interim +// "Researching..." message for slow handler responses. +const longRunningThreshold = 15 * time.Second + +// Plugin implements channels.ChannelPlugin for Slack using Socket Mode. type Plugin struct { - signingSecret string - botToken string - webhookPort int - webhookPath string - srv *http.Server - client *http.Client - apiBase string // overridable for tests + appToken string + botToken string + botUserID string // resolved at startup via auth.test + wsConn *websocket.Conn + connMu sync.Mutex + stopCh chan struct{} + client *http.Client + apiBase string // overridable for tests } // New creates an uninitialised Slack plugin. @@ -52,125 +52,333 @@ func (p *Plugin) Name() string { return "slack" } func (p *Plugin) Init(cfg channels.ChannelConfig) error { settings := channels.ResolveEnvVars(&cfg) - p.signingSecret = settings["signing_secret"] - if p.signingSecret == "" { - return fmt.Errorf("slack: signing_secret is required (set SLACK_SIGNING_SECRET)") + p.appToken = settings["app_token"] + if p.appToken == "" { + return fmt.Errorf("slack: app_token is required (set SLACK_APP_TOKEN)") } p.botToken = settings["bot_token"] if p.botToken == "" { return fmt.Errorf("slack: bot_token is required (set SLACK_BOT_TOKEN)") } - p.webhookPort = cfg.WebhookPort - if p.webhookPort == 0 { - p.webhookPort = defaultWebhookPort + return nil +} + +// resolveBotID calls auth.test to discover the bot's own Slack user ID. +func (p *Plugin) resolveBotID() error { + req, err := http.NewRequest(http.MethodPost, p.apiBase+"/auth.test", nil) + if err != nil { + return fmt.Errorf("creating auth.test request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+p.botToken) + + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("calling auth.test: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading auth.test response: %w", err) + } + + var result struct { + OK bool `json:"ok"` + UserID string `json:"user_id"` + Error string `json:"error,omitempty"` } - p.webhookPath = cfg.WebhookPath - if p.webhookPath == "" { - p.webhookPath = defaultWebhookPath + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("parsing auth.test response: %w", err) + } + if !result.OK { + return fmt.Errorf("auth.test error: %s", result.Error) } + p.botUserID = result.UserID return nil } -func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error { - mux := http.NewServeMux() - mux.HandleFunc(p.webhookPath, p.makeWebhookHandler(handler)) +// extractMentions scans text for Slack user mentions (<@UXXXX> or <@UXXXX|name>) +// and returns a slice of the mentioned user IDs. +func extractMentions(text string) []string { + var mentions []string + for { + start := strings.Index(text, "<@") + if start == -1 { + break + } + end := strings.Index(text[start:], ">") + if end == -1 { + break + } + inner := text[start+2 : start+end] // e.g. "U0123ABC" or "U0123ABC|bob" + if pipeIdx := strings.Index(inner, "|"); pipeIdx != -1 { + inner = inner[:pipeIdx] + } + if inner != "" { + mentions = append(mentions, inner) + } + text = text[start+end+1:] + } + return mentions +} - p.srv = &http.Server{ - Addr: fmt.Sprintf(":%d", p.webhookPort), - Handler: mux, +// stripBotMention removes all occurrences of <@botUserID> (with optional +// display name) from the message text, collapsing extra whitespace. +func stripBotMention(text, botUserID string) string { + for { + start := strings.Index(text, "<@"+botUserID) + if start == -1 { + break + } + end := strings.Index(text[start:], ">") + if end == -1 { + break + } + text = text[:start] + text[start+end+1:] + } + // Collapse runs of multiple spaces into a single space. + for strings.Contains(text, " ") { + text = strings.ReplaceAll(text, " ", " ") } + return strings.TrimSpace(text) +} - go func() { - <-ctx.Done() - p.Stop() //nolint:errcheck - }() +// openConnection calls apps.connections.open to obtain a WebSocket URL. +func (p *Plugin) openConnection() (string, error) { + url := p.apiBase + "/apps.connections.open" + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return "", fmt.Errorf("creating connections.open request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", "Bearer "+p.appToken) - fmt.Printf(" Slack adapter listening on :%d%s\n", p.webhookPort, p.webhookPath) - if err := p.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - return err + resp, err := p.client.Do(req) + if err != nil { + return "", fmt.Errorf("calling apps.connections.open: %w", err) } - return nil -} + defer func() { _ = resp.Body.Close() }() -func (p *Plugin) Stop() error { - if p.srv != nil { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return p.srv.Shutdown(ctx) + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading connections.open response: %w", err) } - return nil + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("apps.connections.open HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + OK bool `json:"ok"` + URL string `json:"url"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("parsing connections.open response: %w", err) + } + if !result.OK { + return "", fmt.Errorf("apps.connections.open error: %s", result.Error) + } + return result.URL, nil } -func (p *Plugin) makeWebhookHandler(handler channels.EventHandler) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) +func (p *Plugin) Start(ctx context.Context, handler channels.EventHandler) error { + p.stopCh = make(chan struct{}) + + // Resolve the bot's own user ID so we can filter mentions. + if err := p.resolveBotID(); err != nil { + fmt.Printf(" slack: warning: could not resolve bot user ID: %v (will respond to all messages)\n", err) + } else { + fmt.Printf(" Slack bot user ID: %s\n", p.botUserID) + } + + for { + select { + case <-ctx.Done(): + return nil + case <-p.stopCh: + return nil + default: + } + + wsURL, err := p.openConnection() + if err != nil { + fmt.Printf(" slack: failed to open connection: %v (retrying in 2s)\n", err) + select { + case <-time.After(2 * time.Second): + continue + case <-ctx.Done(): + return nil + case <-p.stopCh: + return nil + } + } + + fmt.Println(" Slack adapter connected via Socket Mode") + + conn, _, err := websocket.DefaultDialer.DialContext(ctx, wsURL, nil) if err != nil { - http.Error(w, "failed to read body", http.StatusBadRequest) - return + fmt.Printf(" slack: websocket dial failed: %v (retrying in 2s)\n", err) + select { + case <-time.After(2 * time.Second): + continue + case <-ctx.Done(): + return nil + case <-p.stopCh: + return nil + } + } + + p.connMu.Lock() + p.wsConn = conn + p.connMu.Unlock() + + // Read loop — exits on error or close, then reconnects. + if err := p.readLoop(ctx, conn, handler); err != nil { + fmt.Printf(" slack: read loop error: %v (reconnecting in 2s)\n", err) + } + + p.connMu.Lock() + p.wsConn = nil + p.connMu.Unlock() + + select { + case <-time.After(2 * time.Second): + case <-ctx.Done(): + return nil + case <-p.stopCh: + return nil } + } +} - // Verify Slack signature - timestamp := r.Header.Get("X-Slack-Request-Timestamp") - signature := r.Header.Get("X-Slack-Signature") +func (p *Plugin) readLoop(ctx context.Context, conn *websocket.Conn, handler channels.EventHandler) error { + for { + select { + case <-ctx.Done(): + return nil + case <-p.stopCh: + return nil + default: + } - if !verifySlackSignature(p.signingSecret, timestamp, body, signature) { - http.Error(w, "invalid signature", http.StatusUnauthorized) - return + _, message, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("reading websocket message: %w", err) } - // Replay protection: check timestamp within window - ts, err := strconv.ParseInt(timestamp, 10, 64) - if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > replayWindowSec { - http.Error(w, "request too old", http.StatusUnauthorized) - return + var envelope socketEnvelope + if err := json.Unmarshal(message, &envelope); err != nil { + fmt.Printf(" slack: invalid envelope JSON: %v\n", err) + continue } - // Parse outer envelope to check type - var envelope struct { - Type string `json:"type"` - Challenge string `json:"challenge"` + // Acknowledge the envelope immediately. + if envelope.EnvelopeID != "" { + ack := map[string]string{"envelope_id": envelope.EnvelopeID} + if err := conn.WriteJSON(ack); err != nil { + return fmt.Errorf("sending ack: %w", err) + } } - if err := json.Unmarshal(body, &envelope); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) - return + + // Handle disconnect requests from Slack. + if envelope.Type == "disconnect" { + fmt.Println(" slack: received disconnect, will reconnect") + return nil } - // Handle url_verification challenge - if envelope.Type == "url_verification" { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"challenge": envelope.Challenge}) //nolint:errcheck - return + if envelope.Type != "events_api" { + continue } - // Parse event callback + // Parse the inner event payload. var payload slackEventPayload - if err := json.Unmarshal(body, &payload); err != nil { - http.Error(w, "invalid event payload", http.StatusBadRequest) - return + if err := json.Unmarshal(envelope.Payload, &payload); err != nil { + fmt.Printf(" slack: invalid event payload: %v\n", err) + continue } - // Skip bot messages + // Skip bot messages. if payload.Event.BotID != "" { - w.WriteHeader(http.StatusOK) - return + continue + } + + // Skip message subtypes (message_deleted, message_changed, + // channel_join, etc.) — only process plain user messages. + if payload.Event.SubType != "" { + continue + } + + // Skip app_mention events — message events already include + // @mentions, so processing both would cause duplicates. + if payload.Event.Type == "app_mention" { + continue + } + + // Mention-aware filtering (only when bot user ID is known). + if p.botUserID != "" { + if payload.Event.ThreadTS == "" { + // Channel-level message: only respond when the bot is @mentioned. + if !strings.Contains(payload.Event.Text, "<@"+p.botUserID) { + continue + } + } else { + // Threaded message: respond unless another user (not the bot) + // is explicitly @mentioned, meaning the question is directed + // at someone else. + mentions := extractMentions(payload.Event.Text) + if len(mentions) > 0 && !slices.Contains(mentions, p.botUserID) { + continue + } + } } - // Normalize and dispatch - event, err := p.NormalizeEvent(body) + event, err := p.NormalizeEvent(envelope.Payload) if err != nil { - http.Error(w, "normalisation failed", http.StatusBadRequest) - return + fmt.Printf(" slack: normalisation failed: %v\n", err) + continue } - // Acknowledge immediately (Slack requires 200 within 3s) - w.WriteHeader(http.StatusOK) + // Strip bot mention from the message text so the LLM sees clean text. + if p.botUserID != "" { + event.Message = stripBotMention(event.Message, p.botUserID) + } - // Process async go func() { - ctx := context.Background() + // Add :eyes: reaction to indicate we received the message. + _ = p.addReaction(event.WorkspaceID, event.MessageID, "eyes") + + // If the handler takes longer than 15s, send an interim message. + done := make(chan struct{}) + go func() { + select { + case <-time.After(longRunningThreshold): + threadTS := event.ThreadID + if threadTS == "" { + threadTS = event.MessageID + } + payload := map[string]any{ + "channel": event.WorkspaceID, + "text": "Researching, I'll post the result shortly...", + "mrkdwn": true, + } + if threadTS != "" { + payload["thread_ts"] = threadTS + } + _ = p.postMessage(payload) + case <-done: + } + }() + resp, err := handler(ctx, event) + close(done) + + // Remove the :eyes: reaction. + _ = p.removeReaction(event.WorkspaceID, event.MessageID, "eyes") + if err != nil { fmt.Printf("slack: handler error: %v\n", err) return @@ -182,6 +390,23 @@ func (p *Plugin) makeWebhookHandler(handler channels.EventHandler) http.HandlerF } } +func (p *Plugin) Stop() error { + if p.stopCh != nil { + select { + case <-p.stopCh: + // already closed + default: + close(p.stopCh) + } + } + p.connMu.Lock() + defer p.connMu.Unlock() + if p.wsConn != nil { + return p.wsConn.Close() + } + return nil +} + // NormalizeEvent parses raw Slack event JSON into a ChannelEvent. func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { var payload slackEventPayload @@ -189,15 +414,10 @@ func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { return nil, fmt.Errorf("parsing slack event: %w", err) } - // ThreadID is only set for actual Slack threads (thread_ts present). - // For top-level messages, ThreadID is empty so the router groups by user. - // MessageID holds the message timestamp for reply targeting. threadID := payload.Event.ThreadTS messageID := payload.Event.TS if threadID == "" { - // Not a threaded reply — use TS as MessageID for reply targeting - // but leave ThreadID empty for session grouping by user. - messageID = payload.Event.TS + threadID = messageID // Use message TS so thread replies find the same session } return &channels.ChannelEvent{ @@ -212,21 +432,40 @@ func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { } // SendResponse posts a message back to Slack via chat.postMessage. -// For large responses (>8000 chars), splits into a summary message and a -// file upload with the full report. +// If the runtime attached file parts (large tool outputs), those are used +// for the file upload since the LLM text may be truncated. +// For large responses (>4096 chars), uploads the full report as a file +// with a summary message. Falls back to chunked messages on failure. func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error { text := extractText(response) + fileContent, fileName := extractLargestFile(response) + + // If we have a file part from the runtime, upload it and send the text + // as a summary. The file part contains the complete, untruncated tool + // output; the text is the LLM's (potentially truncated) summary. + if fileContent != "" { + if fileName == "" { + fileName = "research-report.md" + } - // For large responses, split into summary + file upload - if len(text) > 4096 { - summary, report := markdown.SplitSummaryAndReport(text) - summaryMrkdwn := markdown.ToSlackMrkdwn(summary) - - // Post summary message threadTS := event.ThreadID if threadTS == "" { threadTS = event.MessageID } + + if err := p.uploadFile(event, fileName, fileContent); err != nil { + fmt.Printf("slack: file upload failed: %v (falling back to chunked messages)\n", err) + // Fall back: send the file content as chunked messages. + return p.sendChunked(event, fileContent) + } + + // File uploaded — send the LLM text as summary with "attached" note. + summary := text + if len(summary) > 600 { + summary, _ = markdown.SplitSummaryAndReport(summary) + } + summaryText := summary + "\n\n_Full report attached as file above._" + summaryMrkdwn := markdown.ToSlackMrkdwn(summaryText) payload := map[string]any{ "channel": event.WorkspaceID, "text": summaryMrkdwn, @@ -235,16 +474,34 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag if threadTS != "" { payload["thread_ts"] = threadTS } - if err := p.postMessage(payload); err != nil { - return err + return p.postMessage(payload) + } + + // No file parts — use text-based logic. + if len(text) > 4096 { + summary, report := markdown.SplitSummaryAndReport(text) + + threadTS := event.ThreadID + if threadTS == "" { + threadTS = event.MessageID } - // Upload full report as file if err := p.uploadFile(event, "research-report.md", report); err != nil { - // Fallback: send as chunked messages + fmt.Printf("slack: file upload failed: %v (falling back to chunked messages)\n", err) return p.sendChunked(event, text) } - return nil + + summaryText := summary + "\n\n_Full report attached as file above._" + summaryMrkdwn := markdown.ToSlackMrkdwn(summaryText) + payload := map[string]any{ + "channel": event.WorkspaceID, + "text": summaryMrkdwn, + "mrkdwn": true, + } + if threadTS != "" { + payload["thread_ts"] = threadTS + } + return p.postMessage(payload) } mrkdwn := markdown.ToSlackMrkdwn(text) @@ -257,7 +514,6 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag "mrkdwn": true, } if i == 0 { - // Reply in the existing thread, or start a new thread from the message. if event.ThreadID != "" { payload["thread_ts"] = event.ThreadID } else if event.MessageID != "" { @@ -295,45 +551,102 @@ func (p *Plugin) sendChunked(event *channels.ChannelEvent, text string) error { return nil } -// uploadFile uploads content as a file to a Slack channel using files.upload. +// uploadFile uploads content as a file to a Slack channel using the +// files.getUploadURLExternal + files.completeUploadExternal flow. func (p *Plugin) uploadFile(event *channels.ChannelEvent, filename, content string) error { + // Step 1: Get an upload URL from Slack. + form := url.Values{} + form.Set("filename", filename) + form.Set("length", strconv.Itoa(len(content))) + + getURLReq, err := http.NewRequest(http.MethodPost, p.apiBase+"/files.getUploadURLExternal", strings.NewReader(form.Encode())) + if err != nil { + return fmt.Errorf("creating getUploadURLExternal request: %w", err) + } + getURLReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + getURLReq.Header.Set("Authorization", "Bearer "+p.botToken) + + getURLResp, err := p.client.Do(getURLReq) + if err != nil { + return fmt.Errorf("calling files.getUploadURLExternal: %w", err) + } + defer func() { _ = getURLResp.Body.Close() }() + + getURLRespBody, _ := io.ReadAll(getURLResp.Body) + var uploadURLResult struct { + OK bool `json:"ok"` + UploadURL string `json:"upload_url"` + FileID string `json:"file_id"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(getURLRespBody, &uploadURLResult); err != nil { + return fmt.Errorf("parsing getUploadURLExternal response: %w", err) + } + if !uploadURLResult.OK { + return fmt.Errorf("files.getUploadURLExternal error: %s", uploadURLResult.Error) + } + + // Step 2: Upload the file content to the provided URL. + uploadReq, err := http.NewRequest(http.MethodPost, uploadURLResult.UploadURL, bytes.NewReader([]byte(content))) + if err != nil { + return fmt.Errorf("creating upload request: %w", err) + } + uploadReq.Header.Set("Content-Type", "application/octet-stream") + + uploadResp, err := p.client.Do(uploadReq) + if err != nil { + return fmt.Errorf("uploading file content: %w", err) + } + defer func() { _ = uploadResp.Body.Close() }() + _, _ = io.ReadAll(uploadResp.Body) + + if uploadResp.StatusCode != http.StatusOK { + return fmt.Errorf("file upload HTTP %d", uploadResp.StatusCode) + } + + // Step 3: Complete the upload and share to the channel/thread. threadTS := event.ThreadID if threadTS == "" { threadTS = event.MessageID } - - payload := map[string]any{ - "channels": event.WorkspaceID, - "filename": filename, - "content": content, - "filetype": "markdown", + completePayload := map[string]any{ + "files": []map[string]string{ + {"id": uploadURLResult.FileID, "title": filename}, + }, + "channel_id": event.WorkspaceID, } if threadTS != "" { - payload["thread_ts"] = threadTS + completePayload["thread_ts"] = threadTS } - body, err := json.Marshal(payload) + completeBody, err := json.Marshal(completePayload) if err != nil { - return fmt.Errorf("marshalling file upload: %w", err) + return fmt.Errorf("marshalling completeUploadExternal: %w", err) } - url := p.apiBase + "/files.upload" - req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + completeReq, err := http.NewRequest(http.MethodPost, p.apiBase+"/files.completeUploadExternal", bytes.NewReader(completeBody)) if err != nil { - return fmt.Errorf("creating file upload request: %w", err) + return fmt.Errorf("creating completeUploadExternal request: %w", err) } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.botToken) + completeReq.Header.Set("Content-Type", "application/json") + completeReq.Header.Set("Authorization", "Bearer "+p.botToken) - resp, err := p.client.Do(req) + completeResp, err := p.client.Do(completeReq) if err != nil { - return fmt.Errorf("uploading file to slack: %w", err) + return fmt.Errorf("calling files.completeUploadExternal: %w", err) } - defer func() { _ = resp.Body.Close() }() + defer func() { _ = completeResp.Body.Close() }() - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("slack files.upload error %d: %s", resp.StatusCode, string(respBody)) + completeRespBody, _ := io.ReadAll(completeResp.Body) + var completeResult struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(completeRespBody, &completeResult); err != nil { + return fmt.Errorf("parsing completeUploadExternal response: %w", err) + } + if !completeResult.OK { + return fmt.Errorf("files.completeUploadExternal error: %s", completeResult.Error) } return nil @@ -368,17 +681,130 @@ func (p *Plugin) postMessage(payload map[string]any) error { return nil } -// verifySlackSignature validates the X-Slack-Signature header using HMAC-SHA256. -func verifySlackSignature(signingSecret, timestamp string, body []byte, signature string) bool { - if signingSecret == "" || timestamp == "" || signature == "" { - return false +// addReaction adds an emoji reaction to a message. +func (p *Plugin) addReaction(channel, timestamp, emoji string) error { + return p.reactAPI("reactions.add", channel, timestamp, emoji) +} + +// removeReaction removes an emoji reaction from a message. +func (p *Plugin) removeReaction(channel, timestamp, emoji string) error { + return p.reactAPI("reactions.remove", channel, timestamp, emoji) +} + +// reactAPI calls a Slack reactions.* endpoint. +func (p *Plugin) reactAPI(method, channel, timestamp, emoji string) error { + payload := map[string]string{ + "channel": channel, + "timestamp": timestamp, + "name": emoji, + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshalling reaction: %w", err) + } + + url := p.apiBase + "/" + method + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating reaction request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.botToken) + + resp, err := p.client.Do(req) + if err != nil { + return fmt.Errorf("calling %s: %w", method, err) + } + defer func() { _ = resp.Body.Close() }() + _, _ = io.ReadAll(resp.Body) + return nil +} + +// unwrapJSONContent checks if text is a JSON object from a tool output and +// converts it to readable markdown. Supports two formats: +// +// 1. Tavily Research: {"content":"…", "sources":[{"url":"…","title":"…"}]} +// 2. Tavily Search: {"answer":"…", "results":[{"title":"…","url":"…","content":"…"}]} +// +// If the text is not JSON or matches neither format, it is returned unchanged. +func unwrapJSONContent(text string) string { + trimmed := strings.TrimSpace(text) + if len(trimmed) == 0 || trimmed[0] != '{' { + return text + } + + // Try Tavily Research format: top-level "content" + "sources". + var research struct { + Content string `json:"content"` + Sources []struct { + URL string `json:"url"` + Title string `json:"title"` + } `json:"sources"` + } + if err := json.Unmarshal([]byte(trimmed), &research); err == nil && research.Content != "" { + result := research.Content + var links []string + for _, s := range research.Sources { + if s.URL == "" { + continue + } + if s.Title != "" { + links = append(links, fmt.Sprintf("- [%s](%s)", s.Title, s.URL)) + } else { + links = append(links, fmt.Sprintf("- %s", s.URL)) + } + } + if len(links) > 0 { + result += "\n\n**Sources:**\n" + strings.Join(links, "\n") + } + return result + } + + // Try Tavily Search format: "answer" + "results". + var search struct { + Answer string `json:"answer"` + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + } `json:"results"` + } + if err := json.Unmarshal([]byte(trimmed), &search); err == nil && (search.Answer != "" || len(search.Results) > 0) { + var sb strings.Builder + if search.Answer != "" { + sb.WriteString(search.Answer) + } + if len(search.Results) > 0 { + if sb.Len() > 0 { + sb.WriteString("\n\n") + } + sb.WriteString("**Sources:**\n") + for _, r := range search.Results { + if r.URL == "" { + continue + } + if r.Title != "" { + fmt.Fprintf(&sb, "- [%s](%s)", r.Title, r.URL) + } else { + fmt.Fprintf(&sb, "- %s", r.URL) + } + if r.Content != "" { + // Include a short excerpt (first 200 chars). + excerpt := r.Content + if len(excerpt) > 200 { + excerpt = excerpt[:200] + "…" + } + sb.WriteString(": " + excerpt) + } + sb.WriteString("\n") + } + } + if sb.Len() > 0 { + return strings.TrimRight(sb.String(), "\n") + } } - baseString := fmt.Sprintf("v0:%s:%s", timestamp, body) - mac := hmac.New(sha256.New, []byte(signingSecret)) - mac.Write([]byte(baseString)) - expected := "v0=" + hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(expected), []byte(signature)) + return text } // extractText concatenates all text parts from an A2A message. @@ -392,7 +818,7 @@ func extractText(msg *a2a.Message) string { if text != "" { text += "\n" } - text += p.Text + text += unwrapJSONContent(p.Text) } } if text == "" { @@ -401,6 +827,31 @@ func extractText(msg *a2a.Message) string { return text } +// extractLargestFile returns the content and filename of the largest file part +// in the message, or empty strings if no file parts exist. +// The runtime attaches large tool outputs as file parts so they aren't +// truncated by LLM output token limits. JSON tool outputs are unwrapped +// into readable markdown. +func extractLargestFile(msg *a2a.Message) (content, filename string) { + if msg == nil { + return "", "" + } + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindFile && p.File != nil && len(p.File.Bytes) > len(content) { + content = unwrapJSONContent(string(p.File.Bytes)) + filename = p.File.Name + } + } + return content, filename +} + +// socketEnvelope is the outer envelope received over the Socket Mode WebSocket. +type socketEnvelope struct { + EnvelopeID string `json:"envelope_id"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + // slackEventPayload represents the outer Slack event callback structure. type slackEventPayload struct { TeamID string `json:"team_id"` @@ -410,6 +861,7 @@ type slackEventPayload struct { // slackEvent represents the inner event fields we care about. type slackEvent struct { Type string `json:"type"` + SubType string `json:"subtype"` Channel string `json:"channel"` User string `json:"user"` Text string `json:"text"` diff --git a/forge-plugins/channels/slack/slack_test.go b/forge-plugins/channels/slack/slack_test.go index 3087dd0..607b6dc 100644 --- a/forge-plugins/channels/slack/slack_test.go +++ b/forge-plugins/channels/slack/slack_test.go @@ -1,48 +1,240 @@ package slack import ( - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" - "strconv" "strings" "testing" - "time" "github.com/initializ/forge/forge-core/a2a" "github.com/initializ/forge/forge-core/channels" ) -func TestVerifySlackSignature_Valid(t *testing.T) { - secret := "test-signing-secret" - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - body := []byte(`{"type":"event_callback","event":{"text":"hello"}}`) +func TestInit_RequiresAppToken(t *testing.T) { + p := New() + err := p.Init(channels.ChannelConfig{ + Adapter: "slack", + Settings: map[string]string{ + "bot_token": "xoxb-test", + }, + }) + if err == nil { + t.Fatal("expected error when app_token is missing") + } + if !strings.Contains(err.Error(), "app_token") { + t.Errorf("error = %q, want mention of app_token", err) + } +} + +func TestInit_RequiresBotToken(t *testing.T) { + p := New() + err := p.Init(channels.ChannelConfig{ + Adapter: "slack", + Settings: map[string]string{ + "app_token": "xapp-test", + }, + }) + if err == nil { + t.Fatal("expected error when bot_token is missing") + } + if !strings.Contains(err.Error(), "bot_token") { + t.Errorf("error = %q, want mention of bot_token", err) + } +} + +func TestInit_Success(t *testing.T) { + p := New() + err := p.Init(channels.ChannelConfig{ + Adapter: "slack", + Settings: map[string]string{ + "app_token": "xapp-test", + "bot_token": "xoxb-test", + }, + }) + if err != nil { + t.Fatalf("Init() unexpected error: %v", err) + } + if p.appToken != "xapp-test" { + t.Errorf("appToken = %q, want xapp-test", p.appToken) + } + if p.botToken != "xoxb-test" { + t.Errorf("botToken = %q, want xoxb-test", p.botToken) + } +} - baseString := fmt.Sprintf("v0:%s:%s", timestamp, body) - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(baseString)) - sig := "v0=" + hex.EncodeToString(mac.Sum(nil)) +func TestOpenConnection(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apps.connections.open" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer xapp-test-token" { + t.Errorf("Authorization = %q, want 'Bearer xapp-test-token'", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"url":"wss://example.com/ws"}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.appToken = "xapp-test-token" + p.apiBase = srv.URL - if !verifySlackSignature(secret, timestamp, body, sig) { - t.Error("expected valid signature to pass") + wsURL, err := p.openConnection() + if err != nil { + t.Fatalf("openConnection() error: %v", err) + } + if wsURL != "wss://example.com/ws" { + t.Errorf("wsURL = %q, want wss://example.com/ws", wsURL) } } -func TestVerifySlackSignature_Invalid(t *testing.T) { - if verifySlackSignature("secret", "12345", []byte("body"), "v0=wrong") { - t.Error("expected invalid signature to fail") +func TestOpenConnection_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.appToken = "xapp-bad" + p.apiBase = srv.URL + + _, err := p.openConnection() + if err == nil { + t.Fatal("expected error for invalid auth") + } + if !strings.Contains(err.Error(), "invalid_auth") { + t.Errorf("error = %q, want mention of invalid_auth", err) } } -func TestVerifySlackSignature_Empty(t *testing.T) { - if verifySlackSignature("", "", nil, "") { - t.Error("expected empty inputs to fail") +func TestAddReaction(t *testing.T) { + var gotPath, gotChannel, gotTS, gotEmoji string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + var payload map[string]string + json.Unmarshal(body, &payload) //nolint:errcheck + gotChannel = payload["channel"] + gotTS = payload["timestamp"] + gotEmoji = payload["name"] + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-test" + p.apiBase = srv.URL + + err := p.addReaction("C123", "1234.5678", "eyes") + if err != nil { + t.Fatalf("addReaction() error: %v", err) + } + if gotPath != "/reactions.add" { + t.Errorf("path = %q, want /reactions.add", gotPath) + } + if gotChannel != "C123" { + t.Errorf("channel = %q, want C123", gotChannel) + } + if gotTS != "1234.5678" { + t.Errorf("timestamp = %q, want 1234.5678", gotTS) + } + if gotEmoji != "eyes" { + t.Errorf("name = %q, want eyes", gotEmoji) + } +} + +func TestRemoveReaction(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-test" + p.apiBase = srv.URL + + err := p.removeReaction("C123", "1234.5678", "eyes") + if err != nil { + t.Fatalf("removeReaction() error: %v", err) + } + if gotPath != "/reactions.remove" { + t.Errorf("path = %q, want /reactions.remove", gotPath) + } +} + +func TestUploadFile(t *testing.T) { + var step int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/files.getUploadURLExternal": + step++ + if step != 1 { + t.Errorf("getUploadURLExternal called at step %d, want 1", step) + } + if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct) + } + if err := r.ParseForm(); err != nil { + t.Fatalf("ParseForm: %v", err) + } + if r.FormValue("filename") != "report.md" { + t.Errorf("filename = %q, want report.md", r.FormValue("filename")) + } + if r.FormValue("length") != "17" { + t.Errorf("length = %q, want 17", r.FormValue("length")) + } + w.Write([]byte(`{"ok":true,"upload_url":"` + "http://" + r.Host + `/upload","file_id":"F123"}`)) //nolint:errcheck + case "/upload": + step++ + if step != 2 { + t.Errorf("upload called at step %d, want 2", step) + } + body, _ := io.ReadAll(r.Body) + if string(body) != "file content here" { + t.Errorf("upload body = %q, want 'file content here'", string(body)) + } + w.WriteHeader(http.StatusOK) + case "/files.completeUploadExternal": + step++ + if step != 3 { + t.Errorf("completeUploadExternal called at step %d, want 3", step) + } + body, _ := io.ReadAll(r.Body) + var payload map[string]any + json.Unmarshal(body, &payload) //nolint:errcheck + if payload["channel_id"] != "C123" { + t.Errorf("channel_id = %v, want C123", payload["channel_id"]) + } + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + default: + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-test" + p.apiBase = srv.URL + + event := &channels.ChannelEvent{ + WorkspaceID: "C123", + MessageID: "1234.5678", + } + + err := p.uploadFile(event, "report.md", "file content here") + if err != nil { + t.Fatalf("uploadFile() error: %v", err) + } + if step != 3 { + t.Errorf("expected 3 API calls, got %d", step) } } @@ -100,9 +292,9 @@ func TestNormalizeEvent_NoThread(t *testing.T) { t.Fatalf("NormalizeEvent() error: %v", err) } - // ThreadID should be empty for non-threaded messages. - if event.ThreadID != "" { - t.Errorf("ThreadID = %q, want empty (no thread_ts)", event.ThreadID) + // ThreadID should equal the message TS so thread replies find the same session. + if event.ThreadID != "1234567890.123456" { + t.Errorf("ThreadID = %q, want 1234567890.123456", event.ThreadID) } // MessageID should hold the message timestamp for reply targeting. if event.MessageID != "1234567890.123456" { @@ -110,72 +302,6 @@ func TestNormalizeEvent_NoThread(t *testing.T) { } } -func TestURLVerificationChallenge(t *testing.T) { - p := New() - p.signingSecret = "test-secret" - p.botToken = "xoxb-test" - p.webhookPort = 0 - p.webhookPath = "/slack/events" - - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - body := `{"type":"url_verification","challenge":"test-challenge-value"}` - - sig := computeSignature("test-secret", timestamp, []byte(body)) - - req := httptest.NewRequest(http.MethodPost, "/slack/events", strings.NewReader(body)) - req.Header.Set("X-Slack-Request-Timestamp", timestamp) - req.Header.Set("X-Slack-Signature", sig) - - rr := httptest.NewRecorder() - - handler := p.makeWebhookHandler(nil) - handler(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200", rr.Code) - } - - var resp map[string]string - if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { - t.Fatalf("decoding response: %v", err) - } - - if resp["challenge"] != "test-challenge-value" { - t.Errorf("challenge = %q, want test-challenge-value", resp["challenge"]) - } -} - -func TestBotMessageSkipped(t *testing.T) { - p := New() - p.signingSecret = "test-secret" - p.botToken = "xoxb-test" - - timestamp := strconv.FormatInt(time.Now().Unix(), 10) - body := `{"type":"event_callback","event":{"type":"message","channel":"C123","user":"U123","text":"bot msg","ts":"1.1","bot_id":"B123"}}` - - sig := computeSignature("test-secret", timestamp, []byte(body)) - - req := httptest.NewRequest(http.MethodPost, "/slack/events", strings.NewReader(body)) - req.Header.Set("X-Slack-Request-Timestamp", timestamp) - req.Header.Set("X-Slack-Signature", sig) - - handlerCalled := false - handler := p.makeWebhookHandler(func(_ context.Context, _ *channels.ChannelEvent) (*a2a.Message, error) { - handlerCalled = true - return nil, nil - }) - - rr := httptest.NewRecorder() - handler(rr, req) - - if rr.Code != http.StatusOK { - t.Errorf("status = %d, want 200", rr.Code) - } - if handlerCalled { - t.Error("handler should not be called for bot messages") - } -} - func TestSendResponse(t *testing.T) { // Mock Slack API srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -280,10 +406,327 @@ func TestExtractText(t *testing.T) { } } -// computeSignature generates a valid Slack signature for testing. -func computeSignature(secret, timestamp string, body []byte) string { - baseString := fmt.Sprintf("v0:%s:%s", timestamp, body) - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(baseString)) - return "v0=" + hex.EncodeToString(mac.Sum(nil)) +func TestExtractLargestFile(t *testing.T) { + t.Run("nil message", func(t *testing.T) { + content, name := extractLargestFile(nil) + if content != "" || name != "" { + t.Errorf("expected empty, got content=%q name=%q", content, name) + } + }) + + t.Run("no file parts", func(t *testing.T) { + msg := &a2a.Message{Parts: []a2a.Part{a2a.NewTextPart("hello")}} + content, name := extractLargestFile(msg) + if content != "" || name != "" { + t.Errorf("expected empty, got content=%q name=%q", content, name) + } + }) + + t.Run("single file part", func(t *testing.T) { + msg := &a2a.Message{Parts: []a2a.Part{ + a2a.NewTextPart("summary"), + a2a.NewFilePart(a2a.FileContent{ + Name: "report.md", + MimeType: "text/markdown", + Bytes: []byte("full report content"), + }), + }} + content, name := extractLargestFile(msg) + if content != "full report content" { + t.Errorf("content = %q, want 'full report content'", content) + } + if name != "report.md" { + t.Errorf("name = %q, want 'report.md'", name) + } + }) + + t.Run("picks largest file", func(t *testing.T) { + msg := &a2a.Message{Parts: []a2a.Part{ + a2a.NewFilePart(a2a.FileContent{Name: "small.md", Bytes: []byte("short")}), + a2a.NewFilePart(a2a.FileContent{Name: "big.md", Bytes: []byte("this is much longer content")}), + }} + content, name := extractLargestFile(msg) + if content != "this is much longer content" { + t.Errorf("content = %q, want largest file content", content) + } + if name != "big.md" { + t.Errorf("name = %q, want 'big.md'", name) + } + }) +} + +func TestResolveBotID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/auth.test" { + t.Errorf("unexpected path: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer xoxb-test-token" { + t.Errorf("Authorization = %q, want 'Bearer xoxb-test-token'", r.Header.Get("Authorization")) + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":true,"user_id":"U123BOT"}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-test-token" + p.apiBase = srv.URL + + err := p.resolveBotID() + if err != nil { + t.Fatalf("resolveBotID() error: %v", err) + } + if p.botUserID != "U123BOT" { + t.Errorf("botUserID = %q, want U123BOT", p.botUserID) + } +} + +func TestResolveBotID_Error(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`)) //nolint:errcheck + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-bad" + p.apiBase = srv.URL + + err := p.resolveBotID() + if err == nil { + t.Fatal("expected error for invalid auth") + } + if !strings.Contains(err.Error(), "invalid_auth") { + t.Errorf("error = %q, want mention of invalid_auth", err) + } +} + +func TestExtractMentions(t *testing.T) { + tests := []struct { + name string + text string + want []string + }{ + {"no mentions", "hello world", nil}, + {"single mention", "<@U123> hello", []string{"U123"}}, + {"multiple mentions", "<@U123> and <@U456>", []string{"U123", "U456"}}, + {"mention with display name", "<@U123|bob> hello", []string{"U123"}}, + {"mixed mentions", "<@U123> and <@U456|alice>", []string{"U123", "U456"}}, + {"mention at end", "hey <@U789>", []string{"U789"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractMentions(tt.text) + if len(got) != len(tt.want) { + t.Fatalf("extractMentions(%q) = %v, want %v", tt.text, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("extractMentions(%q)[%d] = %q, want %q", tt.text, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestStripBotMention(t *testing.T) { + tests := []struct { + name string + text string + botUserID string + want string + }{ + {"removes mention", "<@UBOT> hello there", "UBOT", "hello there"}, + {"removes mention with display name", "<@UBOT|mybot> hello", "UBOT", "hello"}, + {"leaves other mentions", "<@U123> <@UBOT> hello", "UBOT", "<@U123> hello"}, + {"no mention present", "hello world", "UBOT", "hello world"}, + {"multiple bot mentions", "<@UBOT> hey <@UBOT>", "UBOT", "hey"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripBotMention(tt.text, tt.botUserID) + if got != tt.want { + t.Errorf("stripBotMention(%q, %q) = %q, want %q", tt.text, tt.botUserID, got, tt.want) + } + }) + } +} + +func TestUnwrapJSONContent(t *testing.T) { + tests := []struct { + name string + text string + want string + }{ + { + name: "plain text unchanged", + text: "hello world", + want: "hello world", + }, + { + name: "JSON with content and sources", + text: `{"content":"# Report\nSome findings.","sources":[{"url":"https://example.com","title":"Example"},{"url":"https://other.com","title":"Other"}],"status":"completed"}`, + want: "# Report\nSome findings.\n\n**Sources:**\n- [Example](https://example.com)\n- [Other](https://other.com)", + }, + { + name: "JSON with content but empty sources", + text: `{"content":"just content here","sources":[]}`, + want: "just content here", + }, + { + name: "JSON without content field", + text: `{"status":"completed","result":"some data"}`, + want: `{"status":"completed","result":"some data"}`, + }, + { + name: "Tavily search with answer and results", + text: `{"query":"test","answer":"The answer is 42.","results":[{"title":"Wikipedia","url":"https://en.wikipedia.org/wiki/42","content":"42 is the answer to everything.","score":0.95}]}`, + want: "The answer is 42.\n\n**Sources:**\n- [Wikipedia](https://en.wikipedia.org/wiki/42): 42 is the answer to everything.", + }, + { + name: "Tavily search with answer only", + text: `{"query":"test","answer":"Short answer.","results":[]}`, + want: "Short answer.", + }, + { + name: "Tavily search with results only", + text: `{"query":"test","results":[{"title":"Example","url":"https://example.com","content":"Some content.","score":0.9}]}`, + want: "**Sources:**\n- [Example](https://example.com): Some content.", + }, + { + name: "JSON with source missing title", + text: `{"content":"report","sources":[{"url":"https://bare.com","title":""}]}`, + want: "report\n\n**Sources:**\n- https://bare.com", + }, + { + name: "JSON with source missing URL", + text: `{"content":"report","sources":[{"url":"","title":"No URL"}]}`, + want: "report", + }, + { + name: "empty string", + text: "", + want: "", + }, + { + name: "invalid JSON starting with brace", + text: "{not valid json", + want: "{not valid json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := unwrapJSONContent(tt.text) + if got != tt.want { + t.Errorf("unwrapJSONContent() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractText_UnwrapsJSON(t *testing.T) { + msg := &a2a.Message{ + Parts: []a2a.Part{ + a2a.NewTextPart(`{"content":"# Research\nFindings here.","sources":[{"url":"https://example.com","title":"Example"}]}`), + }, + } + got := extractText(msg) + want := "# Research\nFindings here.\n\n**Sources:**\n- [Example](https://example.com)" + if got != want { + t.Errorf("extractText() = %q, want %q", got, want) + } +} + +func TestExtractLargestFile_UnwrapsJSON(t *testing.T) { + raw := `{"query":"test","answer":"The answer is 42.","results":[{"title":"Source","url":"https://example.com","content":"Details here.","score":0.9}]}` + msg := &a2a.Message{ + Parts: []a2a.Part{ + { + Kind: a2a.PartKindFile, + File: &a2a.FileContent{ + Name: "web_search-output.md", + Bytes: []byte(raw), + }, + }, + }, + } + content, filename := extractLargestFile(msg) + if filename != "web_search-output.md" { + t.Errorf("filename = %q, want web_search-output.md", filename) + } + wantContent := "The answer is 42.\n\n**Sources:**\n- [Source](https://example.com): Details here." + if content != wantContent { + t.Errorf("extractLargestFile() content = %q, want %q", content, wantContent) + } +} + +func TestSendResponse_WithFilePart(t *testing.T) { + var uploadCalls, postCalls int + var lastPostText string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/files.getUploadURLExternal": + uploadCalls++ + w.Write([]byte(`{"ok":true,"upload_url":"` + "http://" + r.Host + `/upload","file_id":"F456"}`)) //nolint:errcheck + case "/upload": + w.WriteHeader(http.StatusOK) + case "/files.completeUploadExternal": + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + case "/chat.postMessage": + postCalls++ + body, _ := io.ReadAll(r.Body) + var payload map[string]any + json.Unmarshal(body, &payload) //nolint:errcheck + lastPostText, _ = payload["text"].(string) + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + p := New() + p.botToken = "xoxb-test" + p.apiBase = srv.URL + + event := &channels.ChannelEvent{ + WorkspaceID: "C123", + MessageID: "1234.5678", + } + + // Response with both a text summary and a file part (large tool output). + msg := &a2a.Message{ + Role: a2a.MessageRoleAgent, + Parts: []a2a.Part{ + a2a.NewTextPart("Here is a summary of the research."), + a2a.NewFilePart(a2a.FileContent{ + Name: "web_search-output.md", + MimeType: "text/markdown", + Bytes: []byte("# Full Research Report\n\nThis is the complete untruncated content..."), + }), + }, + } + + err := p.SendResponse(event, msg) + if err != nil { + t.Fatalf("SendResponse() error: %v", err) + } + + if uploadCalls != 1 { + t.Errorf("expected 1 upload call, got %d", uploadCalls) + } + if postCalls != 1 { + t.Errorf("expected 1 post call (summary), got %d", postCalls) + } + if !strings.Contains(lastPostText, "summary of the research") { + t.Errorf("expected summary in post, got %q", lastPostText) + } + if !strings.Contains(lastPostText, "attached") { + t.Errorf("expected 'attached' note in post, got %q", lastPostText) + } } diff --git a/forge-plugins/channels/telegram/telegram.go b/forge-plugins/channels/telegram/telegram.go index d49ec83..20156e5 100644 --- a/forge-plugins/channels/telegram/telegram.go +++ b/forge-plugins/channels/telegram/telegram.go @@ -269,18 +269,45 @@ func (p *Plugin) NormalizeEvent(raw []byte) (*channels.ChannelEvent, error) { } // SendResponse sends a text message back to the Telegram chat. -// For large responses (>8000 chars), sends a summary message and uploads +// If the runtime attached file parts (large tool outputs), those are used +// for the document upload since the LLM text may be truncated. +// For large responses (>4096 chars), sends a summary message and uploads // the full report as a document. func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Message) error { text := extractText(response) - log.Printf("[telegram] SendResponse: text length=%d chars", len(text)) + fileContent, fileName := extractLargestFile(response) + log.Printf("[telegram] SendResponse: text length=%d chars, file part=%d chars", len(text), len(fileContent)) + + // If we have a file part from the runtime, upload it and send the text + // as a summary. The file part contains the complete, untruncated tool + // output; the text is the LLM's (potentially truncated) summary. + if fileContent != "" { + if fileName == "" { + fileName = "research-report.md" + } - // For large responses, split into summary + document upload - if len(text) > 4096 { - summary, report := markdown.SplitSummaryAndReport(text) - summaryHTML := markdown.ToTelegramHTML(summary) + uploadOK := true + if err := p.sendDocument(event, fileName, fileContent); err != nil { + log.Printf("[telegram] sendDocument failed (len=%d): %v", len(fileContent), err) + noReplyEvent := *event + noReplyEvent.MessageID = "" + if err2 := p.sendDocument(&noReplyEvent, fileName, fileContent); err2 != nil { + log.Printf("[telegram] sendDocument retry failed: %v — falling back to chunked messages", err2) + uploadOK = false + } + } + + if !uploadOK { + return p.sendChunked(event, fileContent) + } - // Send summary message + // Document uploaded — send LLM text as summary with "attached" note. + summary := text + if len(summary) > 600 { + summary, _ = markdown.SplitSummaryAndReport(summary) + } + summaryText := summary + "\n\nFull report attached as file above." + summaryHTML := markdown.ToTelegramHTML(summaryText) payload := map[string]any{ "chat_id": event.WorkspaceID, "text": summaryHTML, @@ -290,23 +317,47 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag payload["reply_to_message_id"] = event.MessageID } if err := p.sendMessage(payload); err != nil { - // Fallback plain text delete(payload, "parse_mode") - payload["text"] = summary + payload["text"] = summaryText _ = p.sendMessage(payload) } + return nil + } - // Upload full report as document + // No file parts — use text-based logic. + if len(text) > 4096 { + summary, report := markdown.SplitSummaryAndReport(text) + + uploadOK := true if err := p.sendDocument(event, "research-report.md", report); err != nil { log.Printf("[telegram] sendDocument failed (len=%d): %v", len(report), err) - // Retry without reply context (common Telegram API failure cause) noReplyEvent := *event noReplyEvent.MessageID = "" if err2 := p.sendDocument(&noReplyEvent, "research-report.md", report); err2 != nil { log.Printf("[telegram] sendDocument retry failed: %v — falling back to chunked messages", err2) - return p.sendChunked(event, text) + uploadOK = false } } + + if !uploadOK { + return p.sendChunked(event, text) + } + + summaryText := summary + "\n\nFull report attached as file above." + summaryHTML := markdown.ToTelegramHTML(summaryText) + payload := map[string]any{ + "chat_id": event.WorkspaceID, + "text": summaryHTML, + "parse_mode": "HTML", + } + if event.MessageID != "" { + payload["reply_to_message_id"] = event.MessageID + } + if err := p.sendMessage(payload); err != nil { + delete(payload, "parse_mode") + payload["text"] = summaryText + _ = p.sendMessage(payload) + } return nil } @@ -323,7 +374,6 @@ func (p *Plugin) SendResponse(event *channels.ChannelEvent, response *a2a.Messag payload["reply_to_message_id"] = event.MessageID } if err := p.sendMessage(payload); err != nil { - // Fallback: retry without parse_mode (plain text) delete(payload, "parse_mode") payload["text"] = text if fbErr := p.sendMessage(payload); fbErr != nil { @@ -518,6 +568,23 @@ func extractText(msg *a2a.Message) string { return text } +// extractLargestFile returns the content and filename of the largest file part +// in the message, or empty strings if no file parts exist. +// The runtime attaches large tool outputs as file parts so they aren't +// truncated by LLM output token limits. +func extractLargestFile(msg *a2a.Message) (content, filename string) { + if msg == nil { + return "", "" + } + for _, p := range msg.Parts { + if p.Kind == a2a.PartKindFile && p.File != nil && len(p.File.Bytes) > len(content) { + content = string(p.File.Bytes) + filename = p.File.Name + } + } + return content, filename +} + // Telegram API types (minimal, for parsing). type telegramUpdate struct { diff --git a/forge-plugins/go.mod b/forge-plugins/go.mod index 68e73a0..f285426 100644 --- a/forge-plugins/go.mod +++ b/forge-plugins/go.mod @@ -4,4 +4,6 @@ go 1.25.0 require github.com/initializ/forge/forge-core v0.0.0 +require github.com/gorilla/websocket v1.5.3 + replace github.com/initializ/forge/forge-core => ../forge-core diff --git a/forge-plugins/go.sum b/forge-plugins/go.sum index d1ac988..25a9fc4 100644 --- a/forge-plugins/go.sum +++ b/forge-plugins/go.sum @@ -1,36 +1,2 @@ -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA= -github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= -github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= -github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= -github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= -github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b h1:MQE+LT/ABUuuvEZ+YQAMSXindAdUh7slEmAkup74op4= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/forge-skills/local/embedded/_template/SKILL.md b/forge-skills/local/embedded/_template/SKILL.md new file mode 100644 index 0000000..ff449f1 --- /dev/null +++ b/forge-skills/local/embedded/_template/SKILL.md @@ -0,0 +1,91 @@ +--- +name: my-skill +# category: ops # Optional: sre, research, ops, dev, security, etc. +# tags: # Optional: discovery keywords +# - example +# - starter +description: One-line description of what this skill does +metadata: + forge: + requires: + bins: # Binaries that must exist in PATH + - curl + env: + required: # Env vars that MUST be set + - MY_API_KEY + one_of: [] # At least one of these must be set + optional: [] # Nice-to-have env vars + egress_domains: # Network domains this skill may contact + - api.example.com + # denied_tools: # Tools this skill must NOT use + # - http_request + # - web_search + # timeout_hint: 300 # Suggested timeout in seconds +--- + +# My Skill + +Brief description of the skill's purpose, capabilities, and intended audience. + +## Authentication + +Describe how credentials are obtained and configured. + +```bash +export MY_API_KEY="your-key-here" +``` + +## Quick Start + +### Script-backed execution + +Place executable scripts in `scripts/`. The tool name maps to the script: +underscores in the tool name become hyphens in the filename. + +Example: tool `my_search` → `scripts/my-search.sh` + +```bash +./scripts/my-search.sh '{"query": "hello"}' +``` + +### Binary-backed execution + +If the skill delegates to a compiled binary instead, delete the `scripts/` +directory entirely and document the binary in `requires.bins` above. + +## Tool: my_tool + +Short description of what this tool does. + +**Input:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| query | string | yes | The search query | +| max_results | integer | no | Maximum results (1-20). Default: 5 | + +**Output:** JSON object with `results` array of `{title, url, content, score}`. + +### Response Format + +```json +{ + "results": [ + { + "title": "Example", + "url": "https://example.com", + "content": "Relevant snippet...", + "score": 0.95 + } + ] +} +``` + +### Tips + +- Tip 1 for effective usage +- Tip 2 for edge cases + +## Safety Constraints + +Document any safety rules this skill enforces (read-only, no secrets, etc.). diff --git a/forge-skills/local/embedded/_template/scripts/.gitkeep b/forge-skills/local/embedded/_template/scripts/.gitkeep new file mode 100644 index 0000000..a00f50d --- /dev/null +++ b/forge-skills/local/embedded/_template/scripts/.gitkeep @@ -0,0 +1,12 @@ +# scripts/ directory — Script-backed tool execution +# +# Naming convention: +# Tool name underscores → filename hyphens +# Example: tool `my_search` → scripts/my-search.sh +# +# Each script receives a single JSON argument from the runtime. +# Scripts must be executable (chmod +x). +# +# If your skill is binary-backed (uses a compiled binary listed in +# requires.bins instead of shell scripts), delete this entire +# scripts/ directory.