Skip to content

Commit f97641d

Browse files
feat: implement OpenClaw parity — polls, stickers, emojis, voice messages, reactionMode, ackReaction
New commands (agent → bot): - CreatePollCommand: native Discord polls with answers, duration, multiselect - CreateStickerCommand: create guild stickers from local file - FetchEmojisCommand: request-reply fetch of all guild custom emojis - SendMessageCommand.as_voice: send OGG/Opus files as Discord voice messages (IS_VOICE_MESSAGE flag) New bot behavior: - ReactionMode (off/own/all): configurable via DISCORD_REACTION_MODE env; filters which reaction events are forwarded to NATS - ack_reaction: optional emoji (DISCORD_ACK_REACTION env) to react to incoming messages before publishing to NATS New NATS subjects: agent.poll.create, agent.sticker.create, agent.fetch.emojis Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1dacb0e commit f97641d

8 files changed

Lines changed: 305 additions & 18 deletions

File tree

rsworkspace/crates/discord-agent/src/processor.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ impl MessageProcessor {
292292
reply_to_message_id: Some(message_id),
293293
files: vec![],
294294
components: vec![],
295+
as_voice: false,
295296
};
296297
let subject = subjects::agent::message_send(publisher.prefix());
297298
publisher.publish(&subject, &cmd).await?;
@@ -866,6 +867,7 @@ impl MessageProcessor {
866867
reply_to_message_id: None,
867868
files: vec![],
868869
components: vec![],
870+
as_voice: false,
869871
};
870872
let subject = subjects::agent::message_send(publisher.prefix());
871873
publisher.publish(&subject, &cmd).await?;
@@ -902,6 +904,7 @@ impl MessageProcessor {
902904
reply_to_message_id: None,
903905
files: vec![],
904906
components: vec![],
907+
as_voice: false,
905908
};
906909
let subject = subjects::agent::message_send(publisher.prefix());
907910
publisher.publish(&subject, &cmd).await?;
@@ -1255,6 +1258,7 @@ impl MessageProcessor {
12551258
reply_to_message_id: None,
12561259
files: vec![],
12571260
components: vec![],
1261+
as_voice: false,
12581262
};
12591263
let subject = subjects::agent::message_send(publisher.prefix());
12601264
publisher.publish(&subject, &cmd).await?;

rsworkspace/crates/discord-bot/src/bridge.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ pub struct DiscordBridge {
179179
pub guild_commands_guild_id: Option<u64>,
180180
/// Runtime state for the DM pairing flow (shared with OutboundProcessor).
181181
pub pairing_state: Arc<PairingState>,
182+
/// Controls which reaction events are forwarded to NATS.
183+
pub reaction_mode: crate::config::ReactionMode,
184+
/// Optional emoji to react with when a message is received and will be processed.
185+
pub ack_reaction: Option<String>,
182186
}
183187

