2 Commits
Author SHA1 Message Date
7b93b085cb feat(googlechat): add Google Chat platform adapter (#1424)
* feat(googlechat): scaffold platform skeleton

Add a Google Chat platform adapter registered as "googlechat", wired
into selective compilation via plugin_platform_googlechat.go (build tag
no_googlechat) and the Makefile ALL_PLATFORMS list.

This commit lands the skeleton only: option parsing/validation in New
(gws_path, project, target, subscription, allow_from, trigger,
session_scope, credentials_file) plus stub Start/Reply/Send/Stop.
Receive, send and gating follow in subsequent commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(googlechat): receive messages via gws events subscribe

Start() now supervises a `gws events +subscribe` subprocess: stdout is
scanned as NDJSON (one Workspace Events CloudEvent per line, buffer
enlarged for the full message resource), stderr is logged, and the
process is restarted with a small backoff if it exits while the context
is alive. Stop() cancels the context to kill it.

parseEvent decodes chat.message.v1.created envelopes, ignores non-human
senders (so the app never replies to its own posts), and dispatches a
core.Message carrying the space/thread reply context. Trigger-word,
allow_from gating and session scoping follow in a later commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(googlechat): send replies via gws chat messages create

Reply and Send post content through `gws chat spaces messages create`,
using the generic raw method (--params/--json) rather than the +send
helper so replies can be threaded: when the reply context carries a
thread name, the request sets messageReplyOption and thread.name so the
answer lands in the originating thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(googlechat): add trigger-word, allow_from and session scoping

parseEvent now gates and routes incoming messages:

- trigger word: when configured, only messages starting with the prefix
  are handled (prefix stripped) for user-OAuth mode without a Chat App;
  otherwise argumentText (mention markup already removed by Google) is
  used, falling back to text.
- allow_from via core.AllowList, and a core.IsOldMessage guard on
  createTime so a restart does not replay backlog.
- session_scope space|thread|user via buildSessionKey, plus
  ReconstructReplyCtx so proactive sends reach the right space/thread.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(googlechat): add config example

Document the googlechat platform options in config.example.toml,
including the gws prerequisites and the two operating modes (@mention
with a Chat App service account, vs trigger-word with user OAuth).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(googlechat): add event parsing and reply-arg unit tests

Cover parseEvent (mention vs trigger mode, argumentText fallback,
non-human and wrong-type skipping, allow_from, old-message guard),
buildSessionKey for each scope, ReconstructReplyCtx round-trips, and
buildCreateArgs threaded vs top-level argument shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(googlechat): switch to Chat app Pub/Sub receive + service-account send

Pivot the platform from the Workspace Events API to a registered Google
Chat app's native Cloud Pub/Sub connection. The subscription is fixed, so
there is no Workspace Events subscription expiry or per-restart resource
leak (the two operational problems of the previous approach).

- receive: parse the Chat-app event format — gws wraps each Pub/Sub
  message as {"data":{"type":"MESSAGE","message":{...}}} — instead of
  Workspace Events CloudEvents; pull a fixed subscription via
  `gws events +subscribe --subscription`.
- send: post via the Chat REST API authenticated as the app's service
  account (chat.bot scope) so replies appear as the bot. gws has no
  service-account auth, so sending is native Go via
  golang.org/x/oauth2/google; receiving still uses gws (its own OAuth).
- config: require `subscription`; `credentials_file` is the SA key used
  for replies. Drop the Workspace Events target/project auto-create.

Verified end-to-end against a real Chat app: the receive format matches
gws output and a service-account reply posts as the bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(googlechat): add GCP/Chat app setup guide

Add docs/googlechat.md documenting the end-to-end Google Chat setup:
enabling the Chat + Pub/Sub APIs, creating the Pub/Sub topic/subscription
and granting the Chat push service account publisher, creating a service
account key for bot replies, configuring the Chat app (LIVE, classic
model, Cloud Pub/Sub connection, visibility), cc-connect config, and an
FAQ covering the issues hit during bring-up (app must be LIVE to send,
Workspace-only, add-on vs classic). Link it from the README platform
table.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(googlechat): receive via native Pub/Sub client instead of gws

Replace the `gws events +subscribe` subprocess with a native streaming pull
using cloud.google.com/go/pubsub. Sending was already native Go, so this
removes the gws binary dependency entirely — the platform is now fully native.

Both directions now authenticate with the same service-account key, so
`credentials_file` is required (it also needs roles/pubsub.subscriber on the
subscription for the receive path). The `gws_path` option is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(googlechat): drop trigger, centralize session-key constants, harden session_scope (#5)

- Remove the `trigger` field and its prefix-matching branch from extractContent;
  Google Chat already scopes delivery so a prefix filter is redundant and subtly
  broken for @mention spaces (m.Text still contains the markup).
- Extract sessionKeyPrefix and threadSep constants used by both buildSessionKey
  and ReconstructReplyCtx so the encode/decode pair stays in sync automatically.
- Add a slog.Warn in the normalizeSessionScope default branch so typos like
  "thraed" surface at startup instead of silently falling back to "space".
- Update tests, docs/googlechat.md, and config.example.toml accordingly.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(googlechat): implement FormattingInstructions for Google Chat text formatting (#7)

* feat(googlechat): implement FormattingInstructions for Google Chat text formatting

Adds FormattingInstructions() to *Platform so the engine injects
Google Chat-specific formatting guidance into the agent system prompt,
steering agents away from unsupported ## headings and [text](url) links.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): add block quote and display-text link to FormattingInstructions

Add missing formatting rules per reviewer feedback:
- Block quote syntax (>text)
- Display-text hyperlink syntax (<url|text>) alongside raw URL auto-linking

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): fix test call after base-branch refactor of newTestPlatform

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(googlechat): implement streaming preview (PreviewStarter + MessageUpdater) (#6)

* feat(googlechat): implement streaming preview via PreviewStarter + MessageUpdater

Adds SendPreviewStart (POST a threaded message, return resource-name handle)
and UpdateMessage (PATCH spaces.messages with updateMask=text) so the engine's
real-time streaming-preview path is activated for Google Chat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(googlechat): extract doRequest helper + drain response bodies

Extract a `doRequest` helper that centralizes HTTP execution and non-2xx
error handling. Both `post()` and `SendPreviewStart` become thin wrappers,
eliminating the duplicated logic that would otherwise diverge on future
changes (retries, auth header tweaks, etc.).

Drain response bodies on success paths in `post()`, `SendPreviewStart`,
and `UpdateMessage` so the transport can return TCP connections to the
pool. Addresses the performance issue where each streaming PATCH would
open a new TLS+TCP handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(googlechat): send images and files via attachment upload (#8)

* feat(googlechat): implement SendImage and SendFile via attachment upload

Adds media-upload support to the Google Chat platform so cc-connect can
send images and files (e.g. /diff HTML output) instead of returning
ErrNotSupported.

Two-step flow for each send:
1. Upload raw bytes to the Chat media endpoint (multipart/related POST
   to https://chat.googleapis.com/upload/v1/{space}/attachments:upload)
   and retrieve the attachmentDataRef resource name.
2. Create a Chat message that references the data ref, respecting thread
   context the same way as the existing text send path.

Shared helper postAttachment handles both steps; SendImage and SendFile
delegate to it with appropriate filename/MIME defaults. Compile-time
interface assertions ensure *Platform satisfies core.ImageSender and
core.FileSender.

Adds TestBuildAttachmentRequest mirroring TestBuildSendRequest to cover
the message body shape (attachment data ref + thread, reply option URL).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): address code-review feedback on SendImage/SendFile

- Add rc.space == "" guard to postAttachment (bug fix: prevents malformed
  upload URL when ReconstructReplyCtx("googlechat:") yields empty space)
- Pre-size bytes.Buffer in uploadAttachment to avoid a full double-copy of
  attachment data on large files
- Extract httpErrorBody helper to deduplicate the 3 error-drain sites
- Extract messageURL/applyThread helpers to deduplicate threading URL logic
  shared between buildSendRequest and buildAttachmentRequest
- Extract coalesce helper and simplify SendImage/SendFile
- Use doRequest in postAttachment for the create-message step
- Add HTTP-level unit tests: uploadAttachment (success, 403 error,
  empty resourceName), postAttachment (missing space, two-step flow),
  SendImage/SendFile (default filename/MIME fallbacks)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): use doRequest in uploadAttachment and drain response body

Two follow-up fixes from code review:
- Route upload request through doRequest so it benefits from any future
  centralized logging/retry/tracing alongside post() and postAttachment().
- Drain remaining bytes from resp.Body after json.Decode so the HTTP
  transport can return the connection to the pool (partial reads prevent
  connection reuse, causing a new TLS handshake per SendImage/SendFile call).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): drain resp.Body on decode error in uploadAttachment

Deferred drain ensures the HTTP connection is returned to the pool
even when JSON decoding fails, not just on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): handle Stop error, remove redundant drain defer, add compile-time assertion (#12)

- Return psClient.Close() error from Stop() instead of silently discarding it
- Remove redundant drain defer in uploadAttachment; the json.Decoder already
  consumes the body, and defer resp.Body.Close() handles cleanup
- Add compile-time assertion var _ core.ReplyContextReconstructor = (*Platform)(nil)
  alongside the existing ImageSender and FileSender guards

Closes #10

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): use doRequest in uploadAttachment and drain response body

Two follow-up fixes from code review:
- Route upload request through doRequest so it benefits from any future
  centralized logging/retry/tracing alongside post() and postAttachment().
- Drain remaining bytes from resp.Body after json.Decode so the HTTP
  transport can return the connection to the pool (partial reads prevent
  connection reuse, causing a new TLS handshake per SendImage/SendFile call).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): drain resp.Body on decode error in uploadAttachment

