Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,10 @@ node_modules/
# Cloudflare Workers
.wrangler/

# Official plugin backups (created by start.sh symlink)
*.official/

# Custom
firebase-debug.log
tmp/
output/
5 changes: 4 additions & 1 deletion .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"MD033": false,
"MD041": false,
"MD060": false
}
},
"ignores": [
"**/node_modules/**"
]
}
67 changes: 60 additions & 7 deletions docs/plugins/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Declares the MCP tools, channel capability, and metadata:
### Key differences

- **Discord** `reply`: max 2000 chars/chunk, 25MB/file, 10 files max
- **Telegram** `reply`: max 4096 chars/chunk, 50MB/file, supports MarkdownV2 format
- **Telegram** `reply`: max 4096 chars/chunk, 50MB/file, supports MarkdownV2 format, supports inline keyboard buttons (`buttons` param — local fork)
- **Discord** has `fetch_messages` for channel history lookback; Telegram has no equivalent

---
Expand Down Expand Up @@ -197,13 +197,66 @@ const STATE_DIR = process.env.DISCORD_STATE_DIR
| **Gitignored** | `.claude/` is in `.gitignore` |
| **Portable** | State travels with the project |

### Trade-offs
### Skill path resolution (fixed in local fork)

| Trade-off | Impact |
| ---------------------------------- | ------------------------------------------------- |
| **Skill path mismatch (Issue #1)** | Skills hardcode `~/.claude/channels/<channel>/`, ignoring `*_STATE_DIR`. Pairing fails without workaround. See [Known Issues](../issues.md) |
| **Manual workaround needed** | Must complete pairing at correct project-level path until upstream fix lands |
| **PR #866 pending** | Fix submitted to add env var resolution to skills |
The official plugin skills hardcode `~/.claude/channels/<channel>/`, ignoring
`*_STATE_DIR`. This project's local forks (`external_plugins/`) fix this by
using `$TELEGRAM_STATE_DIR` / `$DISCORD_STATE_DIR` with a fallback to the
global path. See the `$STATE` shorthand in each skill's SKILL.md.

---

## Local Plugin Fork (Symlink Architecture)

This project forks the official channel plugins into `external_plugins/` for
version control and customization. Claude Code's `--channels` flag only
accepts official plugin identifiers and re-extracts plugins on startup,
overwriting the cache. To work around this, `start.sh` symlinks the plugin
cache directory to the local fork.

### How it works

```text
~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.4/
↓ symlink (created by start.sh)
<project>/external_plugins/telegram-channel/
├── .claude-plugin/plugin.json # Plugin manifest
├── .mcp.json # MCP server config
├── package.json # Dependencies
├── server.ts # Forked server (source of truth)
├── skills/ # Access & configure skills
├── node_modules/ # (gitignored)
└── bun.lock
```

### Startup flow

1. `start.sh` resolves the plugin cache base directory
2. For each version dir, creates a symlink → `external_plugins/<channel>-channel/`
3. Backs up original dirs as `<version>.official/` (gitignored)
4. Installs `node_modules` if missing
5. Launches `claude --channels plugin:<channel>@claude-plugins-official`
6. Claude Code follows the symlink and runs our local `server.ts`

### Why symlink instead of sync-to-cache

| Approach | Problem |
| -------- | ------- |
| Pre-sync `cp` before `claude` | Claude re-extracts on startup, overwrites our copy |
| Background watcher | Race condition — bun may load before patch |
| `--plugin-dir` | Doesn't support channel plugins (SessionStart hook error) |
| `--mcp-config` | No channel notification capability |
| **Symlink (chosen)** | Claude sees the dir exists, skips extraction |

### Contributor workflow

```bash
git pull # Get latest plugin code
./start.sh telegram # Auto-symlink + auto-install deps + launch
```

No manual cache management needed. All plugin changes go through
`external_plugins/` in version control.

---

Expand Down
11 changes: 11 additions & 0 deletions external_plugins/discord-channel/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "discord",
"description": "Discord channel for Claude Code \u2014 messaging bridge with built-in access control. Manage pairing, allowlists, and policy via /discord:access.",
"version": "0.0.4",
"keywords": [
"discord",
"messaging",
"channel",
"mcp"
]
}
8 changes: 8 additions & 0 deletions external_plugins/discord-channel/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"discord": {
"command": "bun",
"args": ["run", "--cwd", "${CLAUDE_PLUGIN_ROOT}", "--shell=bun", "--silent", "start"]
}
}
}
1 change: 1 addition & 0 deletions external_plugins/discord-channel/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
143 changes: 143 additions & 0 deletions external_plugins/discord-channel/ACCESS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Discord — Access & Delivery