184188
impl TypeMapKey for DiscordBridge {
@@ -193,6 +197,8 @@ impl DiscordBridge {
193197
access_config: AccessConfig,
194198
presence_enabled: bool,
195199
guild_commands_guild_id: Option<u64>,
200+
reaction_mode: crate::config::ReactionMode,
201+
ack_reaction: Option<String>,
196202
) -> Self {
197203
Self {
198204
publisher: MessagePublisher::new(client, prefix),
@@ -202,6 +208,24 @@ impl DiscordBridge {
202208
presence_enabled,
203209
guild_commands_guild_id,
204210
pairing_state: Arc::new(PairingState::new()),
211+
reaction_mode,
212+
ack_reaction,
213+
}
214+
}
215+
216+
/// Returns the bot's own user ID (0 if not yet set).
217+
pub fn bot_user_id(&self) -> u64 {
218+
self.bot_user_id.load(Ordering::Relaxed)
219+
}
220+
221+
/// Returns true if reaction events should be forwarded based on the configured mode.
222+
///
223+
/// `is_bot_message` — whether the message the reaction was added to was sent by this bot.
224+
pub fn should_publish_reaction(&self, is_bot_message: bool) -> bool {
225+
match self.reaction_mode {
226+
crate::config::ReactionMode::Off => false,
227+
crate::config::ReactionMode::Own => is_bot_message,
228+
crate::config::ReactionMode::All => true,
205229
}
206230
}
207231

rsworkspace/crates/discord-bot/src/config.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ pub struct Config {
3636
pub nats: NatsConfig,
3737
}
3838

39+
/// Controls which reaction events are forwarded to NATS.
40+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
41+
#[serde(rename_all = "snake_case")]
42+
pub enum ReactionMode {
43+
/// Publish no reaction events
44+
Off,
45+
/// Only publish reactions to messages sent by the bot itself
46+
Own,
47+
/// Publish all reaction events (default)
48+
All,
49+
}
50+
51+
impl Default for ReactionMode {
52+
fn default() -> Self {
53+
ReactionMode::All
54+
}
55+
}
56+
3957
/// Discord bot specific configuration
4058
#[derive(Debug, Clone, Serialize, Deserialize)]
4159
pub struct DiscordBotConfig {
@@ -52,6 +70,13 @@ pub struct DiscordBotConfig {
5270
/// Access control configuration
5371
#[serde(default)]
5472
pub access: AccessConfig,
73+
/// Controls which reaction events are forwarded to NATS
74+
#[serde(default)]
75+
pub reaction_mode: ReactionMode,
76+
/// Optional emoji to react with when a message is received and will be processed.
77+
/// Use a unicode emoji (e.g. "👀") or omit to disable.
78+
#[serde(default)]
79+
pub ack_reaction: Option<String>,
5580
}
5681

5782
impl Config {
@@ -123,11 +148,25 @@ impl Config {
123148
.var("DISCORD_GUILD_COMMANDS_GUILD_ID")
124149
.and_then(|s| s.parse::<u64>().ok());
125150

151+
let reaction_mode = match env
152+
.var_or("DISCORD_REACTION_MODE", "all")
153+
.to_lowercase()
154+
.as_str()
155+
{
156+
"off" => ReactionMode::Off,
157+
"own" => ReactionMode::Own,
158+
_ => ReactionMode::All,
159+
};
160+
161+
let ack_reaction = env.var("DISCORD_ACK_REACTION").filter(|s| !s.is_empty());
162+
126163
Ok(Config {
127164
discord: DiscordBotConfig {
128165
bot_token,
129166
presence_enabled,
130167
guild_commands_guild_id,
168+
reaction_mode,
169+
ack_reaction,
131170
access: AccessConfig {
132171
dm_policy,
133172
guild_policy,

rsworkspace/crates/discord-bot/src/handlers/mod.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ impl EventHandler for Handler {
185185
}
186186
}
187187

188+
// Send ack reaction if configured
189+
if let Some(ref emoji) = bridge.ack_reaction {
190+
let reaction = serenity::model::channel::ReactionType::Unicode(emoji.clone());
191+
if let Err(e) = msg.react(&ctx.http, reaction).await {
192+
debug!("Failed to send ack reaction: {}", e);
193+
}
194+
}
195+
188196
if let Err(e) = bridge.publish_message_created(&msg).await {
189197
error!("Failed to publish message_created: {}", e);
190198
}
@@ -326,6 +334,14 @@ impl EventHandler for Handler {
326334
}
327335
}
328336

337+
// Reaction mode filtering
338+
let is_bot_msg = add_reaction.message_author_id
339+
.map(|author| author.get() == bridge.bot_user_id())
340+
.unwrap_or(false);
341+
if !bridge.should_publish_reaction(is_bot_msg) {
342+
return;
343+
}
344+
329345
if let Err(e) = bridge.publish_reaction_add(&add_reaction).await {
330346
error!("Failed to publish reaction_add: {}", e);
331347
}
@@ -358,6 +374,14 @@ impl EventHandler for Handler {
358374
}
359375
}
360376

377+
// Reaction mode filtering
378+
let is_bot_msg = removed_reaction.message_author_id
379+
.map(|author| author.get() == bridge.bot_user_id())
380+
.unwrap_or(false);
381+
if !bridge.should_publish_reaction(is_bot_msg) {
382+
return;
383+
}
384+
361385
if let Err(e) = bridge.publish_reaction_remove(&removed_reaction).await {
362386
error!("Failed to publish reaction_remove: {}", e);
363387
}

rsworkspace/crates/discord-bot/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ async fn main() -> Result<()> {
119119
config.discord.access.clone(),
120120
config.discord.presence_enabled,
121121
config.discord.guild_commands_guild_id,
122+
config.discord.reaction_mode.clone(),
123+
config.discord.ack_reaction.clone(),
122124
));
123125

124126
// Extract pairing_state before bridge is moved into TypeMap so it can be

0 commit comments

Comments
 (0)