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}