foukoapi 0.1.0-alpha.1

Cross-platform bot framework in Rust. Write your handlers once, run the same bot on Telegram and Discord with shared accounts, embeds, keyboards and SQLite storage.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Cross-platform account linking.
//!
//! Two flows live here side by side:
//!
//! 1. One-step flow (recommended for chat UIs):
//!    - Platform A: `/link` -> 6-char link_code (Accounts::start_link).
//!    - Platform B: `/link LINK_CODE` -> accounts linked immediately, B
//!      is primary by default (Accounts::redeem_link). After that the
//!      user flips primary with [`Accounts::set_primary`] if they want.
//!
//! 2. Two-step flow (kept for backwards-compat, safer):
//!    - Platform A: `/link` -> link_code (Accounts::start_link).
//!    - Platform B: `/link LINK_CODE` -> 4-digit confirm_code
//!      (Accounts::begin_confirm). Nothing linked yet.
//!    - Platform A: `/confirm CONFIRM_CODE` -> accounts linked, A is
//!      primary (Accounts::complete_confirm).

use crate::{platform::PlatformKind, storage::Storage, Error, Result};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

/// Account-linking helper.
#[derive(Clone)]
pub struct Accounts {
    storage: Arc<dyn Storage>,
    /// TTL for both the link code and the confirm code, in seconds.
    pub ttl_seconds: u64,
}

impl Accounts {
    /// Wrap any concrete Storage impl.
    pub fn new<S: Storage>(storage: S) -> Self {
        Self::with_arc(Arc::new(storage))
    }

    /// Wrap an already-arced storage handle.
    pub fn with_arc(storage: Arc<dyn Storage>) -> Self {
        Self {
            storage,
            ttl_seconds: 5 * 60,
        }
    }

    /// Override code TTL (default 5 minutes).
    pub fn with_ttl(mut self, ttl_seconds: u64) -> Self {
        self.ttl_seconds = ttl_seconds;
        self
    }

    /// Raw handle to the underlying storage. Used internally by the
    /// built-in `/link` flow; exposed so bots can share the same store.
    pub fn storage_ref(&self) -> &Arc<dyn Storage> {
        &self.storage
    }

    /// Step 1: initiator on platform A gets a 6-char link code.
    pub async fn start_link(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
        let link_code = random_chars(6);
        let value = format!("{}|{}", ident(platform, user_id), now_secs());
        self.storage.set(&key_link(&link_code), &value).await?;
        Ok(link_code)
    }

    /// One-step redeem: swap a link code issued by [`Accounts::start_link`]
    /// for a real link, no extra confirm step. Returns the resulting pair
    /// with the redeeming side (`partner`) chosen as the primary, which
    /// the user can later flip with [`Accounts::set_primary`].
    ///
    /// This is what bots with inline-button UIs usually want: one command,
    /// two taps to pick primary. The more conservative two-step flow
    /// ([`Accounts::begin_confirm`] + [`Accounts::complete_confirm`]) is
    /// still there for anyone who needs it.
    pub async fn redeem_link(
        &self,
        link_code: &str,
        platform: PlatformKind,
        user_id: &str,
    ) -> Result<LinkResult> {
        let link_code = link_code.trim().to_ascii_uppercase();
        let record = self
            .storage
            .get(&key_link(&link_code))
            .await?
            .ok_or_else(|| Error::Other("invalid or expired link code".into()))?;
        let (initiator, issued_at) = parse_ident_ts(&record)?;
        if self.is_expired(issued_at) {
            let _ = self.storage.del(&key_link(&link_code)).await;
            return Err(Error::Other("link code has expired".into()));
        }

        let me = ident(platform, user_id);
        if me == initiator {
            return Err(Error::Other(
                "cannot link an account to itself - use the code on the OTHER platform".into(),
            ));
        }

        // Nobody gets two partners at once. If either side is already
        // linked, unlink them first so the new pairing is clean.
        if self.storage.get(&key_pair(&me)).await?.is_some() {
            return Err(Error::Other(
                "this account is already linked - /unlink first".into(),
            ));
        }
        if self.storage.get(&key_pair(&initiator)).await?.is_some() {
            return Err(Error::Other(
                "the other account is already linked - ask them to /unlink first".into(),
            ));
        }

        let _ = self.storage.del(&key_link(&link_code)).await;
        self.storage.set(&key_pair(&me), &initiator).await?;
        self.storage.set(&key_pair(&initiator), &me).await?;
        // Default primary: the redeeming side. The user can flip it with
        // set_primary right after tapping a button.
        self.storage.set(&key_primary(&me), &me).await?;
        self.storage.set(&key_primary(&initiator), &me).await?;
        Ok(LinkResult {
            primary: me,
            partner: initiator,
        })
    }

