Skip to main content

forge_primitives/
slack.rs

1//! Slack connector shapes that cross a crate boundary: the `[[slack]]`
2//! config entry an operator writes, the conversation shape
3//! `users.conversations` reports, and the subscription record a session
4//! creates.
5
6use std::collections::HashMap;
7use std::time::SystemTime;
8
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12/// One `[[slack]]` entry. The token is a credential, so it lives in
13/// `forge.toml` rather than in the state DB beside the subscriptions.
14#[derive(Clone, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct SlackConfig {
17    /// Tells workspaces apart in the MCP tools and the Inspector.
18    pub workspace: String,
19    /// User token, `xoxp-...`.
20    pub token: String,
21    /// Sweep interval for this workspace, in seconds.
22    #[serde(default = "default_poll_seconds")]
23    pub poll_seconds: u64,
24    /// Sweep interval for the DM class, in seconds. It covers every DM in the
25    /// workspace rather than a list, so it costs one history read per DM per
26    /// pass whatever the subscription count - which is why it runs on its own,
27    /// slower clock beside `poll_seconds`.
28    #[serde(default = "default_dm_poll_seconds")]
29    pub dm_poll_seconds: u64,
30    /// A followed thread with nothing new for this many days is dropped:
31    /// one that quiet is resolved in practice, whatever its parent's age.
32    #[serde(default = "default_thread_idle_days")]
33    pub thread_idle_days: u64,
34    /// A block whose newest message is at or past this many hours old carries
35    /// its age in its header. Zero turns the mark off; nothing is dropped.
36    #[serde(default = "default_stale_after_hours")]
37    pub stale_after_hours: u64,
38}
39
40/// Sweep interval a workspace gets when it names none. Slack's API
41/// allowance is per workspace, and the DM class costs one history call per
42/// DM conversation it covers, so a workspace with a large inbox wants its
43/// own `poll_seconds`.
44pub const DEFAULT_POLL_SECONDS: u64 = 5;
45
46/// Idle window a workspace gets when it names none.
47pub const DEFAULT_THREAD_IDLE_DAYS: u64 = 14;
48
49/// Age a delivered block may reach before its header says so.
50pub const DEFAULT_STALE_AFTER_HOURS: u64 = 24;
51
52/// DM-class sweep interval a workspace gets when it names none.
53///
54/// The two clocks spend one token's allowance for the same method, so the DM
55/// class has to fit beside the conversation pass rather than alone: at
56/// `poll_seconds` 5 that pass costs about 36 calls a minute (a directory list
57/// plus one history read per named conversation, and thread walks on top),
58/// against a measured 77. The largest inbox measured is 91 DMs, one history
59/// call each, so it needs at least 135 seconds and 150 is that with margin. The
60/// smaller inbox is comfortable either way: 48 DMs lands near 20 calls a minute.
61///
62/// The floor is real rather than tidy: at 77 calls a minute the token was
63/// measured clean - 30 back-to-back `conversations.history` calls, no 429 at
64/// all - and deliberately paced runs at 45 to 57 were comfortable, so 77 is the
65/// highest rate observed rather than a projection.
66pub const DEFAULT_DM_POLL_SECONDS: u64 = 150;
67
68/// The slowest a workspace may set the DM clock to. Below this the class
69/// outruns the allowance it shares with the conversation pass, which is the
70/// overrun the key exists to prevent.
71pub const MIN_DM_POLL_SECONDS: u64 = 10;
72
73fn default_poll_seconds() -> u64 {
74    DEFAULT_POLL_SECONDS
75}
76
77fn default_dm_poll_seconds() -> u64 {
78    DEFAULT_DM_POLL_SECONDS
79}
80
81fn default_thread_idle_days() -> u64 {
82    DEFAULT_THREAD_IDLE_DAYS
83}
84
85fn default_stale_after_hours() -> u64 {
86    DEFAULT_STALE_AFTER_HOURS
87}
88
89/// Hand-written because the token must never be printed, the same reason
90/// `SlackClient` writes its own.
91impl std::fmt::Debug for SlackConfig {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        f.debug_struct("SlackConfig")
94            .field("workspace", &self.workspace)
95            .field("token", &"[redacted]")
96            .field("poll_seconds", &self.poll_seconds)
97            .field("dm_poll_seconds", &self.dm_poll_seconds)
98            .field("thread_idle_days", &self.thread_idle_days)
99            .field("stale_after_hours", &self.stale_after_hours)
100            .finish()
101    }
102}
103
104/// Slack sends `purpose` and `topic` as objects carrying their text in
105/// `value`, and omits both on a DM.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
107pub struct SlackConversationText {
108    #[serde(default)]
109    pub value: String,
110}
111
112/// A conversation as `users.conversations` reports it. Fields Slack omits
113/// per conversation type default rather than failing the whole decode.
114#[derive(Debug, Clone, Deserialize)]
115pub struct SlackConversation {
116    pub id: String,
117    #[serde(default)]
118    pub name: Option<String>,
119    #[serde(default)]
120    pub is_channel: bool,
121    #[serde(default)]
122    pub is_private: bool,
123    #[serde(default)]
124    pub is_im: bool,
125    #[serde(default)]
126    pub is_mpim: bool,
127    #[serde(default)]
128    pub is_archived: bool,
129    /// The other member's user id, present on a DM.
130    #[serde(default)]
131    pub user: Option<String>,
132    #[serde(default)]
133    pub purpose: Option<SlackConversationText>,
134    #[serde(default)]
135    pub topic: Option<SlackConversationText>,
136}
137
138/// What one subscription watches inside a workspace.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub enum SlackSubscriptionTarget {
141    /// Every DM in the workspace, group DMs included - the DM class the
142    /// design settled on, so a new DM needs no new subscription.
143    DirectMessages,
144    /// One conversation, with the mode that decides what reaches the session.
145    /// `name` is the display name, which may arrive after the record is
146    /// written: the sweep backfills it from its directory, and a record
147    /// still without one renders the raw id.
148    Conversation {
149        id: String,
150        #[serde(default)]
151        name: Option<String>,
152        mode: SlackWatchMode,
153    },
154    /// Every message in this workspace that mentions the user, including
155    /// public channels they are not in. Not private channels they are not
156    /// in, which they could not read anyway.
157    ///
158    /// Swept by one search per workspace rather than by conversation, so
159    /// the per-conversation matching path never claims it - letting both
160    /// claim a mention would deliver it twice.
161    Mentions,
162}
163
164/// What a conversation subscription lets through.
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166pub enum SlackWatchMode {
167    /// Every message.
168    All,
169    /// Only messages that mention the user.
170    MentionsOnly,
171}
172
173/// One watched target, owned by one session.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct SlackSubscription {
176    pub id: Uuid,
177    /// The `[[slack]]` label this watches.
178    pub workspace: String,
179    /// Owning project. Named `project` and `team_role` to match
180    /// `GotifySubscription`; a rename would decode to `None` and silently
181    /// reroute every worker's subscription to the lead.
182    pub project: String,
183    pub team_role: Option<String>,
184    pub target: SlackSubscriptionTarget,
185    pub created_at: SystemTime,
186}
187
188/// A file as `files.info` reports it. `url_private` is a bearer fetch, not
189/// a URL, and `name` is whatever the uploader chose to call it - untrusted
190/// input as far as this code is concerned.
191#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
192pub struct SlackFile {
193    pub id: String,
194    #[serde(default)]
195    pub name: String,
196    #[serde(default)]
197    pub url_private: String,
198    /// Where the file was shared. The upload call answers no message `ts`,
199    /// so this is the only place a posted file's message is named.
200    #[serde(default, deserialize_with = "shares_or_null")]
201    pub shares: SlackFileShares,
202}
203
204/// Slack sends `shares: null` on a file it has not shared; that is not an
205/// object, and it reads as no shares rather than failing the whole file.
206fn shares_or_null<'de, D>(deserializer: D) -> Result<SlackFileShares, D::Error>
207where
208    D: serde::Deserializer<'de>,
209{
210    Option::<SlackFileShares>::deserialize(deserializer).map(Option::unwrap_or_default)
211}
212
213/// A file's shares, keyed by visibility and then by conversation id.
214#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
215pub struct SlackFileShares {
216    #[serde(default)]
217    pub public: HashMap<String, Vec<SlackFileShare>>,
218    #[serde(default)]
219    pub private: HashMap<String, Vec<SlackFileShare>>,
220}
221
222/// One message a file was shared as, in one conversation.
223#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
224pub struct SlackFileShare {
225    pub ts: String,
226}
227
228/// One hit from `search.messages`. `conversation_name` is absent on a DM,
229/// which has no name rather than an empty one. `user` is the author's id,
230/// which the delivered block carries - `username` is only a handle. A
231/// file-share with no text is a real hit, so the files ride along for
232/// delivery and `slack__attachment`.
233#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
234pub struct SlackSearchMatch {
235    pub ts: String,
236    #[serde(default)]
237    pub text: String,
238    pub conversation_id: String,
239    #[serde(default)]
240    pub conversation_name: Option<String>,
241    #[serde(default)]
242    pub username: Option<String>,
243    #[serde(default)]
244    pub user: Option<String>,
245    #[serde(default)]
246    pub thread_ts: Option<String>,
247    #[serde(default)]
248    pub files: Vec<SlackFile>,
249}
250
251/// One user as `users.info` and `users.list` report them. Every optional
252/// field is optional because Slack omits fields per user type rather than
253/// sending nulls.
254#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
255pub struct SlackUser {
256    pub id: String,
257    #[serde(default)]
258    pub name: String,
259    #[serde(default)]
260    pub real_name: Option<String>,
261    #[serde(default)]
262    pub tz: Option<String>,
263    /// A deactivated member keeps their row; they cannot be notified.
264    #[serde(default)]
265    pub deleted: bool,
266    #[serde(default)]
267    pub profile: Option<SlackUserProfile>,
268}
269
270/// The part of a user's profile a name lookup needs. `display_name` is what
271/// people call themselves, which is often neither the handle nor the legal
272/// name.
273#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
274pub struct SlackUserProfile {
275    #[serde(default)]
276    pub display_name: Option<String>,
277}
278
279/// One pinned message as `pins.list` reports it. Pins are messages, and
280/// Slack omits the message object on any other row, which carries nothing
281/// readable.
282#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
283pub struct SlackPin {
284    #[serde(default)]
285    pub created: u64,
286    #[serde(default)]
287    pub created_by: Option<String>,
288    #[serde(default)]
289    pub message: Option<SlackPinMessage>,
290}
291
292/// The message body of a pinned row.
293#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
294pub struct SlackPinMessage {
295    pub ts: String,
296    #[serde(default)]
297    pub user: Option<String>,
298    #[serde(default)]
299    pub text: String,
300}
301
302/// One channel bookmark as `bookmarks.list` reports it.
303#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
304pub struct SlackBookmark {
305    pub id: String,
306    #[serde(default)]
307    pub title: Option<String>,
308    #[serde(default)]
309    pub link: Option<String>,
310}
311
312/// One session's ownership of a followed thread, named after the
313/// `SlackSubscription` fields it is taken from.
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
315pub struct SlackThreadOwner {
316    pub project: String,
317    pub team_role: Option<String>,
318}
319
320/// What the store keeps about one followed thread: how far its replies have
321/// been read, the newest edit already handed over, and the sessions whose
322/// subscriptions put the thread on their radar.
323///
324/// The cursor is usually the string Slack sent, but not always: a thread
325/// forge starts tracking seeds it one microsecond below the reply that
326/// brought the thread in, since the cursor is exclusive. The per-tick read
327/// sends one microsecond below the cursor as the walk's lower bound, so a
328/// reply at the cursor is re-read for an edit; the periodic whole read passes
329/// none. It is compared as a string throughout.
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
331pub struct SlackThreadRecord {
332    pub cursor: String,
333    /// The newest edit stamp this thread has already handed over. Durable
334    /// with the row: held in memory only, a restart re-offers every
335    /// correction in the thread on the next whole read. A row written before
336    /// this field existed reads as `None` and is approximated by its cursor,
337    /// which drops an unjudged edit below that cursor and can re-offer an
338    /// already-handed one above it.
339    pub edit_watermark: Option<String>,
340    pub owners: Vec<SlackThreadOwner>,
341    /// Whether the user is in this thread, once that has been read off its
342    /// replies. Kept because a reply cannot un-happen: once true, every walk
343    /// can read past the cursor instead of reading the thread whole.
344    #[serde(default)]
345    pub participating: bool,
346}
347
348/// One followed thread as the pump sees it, minus the cursor it reads
349/// through its own port method.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct SlackFollowedThread {
352    pub parent_ts: String,
353    pub owners: Vec<SlackThreadOwner>,
354    /// Whether the user is already known to be in this thread.
355    pub participating: bool,
356}
357
358/// The thread a held draft replies into, named for the card: the parent's
359/// author and one flattened line of its text, so the reader is not asked to
360/// read a ts.
361#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
362pub struct SlackThreadName {
363    /// The parent's author: a bot's own name when the parent carries one,
364    /// else the resolved display name, else the raw user id when that lookup
365    /// fails. Never invented.
366    pub author: String,
367    /// The parent's text flattened to one line and cut at the last space
368    /// before the bound where there is one. Empty when the parent carried no
369    /// text.
370    pub excerpt: String,
371}
372
373/// One person a held draft tags. `name` is the resolved display name; `None`
374/// when the lookup failed, and the card draws the id rather than a guess.
375#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
376pub struct SlackPerson {
377    pub id: String,
378    pub name: Option<String>,
379}
380
381/// A composed but unsent Slack message. The workspace holds it until the
382/// user approves it in the dock prompt; nothing reaches Slack otherwise.
383#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
384pub struct SlackDraft {
385    pub id: Uuid,
386    pub workspace: String,
387    pub conversation: String,
388    /// Display name for the conversation, which the approval prompt shows
389    /// in place of the id: the DM partner's id when Slack names none.
390    pub conversation_label: String,
391    /// `None` posts a root message; `Some(ts)` replies into that thread.
392    pub thread_ts: Option<String>,
393    pub text: String,
394    /// What the draft would do, which the approval prompt's verbs follow:
395    /// the MCP tool alone cannot tell an edit from a delete, and the card
396    /// must not say "post" for either.
397    pub operation: SlackDraftOperation,
398    /// The thread named for the card. `None` on a root message, and `None`
399    /// when the parent could not be read: nothing is named that was not read.
400    #[serde(default)]
401    pub thread: Option<SlackThreadName>,
402    /// Every person the draft's text tags, in order of first appearance. A
403    /// mention the lookup could not name keeps its id, so no tag is dropped.
404    #[serde(default)]
405    pub tagged: Vec<SlackPerson>,
406}
407
408/// What a held draft would do in Slack.
409#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum SlackDraftOperation {
412    Post,
413    Edit,
414    Delete,
415    React,
416    Unreact,
417    Upload,
418}
419
420/// The reader's answer to a held draft, which is what the blocked call
421/// receives.
422#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
423#[serde(rename_all = "snake_case")]
424pub enum SlackDraftAnswer {
425    /// Let it go out.
426    Approve,
427    /// Do not send it.
428    Decline,
429    /// Do not send it, and hand the note back to the agent to revise.
430    SendBack { note: String },
431}
432
433/// What ended a held draft, which a view that did not answer it has no
434/// other way to learn.
435#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
436#[serde(rename_all = "snake_case")]
437pub enum SlackDraftEnding {
438    /// The reader answered it, in whichever view: `approved` says whether
439    /// the message went out.
440    Answered { approved: bool },
441    /// The reader sent it back with words for the agent; nothing went out.
442    SentBack,
443    /// The asking session went away before anyone answered.
444    Abandoned,
445}
446
447/// One message as the pump sees it, after matching. Carries what delivery
448/// needs and nothing the wire happened to include.
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct SlackMessage {
451    pub workspace: String,
452    pub conversation: String,
453    /// Conversation name for display; the DM partner's id when unnamed.
454    /// Not `Option`, so a caller never has to invent a fallback.
455    pub conversation_label: String,
456    pub ts: String,
457    /// `None` on a message with no thread at all. Equal to `ts` on a
458    /// thread's parent, and the parent's `ts` on a reply.
459    pub thread_ts: Option<String>,
460    pub user: Option<String>,
461    /// The name to display for the author, resolved by the connector: the
462    /// handle the search path sends, a bot's own name, or a `users.info`
463    /// lookup. `None` means no name is known, and `user` carries a raw id
464    /// that must never be shown as one.
465    pub author: Option<String>,
466    pub text: String,
467    /// Set on a reply, carrying the parent's author. This, not `thread_ts`,
468    /// is what marks a reply: a parent carries `thread_ts` too.
469    pub parent_user_id: Option<String>,
470    /// A parent's newest reply `ts`. Absent on a message with no replies.
471    pub latest_reply: Option<String>,
472    /// The `ts` of the message's newest edit, absent on one never edited.
473    /// An edit keeps the message's own `ts`, so this is what says the text
474    /// changed after it was sent - and what keeps a redelivered correction
475    /// distinct from a re-run of the delivery it corrects.
476    pub edited: Option<String>,
477    /// Files shared on the message. A file-share with no text is a real
478    /// message, and the ids are what `slack__attachment` fetches with.
479    pub files: Vec<SlackFile>,
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485
486    #[test]
487    fn a_token_never_reaches_the_config_debug_output() {
488        let config = SlackConfig {
489            workspace: "acme".to_owned(),
490            token: "xoxp-supersecret".to_owned(),
491            poll_seconds: 30,
492            dm_poll_seconds: 150,
493            thread_idle_days: 14,
494            stale_after_hours: 24,
495        };
496        let rendered = format!("{config:?}");
497        assert!(!rendered.contains("supersecret"), "token leaked: {rendered}");
498        assert!(rendered.contains("acme"), "the label stays readable: {rendered}");
499    }
500
501    /// Records written before conversation names were captured carry no
502    /// `name` key; dropping them at decode would silently unsubscribe
503    /// every session on restart.
504    #[test]
505    fn a_target_recorded_without_a_name_still_decodes() {
506        let old = r#"{"Conversation":{"id":"C0C0T5E6RM1","mode":"All"}}"#;
507        let target: SlackSubscriptionTarget =
508            serde_json::from_str(old).expect("the old shape decodes");
509        assert_eq!(
510            target,
511            SlackSubscriptionTarget::Conversation {
512                id: "C0C0T5E6RM1".to_owned(),
513                name: None,
514                mode: SlackWatchMode::All,
515            },
516            "a missing name decodes as None, never as a decode failure",
517        );
518    }
519
520    /// A send-back carries the reader's words, so the shape has to hold them
521    /// whole: a note flattened to a tag would reach the agent as a hint rather
522    /// than as what the reader said.
523    #[test]
524    fn a_draft_answer_carries_the_note_verbatim() {
525        let answer = SlackDraftAnswer::SendBack { note: "tone it down".to_owned() };
526        let encoded = serde_json::to_value(&answer).expect("an answer serialises");
527        assert_eq!(
528            encoded,
529            serde_json::json!({ "send_back": { "note": "tone it down" } }),
530            "the note rides the variant as its own field",
531        );
532        assert_eq!(
533            serde_json::from_value::<SlackDraftAnswer>(encoded).expect("and decodes back"),
534            answer,
535        );
536    }
537
538    /// The card's verbs are chosen from this field, and the client narrows an
539    /// unknown value to "post" - so every name is pinned: a renamed variant
540    /// would draw post copy for a delete with nothing else red.
541    #[test]
542    fn a_draft_operation_crosses_as_the_name_the_card_reads() {
543        for (operation, name) in [
544            (SlackDraftOperation::Post, "post"),
545            (SlackDraftOperation::Edit, "edit"),
546            (SlackDraftOperation::Delete, "delete"),
547            (SlackDraftOperation::React, "react"),
548            (SlackDraftOperation::Unreact, "unreact"),
549            (SlackDraftOperation::Upload, "upload"),
550        ] {
551            assert_eq!(
552                serde_json::to_value(operation).expect("an operation serialises"),
553                serde_json::json!(name),
554                "this is the name the card reads the operation from",
555            );
556        }
557    }
558
559    /// The card names the thread it replies into and the people it tags
560    /// instead of drawing a ts and raw ids. A draft recorded before those
561    /// fields existed still decodes - the frame is additive, and a client
562    /// older than it draws the line it always drew.
563    #[test]
564    fn a_draft_carries_its_thread_and_tags_and_an_older_draft_still_decodes() {
565        let draft = SlackDraft {
566            id: Uuid::nil(),
567            workspace: "acme".to_owned(),
568            conversation: "C1".to_owned(),
569            conversation_label: "staging".to_owned(),
570            thread_ts: Some("1758901234.482910".to_owned()),
571            text: "confirmed <@U2>".to_owned(),
572            operation: SlackDraftOperation::Post,
573            thread: Some(SlackThreadName {
574                author: "alice".to_owned(),
575                excerpt: "the staging deploy is green...".to_owned(),
576            }),
577            tagged: vec![
578                SlackPerson { id: "U2".to_owned(), name: Some("bob".to_owned()) },
579                SlackPerson { id: "U3".to_owned(), name: None },
580            ],
581        };
582        let encoded = serde_json::to_value(&draft).expect("a draft serialises");
583        assert_eq!(
584            encoded["thread"],
585            serde_json::json!({
586                "author": "alice",
587                "excerpt": "the staging deploy is green...",
588            }),
589            "the thread crosses as its author and one line of the parent",
590        );
591        assert_eq!(
592            encoded["tagged"],
593            serde_json::json!([
594                {"id": "U2", "name": "bob"},
595                {"id": "U3", "name": null},
596            ]),
597            "an unresolved mention crosses with no name rather than being dropped",
598        );
599        assert_eq!(serde_json::from_value::<SlackDraft>(encoded).expect("and decodes back"), draft,);
600
601        let older = serde_json::json!({
602            "id": Uuid::nil(),
603            "workspace": "acme",
604            "conversation": "C1",
605            "conversation_label": "staging",
606            "thread_ts": null,
607            "text": "hello",
608            "operation": "post",
609        });
610        let decoded: SlackDraft =
611            serde_json::from_value(older).expect("a draft recorded before the fields decodes");
612        assert_eq!(
613            (decoded.thread, decoded.tagged),
614            (None, Vec::new()),
615            "the absent fields read as no thread and no tags",
616        );
617    }
618
619    /// A view that did not answer still hears how the draft ended, and a
620    /// send-back is not a plain decline: the reader wrote something.
621    #[test]
622    fn a_sent_back_ending_crosses_as_its_own_name() {
623        assert_eq!(
624            serde_json::to_value(SlackDraftEnding::SentBack).expect("an ending serialises"),
625            serde_json::json!("sent_back"),
626        );
627    }
628}