Adding Slack modules
This commit is contained in:
301
README.md
301
README.md
@@ -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 |
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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
117
internal/modules/modules.go
Normal 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
58
modules.example.yaml
Normal 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"}}"
|
||||||
Reference in New Issue
Block a user