    /// Flip the primary account for a linked pair. `chosen` must be one
    /// of the two identities already in the link. Fast-path replacement
    /// for the older [`Accounts::propose_primary`] +
    /// [`Accounts::commit_primary`] dance, meant for button flows where
    /// the tap itself is the confirmation.
    pub async fn set_primary(
        &self,
        platform: PlatformKind,
        user_id: &str,
        chosen: &str,
    ) -> Result<()> {
        let me = ident(platform, user_id);
        let partner = self
            .storage
            .get(&key_pair(&me))
            .await?
            .ok_or_else(|| Error::Other("you have no linked account yet".into()))?;
        if chosen != me && chosen != partner {
            return Err(Error::Other(
                "that identity is not part of your link".into(),
            ));
        }
        self.storage.set(&key_primary(&me), chosen).await?;
        self.storage.set(&key_primary(&partner), chosen).await?;
        Ok(())
    }

    /// Step 2 (two-step flow): platform B redeems the link code and gets a 4-digit confirm code.
    pub async fn begin_confirm(
        &self,
        link_code: &str,
        platform: PlatformKind,
        user_id: &str,
    ) -> Result<String> {
        let link_code = link_code.trim().to_ascii_uppercase();
        let record = self
            .storage
            .get(&key_link(&link_code))
            .await?
            .ok_or_else(|| Error::Other("invalid or expired link code".into()))?;
        let (initiator, issued_at) = parse_ident_ts(&record)?;
        if self.is_expired(issued_at) {
            let _ = self.storage.del(&key_link(&link_code)).await;
            return Err(Error::Other("link code has expired".into()));
        }

        let me = ident(platform, user_id);
        if me == initiator {
            return Err(Error::Other(
                "cannot link an account to itself - use the code on the OTHER platform".into(),
            ));
        }

        let _ = self.storage.del(&key_link(&link_code)).await;

        let confirm_code = random_digits(4);
        let value = format!("{initiator}|{me}|{}", now_secs());
        self.storage
            .set(&key_pending(&initiator, &confirm_code), &value)
            .await?;
        Ok(confirm_code)
    }

    /// Step 3: initiator on platform A confirms the 4-digit code.
    pub async fn complete_confirm(
        &self,
        confirm_code: &str,
        platform: PlatformKind,
        user_id: &str,
    ) -> Result<LinkResult> {
        let me = ident(platform, user_id);
        let confirm_code = confirm_code.trim().to_owned();
        let raw = self
            .storage
            .get(&key_pending(&me, &confirm_code))
            .await?
            .ok_or_else(|| {
                Error::Other("wrong confirm code - ask the other platform for a fresh one".into())
            })?;
        let mut parts = raw.splitn(3, '|');
        let initiator = parts.next().unwrap_or("").to_owned();
        let partner = parts.next().unwrap_or("").to_owned();
        let issued_at: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
        if self.is_expired(issued_at) {
            let _ = self.storage.del(&key_pending(&me, &confirm_code)).await;
            return Err(Error::Other("confirm code has expired".into()));
        }
        if initiator != me {
            return Err(Error::Other(
                "this confirm code belongs to a different account".into(),
            ));
        }

        self.storage.set(&key_pair(&me), &partner).await?;
        self.storage.set(&key_pair(&partner), &me).await?;
        // Initial primary defaults to the initiator. The user can change it
        // with propose_primary + commit_primary right after.
        self.storage.set(&key_primary(&me), &me).await?;
        self.storage.set(&key_primary(&partner), &me).await?;

        let _ = self.storage.del(&key_pending(&me, &confirm_code)).await;
        Ok(LinkResult {
            primary: me.clone(),
            partner: partner.clone(),
        })
    }

    /// Propose a new primary account for a linked pair. Returns a short
    /// 4-digit confirmation code; the same caller must then pass it to
    /// [`Accounts::commit_primary`] to actually change anything.
    ///
    /// `chosen_identity` must be one of the two currently linked identities.
    pub async fn propose_primary(
        &self,
        platform: PlatformKind,
        user_id: &str,
        chosen_identity: &str,
    ) -> Result<String> {
        let me = ident(platform, user_id);
        let partner = self
            .storage
            .get(&key_pair(&me))
            .await?
            .ok_or_else(|| Error::Other("you have no linked account yet".into()))?;
        if chosen_identity != me && chosen_identity != partner {
            return Err(Error::Other(
                "that identity is not part of your link".into(),
            ));
        }
        let code = random_digits(4);
        let value = format!("{me}|{chosen_identity}|{}", now_secs());
        self.storage
            .set(&key_primary_pending(&me, &code), &value)
            .await?;
        Ok(code)
    }

