Add a Blogger delivery channel with one-time OAuth setup
BloggerDeliverer posts each briefing (markdown -> HTML, H1 becomes the post title with the report headline) via the Blogger API v3 using a long-lived refresh token; `us2kr blogger-auth` runs the loopback OAuth flow once and prints the BLOGGER_* lines for .env. Enabled only when all four BLOGGER_* settings are present. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+120
@@ -85,6 +85,16 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
notify.add_argument("--message", default="US2KR test notification", help="text to send")
|
||||
|
||||
sub.add_parser("show-config", help="print effective settings (secrets masked)")
|
||||
|
||||
auth = sub.add_parser(
|
||||
"blogger-auth",
|
||||
help="one-time OAuth flow for the Blogger channel (prints BLOGGER_* lines for .env)",
|
||||
)
|
||||
auth.add_argument("--client-id", default=None, help="OAuth client id (default: BLOGGER_CLIENT_ID)")
|
||||
auth.add_argument(
|
||||
"--client-secret", default=None, help="OAuth client secret (default: BLOGGER_CLIENT_SECRET)"
|
||||
)
|
||||
auth.add_argument("--timeout", type=int, default=300, help="seconds to wait for the browser consent")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -140,16 +150,126 @@ def cmd_show_config(_args: argparse.Namespace, settings: Settings) -> int:
|
||||
print(f"{name} = {shown}")
|
||||
print(f"db_path = {settings.db_path}")
|
||||
print(f"telegram_enabled = {settings.telegram_enabled}")
|
||||
print(f"blogger_enabled = {settings.blogger_enabled}")
|
||||
print(f"project_root = {PROJECT_ROOT}")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
def cmd_blogger_auth(args: argparse.Namespace, settings: Settings) -> int:
|
||||
"""Run the loopback OAuth flow once; print the BLOGGER_* env lines and the user's blog ids."""
|
||||
import threading
|
||||
import urllib.parse
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from secrets import token_urlsafe
|
||||
|
||||
import httpx
|
||||
|
||||
client_id = args.client_id or settings.blogger_client_id
|
||||
client_secret = args.client_secret or settings.blogger_client_secret
|
||||
if not (client_id and client_secret):
|
||||
print(
|
||||
"error: pass --client-id/--client-secret or set BLOGGER_CLIENT_ID / BLOGGER_CLIENT_SECRET",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return EXIT_ERROR
|
||||
|
||||
state = token_urlsafe(16)
|
||||
received: dict[str, str] = {}
|
||||
done = threading.Event()
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self) -> None: # http.server API naming
|
||||
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
||||
received.update({k: v[0] for k, v in query.items()})
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write("<h3>인증 완료 — 터미널로 돌아가세요.</h3>".encode())
|
||||
done.set()
|
||||
|
||||
def log_message(self, *_args: object) -> None: # keep the terminal clean
|
||||
pass
|
||||
|
||||
server = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
port = server.server_address[1]
|
||||
redirect_uri = f"http://127.0.0.1:{port}"
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
|
||||
auth_url = "https://accounts.google.com/o/oauth2/v2/auth?" + urllib.parse.urlencode(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "https://www.googleapis.com/auth/blogger",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent", # force a refresh_token even if previously consented
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
print("브라우저에서 Google 계정으로 승인하세요 (자동으로 열리지 않으면 URL을 직접 여세요):\n")
|
||||
print(auth_url + "\n")
|
||||
webbrowser.open(auth_url)
|
||||
|
||||
try:
|
||||
if not done.wait(timeout=args.timeout):
|
||||
print("error: timed out waiting for the browser consent", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
finally:
|
||||
server.shutdown()
|
||||
if received.get("state") != state:
|
||||
print("error: OAuth state mismatch - try again", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
code = received.get("code")
|
||||
if not code:
|
||||
print(f"error: consent denied ({received.get('error', 'no code returned')})", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
token_resp = client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
print(f"error: token exchange failed: {token_resp.text[:300]}", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
tokens = token_resp.json()
|
||||
refresh_token = tokens.get("refresh_token")
|
||||
if not refresh_token:
|
||||
print("error: no refresh_token returned - remove the app's prior access and retry", file=sys.stderr)
|
||||
return EXIT_ERROR
|
||||
|
||||
blogs_resp = client.get(
|
||||
"https://www.googleapis.com/blogger/v3/users/self/blogs",
|
||||
headers={"Authorization": f"Bearer {tokens['access_token']}"},
|
||||
)
|
||||
blogs = blogs_resp.json().get("items", []) if blogs_resp.status_code == 200 else []
|
||||
|
||||
print("\n이 계정의 블로그:")
|
||||
for blog in blogs:
|
||||
print(f" - {blog.get('name')} id={blog.get('id')} url={blog.get('url')}")
|
||||
blog_id = blogs[0]["id"] if len(blogs) == 1 else "<위 목록에서 선택>"
|
||||
print("\n.env에 추가할 내용:\n")
|
||||
print(f"BLOGGER_CLIENT_ID={client_id}")
|
||||
print(f"BLOGGER_CLIENT_SECRET={client_secret}")
|
||||
print(f"BLOGGER_REFRESH_TOKEN={refresh_token}")
|
||||
print(f"BLOGGER_BLOG_ID={blog_id}")
|
||||
return EXIT_OK
|
||||
|
||||
|
||||
COMMANDS = {
|
||||
"run-once": cmd_run_once,
|
||||
"daemon": cmd_daemon,
|
||||
"next-run": cmd_next_run,
|
||||
"test-notify": cmd_test_notify,
|
||||
"show-config": cmd_show_config,
|
||||
"blogger-auth": cmd_blogger_auth,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user