What
crates/buzz-workflow/src/executor.rs:582 carries a maintainer TODO:
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
-
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.
-
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.
What
crates/buzz-workflow/src/executor.rs:582carries a maintainer TODO:The
send_dmaction is already part of the workflow schema (ActionDef::SendDmin
schema.rs, documented as "Recipient — pubkey hex or{{trigger.author}}"),already flows through template resolution (
resolve_step_templates), and anyworkflow that uses it today fails at dispatch with
NotImplemented.I would like to implement it, scoped to
send_dmonly (the neighbouringSetChannelTopicTODO at :588 is left for a separate change).Proposed design — reuse the two patterns the codebase already establishes
Emission path: mirror the sibling
SendMessagearm exactly.The executor loads the run + workflow (community-scoped) to get the owner
pubkey, then calls a new
ActionSink::send_dmmethod. The relay-sideRelayActionSinkbuilds a kind:9 event, signs it with the relay keypair,attributes the workflow owner via a
ptag, persists viainsert_event_with_thread_metadata, and dispatches post-persist sideeffects via
dispatch_persistent_event— byte-for-byte the same flow assend_messageincrates/buzz-relay/src/workflow_sink.rs.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:41010handle_dm_opencommand handler uses).
send_dmopens (or reuses) the owner↔recipient DMchannel and posts the kind:9 into it. When the open creates a new channel,
the post-create side effects of
handle_dm_openare mirrored (membershipcache invalidation,
dm_createdsystem 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 agentwake (
event_mentions_agentin buzz-acp) and push delivery arep-tag gated —a DM that does not wake its recipient would be dead on arrival.
Decisions flagged for maintainers
send_messagealready establishes: the relay keypair signs, the workflowowner is attributed via
ptag, 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.
to== workflow owner) is rejected as invalid input rather thancreating a 1-participant channel (
create_dmrequires ≥2 participants).SendMessage, the DM body is not written to relay logs.Tests
whitespace, malformed, unresolved-template literals) following the existing
resolve_send_message_channeltest pattern inexecutor.rs.#[ignore]) integration test inworkflow_sink.rsmirroring
workflow_send_message_p_tags_mentioned_member: asserts channeltype/visibility, both
ptags, idempotent channel reuse across sends, andself-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.