Proposal: implement the `send_dm` workflow action (WF-07 TODO)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## What
`crates/buzz-workflow/src/executor.rs:582` carries a maintainer TODO:
```rust
SendDm { to, text: _ } => {
warn!(run_id = %run_id, step = step_id, "SendDm not yet implemented (to={to})");
// TODO (WF-07): emit DM event.
Err(WorkflowError::NotImplemented("SendDm".into()))
}
```
The `send_dm` action is already part of the workflow schema (`ActionDef::SendDm`
in `schema.rs`, documented as "Recipient — pubkey hex or `{{trigger.author}}`"),
already flows through template resolution (`resolve_step_templates`), and any
workflow that uses it today fails at dispatch with `NotImplemented`.
I would like to implement it, scoped to `send_dm` only (the neighbouring
`SetChannelTopic` TODO at :588 is left for a separate change).
## Proposed design — reuse the two patterns the codebase already establishes
1. **Emission path**: mirror the sibling `SendMessage` arm exactly.
The executor loads the run + workflow (community-scoped) to get the owner
pubkey, then calls a new `ActionSink::send_dm` method. The relay-side
`RelayActionSink` builds a kind:9 event, signs it with the **relay keypair**,
attributes the workflow owner via a `p` tag, persists via
`insert_event_with_thread_metadata`, and dispatches post-persist side
effects via `dispatch_persistent_event` — byte-for-byte the same flow as
`send_message` in `crates/buzz-relay/src/workflow_sink.rs`.
2. **DM encoding**: Buzz DMs are not NIP-17 wrapped events — they are private
channels (`channel_type='dm'`) opened idempotently by participant-set hash
(`buzz_db::dm::open_dm`, the same path the kind:41010 `handle_dm_open`
command handler uses). `send_dm` opens (or reuses) the owner↔recipient DM
channel and posts the kind:9 into it. When the open creates a new channel,
the post-create side effects of `handle_dm_open` are mirrored (membership
cache invalidation, `dm_created` system message, group discovery events,
membership notifications); on re-open, the owner's NIP-DV visibility
snapshot is refreshed.
The recipient is additionally `p`-tagged on the message event, because agent
wake (`event_mentions_agent` in buzz-acp) and push delivery are `p`-tag gated —
a DM that does not wake its recipient would be dead on arrival.
## Decisions flagged for maintainers
- **Whose key signs a workflow DM?** This implementation follows the pattern
`send_message` already establishes: the relay keypair signs, the workflow
owner is attributed via `p` tag, and the DM channel participant set is
{workflow owner, recipient}. If the intent for WF-07 was instead
owner-key-signed events (or a dedicated workflow identity), the sink method
is the single place to change.
- **Self-DM** (`to` == workflow owner) is rejected as invalid input rather than
creating a 1-participant channel (`create_dm` requires ≥2 participants).
- **Privacy**: unlike `SendMessage`, the DM body is not written to relay logs.
## Tests
- Pure unit tests for recipient normalization (hex, npub, uppercase,
whitespace, malformed, unresolved-template literals) following the existing
`resolve_send_message_channel` test pattern in `executor.rs`.
- A Postgres-gated (`#[ignore]`) integration test in `workflow_sink.rs`
mirroring `workflow_send_message_p_tags_mentioned_member`: asserts channel
type/visibility, both `p` tags, idempotent channel reuse across sends, and
self-DM rejection.
I've put the implementation up as **draft PR #2614** for reference. Per
CONTRIBUTING's guidance to open an issue first for significant changes, I'm
keeping it in draft and treating this issue as the place to settle the design —
especially the signing-key question above — before it goes up for review.
Contributor guide
Assessment
This issue has not been assessed yet.