Deferred drain ensures the HTTP connection is returned to the pool
even when JSON decoding fails, not just on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(googlechat): clear lint issues in googlechat package (#13)

* fix(googlechat): resolve package lint errors

Handle all body/multipart close errors, remove deprecated auth option usage, and tighten HTTP test writers so golangci-lint passes for platform/googlechat.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(googlechat): ack Pub/Sub message after handler, nack on panic

Previously m.Ack() was called before p.handler(), so a handler panic or
failure would silently drop the message without redelivery. Move ack to
after handler completion and nack on panic so Pub/Sub can redeliver.

Introduce the ackable interface and dispatchMessage to make the timing
testable without a real Pub/Sub client, and add three regression tests
covering the success, panic, and parse-failure paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(googlechat): add unit tests for New() error cases (#14)

Closes #11

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-13 16:49:41 +08:00
keinstnandClaude Sonnet 4.6 523f109a98 fix(claudecode): detect missing work_dir at startup with clear error (#1425)
When work_dir does not exist, New() now returns
`claudecode: work_dir "/bad/path" does not exist` instead of the
confusing `fork/exec … no such file or directory` message that blamed
the Claude binary. Also fixes existing tests that used hard-coded paths
not guaranteed to exist by switching them to t.TempDir().

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-25 09:51:38 +08:00