Skip to main content

clickup_cli/commands/
chat.rs

1use crate::client::ClickUpClient;
2use crate::commands::auth::resolve_token;
3use crate::commands::workspace::resolve_workspace;
4use crate::error::CliError;
5use crate::output::OutputConfig;
6use crate::Cli;
7use clap::Subcommand;
8
9#[derive(Subcommand)]
10pub enum ChatCommands {
11    /// List channels in the workspace
12    #[command(name = "channel-list")]
13    ChannelList {
14        /// Include closed channels
15        #[arg(long)]
16        include_closed: bool,
17    },
18    /// Create a channel
19    #[command(name = "channel-create")]
20    ChannelCreate {
21        /// Channel name
22        #[arg(long)]
23        name: String,
24        /// Visibility: PUBLIC or PRIVATE
25        #[arg(long)]
26        visibility: Option<String>,
27    },
28    /// Get a channel by ID
29    #[command(name = "channel-get")]
30    ChannelGet {
31        /// Channel ID
32        id: String,
33    },
34    /// Update a channel
35    #[command(name = "channel-update")]
36    ChannelUpdate {
37        /// Channel ID
38        id: String,
39        /// New name
40        #[arg(long)]
41        name: Option<String>,
42        /// New topic
43        #[arg(long)]
44        topic: Option<String>,
45    },
46    /// Delete a channel
47    #[command(name = "channel-delete")]
48    ChannelDelete {
49        /// Channel ID
50        id: String,
51    },
52    /// List followers of a channel
53    #[command(name = "channel-followers")]
54    ChannelFollowers {
55        /// Channel ID
56        id: String,
57    },
58    /// List members of a channel
59    #[command(name = "channel-members")]
60    ChannelMembers {
61        /// Channel ID
62        id: String,
63    },
64    /// Create or get a direct message channel
65    Dm {
66        /// User ID(s) to send a DM to
67        user_ids: Vec<String>,
68    },
69    /// List messages in a channel
70    #[command(name = "message-list")]
71    MessageList {
72        /// Channel ID
73        #[arg(long)]
74        channel: String,
75    },
76    /// Send a message to a channel
77    #[command(name = "message-send")]
78    MessageSend {
79        /// Channel ID
80        #[arg(long)]
81        channel: String,
82        /// Message text
83        #[arg(long)]
84        text: String,
85        /// Message type: message or post
86        #[arg(long, default_value = "message")]
87        r#type: String,
88    },
89    /// Update a message
90    #[command(name = "message-update")]
91    MessageUpdate {
92        /// Message ID
93        id: String,
94        /// New message text
95        #[arg(long)]
96        text: String,
97    },
98    /// Delete a message
99    #[command(name = "message-delete")]
100    MessageDelete {
101        /// Message ID
102        id: String,
103    },
104    /// List reactions on a message
105    #[command(name = "reaction-list")]
106    ReactionList {
107        /// Message ID
108        msg_id: String,
109    },
110    /// Add a reaction to a message
111    #[command(name = "reaction-add")]
112    ReactionAdd {
113        /// Message ID
114        msg_id: String,
115        /// Emoji name
116        #[arg(long)]
117        emoji: String,
118    },
119    /// Remove a reaction from a message
120    #[command(name = "reaction-remove")]
121    ReactionRemove {
122        /// Message ID
123        msg_id: String,
124        /// Emoji name
125        emoji: String,
126    },
127    /// List replies to a message
128    #[command(name = "reply-list")]
129    ReplyList {
130        /// Message ID
131        msg_id: String,
132    },
133    /// Send a reply to a message
134    #[command(name = "reply-send")]
135    ReplySend {
136        /// Message ID
137        msg_id: String,
138        /// Reply text
139        #[arg(long)]
140        text: String,
141    },
142    /// Get users tagged in a message
143    #[command(name = "tagged-users")]
144    TaggedUsers {
145        /// Message ID
146        msg_id: String,
147    },
148}
149
150const CHANNEL_FIELDS: &[&str] = &["id", "name", "visibility", "type"];
151const MESSAGE_FIELDS: &[&str] = &["id", "content", "type", "date"];
152
153pub async fn execute(command: ChatCommands, cli: &Cli) -> Result<(), CliError> {
154    let token = resolve_token(cli)?;
155    let client = ClickUpClient::new(&token, cli.timeout)?;
156    let ws_id = resolve_workspace(cli)?;
157    let output = OutputConfig::from_cli(&cli.output, &cli.fields, cli.no_header, cli.quiet);
158    let base = format!("/v3/workspaces/{}/chat", ws_id);
159
160    match command {
161        ChatCommands::ChannelList { include_closed } => {
162            let channels = crate::commands::pagination::walk_cursor(
163                cli,
164                &client,
165                &["data", "channels"],
166                |cursor| {
167                    let mut qs: Vec<String> = Vec::new();
168                    if include_closed {
169                        qs.push("include_closed=true".to_string());
170                    }
171                    if let Some(c) = cursor {
172                        qs.push(format!("cursor={}", c));
173                    }
174                    if qs.is_empty() {
175                        format!("{}/channels", base)
176                    } else {
177                        format!("{}/channels?{}", base, qs.join("&"))
178                    }
179                },
180            )
181            .await?;
182            output.print_items(&channels, CHANNEL_FIELDS, "id");
183            Ok(())
184        }
185        ChatCommands::ChannelCreate { name, visibility } => {
186            let mut body = serde_json::json!({ "name": name });
187            if let Some(v) = visibility {
188                body["visibility"] = serde_json::Value::String(v);
189            }
190            let resp = client.post(&format!("{}/channels", base), &body).await?;
191            output.print_single(&resp, CHANNEL_FIELDS, "id");
192            Ok(())
193        }
194        ChatCommands::ChannelGet { id } => {
195            let resp = client.get(&format!("{}/channels/{}", base, id)).await?;
196            output.print_single(&resp, CHANNEL_FIELDS, "id");
197            Ok(())
198        }
199        ChatCommands::ChannelUpdate { id, name, topic } => {
200            let mut body = serde_json::Map::new();
201            if let Some(n) = name {
202                body.insert("name".into(), serde_json::Value::String(n));
203            }
204            if let Some(t) = topic {
205                body.insert("topic".into(), serde_json::Value::String(t));
206            }
207            let resp = client
208                .patch(
209                    &format!("{}/channels/{}", base, id),
210                    &serde_json::Value::Object(body),
211                )
212                .await?;
213            output.print_single(&resp, CHANNEL_FIELDS, "id");
214            Ok(())
215        }
216        ChatCommands::ChannelDelete { id } => {
217            client.delete(&format!("{}/channels/{}", base, id)).await?;
218            output.print_message(&format!("Channel {} deleted", id));
219            Ok(())
220        }
221        ChatCommands::ChannelFollowers { id } => {
222            let followers =
223                crate::commands::pagination::walk_cursor(cli, &client, &["data"], |cursor| {
224                    match cursor {
225                        Some(c) => format!("{}/channels/{}/followers?cursor={}", base, id, c),
226                        None => format!("{}/channels/{}/followers", base, id),
227                    }
228                })
229                .await?;
230            output.print_items(&followers, &["id", "name", "username", "email"], "id");
231            Ok(())
232        }
233        ChatCommands::ChannelMembers { id } => {
234            let members =
235                crate::commands::pagination::walk_cursor(cli, &client, &["data"], |cursor| {
236                    match cursor {
237                        Some(c) => format!("{}/channels/{}/members?cursor={}", base, id, c),
238                        None => format!("{}/channels/{}/members", base, id),
239                    }
240                })
241                .await?;
242            output.print_items(&members, &["id", "name", "username", "email"], "id");
243            Ok(())
244        }
245        ChatCommands::Dm { user_ids } => {
246            let body = serde_json::json!({ "user_ids": user_ids });
247            let resp = client
248                .post(&format!("{}/channels/direct_message", base), &body)
249                .await?;
250            output.print_single(&resp, CHANNEL_FIELDS, "id");
251            Ok(())
252        }
253        ChatCommands::MessageList { channel } => {
254            let messages = crate::commands::pagination::walk_cursor(
255                cli,
256                &client,
257                &["data", "messages"],
258                |cursor| match cursor {
259                    Some(c) => format!("{}/channels/{}/messages?cursor={}", base, channel, c),
260                    None => format!("{}/channels/{}/messages", base, channel),
261                },
262            )
263            .await?;
264            output.print_items(&messages, MESSAGE_FIELDS, "id");
265            Ok(())
266        }
267        ChatCommands::MessageSend {
268            channel,
269            text,
270            r#type,
271        } => {
272            let body = serde_json::json!({ "content": text, "type": r#type });
273            let resp = client
274                .post(&format!("{}/channels/{}/messages", base, channel), &body)
275                .await?;
276            output.print_single(&resp, MESSAGE_FIELDS, "id");
277            Ok(())
278        }
279        ChatCommands::MessageUpdate { id, text } => {
280            let body = serde_json::json!({ "content": text });
281            let resp = client
282                .patch(&format!("{}/messages/{}", base, id), &body)
283                .await?;
284            output.print_single(&resp, MESSAGE_FIELDS, "id");
285            Ok(())
286        }
287        ChatCommands::MessageDelete { id } => {
288            client.delete(&format!("{}/messages/{}", base, id)).await?;
289            output.print_message(&format!("Message {} deleted", id));
290            Ok(())
291        }
292        ChatCommands::ReactionList { msg_id } => {
293            let reactions =
294                crate::commands::pagination::walk_cursor(cli, &client, &["data"], |cursor| {
295                    match cursor {
296                        Some(c) => format!("{}/messages/{}/reactions?cursor={}", base, msg_id, c),
297                        None => format!("{}/messages/{}/reactions", base, msg_id),
298                    }
299                })
300                .await?;
301            output.print_items(&reactions, &["reaction", "user", "date"], "reaction");
302            Ok(())
303        }
304        ChatCommands::ReactionAdd { msg_id, emoji } => {
305            let body = serde_json::json!({ "reaction": emoji });
306            let resp = client
307                .post(&format!("{}/messages/{}/reactions", base, msg_id), &body)
308                .await?;
309            println!("{}", serde_json::to_string_pretty(&resp).unwrap());
310            Ok(())
311        }
312        ChatCommands::ReactionRemove { msg_id, emoji } => {
313            // Emoji like 👍 contain bytes outside the URL path's unreserved set;
314            // percent-encode the segment so the request is well-formed.
315            let encoded: String = emoji
316                .bytes()
317                .flat_map(|byte| match byte {
318                    b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
319                        vec![byte as char]
320                    }
321                    _ => format!("%{:02X}", byte).chars().collect(),
322                })
323                .collect();
324            client
325                .delete(&format!(
326                    "{}/messages/{}/reactions/{}",
327                    base, msg_id, encoded
328                ))
329                .await?;
330            output.print_message(&format!(
331                "Reaction '{}' removed from message {}",
332                emoji, msg_id
333            ));
334            Ok(())
335        }
336        ChatCommands::ReplyList { msg_id } => {
337            let replies = crate::commands::pagination::walk_cursor(
338                cli,
339                &client,
340                &["data", "replies"],
341                |cursor| match cursor {
342                    Some(c) => format!("{}/messages/{}/replies?cursor={}", base, msg_id, c),
343                    None => format!("{}/messages/{}/replies", base, msg_id),
344                },
345            )
346            .await?;
347            output.print_items(&replies, MESSAGE_FIELDS, "id");
348            Ok(())
349        }
350        ChatCommands::ReplySend { msg_id, text } => {
351            let body = serde_json::json!({ "content": text });
352            let resp = client
353                .post(&format!("{}/messages/{}/replies", base, msg_id), &body)
354                .await?;
355            output.print_single(&resp, MESSAGE_FIELDS, "id");
356            Ok(())
357        }
358        ChatCommands::TaggedUsers { msg_id } => {
359            let users =
360                crate::commands::pagination::walk_cursor(cli, &client, &["data"], |cursor| {
361                    match cursor {
362                        Some(c) => {
363                            format!("{}/messages/{}/tagged_users?cursor={}", base, msg_id, c)
364                        }
365                        None => format!("{}/messages/{}/tagged_users", base, msg_id),
366                    }
367                })
368                .await?;
369            output.print_items(&users, &["id", "name", "username", "email"], "id");
370            Ok(())
371        }
372    }
373}