    /// Commit the primary change after double-checking the code issued by
    /// [`Accounts::propose_primary`].
    pub async fn commit_primary(
        &self,
        platform: PlatformKind,
        user_id: &str,
        code: &str,
    ) -> Result<String> {
        let me = ident(platform, user_id);
        let code = code.trim().to_owned();
        let raw = self
            .storage
            .get(&key_primary_pending(&me, &code))
            .await?
            .ok_or_else(|| Error::Other("wrong or expired primary-change code".into()))?;
        let mut parts = raw.splitn(3, '|');
        let owner = parts.next().unwrap_or("").to_owned();
        let chosen = parts.next().unwrap_or("").to_owned();
        let issued_at: u64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
        if self.is_expired(issued_at) {
            let _ = self.storage.del(&key_primary_pending(&me, &code)).await;
            return Err(Error::Other("primary-change code expired".into()));
        }
        if owner != me {
            return Err(Error::Other(
                "that code was issued for another account".into(),
            ));
        }
        let partner = self
            .storage
            .get(&key_pair(&me))
            .await?
            .ok_or_else(|| Error::Other("link vanished while you were thinking".into()))?;

        self.storage.set(&key_primary(&me), &chosen).await?;
        self.storage.set(&key_primary(&partner), &chosen).await?;
        let _ = self.storage.del(&key_primary_pending(&me, &code)).await;
        Ok(chosen)
    }

    /// Partner identity (if any) for (platform, user_id).
    pub async fn partner_for(
        &self,
        platform: PlatformKind,
        user_id: &str,
    ) -> Result<Option<String>> {
        self.storage.get(&key_pair(&ident(platform, user_id))).await
    }

    /// Primary identity for (platform, user_id). Falls back to self when not linked.
    pub async fn primary_for(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
        let me = ident(platform, user_id);
        match self.storage.get(&key_primary(&me)).await? {
            Some(p) => Ok(p),
            None => Ok(me),
        }
    }

    /// Return the language code saved for this user (via their primary
    /// identity). Defaults to `"en"` if nothing is stored yet.
    pub async fn lang_for(&self, platform: PlatformKind, user_id: &str) -> Result<String> {
        let primary = self.primary_for(platform, user_id).await?;
        Ok(self
            .storage
            .get(&key_lang(&primary))
            .await?
            .unwrap_or_else(|| "en".to_owned()))
    }

    /// Change the language code for this user. Stored under the primary
    /// identity so every linked platform shares the same language.
    pub async fn set_lang(&self, platform: PlatformKind, user_id: &str, lang: &str) -> Result<()> {
        let primary = self.primary_for(platform, user_id).await?;
        self.storage.set(&key_lang(&primary), lang).await
    }

    /// Break the link for (platform, user_id). Returns the partner that was unlinked.
    pub async fn unlink(&self, platform: PlatformKind, user_id: &str) -> Result<Option<String>> {
        let me = ident(platform, user_id);
        let partner = self.storage.get(&key_pair(&me)).await?;
        if let Some(other) = partner.as_ref() {
            let _ = self.storage.del(&key_pair(&me)).await;
            let _ = self.storage.del(&key_pair(other)).await;
            let _ = self.storage.del(&key_primary(&me)).await;
            let _ = self.storage.del(&key_primary(other)).await;
        }
        Ok(partner)
    }

    fn is_expired(&self, issued_at: u64) -> bool {
        now_secs().saturating_sub(issued_at) > self.ttl_seconds
    }
}

/// Result of a successful complete_confirm.
#[derive(Debug, Clone)]
pub struct LinkResult {
    /// The identity picked as primary (always the initiator).
    pub primary: String,
    /// The identity linked to the primary.
    pub partner: String,
}

fn ident(platform: PlatformKind, user_id: &str) -> String {
    format!("{platform}:{user_id}")
}
fn key_link(code: &str) -> String {
    format!("foukoapi:link:code:{code}")
}
fn key_pending(owner: &str, confirm_code: &str) -> String {
    format!("foukoapi:link:pending:{owner}:{confirm_code}")
}
fn key_pair(ident: &str) -> String {
    format!("foukoapi:link:pair:{ident}")
}
fn key_primary(ident: &str) -> String {
    format!("foukoapi:link:primary:{ident}")
}
fn key_primary_pending(owner: &str, code: &str) -> String {
    format!("foukoapi:link:primary_pending:{owner}:{code}")
}
fn key_lang(primary: &str) -> String {
    format!("foukoapi:user:{primary}:lang")
}

fn parse_ident_ts(s: &str) -> Result<(String, u64)> {
    s.split_once('|')
        .and_then(|(a, b)| b.parse::<u64>().ok().map(|ts| (a.to_owned(), ts)))
        .ok_or_else(|| Error::Other("corrupted link record".into()))
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

fn random_chars(len: usize) -> String {
    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
    let mut seed: u64 = now_secs().wrapping_mul(0x9E3779B97F4A7C15) ^ 0xDEADBEEFCAFEBABE;
    let mut out = String::with_capacity(len);
    for _ in 0..len {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        out.push(ALPHABET[(seed as usize) % ALPHABET.len()] as char);
    }
    out
}
fn random_digits(len: usize) -> String {
    let mut seed: u64 = now_secs().wrapping_mul(0xDEADBEEF_CAFEF00D) ^ 0x1234_5678_9ABC_DEF0;
    let mut out = String::with_capacity(len);
    for _ in 0..len {
        seed ^= seed << 13;
        seed ^= seed >> 7;
        seed ^= seed << 17;
        out.push(char::from_digit((seed % 10) as u32, 10).unwrap_or('0'));
    }
    out
}