Adding Slack modules

This commit is contained in:
lwoodard
2026-04-13 11:27:18 -06:00
parent 1d9f0b519b
commit 639a08a808
5 changed files with 522 additions and 9 deletions

301
README.md
View File

@@ -21,6 +21,8 @@ No Elgato software required — communicates directly with the device over USB H
- Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login - Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
- No Stream Deck app, no Node.js, no Electron - No Stream Deck app, no Node.js, no Electron
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See [Modules](#modules).
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap) **Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
--- ---
@@ -37,13 +39,16 @@ streamdeck-go/
├── internal/ ├── internal/
│ ├── config/ │ ├── config/
│ │ └── config.go # YAML parsing with XDG-aware defaults │ │ └── config.go # YAML parsing with XDG-aware defaults
── device/ ── device/
└── streamdeck.go # USB HID communication, image encoding, button reads └── streamdeck.go # USB HID communication, image encoding, button reads
│ └── modules/
│ └── modules.go # Module registry, template resolution, env/expiry helpers
├── systemd/ ├── systemd/
│ └── streamdeck-go.service # systemd user service (Linux) │ └── streamdeck-go.service # systemd user service (Linux)
├── launchd/ ├── launchd/
│ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS) │ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS)
├── config.example.yaml # Starter config (copied to ~/.config on install) ├── config.example.yaml # Starter config (copied to ~/.config on install)
├── modules.example.yaml # Example modules file (Slack integration)
├── Makefile # build / install / uninstall ├── Makefile # build / install / uninstall
├── go.mod ├── go.mod
└── go.sum └── go.sum
@@ -53,10 +58,15 @@ streamdeck-go/
``` ```
~/.config/streamdeck-go/config.yaml ~/.config/streamdeck-go/config.yaml
~/.config/streamdeck-go/modules.yaml (optional)
├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload config → restart run() ├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload both → restart run()
└──▶ run(ctx) └──▶ run(ctx, registry)
├── module resolution ──▶ key has module/function?
│ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved)
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID) ├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
@@ -391,6 +401,94 @@ keys:
--- ---
### Launching applications
On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently.
#### macOS — `open -a`
The `open -a` command launches (or focuses) a macOS application by name:
```yaml
keys:
0:
icon: firefox.png
command: "open -a Firefox"
1:
icon: slack.png
command: "open -a Slack"
2:
icon: ghostty.png
command: "open -a Ghostty"
3:
icon: finder.png
command: "open -a Finder ~/Documents" # open Finder to a specific folder
4:
icon: vscode.png
command: "open -a 'Visual Studio Code'" # quote names with spaces
```
**How `open -a` works:**
- If the app is already running, it brings it to the front (no duplicate launched).
- If the app is not running, it launches it.
- The app name matches what's in `/Applications/` minus the `.app` extension.
- To find the exact name: `ls /Applications/` or `osascript -e 'tell application "System Events" to get name of every process whose background only is false'`
**Opening files and URLs:**
```yaml
keys:
5:
icon: project.png
command: "open -a 'Visual Studio Code' ~/Projects/myproject" # open a folder in VS Code
6:
icon: notes.png
command: "open ~/Documents/notes.txt" # opens in default app for .txt
7:
icon: github.png
command: "open https://github.com" # opens in default browser
```
#### Linux — direct binary
```yaml
keys:
0:
icon: firefox.png
command: firefox
1:
icon: slack.png
command: slack
2:
icon: ghostty.png
command: ghostty
3:
icon: files.png
command: nautilus ~/Documents
```
Most Linux desktop apps can also be launched with their `.desktop` file via `gtk-launch`:
```yaml
keys:
4:
icon: vscode.png
command: "gtk-launch code" # uses the .desktop file name (without .desktop)
```
#### Cross-platform keys
If you use the same config on both platforms, you can use a shell one-liner:
```yaml
keys:
0:
icon: browser.png
command: "if [ \"$(uname)\" = \"Darwin\" ]; then open -a Firefox; else firefox; fi"
```
---
### Terminal & SSH commands ### Terminal & SSH commands
Any shell command works — including launching terminals and SSH sessions. Any shell command works — including launching terminals and SSH sessions.
@@ -404,7 +502,7 @@ keys:
command: ghostty # Linux command: ghostty # Linux
1: 1:
icon: terminal.png icon: terminal.png
command: open -a Terminal # macOS — or: open -a iTerm command: open -a Ghostty # macOS — or: open -a Terminal, open -a iTerm
``` ```
**SSH:** **SSH:**
@@ -506,6 +604,199 @@ commands:
--- ---
### Modules
Modules let you define reusable commands in a separate `modules.yaml` file instead of inlining shell commands in your config. This is especially useful for API-driven integrations like Slack where commands are long `curl` calls with tokens and JSON payloads.
#### How it works
1. Create `~/.config/streamdeck-go/modules.yaml` (next to your `config.yaml`).
2. Define modules with named functions. Each function has an `exec` template and optional default `params`.
3. Reference them in `config.yaml` with `module`, `function`, and optional `params` overrides.
The daemon watches `modules.yaml` for changes alongside `config.yaml` — edits to either file trigger a live reload.
#### Module file format
```yaml
# ~/.config/streamdeck-go/modules.yaml
modules:
slack:
set_status:
params:
emoji: ":speech_balloon:"
text: "In a meeting"
expiry: "1h"
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
params:
presence: "away"
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"presence":"{{.presence}}"}'
snooze:
params:
minutes: "60"
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"num_minutes":{{.minutes}}}'
end_snooze:
exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
```
A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root.
#### Using modules in config.yaml
Reference a module function instead of writing inline commands:
```yaml
keys:
# Set Slack status to "In a meeting" for 1 hour (uses module defaults)
10:
icon: meeting.png
module: slack
function: set_status
# Override default params — different emoji, text, and duration
11:
icon: lunch.png
module: slack
function: set_status
params:
emoji: ":knife_fork_plate:"
text: "Out to lunch"
expiry: "30m"
# Clear Slack status
12:
icon: clear-status.png
module: slack
function: clear_status
# Go away / come back
13:
icon: away.png
module: slack
function: set_presence
params:
presence: "away"
14:
icon: active.png
module: slack
function: set_presence
params:
presence: "auto"
# Snooze notifications for 60 minutes
15:
icon: snooze.png
module: slack
function: snooze
# End snooze
16:
icon: unsnooze.png
module: slack
function: end_snooze
```
Poll commands also support modules — use `module`, `function`, and `params` inside the `poll` block:
```yaml
keys:
17:
icon_true: dnd-on.png
icon_false: dnd-off.png
module: slack
function: snooze
poll:
module: slack
function: check_dnd
interval: 10s
match: "snooze_enabled.*true"
```
#### Template helpers
Two helpers are available in `exec` templates:
| Helper | Usage | Description |
|---|---|---|
| `env` | `{{env "VAR_NAME"}}` | Returns the value of an environment variable |
| `expiry` | `{{expiry .duration}}` | Converts a Go duration string (e.g. `"1h"`, `"30m"`) to a Unix epoch timestamp. `"0"` or `""` returns `"0"` (no expiry) |
#### Secrets and tokens
**Tokens and secrets must never go in `modules.yaml` or `config.yaml`.** Use the `{{env "VAR_NAME"}}` template helper to read them from environment variables at runtime.
**Setting up your Slack token:**
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) with these OAuth scopes:
- `users.profile:write` — set/clear status
- `users:write` — set presence (away/auto)
- `dnd:write` — snooze/unsnooze notifications
2. Install the app to your workspace and copy the **User OAuth Token** (`xoxp-...`).
3. Export the token in your shell profile (`~/.zshrc`, `~/.bash_profile`, etc.):
```bash
export SLACK_TOKEN="xoxp-your-token-here"
```
4. Make sure the service can see the variable:
**macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` inside the `<dict>` block:
```xml
<key>EnvironmentVariables</key>
<dict>
<key>SLACK_TOKEN</key>
<string>xoxp-your-token-here</string>
</dict>
```
**Linux** — import into the systemd user environment:
```bash
systemctl --user import-environment SLACK_TOKEN
```
Or add an `Environment=` line via `systemctl --user edit streamdeck-go`:
```ini
[Service]
Environment=SLACK_TOKEN=xoxp-your-token-here
```
> **Security notes:**
> - The `modules.yaml` and `config.yaml` files can safely live in your dotfiles repo — they contain no secrets.
> - On Linux, the systemd override file (`~/.config/systemd/user/streamdeck-go.service.d/override.conf`) is user-readable only (mode 600). Still, prefer `import-environment` over hardcoding tokens in unit files when possible.
> - On macOS, launchd plist files in `~/Library/LaunchAgents/` are user-readable only by default.
> - Never commit tokens to git. Add `.env` files to `.gitignore` if you use one.
---
### Supported icon formats ### Supported icon formats
| Format | Notes | | Format | Notes |

View File

@@ -23,6 +23,7 @@ import (
"github.com/WoodardDigital/streamdeck-go/internal/config" "github.com/WoodardDigital/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/device" "github.com/WoodardDigital/streamdeck-go/internal/device"
"github.com/WoodardDigital/streamdeck-go/internal/modules"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg" "github.com/srwiley/oksvg"
"github.com/srwiley/rasterx" "github.com/srwiley/rasterx"
@@ -51,6 +52,11 @@ func main() {
log.Fatalf("config: %v", err) log.Fatalf("config: %v", err)
} }
reg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath))
if err != nil {
log.Fatalf("modules: %v", err)
}
// Open device — if not present at startup, wait for it. // Open device — if not present at startup, wait for it.
sd := mustConnect(cfg.Device.VendorID, cfg.Device.ProductID) sd := mustConnect(cfg.Device.VendorID, cfg.Device.ProductID)
@@ -61,7 +67,7 @@ func main() {
startRun := func() { startRun := func() {
go func() { go func() {
deviceDied := run(ctx, sd, cfg) deviceDied := run(ctx, sd, cfg, reg)
runDone <- deviceDied runDone <- deviceDied
}() }()
} }
@@ -91,13 +97,15 @@ func main() {
ctx, cancel = context.WithCancel(context.Background()) ctx, cancel = context.WithCancel(context.Background())
startRun() startRun()
// Config file changed — reload and restart run(). // Config file or modules file changed — reload and restart run().
case event, ok := <-watcher.Events: case event, ok := <-watcher.Events:
if !ok { if !ok {
cancel() cancel()
return return
} }
if filepath.Clean(event.Name) != filepath.Clean(*cfgPath) { cfgChanged := filepath.Clean(event.Name) == filepath.Clean(*cfgPath)
modChanged := filepath.Clean(event.Name) == filepath.Clean(config.ModulesPath(*cfgPath))
if !cfgChanged && !modChanged {
continue continue
} }
if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) {
@@ -114,6 +122,12 @@ func main() {
} else { } else {
cfg = newCfg cfg = newCfg
} }
newReg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath))
if err != nil {
log.Printf("reload: bad modules: %v — keeping current modules", err)
} else {
reg = newReg
}
ctx, cancel = context.WithCancel(context.Background()) ctx, cancel = context.WithCancel(context.Background())
startRun() startRun()
@@ -129,7 +143,7 @@ func main() {
// run initialises the deck and handles button presses until ctx is cancelled // run initialises the deck and handles button presses until ctx is cancelled
// or the device dies. Returns true if the device died, false if ctx was cancelled. // or the device dies. Returns true if the device died, false if ctx was cancelled.
func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config) (deviceDied bool) { func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) {
// Recover from any panic inside this goroutine tree. // Recover from any panic inside this goroutine tree.
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@@ -157,6 +171,24 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config) (device
triggers := make(map[int]chan struct{}) triggers := make(map[int]chan struct{})
for keyIdx, keyCfg := range cfg.Keys { for keyIdx, keyCfg := range cfg.Keys {
// Resolve module-based commands to shell strings before any other logic.
if keyCfg.Module != "" {
resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params)
if err != nil {
log.Printf("key %d: module: %v", keyIdx, err)
continue
}
keyCfg.Command = resolved
}
if keyCfg.Poll != nil && keyCfg.Poll.Module != "" {
resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params)
if err != nil {
log.Printf("key %d: poll module: %v", keyIdx, err)
continue
}
keyCfg.Poll.Command = resolved
}
// Toggle/status key: managed by a polling goroutine. // Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil { if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" { if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {

View File

@@ -13,6 +13,11 @@ type PollConfig struct {
Command string `yaml:"command"` // shell command whose output is checked Command string `yaml:"command"` // shell command whose output is checked
Interval string `yaml:"interval"` // how often to poll, e.g. "2s" (default: "2s") Interval string `yaml:"interval"` // how often to poll, e.g. "2s" (default: "2s")
Match string `yaml:"match"` // substring to find in output → "on" state; omit to use exit code 0 Match string `yaml:"match"` // substring to find in output → "on" state; omit to use exit code 0
// Module-based alternative to Command: resolved to a shell string at startup.
Module string `yaml:"module"`
Function string `yaml:"function"`
Params map[string]string `yaml:"params"`
} }
// KeyConfig defines what a single Stream Deck key does. // KeyConfig defines what a single Stream Deck key does.
@@ -24,6 +29,16 @@ type KeyConfig struct {
IconTrue string `yaml:"icon_true"` // icon when poll match is true IconTrue string `yaml:"icon_true"` // icon when poll match is true
IconFalse string `yaml:"icon_false"` // icon when poll match is false IconFalse string `yaml:"icon_false"` // icon when poll match is false
Poll *PollConfig `yaml:"poll"` Poll *PollConfig `yaml:"poll"`
// Module-based alternative to Command: resolved to a shell string at startup.
Module string `yaml:"module"`
Function string `yaml:"function"`
Params map[string]string `yaml:"params"`
}
// ModulesPath returns the path to the modules.yaml file next to the given config file.
func ModulesPath(cfgPath string) string {
return filepath.Join(filepath.Dir(cfgPath), "modules.yaml")
} }
// Config is the top-level structure of the YAML config file. // Config is the top-level structure of the YAML config file.

117
internal/modules/modules.go Normal file
View File

@@ -0,0 +1,117 @@
package modules
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strconv"
"text/template"
"time"
"gopkg.in/yaml.v3"
)
// FunctionDef is a single callable function within a module.
type FunctionDef struct {
Exec string `yaml:"exec"` // Go text/template that renders to a shell command
Params map[string]string `yaml:"params"` // default parameter values
}
// ModuleDef maps function names to their definitions.
type ModuleDef map[string]FunctionDef
// Registry holds all loaded modules.
type Registry struct {
Modules map[string]ModuleDef `yaml:"modules"`
}
// LoadRegistry reads a modules.yaml file.
// A missing file is not an error — returns an empty Registry so callers don't need to
// special-case systems without a modules.yaml.
func LoadRegistry(path string) (*Registry, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &Registry{}, nil
}
return nil, fmt.Errorf("read modules %q: %w", path, err)
}
var r Registry
if err := yaml.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("parse modules %q: %w", path, err)
}
return &r, nil
}
// Resolve looks up module/function, merges params (defaults overridden by overrides),
// and renders the exec template. Returns the shell command string ready for sh -c.
func (r *Registry) Resolve(module, function string, overrides map[string]string) (string, error) {
if r == nil || r.Modules == nil {
return "", fmt.Errorf("no modules loaded")
}
mod, ok := r.Modules[module]
if !ok {
return "", fmt.Errorf("unknown module %q", module)
}
fn, ok := mod[function]
if !ok {
return "", fmt.Errorf("module %q has no function %q", module, function)
}
// Merge: start with defaults, apply overrides on top.
params := make(map[string]string, len(fn.Params)+len(overrides))
for k, v := range fn.Params {
params[k] = v
}
for k, v := range overrides {
params[k] = v
}
tmpl, err := template.New("exec").Funcs(funcMap()).Parse(fn.Exec)
if err != nil {
return "", fmt.Errorf("module %q function %q: parse template: %w", module, function, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, params); err != nil {
return "", fmt.Errorf("module %q function %q: render template: %w", module, function, err)
}
return buf.String(), nil
}
// Execute resolves the module/function and runs it via sh -c.
func (r *Registry) Execute(module, function string, overrides map[string]string) error {
shell, err := r.Resolve(module, function, overrides)
if err != nil {
return err
}
c := exec.Command("sh", "-c", shell)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return c.Run()
}
// funcMap returns the template helper functions available in exec templates.
func funcMap() template.FuncMap {
return template.FuncMap{
// env returns the value of an environment variable.
// Use this to keep secrets (tokens, passwords) out of modules.yaml.
"env": os.Getenv,
// expiry converts a duration string like "+1h" to a Unix epoch timestamp string.
// Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h").
// Note: time.ParseDuration does not support days (d) or weeks (w).
"expiry": func(s string) string {
if s == "" || s == "0" {
return "0"
}
d, err := time.ParseDuration(s)
if err != nil {
return "0"
}
return strconv.FormatInt(time.Now().Add(d).Unix(), 10)
},
}
}

58
modules.example.yaml Normal file
View File

@@ -0,0 +1,58 @@
# Example modules.yaml — copy to ~/.config/streamdeck-go/modules.yaml
#
# Required Slack token scopes: users.profile:write, users:write, dnd:write
# Export your token: export SLACK_TOKEN="xoxp-..."
modules:
slack:
set_status:
params:
emoji: ":speech_balloon:"
text: "In a meeting"
expiry: "1h"
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
params:
presence: "away"
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"presence":"{{.presence}}"}'
snooze:
params:
minutes: "60"
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"num_minutes":{{.minutes}}}'
go_offline:
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"presence":"away"}' && \
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
end_snooze:
exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"