Add optional AI generation, MQTT, OAuth refresh and calendar recurrence

This commit is contained in:
Pigway
2026-10-08 23:44:07 +08:00
parent 69a0841868
commit 8209aadbb8
24 changed files with 1113 additions and 37 deletions
@@ -2,6 +2,7 @@
import json
import re
import time
from pathlib import Path
from urllib.parse import urlencode, urlsplit
@@ -30,6 +31,29 @@ class CloudDrives:
for key in ["access_token", "refresh_token", "client_secret"]:
if value.get(key):
self.settings.additional_secrets.append(value[key])
if (
value.get("expires_at")
and float(value["expires_at"]) <= time.time() + 60
):
if source.credential_env:
raise FlowError(
E.CONFIG_REQUIRED,
"Environment OAuth token expired; update its secret",
409,
)
from pigway_frame_flow.providers.oauth import OAuthTokens
if not hasattr(self.photos, "oauth_tokens"):
self.photos.oauth_tokens = OAuthTokens(
self.settings, self.photos.assets
)
with self.photos.oauth_tokens.lock:
latest = json.loads(path.read_text())
value = (
latest
if float(latest.get("expires_at", 0)) > time.time() + 60
else self.photos.oauth_tokens.refresh(source.kind, latest, path)
)
return value
except (ValueError, TypeError, AttributeError):
raise FlowError(
+3 -20
View File
@@ -139,26 +139,9 @@ class ExternalProviders:
)
result: dict
if config.kind == "ics":
events = []
text = (
raw.decode("utf-8-sig").replace("\r\n ", "").replace("\r\n", "\n")
)
for block in text.split("BEGIN:VEVENT")[1:]:
block = block.split("END:VEVENT")[0]
values = {
line.split(":", 1)[0].split(";")[0]: line.split(":", 1)[1]
for line in block.splitlines()
if ":" in line
}
if "RRULE" in values:
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Recurring ICS events require a calendar provider with expanded instances",
422,
)
if values.get("SUMMARY"):
events.append(values["SUMMARY"].replace("\\n", " "))
result = {"events": events[:100]}
from pigway_frame_flow.providers.ical import calendar_events
result = calendar_events(raw)
elif config.kind == "google-calendar":
result = {
"events": [
+101
View File
@@ -0,0 +1,101 @@
"""Bounded read-only iCalendar expansion for the next seven days."""
import re
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo
from dateutil.rrule import rrulestr
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
def calendar_events(raw, now=None):
now = now or datetime.now(UTC)
end = now + timedelta(days=7)
text = re.sub(r"\r?\n[ \t]", "", raw.decode("utf-8-sig"))
blocks = text.split("BEGIN:VEVENT")[1:]
if len(blocks) > 2000:
raise FlowError(E.INVALID_REQUEST, "Calendar exceeds 2000 events")
def stamp(key, value):
zone = re.search(r"TZID=([^;:]+)", key)
tz = ZoneInfo(zone[1]) if zone else ZoneInfo("Asia/Shanghai")
if len(value) == 8:
return datetime.strptime(value, "%Y%m%d").replace(tzinfo=tz)
fmt = "%Y%m%dT%H%M%SZ" if value.endswith("Z") else "%Y%m%dT%H%M%S"
return datetime.strptime(value, fmt).replace(
tzinfo=UTC if value.endswith("Z") else tz
)
parsed: list[tuple] = []
for block in blocks:
props: dict[str, list[tuple[str, str]]] = {}
for line in block.split("END:VEVENT")[0].splitlines():
if ":" in line:
key, value = line.split(":", 1)
props.setdefault(key.split(";")[0], []).append((key, value))
if "DTSTART" not in props or "SUMMARY" not in props:
continue
start = stamp(*props["DTSTART"][0])
uid = props.get("UID", [(None, str(len(parsed)))])[0][1]
override = (
stamp(*props["RECURRENCE-ID"][0]) if "RECURRENCE-ID" in props else None
)
parsed.append((props, start, uid, override))
overrides = {
(uid, original.astimezone(UTC)) for _, _, uid, original in parsed if original
}
result = []
for props, start, uid, override in parsed:
if props.get("STATUS", [(None, "")])[0][1] == "CANCELLED":
continue
occurrences = [start]
if "RRULE" in props and not override:
rule = props["RRULE"][0][1]
if not re.search(r"(?:^|;)FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)", rule):
raise FlowError(
E.UNSUPPORTED_CAPABILITY,
"Calendar frequency must be daily or slower",
422,
)
if now.year - start.year > 150:
raise FlowError(
E.INVALID_REQUEST, "Calendar recurrence starts too far in the past"
)
occurrences = rrulestr(rule, dtstart=start).between(
now - timedelta(days=1), end, inc=True
)
for key, value in props.get("RDATE", []):
occurrences.extend(stamp(key, item) for item in value.split(","))
excluded = {
stamp(key, item).astimezone(UTC)
for key, value in props.get("EXDATE", [])
for item in value.split(",")
}
title = (
props["SUMMARY"][0][1]
.replace("\\n", " ")
.replace("\\,", ",")
.replace("\\;", ";")
.replace("\\\\", "\\")
)
all_day = len(props["DTSTART"][0][1]) == 8
for occurrence in sorted(set(occurrences)):
instant = occurrence.astimezone(UTC)
lower = (
now.astimezone(occurrence.tzinfo).replace(
hour=0, minute=0, second=0, microsecond=0
)
if all_day
else now
)
if instant in excluded or (not override and (uid, instant) in overrides):
continue
if lower <= instant < end:
result.append({"title": title, "start": occurrence.isoformat()})
if len(result) > 1000:
raise FlowError(
E.INVALID_REQUEST, "Calendar expansion exceeds 1000 instances"
)
return {"events": sorted(result, key=lambda x: x["start"])}
+90
View File
@@ -0,0 +1,90 @@
"""Fixed-provider OAuth refresh. No secret payloads in durable operation history."""
import json
import threading
import time
from urllib.parse import urlencode
from pigway_frame_flow.errors import ErrorCode as E
from pigway_frame_flow.errors import FlowError
from pigway_frame_flow.extensions.secrets import save_secret
class OAuthTokens:
ENDPOINTS = {
"google-drive": "https://oauth2.googleapis.com/token",
"alipan": "https://openapi.aliyundrive.com/oauth/access_token",
"baidu": "https://openapi.baidu.com/oauth/2.0/token",
}
def __init__(self, settings, assets):
self.settings, self.assets = settings, assets
self.lock = threading.RLock()
def refresh(self, kind, credentials, path=None):
with self.lock:
if not all(
credentials.get(key)
for key in ("refresh_token", "client_id", "client_secret")
):
raise FlowError(
E.CONFIG_REQUIRED,
"OAuth refresh requires client ID, client secret and refresh token",
409,
)
values = {
key: credentials[key]
for key in ("refresh_token", "client_id", "client_secret")
}
values["grant_type"] = "refresh_token"
for key in ("access_token", "refresh_token", "client_secret"):
if credentials.get(key):
self.settings.additional_secrets.append(credentials[key])
endpoint = self.ENDPOINTS[kind]
host = endpoint.split("/")[2]
if host not in self.settings.image_url_hosts:
self.settings.image_url_hosts.append(host)
as_json = kind == "alipan"
body = (
json.dumps(values).encode() if as_json else urlencode(values).encode()
)
try:
raw = self.assets.fetch_url(
endpoint,
method="POST",
headers={
"Content-Type": "application/json"
if as_json
else "application/x-www-form-urlencoded"
},
body=body,
limit=65536,
)
response = json.loads(raw)
if (
not isinstance(response.get("access_token"), str)
or not response["access_token"]
):
raise ValueError()
expiry = int(response["expires_in"])
if not 60 <= expiry <= 366 * 86400:
raise ValueError()
updated = {
**credentials,
"access_token": response["access_token"],
"expires_at": time.time() + expiry,
}
if response.get("refresh_token"):
updated["refresh_token"] = response["refresh_token"]
for key in ("access_token", "refresh_token"):
if updated.get(key):
self.settings.additional_secrets.append(updated[key])
if path:
save_secret(path, json.dumps(updated))
return updated
except Exception:
raise FlowError(
E.CLOUD_UNAVAILABLE,
"OAuth refresh failed; reconnect or refresh credentials manually",
502,
) from None