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