File size: 2,516 Bytes
34b0226
 
bdd1640
34b0226
 
bdd1640
 
34b0226
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
34b0226
bdd1640
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bdd1640
 
 
 
 
34b0226
 
 
 
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
"""Publishing to X, LinkedIn and Bluesky. Each platform module exposes:

    publish(db, account, posts: list[str], media: list[Media] | None) -> (external_id, external_url)
    metrics(db, account, external_id) -> dict

A list with more than one post is published as a thread (reply chain) where the platform supports it. Media (images or
one video, from social.media) goes on the first post; only X and LinkedIn take it.
"""

from datetime import UTC, datetime

from sqlalchemy.orm import Session

from app.models import SocialAccount
from app.services.crypto import decrypt, encrypt


class SocialError(RuntimeError):
    """permanent: don't retry this post. reconnect: the account's connection is gone (expired, revoked, refused):
    the account is marked down and its scheduled posts pause until it is reconnected."""

    def __init__(self, message: str, permanent: bool = False, reconnect: bool = False):
        super().__init__(message)
        self.permanent = permanent or reconnect
        self.reconnect = reconnect


PLATFORM_LABELS = {"x": "X", "linkedin": "LinkedIn", "bluesky": "Bluesky", "substack_notes": "Substack Notes"}
AUTO_POST = {"x", "linkedin", "bluesky"}
LIMITS = {"x": 280, "bluesky": 300, "linkedin": 3000, "substack_notes": 2000}


def token(account: SocialAccount) -> str:
    value = decrypt(account.access_token)
    if not value:
        raise SocialError("Stored credentials could not be read. Reconnect the account.", permanent=True)
    return value


def save_tokens(db: Session, account: SocialAccount, access: str, refresh: str | None, expires_in: int | None) -> None:
    account.access_token = encrypt(access)
    if refresh:
        account.refresh_token = encrypt(refresh)
    if expires_in:
        account.expires_at = datetime.fromtimestamp(datetime.now(UTC).timestamp() + int(expires_in), UTC)
    account.status = "active"
    db.commit()


def expired(account: SocialAccount) -> bool:
    if not account.expires_at:
        return False
    exp = account.expires_at if account.expires_at.tzinfo else account.expires_at.replace(tzinfo=UTC)
    return exp.timestamp() - 120 < datetime.now(UTC).timestamp()


def scope_set(scopes: str | None) -> set[str]:
    """Granted scopes, whether the platform listed them with spaces or commas."""
    return {s for s in (scopes or "").replace(",", " ").split() if s}


def module(platform: str):
    from app.services.social import bluesky, linkedin, x

    return {"x": x, "linkedin": linkedin, "bluesky": bluesky}[platform]