File size: 7,566 Bytes
5655a42 | 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 | """``/setup-files`` in-chat OAuth setup flow for native attachment delivery.
Extracted from ``adapter.py``: ``GoogleChatAdapter._handle_setup_files_command``
delegates here. Logs under the adapter's pinned logger name.
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import logging
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger("gateway.platforms.google_chat")
_NOT_CONFIGURED_TEXT = (
"π§ Native attachment delivery is **not configured**.\n"
"**Step 1 (one-time, on the host):** create OAuth client credentials at "
"https://console.cloud.google.com/apis/credentials β *Create credentials* β "
"*OAuth client ID* β *Desktop app*. Download the JSON. Then on the host run:\n"
"```\npython -m plugins.platforms.google_chat.oauth --client-secret /path/to/client_secret.json\n```\n"
"**Step 2:** come back here and send `/setup-files start`."
)
_START_INSTRUCTIONS = (
"1. Open this URL in your browser and authorize:\n{auth_url}\n\n"
"2. After clicking *Allow*, your browser will fail to load "
"`http://localhost:1/?...&code=...`. That's expected.\n\n"
"3. Copy the entire failed URL from the browser's URL bar and paste it back here as: "
"`/setup-files <PASTE_URL>` (or just the `code=...` value).\n\n"
"Tip: the URL contains your access grant β keep it private."
)
_START_EXIT_TEXT = (
"β Couldn't generate the OAuth URL. Check the gateway logs and verify the client_secret.json is valid."
)
_EXCHANGE_EXIT_TEXT = (
"β Token exchange failed. The code may have expired or the URL is malformed. "
"Send `/setup-files start` to get a fresh OAuth URL."
)
_REVOKE_EXIT_OUTPUT = "Revoke completed (some steps may have been skipped)."
_EXITED = object() # _run_helper marker: helper called sys.exit but the step tolerates it
async def _run_captured(fn: Callable[..., Any], *args: Any) -> str:
"""Run ``fn`` in a thread with stdout captured (the oauth helpers print their output)."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
await asyncio.to_thread(fn, *args)
return buf.getvalue()
async def handle_setup_files_command(
adapter: Any, chat_id: str, thread_id: Optional[str], raw_text: str,
sender_email: Optional[str] = None) -> bool:
"""Run the in-chat OAuth setup flow. Returns True when the message was consumed.
``sender_email`` is the per-user OAuth key; ``None`` falls back to the legacy
single-user token slot so pre-multi-user installs keep working.
Subcommands: ``/setup-files`` (status), ``start`` (OAuth URL), ``revoke``,
``<CODE_OR_URL>`` (exchange). Requires client_secret.json on the host.
"""
from . import oauth as oauth_helper
# Same normalization as the token-path sanitizer so cache lookups stay consistent.
sender_key = sender_email.strip().lower() if sender_email else None
parts = raw_text.split(maxsplit=1)
arg = parts[1].strip() if len(parts) > 1 else ""
async def _reply(text: str) -> None:
body: Dict[str, Any] = {"text": text}
if thread_id:
body["thread"] = {"name": thread_id}
try:
await adapter._create_message(chat_id, body)
except Exception:
logger.debug("[GoogleChat] /setup-files reply send failed", exc_info=True)
async def _run_helper(step: str, exit_text: Optional[str], fn: Callable[..., Any], *args: Any):
"""Captured helper output; ``None`` after replying on failure. ``exit_text``
is the reply on ``SystemExit`` (the helpers' failure signal); ``None``
tolerates the exit and returns ``_EXITED``."""
try:
return await _run_captured(fn, *args)
except SystemExit:
if exit_text is None:
return _EXITED
await _reply(exit_text)
except Exception as exc:
logger.warning("[GoogleChat] /setup-files %s failed: %s", step, exc)
await _reply(f"β Error{' revoking' if step == 'revoke' else ''}: {exc}")
return None
def _set_user_creds(creds: Any, api: Any) -> None:
"""Set (or evict, with ``None``) only the sender's slot: Bob revoking must not
break Alice's per-user token nor the shared legacy fallback."""
if not sender_key:
adapter._user_credentials, adapter._user_chat_api = creds, api
elif creds is None:
adapter._user_creds_by_email.pop(sender_key, None)
adapter._user_chat_api_by_email.pop(sender_key, None)
else:
adapter._user_creds_by_email[sender_key] = creds
adapter._user_chat_api_by_email[sender_key] = api
if not arg:
client_secret_present = oauth_helper._client_secret_path().exists()
token_path = oauth_helper._token_path(sender_key)
creds = oauth_helper.load_user_credentials(sender_key) if token_path.exists() else None
if creds is not None:
who = sender_key or "shared (legacy)"
await _reply(
f"β
Native attachment delivery is **active** for `{who}`.\n"
f"Token: `{token_path}`\nSend `/setup-files revoke` to disable.")
elif not client_secret_present:
await _reply(_NOT_CONFIGURED_TEXT)
else:
await _reply(
"π§ Client credentials are stored but you haven't authorized yet. "
"Send `/setup-files start` to begin."
)
return True
if arg == "start":
if not oauth_helper._client_secret_path().exists():
await _reply(
"β οΈ No client credentials stored for this profile. Send "
"`/setup-files` (no args) for setup instructions."
)
return True
output = await _run_helper("start", _START_EXIT_TEXT, oauth_helper.get_auth_url, sender_key)
if output is not None:
await _reply(_START_INSTRUCTIONS.format(auth_url=output.strip().splitlines()[-1]))
return True
if arg == "revoke":
output = await _run_helper("revoke", None, oauth_helper.revoke, sender_key)
if output is None:
return True
output = _REVOKE_EXIT_OUTPUT if output is _EXITED else (output.strip() or "Revoked.")
_set_user_creds(None, None)
await _reply(f"β
Done.\n```\n{output}\n```")
return True
# Anything else is the auth code or the pasted failed-redirect URL.
output = await _run_helper("exchange", _EXCHANGE_EXIT_TEXT, oauth_helper.exchange_auth_code, arg, sender_key)
if output is None:
return True
# Re-load credentials so the next file send uses them without a gateway restart.
try:
new_creds = await asyncio.to_thread(oauth_helper.load_user_credentials, sender_key)
if new_creds is not None:
new_api = await asyncio.to_thread(lambda: oauth_helper.build_user_chat_service(new_creds))
_set_user_creds(new_creds, new_api)
await _reply("β
Authorized! Native attachment delivery is now active. Try asking me to send you a PDF.")
return True
except Exception as exc:
logger.warning("[GoogleChat] post-exchange creds load failed: %s", exc)
await _reply(
"β οΈ Token exchanged but the gateway couldn't load the new credentials in-memory. "
f"Restart the gateway and the token at `{oauth_helper._token_path(sender_key)}` will be picked up.\n"
f"Helper output:\n```\n{output.strip()}\n```")
return True
|