foukoapi 0.1.2-alpha.1

Cross-platform bot framework in Rust: one codebase, many platforms. Shared accounts, embeds, keyboards, economy, i18n and pluggable storage; Telegram and Discord adapters included.
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
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
//! 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).
    ///
    /// Self-healing: a crash between the two pair writes in
    /// [`Accounts::redeem_link`] can leave a one-sided link. If we see our
    /// side but the partner's back-reference is missing, repair it here.
    pub async fn partner_for(
        &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(p) = partner.as_deref() {
            if self.storage.get(&key_pair(p)).await?.is_none() {
                tracing::warn!(me = %me, partner = %p, "accounts: repairing one-sided link");
                self.storage.set(&key_pair(p), &me).await?;
            }
        }
        Ok(partner)
    }

    /// 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 = entropy_seed();
    let mut out = String::with_capacity(len);
    for _ in 0..len {
        seed = xorshift(seed);
        out.push(ALPHABET[(seed as usize) % ALPHABET.len()] as char);
    }
    out
}
fn random_digits(len: usize) -> String {
    let mut seed = entropy_seed();
    let mut out = String::with_capacity(len);
    for _ in 0..len {
        seed = xorshift(seed);
        out.push(char::from_digit((seed % 10) as u32, 10).unwrap_or('0'));
    }
    out
}

fn xorshift(mut seed: u64) -> u64 {
    seed ^= seed << 13;
    seed ^= seed >> 7;
    seed ^= seed << 17;
    seed
}

/// Seed a code generator without pulling in a crypto RNG dependency.
/// Mixes wall-clock nanos, the process id and a global call counter
/// through several hasher rounds, so codes issued in the same second
/// (or across restarts) don't collide or line up predictably.
fn entropy_seed() -> u64 {
    use std::hash::{Hash, Hasher};
    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.as_nanos())
        .unwrap_or(0);
    let count = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    let mut seed: u64 = 0x9E37_79B9_7F4A_7C15;
    for round in 0..3u64 {
        let mut h = std::collections::hash_map::DefaultHasher::new();
        seed.hash(&mut h);
        nanos.hash(&mut h);
        std::process::id().hash(&mut h);
        count.hash(&mut h);
        round.hash(&mut h);
        seed = h.finish();
    }
    // Never hand xorshift a zero state.
    seed | 1
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::storage::MemoryStorage;

    #[test]
    fn codes_issued_back_to_back_differ() {
        // The old seconds-based seed made same-second codes identical.
        let a = random_chars(6);
        let b = random_chars(6);
        assert_ne!(a, b);
        let c = random_digits(4);
        let d = random_digits(4);
        // Digits have a small space; only assert the generator state moved.
        assert!(c.len() == 4 && d.len() == 4);
    }

    #[tokio::test]
    async fn partner_for_repairs_one_sided_link() {
        let storage = MemoryStorage::new();
        let accounts = Accounts::new(storage.clone());
        // Simulate a crash between the two pair writes: only A -> B exists.
        storage
            .set(&key_pair("telegram:1"), "discord:2")
            .await
            .unwrap();
        let partner = accounts
            .partner_for(PlatformKind::Telegram, "1")
            .await
            .unwrap();
        assert_eq!(partner.as_deref(), Some("discord:2"));
        // The read healed the reverse edge.
        assert_eq!(
            storage
                .get(&key_pair("discord:2"))
                .await
                .unwrap()
                .as_deref(),
            Some("telegram:1")
        );
    }
}