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
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,17 +224,20 @@ The Scene, Scene Collection, Source, Input, and Transition pickers in the Proper
### Discord plugin

1. Install the plugin (see [Installing a plugin](#installing-a-plugin) above)
2. Open Discord desktop app
3. In the button's Property Inspector, enter Discord application `client_id`
4. Configure `relay_url` (recommended) so your `client_secret` and refresh token stay server-side
5. Click **Authorize** and approve the Discord prompt
6. For voice/text channel actions, pick a guild and channel from the inspector dropdowns
2. Open Discord desktop app and keep it running
3. Create a Discord developer application and copy its **Client ID**
4. In Discord Developer Portal → **OAuth2**, add `http://127.0.0.1` to **Redirects**
4. In the button's Property Inspector, enter the Discord **Client ID**
5. Configure a relay URL (recommended) so `client_secret` and refresh tokens stay server-side
6. Click **Authorize** once and approve the Discord prompt
7. For voice/text channel actions, pick a guild and channel from the inspector dropdowns

Hosted relay quick start (free, Cloudflare Workers):
Cloudflare Workers free-tier relay (recommended):

```bash
# from repo root
cd discord-relay
npm i -g wrangler
wrangler login
wrangler kv namespace create DISCORD_SESSIONS
wrangler kv namespace create DISCORD_SESSIONS --preview
Expand All @@ -246,6 +249,36 @@ wrangler deploy

Then paste the deployed Worker URL into the `Relay URL` field in the Discord inspector. If you set `RELAY_API_KEY`, also paste it into `Relay API Key`.

#### What clone-users must run for Discord

- The app itself (`npm run electron:dev` in dev mode or installed packaged app)
- Discord desktop app (must be open for Discord RPC)
- No local relay process if you use deployed Cloudflare Worker

#### No-host fallback options

- **Local relay (zero hosting cost):** run one command from repo root and use local relay URL
- **Direct token mode (least secure):** leave `Relay URL` empty and provide `Client Secret` in inspector

Security note: direct token mode exposes secrets to local plugin settings, while relay mode keeps them server-side.

Local relay one-command startup:

```bash
npm run discord-relay:local:setup
```

Then set `Relay URL` to `http://127.0.0.1:8787` in the Discord inspector.

Manual (non-interactive) startup also works:

```bash
export DISCORD_CLIENT_SECRET="your-discord-client-secret"
export DISCORD_RELAY_MASTER_KEY="long-random-local-passphrase"
export RELAY_API_KEY="optional-local-relay-key"
npm run discord-relay:local
```

---

## Installation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const OPCODE_PONG = 4

const REQUEST_TIMEOUT_MS = 12_000
const RELAY_TIMEOUT_MS = 12_000
const DEFAULT_DISCORD_REDIRECT_URI = 'http://127.0.0.1'

async function postJson(url, payload, headers = {}) {
const controller = new AbortController()
Expand Down Expand Up @@ -353,18 +354,19 @@ class DiscordRpcClient {
}
}

async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId }) {
async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayApiKey, relaySessionId, redirectUri }) {
const relayBaseUrl = normalizeRelayUrl(relayUrl)
const relayKey = String(relayApiKey || '').trim()
const relaySession = String(relaySessionId || '').trim()
const redirect = String(redirectUri || DEFAULT_DISCORD_REDIRECT_URI).trim()

if (relayBaseUrl) {
if (!clientId) throw new Error('Missing client ID for relay exchange')
if (!code) throw new Error('Missing authorization code from Discord')

const relayPayload = await postJson(
`${relayBaseUrl}/oauth/discord/exchange`,
{ clientId, code, sessionId: relaySession || null },
{ clientId, code, sessionId: relaySession || null, redirectUri: redirect },
relayKey ? { 'x-relay-key': relayKey } : {}
)

Expand All @@ -387,6 +389,7 @@ async function exchangeAuthCode({ clientId, clientSecret, code, relayUrl, relayA
client_secret: clientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: redirect,
})

const res = await fetch('https://discord.com/api/oauth2/token', {
Expand Down
3 changes: 3 additions & 0 deletions discord-plugin/com.discord.streamdeck.sdPlugin/bin/plugin.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ACTION_PTT = 'com.discord.streamdeck.ptt'
const ACTION_PTM = 'com.discord.streamdeck.ptm'

const HOLD_ACTIONS = new Set([ACTION_PTT, ACTION_PTM])
const DEFAULT_DISCORD_REDIRECT_URI = 'http://127.0.0.1'

const rpc = new DiscordRpcClient({ logger: console })

Expand All @@ -34,6 +35,7 @@ function sanitizeSettings(settings = {}) {
relayUrl: String(settings.relayUrl || '').trim(),
relayApiKey: String(settings.relayApiKey || '').trim(),
relaySessionId: String(settings.relaySessionId || '').trim(),
redirectUri: String(settings.redirectUri || DEFAULT_DISCORD_REDIRECT_URI).trim(),
accessToken: String(settings.accessToken || '').trim(),
refreshToken: String(settings.refreshToken || '').trim(),
expiresAt: Number(settings.expiresAt || 0),
Expand Down Expand Up @@ -257,6 +259,7 @@ async function handleInspectorMessage(actionUUID, payload) {
relayUrl: s.relayUrl,
relayApiKey: s.relayApiKey,
relaySessionId: s.relaySessionId,
redirectUri: s.redirectUri,
})
await rpc.authenticate(token.accessToken)

Expand Down
79 changes: 79 additions & 0 deletions discord-relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,36 @@ The Stream Deck Discord plugin calls this relay for OAuth token exchange and ref
- `POST /oauth/discord/access`
- `POST /oauth/discord/revoke`

The local server (`../discord-relay/local-server.mjs`) exposes the same endpoints for zero-host deployments.

## Required bindings and secrets

1. Cloudflare KV namespace bound as `DISCORD_SESSIONS`
1. Secret: `DISCORD_CLIENT_SECRET`
1. Optional secret: `RELAY_API_KEY`

## Cloudflare Free Tier Plan

This relay is designed for low-cost personal/small-team usage and works well on the Cloudflare Workers free tier.

What users run locally:

- Tech Stack Stream Deck app
- Discord desktop app

What runs in Cloudflare:

- OAuth code exchange (`/oauth/discord/exchange`)
- Access token refresh (`/oauth/discord/access`)
- Session revoke (`/oauth/discord/revoke`)
- Refresh-token session storage in KV (`DISCORD_SESSIONS`)

This lets users click **Authorize** once in Discord, while keeping client secret and refresh tokens off their machine.

Discord application requirement:

- In Discord Developer Portal → **OAuth2**, add `http://127.0.0.1` to the app Redirects list.

## Local dev

1. Install Wrangler
Expand All @@ -32,15 +56,70 @@ The Stream Deck Discord plugin calls this relay for OAuth token exchange and ref
1. Start worker
- `wrangler dev`

When running `wrangler dev`, use the local URL shown in terminal (usually `http://127.0.0.1:8787`) as the plugin `Relay URL`.

## Local encrypted relay (no hosting cost)

Run from the project root:

```bash
npm run discord-relay:local:setup
```

Or run non-interactive mode:

```bash
export DISCORD_CLIENT_SECRET="your-discord-client-secret"
export DISCORD_RELAY_MASTER_KEY="long-random-local-passphrase"
export RELAY_API_KEY="optional-local-relay-key"
npm run discord-relay:local
```

Defaults:

- Listens on `http://127.0.0.1:8787`
- Encrypted session store at `~/.config/tech-stack-streamdeck/discord-relay-sessions.enc.json`
- Session TTL defaults to 30 days

Optional overrides:

- `DISCORD_RELAY_PORT`
- `DISCORD_RELAY_STORE`
- `SESSION_TTL_SECONDS`

## Deploy

1. `wrangler deploy`
1. Copy deployed worker URL
1. Put relay URL into the Discord action inspector
1. Put `RELAY_API_KEY` into the plugin setting if you enabled it

## Clone User Setup (Discord)

1. Clone and run the main app
1. Install the Discord plugin package into `~/.config/tech-stack-streamdeck/plugins/`
1. Open Discord desktop app
1. In the plugin inspector, set:
- `Client ID`
- `Relay URL` (deployed Worker URL)
- `Relay API Key` (if enabled)
1. Click **Authorize** once

After this, token refresh is automatic through relay session state.

## Security notes

- In hosted mode, the plugin should not store `clientSecret` or `refreshToken`.
- The relay stores refresh tokens in KV with TTL (`SESSION_TTL_SECONDS`).
- Rotate `RELAY_API_KEY` and `DISCORD_CLIENT_SECRET` regularly.

## No-hosting-cost fallback

If you do not want any hosted service cost, run the relay only on your machine:

1. Export `DISCORD_CLIENT_SECRET`
1. Export `DISCORD_RELAY_MASTER_KEY`
1. Run `npm run discord-relay:local:setup` (or `npm run discord-relay:local` if env vars are already exported)
1. Put `http://127.0.0.1:8787` into plugin `Relay URL`

This keeps costs at zero, but the local relay must be running while using Discord actions.
Loading