Discord only allows DMs between accounts that share a server. Who can DM your bot depends on where it's installed: one private server means only that server's members can reach it; a public community means every member there can open a DM.

The **Public Bot** toggle in the Developer Portal (Bot tab, on by default) controls who can add the bot to new servers. Turn it off and only your own account can install it. This is your first gate, and it's enforced by Discord rather than by this process.

For DMs that do get through, the default policy is **pairing**. An unknown sender gets a 6-character code in reply and their message is dropped. You run `/discord:access pair <code>` from your assistant session to approve them. Once approved, their messages pass through.

All state lives in `$DISCORD_STATE_DIR/access.json` (defaults to `~/.claude/channels/discord/` if the env var is unset; this project's `start.sh` sets it to a project-level path). The `/discord:access` skill commands edit this file; the server re-reads it on every inbound message, so changes take effect without a restart. Set `DISCORD_ACCESS_MODE=static` to pin config to what was on disk at boot (pairing is unavailable in static mode since it requires runtime writes).

## At a glance

| | |
| --- | --- |
| Default policy | `pairing` |
| Sender ID | User snowflake (numeric, e.g. `184695080709324800`) |
| Group key | Channel snowflake — not guild ID |
| Config file | `$DISCORD_STATE_DIR/access.json` |

## DM policies

`dmPolicy` controls how DMs from senders not on the allowlist are handled.

| Policy | Behavior |
| --- | --- |
| `pairing` (default) | Reply with a pairing code, drop the message. Approve with `/discord:access pair <code>`. |
| `allowlist` | Drop silently. No reply. Use this once everyone who needs access is already on the list, or if pairing replies would attract spam. |
| `disabled` | Drop everything, including allowlisted users and guild channels. |

```text
/discord:access policy allowlist
```

## User IDs

Discord identifies users by **snowflakes**: permanent numeric IDs like `184695080709324800`. Usernames are mutable; snowflakes aren't. The allowlist stores snowflakes.

Pairing captures the ID automatically. To add someone manually, enable **User Settings → Advanced → Developer Mode** in Discord, then right-click any user and choose **Copy User ID**. Your own ID is available by right-clicking your avatar in the lower-left.

```text
/discord:access allow 184695080709324800
/discord:access remove 184695080709324800
```

## Guild channels

Guild channels are off by default. Opt each one in individually, keyed on the **channel** snowflake (not the guild). Threads inherit their parent channel's opt-in; no separate entry needed. Find channel IDs the same way as user IDs: Developer Mode, right-click the channel, Copy Channel ID.

```text
/discord:access group add 846209781206941736
```

With the default `requireMention: true`, the bot responds only when @mentioned or replied to. Pass `--no-mention` to process every message in the channel, or `--allow id1,id2` to restrict which members can trigger it.

```text
/discord:access group add 846209781206941736 --no-mention
/discord:access group add 846209781206941736 --allow 184695080709324800,221773638772129792
/discord:access group rm 846209781206941736
```

## Mention detection

In channels with `requireMention: true`, any of the following triggers the bot:

- A structured `@botname` mention (typed via Discord's autocomplete)
- A reply to one of the bot's recent messages
- A match against any regex in `mentionPatterns`

Example regex setup for a nickname trigger:

```text
/discord:access set mentionPatterns '["^hey claude\\b", "\\bassistant\\b"]'
```

## Delivery

Configure outbound behavior with `/discord:access set <key> <value>`.

**`ackReaction`** reacts to inbound messages on receipt as a "seen" acknowledgment. Unicode emoji work directly; custom server emoji require the full `<:name:id>` form. The emoji ID is at the end of the URL when you right-click the emoji and copy its link. Empty string disables.

```text
/discord:access set ackReaction 🔨
/discord:access set ackReaction ""
```

**`replyToMode`** controls threading on chunked replies. When a long response is split, `first` (default) threads only the first chunk under the inbound message; `all` threads every chunk; `off` sends all chunks standalone.

**`textChunkLimit`** sets the split threshold. Discord rejects messages over 2000 characters, which is the hard ceiling.

**`chunkMode`** chooses the split strategy: `length` cuts exactly at the limit; `newline` prefers paragraph boundaries.

## Skill reference

| Command | Effect |
| --- | --- |
| `/discord:access` | Print current state: policy, allowlist, pending pairings, enabled channels. |
| `/discord:access pair a4f91c` | Approve pairing code `a4f91c`. Adds the sender to `allowFrom` and sends a confirmation on Discord. |
| `/discord:access deny a4f91c` | Discard a pending code. The sender is not notified. |
| `/discord:access allow 184695080709324800` | Add a user snowflake directly. |
| `/discord:access remove 184695080709324800` | Remove from the allowlist. |
| `/discord:access policy allowlist` | Set `dmPolicy`. Values: `pairing`, `allowlist`, `disabled`. |
| `/discord:access group add 846209781206941736` | Enable a guild channel. Flags: `--no-mention`, `--allow id1,id2`. |
| `/discord:access group rm 846209781206941736` | Disable a guild channel. |
| `/discord:access set ackReaction 🔨` | Set a config key: `ackReaction`, `replyToMode`, `textChunkLimit`, `chunkMode`, `mentionPatterns`. |

## Config file

`$DISCORD_STATE_DIR/access.json`. Absent file is equivalent to `pairing` policy with empty lists, so the first DM triggers pairing.

```jsonc
{
// Handling for DMs from senders not in allowFrom.
"dmPolicy": "pairing",

// User snowflakes allowed to DM.
"allowFrom": ["184695080709324800"],

// Guild channels the bot is active in. Empty object = DM-only.
"groups": {
"846209781206941736": {
// true: respond only to @mentions and replies.
"requireMention": true,
// Restrict triggers to these senders. Empty = any member (subject to requireMention).
"allowFrom": []
}
},

// Case-insensitive regexes that count as a mention.
"mentionPatterns": ["^hey claude\\b"],

// Reaction on receipt. Empty string disables.
"ackReaction": "👀",

// Threading on chunked replies: first | all | off
"replyToMode": "first",

// Split threshold. Discord rejects > 2000.
"textChunkLimit": 2000,

// length = cut at limit. newline = prefer paragraph boundaries.
"chunkMode": "newline"
}
```
113 changes: 113 additions & 0 deletions external_plugins/discord-channel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Discord

Connect a Discord bot to your Claude Code with an MCP server.

When the bot receives a message, the MCP server forwards it to Claude and provides tools to reply, react, and edit messages.

## Prerequisites

- [Bun](https://bun.sh) — the MCP server runs on Bun. Install with `curl -fsSL https://bun.sh/install | bash`.

## Quick Setup

> Default pairing flow for a single-user DM bot. See [ACCESS.md](./ACCESS.md) for groups and multi-user setups.

**1. Create a Discord application and bot.**

Go to the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. Give it a name.

Navigate to **Bot** in the sidebar. Give your bot a username.

Scroll down to **Privileged Gateway Intents** and enable **Message Content Intent** — without this the bot receives messages with empty content.

**2. Generate a bot token.**

Still on the **Bot** page, scroll up to **Token** and press **Reset Token**. Copy the token — it's only shown once. Hold onto it for step 5.

**3. Invite the bot to a server.**

Discord won't let you DM a bot unless you share a server with it.

Navigate to **OAuth2** → **URL Generator**. Select the `bot` scope. Under **Bot Permissions**, enable:

- View Channels
- Send Messages
- Send Messages in Threads
- Read Message History
- Attach Files
- Add Reactions

Integration type: **Guild Install**. Copy the **Generated URL**, open it, and add the bot to any server you're in.

> For DM-only use you technically need zero permissions — but enabling them now saves a trip back when you want guild channels later.

**4. Install the plugin.**

These are Claude Code commands — run `claude` to start a session first.

Install the plugin:

```text
/plugin install discord@claude-plugins-official
```

**5. Give the server the token.**

```text
/discord:configure MTIz...
```

Writes `DISCORD_BOT_TOKEN=...` to `~/.claude/channels/discord/.env`. You can also write that file by hand, or set the variable in your shell environment — shell takes precedence.

> To run multiple bots on one machine (different tokens, separate allowlists), point `DISCORD_STATE_DIR` at a different directory per instance.

**6. Relaunch with the channel flag.**

The server won't connect without this — exit your session and start a new one:

```sh
claude --channels plugin:discord@claude-plugins-official
```

**7. Pair.**

With Claude Code running from the previous step, DM your bot on Discord — it replies with a pairing code. If the bot doesn't respond, make sure your session is running with `--channels`. In your Claude Code session:

```text
/discord:access pair <code>
```

Your next DM reaches the assistant.

**8. Lock it down.**

Pairing is for capturing IDs. Once you're in, switch to `allowlist` so strangers don't get pairing-code replies. Ask Claude to do it, or `/discord:access policy allowlist` directly.

## Access control

See **[ACCESS.md](./ACCESS.md)** for DM policies, guild channels, mention detection, delivery config, skill commands, and the `access.json` schema.

Quick reference: IDs are Discord **snowflakes** (numeric — enable Developer Mode, right-click → Copy ID). Default policy is `pairing`. Guild channels are opt-in per channel ID.

## Tools exposed to the assistant

| Tool | Purpose |
| --- | --- |
| `reply` | Send to a channel. Takes `chat_id` + `text`, optionally `reply_to` (message ID) for native threading and `files` (absolute paths) for attachments — max 10 files, 25MB each. Auto-chunks; files attach to the first chunk. Returns the sent message ID(s). |
| `react` | Add an emoji reaction to any message by ID. Unicode emoji work directly; custom emoji need `<:name:id>` form. |
| `edit_message` | Edit a message the bot previously sent. Useful for "working…" → result progress updates. Only works on the bot's own messages. |
| `fetch_messages` | Pull recent history from a channel (oldest-first). Capped at 100 per call. Each line includes the message ID so the model can `reply_to` it; messages with attachments are marked `+Natt`. Discord's search API isn't exposed to bots, so this is the only lookback. |
| `download_attachment` | Download all attachments from a specific message by ID to `~/.claude/channels/discord/inbox/`. Returns file paths + metadata. Use when `fetch_messages` shows a message has attachments. |

Inbound messages trigger a typing indicator automatically — Discord shows
"botname is typing…" while the assistant works on a response.

## Attachments

Attachments are **not** auto-downloaded. The `<channel>` notification lists
each attachment's name, type, and size — the assistant calls
`download_attachment(chat_id, message_id)` when it actually wants the file.
Downloads land in `~/.claude/channels/discord/inbox/`.

Same path for attachments on historical messages found via `fetch_messages`
(messages with attachments are marked `+Natt`).
Loading
Loading