Skip to main content

notbot/
autojoiner.rs

1//! Make the bot join and leave rooms as instructed.
2//!
3//! # Configuration
4//!
5//! [`ModuleConfig`]
6//!
7//! ```toml
8//! [module."notbot::autojoiner"]
9//! homeservers = [
10//!     "is-a.cat",
11//!     "hackerspace.pl",
12//! ]
13//! leave_message = "goodbye 😿"
14//! ```
15//!
16//! # Usage
17//!
18//! The bot responds to chat commands only from bot admins.
19//!
20//! Keywords:
21//! * `join room-name` - attempts to join a room by name. [`join_processor`], [`join_consumer`].
22//! * `leave [room-name]` - will leave either the named, or - if name's not present - current room. [`leave_processor`]
23//!
24//! The bot will also attempt to join rooms when invited, and the room has `room_id` on one of the allowed homeservers. [`autojoiner`]
25
26use crate::prelude::*;
27
28use tokio::time::{Duration, sleep};
29
30use prometheus::Counter;
31use prometheus::{opts, register_counter};
32
33static ROOM_INVITES: LazyLock<Counter> = LazyLock::new(|| {
34    register_counter!(opts!("room_invite_events_total", "Number of room invites",)).unwrap()
35});
36
37/// Module configuration
38#[derive(Clone, Deserialize)]
39pub struct ModuleConfig {
40    /// List of homeservers to which room ids belong to that the bot will be allowed to join
41    pub homeservers: Vec<String>,
42    /// Keywords for join requests.
43    #[serde(default = "keywords_join")]
44    pub keywords_join: Vec<String>,
45    /// Keywords for leave requests.
46    #[serde(default = "keywords_leave")]
47    pub keywords_leave: Vec<String>,
48    #[serde(default = "leave_message")]
49    /// Message the bot will send to the channel when instructed to leave
50    pub leave_message: String,
51}
52
53fn keywords_join() -> Vec<String> {
54    vec!["join".s()]
55}
56
57fn keywords_leave() -> Vec<String> {
58    vec!["leave".s(), "part".s()]
59}
60
61fn leave_message() -> String {
62    String::from("goodbye 😿")
63}
64
65pub(crate) fn starter(mx: &Client, config: &Config) -> anyhow::Result<Vec<ModuleInfo>> {
66    info!("registering autojoiner");
67    let autojoiner_config: ModuleConfig = config.typed_module_config(module_path!())?;
68    let autojoiner_handle =
69        mx.add_event_handler(move |ev, mx, room| autojoiner(ev, mx, room, autojoiner_config));
70
71    let module_config: ModuleConfig = config.typed_module_config(module_path!())?;
72
73    let (join_tx, join_rx) = mpsc::channel::<ConsumerEvent>(1);
74    let join = ModuleInfo {
75        name: "join".s(),
76        help: "makes the bot join a channel".s(),
77        acl: vec![Acl::SpecificUsers(config.admins())],
78        trigger: TriggerType::Keyword(module_config.keywords_join.clone()),
79        channel: join_tx,
80        error_prefix: None,
81    };
82    tokio::task::spawn(join_consumer(join_rx, mx.clone(), autojoiner_handle));
83
84    let (leave_tx, leave_rx) = mpsc::channel::<ConsumerEvent>(1);
85    let leave = ModuleInfo {
86        name: "leave".s(),
87        help: "makes the bot leave a channel".s(),
88        acl: vec![Acl::SpecificUsers(config.admins())],
89        trigger: TriggerType::Keyword(module_config.keywords_leave.clone()),
90        channel: leave_tx,
91        error_prefix: Some("couldn't leave room".s()),
92    };
93    leave.spawn(leave_rx, module_config, leave_processor);
94
95    Ok(vec![join, leave])
96}
97
98/// Leaves rooms when requested to do so. Will optionally take a room name argument, to leave a different room than current one.
99///
100/// # Errors
101/// Will return `Err` if:
102/// * can't resolve room provided as argument
103/// * sending goodbye message fails
104/// * leaving the room fails
105pub async fn leave_processor(event: ConsumerEvent, config: ModuleConfig) -> anyhow::Result<()> {
106    let leave_room = if let Some(room_str) = event.args {
107        maybe_get_room(&event.room.client(), &room_str).await?
108    } else {
109        event.room
110    };
111
112    leave_room
113        .send(RoomMessageEventContent::text_plain(config.leave_message))
114        .await?;
115    leave_room.leave().await?;
116    Ok(())
117}
118
119/// Forwards join requests to [`join_processor`] and stops the invite event listener as needed.
120///
121/// # Errors
122/// Will return `Err` if:
123/// * event channel gets closed
124pub async fn join_consumer(
125    mut rx: mpsc::Receiver<ConsumerEvent>,
126    mx: Client,
127    autojoiner_handle: EventHandlerHandle,
128) -> anyhow::Result<()> {
129    loop {
130        let Some(event) = rx.recv().await else {
131            warn!("channel closed");
132            info!("stopping the autojoiner");
133            mx.remove_event_handler(autojoiner_handle);
134            bail!("channel closed");
135        };
136
137        if let Err(e) = join_processor(mx.clone(), event.clone()).await {
138            error!("couldn't join the room: {e}");
139            if let Err(ee) = event
140                .room
141                .send(RoomMessageEventContent::text_plain(format!(
142                    "couldn't join room: {e}"
143                )))
144                .await
145            {
146                error!("couldn't send error message: {ee}");
147            };
148        };
149    }
150}
151
152/// Processes join requests.
153///
154/// # Errors
155/// Will return `Err` if:
156/// * no argument is provided
157/// * argument doesn't parse as room
158/// * joining room fails.
159pub async fn join_processor(mx: Client, event: ConsumerEvent) -> anyhow::Result<()> {
160    if let Some(room_str) = event.args {
161        let room = maybe_get_room(&mx, &room_str).await?;
162        info!("joining room: {room_str} {}", room.room_id());
163        let mut delay = 2;
164        let mut joined = true;
165
166        while let Err(err) = room.join().await {
167            // retry autojoin due to synapse sending invites, before the
168            // invited user can join for more information see
169            // https://github.com/element-hq/synapse/issues/4345
170            error!(
171                "Failed to join room {} ({err:?}), retrying in {delay}s",
172                room.room_id()
173            );
174
175            sleep(Duration::from_secs(delay)).await;
176            delay *= 2;
177
178            if delay > 3600 {
179                error!("Can't join room {} ({err:?})", room.room_id());
180                joined = false;
181                break;
182            }
183        }
184
185        trace!("Successfully joined room {}", room.room_id());
186        let response = if joined {
187            format!("joined {room_str}")
188        } else {
189            bail!("couldn't join {room_str}")
190        };
191        event
192            .room
193            .send(RoomMessageEventContent::text_plain(response))
194            .await?;
195    } else {
196        bail!("missing argument: room");
197    }
198
199    Ok(())
200}
201
202/// Listens for invitation events, and joins the appropriate room if the room id is from one of the permitted homeservers.
203///
204/// # Errors
205/// Will return `Err` if:
206/// * invite not ment for us
207/// * retrieving room server name fails
208/// * room is on wrong homeserver
209#[allow(clippy::unused_async)]
210pub async fn autojoiner(
211    room_member: StrippedRoomMemberEvent,
212    client: Client,
213    room: Room,
214    module_config: ModuleConfig,
215) -> anyhow::Result<()> {
216    // ignore invites not meant for us
217    if room_member.state_key
218        != client
219            .user_id()
220            .ok_or_else(|| anyhow!("missing our own userid!?"))?
221    {
222        bail!("invite not ment for us");
223    }
224
225    trace!("getting homeserver name for room");
226    let Some(room_homeserver) = &room.room_id().server_name() else {
227        bail!("retrieving room server name fails");
228    };
229
230    trace!(
231        "checking if invite is for a room on permitted homeserver: {:#?}, {:#?}",
232        &room_homeserver, &module_config.homeservers
233    );
234    if !module_config
235        .homeservers
236        .contains(&room_homeserver.to_string())
237    {
238        bail!("room is on wrong homeserver");
239    };
240
241    ROOM_INVITES.inc();
242
243    tokio::spawn(async move {
244        info!("Autojoining room {}", room.room_id());
245        let mut delay = 2;
246
247        while let Err(err) = room.join().await {
248            // retry autojoin due to synapse sending invites, before the
249            // invited user can join for more information see
250            // https://github.com/matrix-org/synapse/issues/4345
251            error!(
252                "Failed to join room {} ({err:?}), retrying in {delay}s",
253                room.room_id()
254            );
255
256            sleep(Duration::from_secs(delay)).await;
257            delay *= 2;
258
259            if delay > 3600 {
260                error!("Can't join room {} ({err:?})", room.room_id());
261                break;
262            }
263        }
264        trace!("Successfully joined room {}", room.room_id());
265    });
266
267    Ok(())
268}