-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtoken_refresher.py
More file actions
139 lines (119 loc) · 5.34 KB
/
Copy pathtoken_refresher.py
File metadata and controls
139 lines (119 loc) · 5.34 KB
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
# token_refresher.py — keeps Threads long-lived tokens alive.
#
# Meta: a long-lived token lasts 60 days and can be refreshed once it is >= 24 h old
# and not yet expired; a refresh returns a NEW token valid for another 60 days.
# A token that is not refreshed within 60 days dies permanently.
# https://developers.facebook.com/docs/threads/get-started/long-lived-tokens/
import html
import threading
from typing import Optional
import requests
import config
from db_utils import get_db
stop_event = threading.Event()
REFRESH_URL = "https://graph.threads.net/refresh_access_token"
REFRESH_AFTER_DAYS = 20 # refresh when token is older than this (leaves ~40 days of retries)
CHECK_INTERVAL_SEC = 6 * 3600
OAUTH_INVALID_CODE = 190 # Graph API: "invalid / expired / revoked access token"
def _notify_user(tg_user_id: Optional[str], text: str) -> None:
"""Best-effort Telegram message; the web process has no aiogram Bot, so plain HTTP."""
if not config.TG_BOT_TOKEN or not tg_user_id:
return
try:
requests.post(
f"https://api.telegram.org/bot{config.TG_BOT_TOKEN}/sendMessage",
json={"chat_id": tg_user_id, "text": text, "parse_mode": "HTML"},
timeout=10,
)
except requests.RequestException:
pass
def _refresh_one(token: str, proxy: Optional[str], age_days: Optional[float]):
"""
Returns (outcome, value):
("ok", new_token) – refreshed
("revoked", message) – token is permanently invalid, user must reconnect
("retry", message) – transient / unknown problem, try again next cycle
"""
proxies = {"http": proxy, "https": proxy} if proxy else None
try:
resp = requests.get(
REFRESH_URL,
params={"grant_type": "th_refresh_token", "access_token": token},
proxies=proxies,
timeout=20,
)
except requests.RequestException as exc:
return "retry", f"network error: {exc.__class__.__name__}"
try:
data = resp.json()
except ValueError:
data = {}
if not isinstance(data, dict):
data = {}
new_token = data.get("access_token")
if resp.ok and new_token:
return "ok", new_token
err = data.get("error") if isinstance(data.get("error"), dict) else {}
message = f"HTTP {resp.status_code}: {str(err.get('message') or resp.text)[:200]}"
if err.get("code") == OAUTH_INVALID_CODE:
return "revoked", message
# Older than 60 days and refusing → it has expired for good
if age_days is not None and age_days >= 60 and 400 <= resp.status_code < 500:
return "revoked", message
return "retry", message
def refresh_due_tokens() -> dict:
stats = {"ok": 0, "revoked": 0, "retry": 0}
with get_db() as conn:
rows = conn.execute(
"""
SELECT id, name, tg_user_id, access_token, proxy,
CASE WHEN token_updated_at IS NULL THEN NULL
ELSE julianday('now') - julianday(token_updated_at) END AS age_days
FROM accounts
WHERE access_token IS NOT NULL AND access_token != ''
AND (token_status IS NULL OR token_status != 'revoked')
AND (token_updated_at IS NULL OR token_updated_at < datetime('now', ?))
""",
(f"-{REFRESH_AFTER_DAYS} days",),
).fetchall()
for row in rows:
if stop_event.is_set():
break
outcome, value = _refresh_one(row["access_token"], row["proxy"], row["age_days"])
stats[outcome] += 1
with get_db() as conn:
if outcome == "ok":
# Compare-and-set: don't clobber a token the user replaced meanwhile
conn.execute(
"UPDATE accounts SET access_token = ?, token_updated_at = datetime('now'), "
"token_status = 'ok' WHERE id = ? AND access_token = ?",
(value, row["id"], row["access_token"]),
)
print(f"🔄 [Tokens] refreshed account {row['id']} ({row['name']})")
elif outcome == "revoked":
conn.execute(
"UPDATE accounts SET is_active = 0, token_status = 'revoked' WHERE id = ?",
(row["id"],),
)
print(f"🚫 [Tokens] account {row['id']} ({row['name']}) token invalid: {value}")
_notify_user(
row["tg_user_id"],
f"⚠️ Токен Threads-аккаунта <b>{html.escape(row['name'] or '')}</b> "
f"больше не действует, аккаунт отключён.\n"
f"Откройте панель и подключите его заново.",
)
else:
print(f"⏳ [Tokens] account {row['id']} ({row['name']}) will retry: {value}")
stop_event.wait(1) # be gentle with the API
return stats
def token_refresh_loop() -> None:
print("🔄 [Tokens] refresher started")
stop_event.wait(60) # let the app finish starting
while not stop_event.is_set():
try:
stats = refresh_due_tokens()
if any(stats.values()):
print(f"🔄 [Tokens] cycle done: {stats}")
except Exception as exc:
print(f"❌ [Tokens] cycle error: {exc}")
stop_event.wait(CHECK_INTERVAL_SEC)