@kodelyth/googlechat 2026.5.39 → 2026.5.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/api.ts +3 -0
  2. package/channel-config-api.ts +1 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/config-api.ts +2 -0
  5. package/contract-api.ts +5 -0
  6. package/dist/actions-YK1wn4ed.js +160 -0
  7. package/dist/api-BkZX4VNX.js +633 -0
  8. package/dist/api.js +3 -0
  9. package/dist/channel-DFZdjXD6.js +584 -0
  10. package/dist/channel-config-api.js +6 -0
  11. package/dist/channel-plugin-api.js +2 -0
  12. package/dist/channel.runtime-en3RNg9S.js +998 -0
  13. package/dist/contract-api.js +3 -0
  14. package/dist/doctor-contract-8SF6XoKj.js +151 -0
  15. package/dist/doctor-contract-api.js +2 -0
  16. package/dist/index.js +22 -0
  17. package/dist/runtime-api-DUH2Cg-0.js +29 -0
  18. package/dist/runtime-api.js +2 -0
  19. package/dist/secret-contract-DWX4ikgT.js +99 -0
  20. package/dist/secret-contract-api.js +2 -0
  21. package/dist/setup-entry.js +15 -0
  22. package/dist/setup-plugin-api.js +75 -0
  23. package/dist/setup-surface-B3Fa7XRx.js +321 -0
  24. package/dist/test-api.js +3 -0
  25. package/doctor-contract-api.ts +1 -0
  26. package/index.ts +20 -0
  27. package/klaw.plugin.json +2 -967
  28. package/package.json +4 -4
  29. package/runtime-api.ts +55 -0
  30. package/secret-contract-api.ts +5 -0
  31. package/setup-entry.ts +13 -0
  32. package/setup-plugin-api.ts +3 -0
  33. package/src/accounts.ts +181 -0
  34. package/src/actions.test.ts +289 -0
  35. package/src/actions.ts +227 -0
  36. package/src/api.ts +316 -0
  37. package/src/approval-auth.test.ts +24 -0
  38. package/src/approval-auth.ts +32 -0
  39. package/src/auth.ts +218 -0
  40. package/src/channel-config.test.ts +39 -0
  41. package/src/channel.adapters.ts +340 -0
  42. package/src/channel.deps.runtime.ts +29 -0
  43. package/src/channel.runtime.ts +17 -0
  44. package/src/channel.setup.ts +98 -0
  45. package/src/channel.test.ts +784 -0
  46. package/src/channel.ts +277 -0
  47. package/src/config-schema.test.ts +31 -0
  48. package/src/config-schema.ts +3 -0
  49. package/src/doctor-contract.test.ts +75 -0
  50. package/src/doctor-contract.ts +182 -0
  51. package/src/doctor.ts +57 -0
  52. package/src/gateway.ts +63 -0
  53. package/src/google-auth.runtime.test.ts +543 -0
  54. package/src/google-auth.runtime.ts +568 -0
  55. package/src/group-policy.ts +17 -0
  56. package/src/monitor-access.test.ts +491 -0
  57. package/src/monitor-access.ts +465 -0
  58. package/src/monitor-durable.test.ts +39 -0
  59. package/src/monitor-durable.ts +23 -0
  60. package/src/monitor-reply-delivery.ts +156 -0
  61. package/src/monitor-routing.ts +65 -0
  62. package/src/monitor-types.ts +33 -0
  63. package/src/monitor-webhook.test.ts +587 -0
  64. package/src/monitor-webhook.ts +303 -0
  65. package/src/monitor.reply-delivery.test.ts +144 -0
  66. package/src/monitor.test.ts +159 -0
  67. package/src/monitor.ts +527 -0
  68. package/src/monitor.webhook-routing.test.ts +257 -0
  69. package/src/runtime.ts +9 -0
  70. package/src/secret-contract.test.ts +60 -0
  71. package/src/secret-contract.ts +161 -0
  72. package/src/setup-core.ts +40 -0
  73. package/src/setup-surface.ts +243 -0
  74. package/src/setup.test.ts +619 -0
  75. package/src/targets.test.ts +453 -0
  76. package/src/targets.ts +66 -0
  77. package/src/types.config.ts +3 -0
  78. package/src/types.ts +73 -0
  79. package/test-api.ts +2 -0
  80. package/tsconfig.json +16 -0
  81. package/api.js +0 -7
  82. package/channel-config-api.js +0 -7
  83. package/channel-plugin-api.js +0 -7
  84. package/contract-api.js +0 -7
  85. package/doctor-contract-api.js +0 -7
  86. package/index.js +0 -7
  87. package/runtime-api.js +0 -7
  88. package/secret-contract-api.js +0 -7
  89. package/setup-entry.js +0 -7
  90. package/setup-plugin-api.js +0 -7
  91. package/test-api.js +0 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodelyth/googlechat",
3
- "version": "2026.5.39",
3
+ "version": "2026.5.42",
4
4
  "description": "Klaw Google Chat channel plugin",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "devDependencies": {
16
16
  "@kodelyth/plugin-sdk": "1.0.1",
17
- "@kodelyth/klaw": "2026.5.41"
17
+ "@kodelyth/klaw": "2026.5.42"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "@kodelyth/klaw": ">=2026.5.19"
@@ -26,9 +26,9 @@
26
26
  },
27
27
  "klaw": {
28
28
  "extensions": [
29
- "./index.js"
29
+ "./index.ts"
30
30
  ],
31
- "setupEntry": "./setup-entry.js",
31
+ "setupEntry": "./setup-entry.ts",
32
32
  "channel": {
33
33
  "id": "googlechat",
34
34
  "label": "Google Chat",
package/runtime-api.ts ADDED
@@ -0,0 +1,55 @@
1
+ // Private runtime barrel for the bundled Google Chat extension.
2
+ // Keep this barrel thin and avoid broad plugin-sdk surfaces during bootstrap.
3
+
4
+ export { DEFAULT_ACCOUNT_ID } from "klaw/plugin-sdk/account-id";
5
+ export {
6
+ createActionGate,
7
+ jsonResult,
8
+ readNumberParam,
9
+ readReactionParams,
10
+ readStringParam,
11
+ } from "klaw/plugin-sdk/channel-actions";
12
+ export { buildChannelConfigSchema } from "klaw/plugin-sdk/channel-config-primitives";
13
+ export type {
14
+ ChannelMessageActionAdapter,
15
+ ChannelMessageActionName,
16
+ ChannelStatusIssue,
17
+ } from "klaw/plugin-sdk/channel-contract";
18
+ export { missingTargetError } from "klaw/plugin-sdk/channel-feedback";
19
+ export {
20
+ createAccountStatusSink,
21
+ runPassiveAccountLifecycle,
22
+ } from "klaw/plugin-sdk/channel-lifecycle";
23
+ export { createChannelPairingController } from "klaw/plugin-sdk/channel-pairing";
24
+ export { createChannelMessageReplyPipeline } from "klaw/plugin-sdk/channel-message";
25
+ export { PAIRING_APPROVED_MESSAGE } from "klaw/plugin-sdk/channel-status";
26
+ export { chunkTextForOutbound } from "klaw/plugin-sdk/text-chunking";
27
+ export type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
28
+ export { GoogleChatConfigSchema } from "klaw/plugin-sdk/bundled-channel-config-schema";
29
+ export {
30
+ GROUP_POLICY_BLOCKED_LABEL,
31
+ resolveAllowlistProviderRuntimeGroupPolicy,
32
+ resolveDefaultGroupPolicy,
33
+ warnMissingProviderGroupPolicyFallbackOnce,
34
+ } from "klaw/plugin-sdk/runtime-group-policy";
35
+ export { isDangerousNameMatchingEnabled } from "klaw/plugin-sdk/dangerous-name-runtime";
36
+ export { readRemoteMediaBuffer, resolveChannelMediaMaxBytes } from "klaw/plugin-sdk/media-runtime";
37
+ export { loadOutboundMediaFromUrl } from "klaw/plugin-sdk/outbound-media";
38
+ export type { PluginRuntime } from "klaw/plugin-sdk/runtime-store";
39
+ export { fetchWithSsrFGuard } from "klaw/plugin-sdk/ssrf-runtime";
40
+ export type { GoogleChatAccountConfig, GoogleChatConfig } from "klaw/plugin-sdk/config-contracts";
41
+ export { extractToolSend } from "klaw/plugin-sdk/tool-send";
42
+ export { resolveInboundMentionDecision } from "klaw/plugin-sdk/channel-inbound";
43
+ export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "klaw/plugin-sdk/inbound-envelope";
44
+ export { resolveWebhookPath } from "klaw/plugin-sdk/webhook-ingress";
45
+ export {
46
+ registerWebhookTargetWithPluginRoute,
47
+ resolveWebhookTargetWithAuthOrReject,
48
+ withResolvedWebhookRequestPipeline,
49
+ } from "klaw/plugin-sdk/webhook-targets";
50
+ export {
51
+ createWebhookInFlightLimiter,
52
+ readJsonWebhookBodyOrReject,
53
+ type WebhookInFlightLimiter,
54
+ } from "klaw/plugin-sdk/webhook-request-guards";
55
+ export { setGoogleChatRuntime } from "./src/runtime.js";
@@ -0,0 +1,5 @@
1
+ export {
2
+ channelSecrets,
3
+ collectRuntimeConfigAssignments,
4
+ secretTargetRegistryEntries,
5
+ } from "./src/secret-contract.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { defineBundledChannelSetupEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./setup-plugin-api.js",
7
+ exportName: "googlechatSetupPlugin",
8
+ },
9
+ secrets: {
10
+ specifier: "./secret-contract-api.js",
11
+ exportName: "channelSecrets",
12
+ },
13
+ });
@@ -0,0 +1,3 @@
1
+ // Keep bundled setup entry imports narrow so setup loads do not pull the
2
+ // broader Google Chat runtime plugin surface.
3
+ export { googlechatSetupPlugin } from "./src/channel.setup.js";
@@ -0,0 +1,181 @@
1
+ import {
2
+ createAccountListHelpers,
3
+ DEFAULT_ACCOUNT_ID,
4
+ normalizeAccountId,
5
+ type KlawConfig,
6
+ resolveAccountEntry,
7
+ resolveMergedAccountConfig,
8
+ } from "klaw/plugin-sdk/account-resolution";
9
+ import { safeParseJsonWithSchema, safeParseWithSchema } from "klaw/plugin-sdk/extension-shared";
10
+ import { mergePairLoopGuardConfig } from "klaw/plugin-sdk/pair-loop-guard-runtime";
11
+ import { isSecretRef } from "klaw/plugin-sdk/secret-input";
12
+ import { normalizeOptionalString } from "klaw/plugin-sdk/string-coerce-runtime";
13
+ import { z } from "zod";
14
+ import type { GoogleChatAccountConfig } from "./types.config.js";
15
+
16
+ type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
17
+
18
+ export type ResolvedGoogleChatAccount = {
19
+ accountId: string;
20
+ name?: string;
21
+ enabled: boolean;
22
+ config: GoogleChatAccountConfig;
23
+ credentialSource: GoogleChatCredentialSource;
24
+ credentials?: Record<string, unknown>;
25
+ credentialsFile?: string;
26
+ };
27
+
28
+ export type GoogleChatConfigAccessorAccount = {
29
+ config: GoogleChatAccountConfig;
30
+ };
31
+
32
+ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
33
+ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
34
+ const JsonRecordSchema = z.record(z.string(), z.unknown());
35
+
36
+ const {
37
+ listAccountIds: listGoogleChatAccountIds,
38
+ resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
39
+ } = createAccountListHelpers("googlechat", {
40
+ implicitDefaultAccount: {
41
+ channelKeys: ["serviceAccount", "serviceAccountRef", "serviceAccountFile"],
42
+ envVars: [ENV_SERVICE_ACCOUNT, ENV_SERVICE_ACCOUNT_FILE],
43
+ },
44
+ });
45
+ export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
46
+
47
+ function mergeGoogleChatAccountConfig(cfg: KlawConfig, accountId: string): GoogleChatAccountConfig {
48
+ const raw = cfg.channels?.["googlechat"] ?? {};
49
+ const base = resolveMergedAccountConfig<GoogleChatAccountConfig>({
50
+ channelConfig: raw as GoogleChatAccountConfig,
51
+ accounts: raw.accounts as Record<string, Partial<GoogleChatAccountConfig>> | undefined,
52
+ accountId,
53
+ omitKeys: ["defaultAccount"],
54
+ nestedObjectKeys: ["botLoopProtection"],
55
+ });
56
+ const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {};
57
+ if (accountId === DEFAULT_ACCOUNT_ID) {
58
+ return base;
59
+ }
60
+ const {
61
+ enabled: _ignoredEnabled,
62
+ dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching,
63
+ serviceAccount: _ignoredServiceAccount,
64
+ serviceAccountRef: _ignoredServiceAccountRef,
65
+ serviceAccountFile: _ignoredServiceAccountFile,
66
+ ...defaultAccountShared
67
+ } = defaultAccountConfig;
68
+ // In multi-account setups, allow accounts.default to provide shared defaults
69
+ // (for example webhook/audience fields) while preserving top-level and account overrides.
70
+ const botLoopProtection = mergePairLoopGuardConfig(
71
+ defaultAccountShared.botLoopProtection,
72
+ base.botLoopProtection,
73
+ );
74
+ return {
75
+ ...defaultAccountShared,
76
+ ...base,
77
+ ...(botLoopProtection ? { botLoopProtection } : {}),
78
+ } as GoogleChatAccountConfig;
79
+ }
80
+
81
+ export function resolveGoogleChatConfigAccessorAccount(params: {
82
+ cfg: KlawConfig;
83
+ accountId?: string | null;
84
+ }): GoogleChatConfigAccessorAccount {
85
+ const accountId = normalizeAccountId(
86
+ params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount,
87
+ );
88
+ return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) };
89
+ }
90
+
91
+ function parseServiceAccount(value: unknown): Record<string, unknown> | null {
92
+ if (isSecretRef(value)) {
93
+ return null;
94
+ }
95
+
96
+ if (typeof value === "string") {
97
+ const trimmed = value.trim();
98
+ if (!trimmed) {
99
+ return null;
100
+ }
101
+ return safeParseJsonWithSchema(JsonRecordSchema, trimmed);
102
+ }
103
+
104
+ return safeParseWithSchema(JsonRecordSchema, value);
105
+ }
106
+
107
+ function resolveCredentialsFromConfig(params: {
108
+ accountId: string;
109
+ account: GoogleChatAccountConfig;
110
+ }): {
111
+ credentials?: Record<string, unknown>;
112
+ credentialsFile?: string;
113
+ source: GoogleChatCredentialSource;
114
+ } {
115
+ const { account, accountId } = params;
116
+ const inline = parseServiceAccount(account.serviceAccount);
117
+ if (inline) {
118
+ return { credentials: inline, source: "inline" };
119
+ }
120
+
121
+ if (isSecretRef(account.serviceAccount)) {
122
+ throw new Error(
123
+ `channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccount.source}:${account.serviceAccount.provider}:${account.serviceAccount.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
124
+ );
125
+ }
126
+
127
+ if (isSecretRef(account.serviceAccountRef)) {
128
+ throw new Error(
129
+ `channels.googlechat.accounts.${accountId}.serviceAccount: unresolved SecretRef "${account.serviceAccountRef.source}:${account.serviceAccountRef.provider}:${account.serviceAccountRef.id}". Resolve this command against an active gateway runtime snapshot before reading it.`,
130
+ );
131
+ }
132
+
133
+ const file = normalizeOptionalString(account.serviceAccountFile);
134
+ if (file) {
135
+ return { credentialsFile: file, source: "file" };
136
+ }
137
+
138
+ if (accountId === DEFAULT_ACCOUNT_ID) {
139
+ const envJson = process.env[ENV_SERVICE_ACCOUNT];
140
+ const envInline = parseServiceAccount(envJson);
141
+ if (envInline) {
142
+ return { credentials: envInline, source: "env" };
143
+ }
144
+ const envFile = normalizeOptionalString(process.env[ENV_SERVICE_ACCOUNT_FILE]);
145
+ if (envFile) {
146
+ return { credentialsFile: envFile, source: "env" };
147
+ }
148
+ }
149
+
150
+ return { source: "none" };
151
+ }
152
+
153
+ export function resolveGoogleChatAccount(params: {
154
+ cfg: KlawConfig;
155
+ accountId?: string | null;
156
+ }): ResolvedGoogleChatAccount {
157
+ const accountId = normalizeAccountId(
158
+ params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
159
+ );
160
+ const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
161
+ const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
162
+ const accountEnabled = merged.enabled !== false;
163
+ const enabled = baseEnabled && accountEnabled;
164
+ const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
165
+
166
+ return {
167
+ accountId,
168
+ name: normalizeOptionalString(merged.name),
169
+ enabled,
170
+ config: merged,
171
+ credentialSource: credentials.source,
172
+ credentials: credentials.credentials,
173
+ credentialsFile: credentials.credentialsFile,
174
+ };
175
+ }
176
+
177
+ export function listEnabledGoogleChatAccounts(cfg: KlawConfig): ResolvedGoogleChatAccount[] {
178
+ return listGoogleChatAccountIds(cfg)
179
+ .map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
180
+ .filter((account) => account.enabled);
181
+ }
@@ -0,0 +1,289 @@
1
+ import path from "node:path";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn());
5
+ const resolveGoogleChatAccount = vi.hoisted(() => vi.fn());
6
+ const createGoogleChatReaction = vi.hoisted(() => vi.fn());
7
+ const deleteGoogleChatReaction = vi.hoisted(() => vi.fn());
8
+ const listGoogleChatReactions = vi.hoisted(() => vi.fn());
9
+ const sendGoogleChatMessage = vi.hoisted(() => vi.fn());
10
+ const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn());
11
+ const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn());
12
+ const getGoogleChatRuntime = vi.hoisted(() => vi.fn());
13
+
14
+ vi.mock("./accounts.js", () => ({
15
+ listEnabledGoogleChatAccounts,
16
+ resolveGoogleChatAccount,
17
+ }));
18
+
19
+ vi.mock("./api.js", () => ({
20
+ createGoogleChatReaction,
21
+ deleteGoogleChatReaction,
22
+ listGoogleChatReactions,
23
+ sendGoogleChatMessage,
24
+ uploadGoogleChatAttachment,
25
+ }));
26
+
27
+ vi.mock("./runtime.js", () => ({
28
+ getGoogleChatRuntime,
29
+ }));
30
+
31
+ vi.mock("./targets.js", () => ({
32
+ resolveGoogleChatOutboundSpace,
33
+ }));
34
+
35
+ let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions;
36
+
37
+ describe("googlechat message actions", () => {
38
+ beforeAll(async () => {
39
+ ({ googlechatMessageActions } = await import("./actions.js"));
40
+ });
41
+
42
+ beforeEach(() => {
43
+ vi.clearAllMocks();
44
+ });
45
+
46
+ afterAll(() => {
47
+ vi.doUnmock("./accounts.js");
48
+ vi.doUnmock("./api.js");
49
+ vi.doUnmock("./runtime.js");
50
+ vi.doUnmock("./targets.js");
51
+ vi.resetModules();
52
+ });
53
+
54
+ function buildAccount(overrides: Record<string, unknown> = {}) {
55
+ return {
56
+ accountId: "default",
57
+ enabled: true,
58
+ credentialSource: "service-account",
59
+ config: {},
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ function expectJsonResult(result: unknown, details: Record<string, unknown>) {
65
+ expect(result).toEqual({
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: JSON.stringify(details, null, 2),
70
+ },
71
+ ],
72
+ details,
73
+ });
74
+ }
75
+
76
+ it("describes send and reaction actions only when enabled accounts exist", () => {
77
+ listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
78
+ expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
79
+
80
+ listEnabledGoogleChatAccounts.mockReturnValueOnce([
81
+ {
82
+ enabled: true,
83
+ credentialSource: "service-account",
84
+ config: { actions: { reactions: true } },
85
+ },
86
+ ]);
87
+
88
+ expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
89
+ actions: ["send", "upload-file", "react", "reactions"],
90
+ });
91
+ });
92
+
93
+ it("honors account-scoped reaction gates during discovery", () => {
94
+ resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
95
+ enabled: true,
96
+ credentialSource: "service-account",
97
+ config: {
98
+ actions: { reactions: accountId === "work" },
99
+ },
100
+ }));
101
+
102
+ expect(
103
+ googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }),
104
+ ).toEqual({
105
+ actions: ["send", "upload-file"],
106
+ });
107
+ expect(
108
+ googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }),
109
+ ).toEqual({
110
+ actions: ["send", "upload-file", "react", "reactions"],
111
+ });
112
+ });
113
+
114
+ it("sends messages with uploaded media through the resolved space", async () => {
115
+ const account = buildAccount({
116
+ config: { mediaMaxMb: 5 },
117
+ });
118
+ resolveGoogleChatAccount.mockReturnValue(account);
119
+ resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
120
+ const readRemoteMediaBuffer = vi.fn(async () => ({
121
+ buffer: Buffer.from("remote-bytes"),
122
+ fileName: "remote.png",
123
+ contentType: "image/png",
124
+ }));
125
+ getGoogleChatRuntime.mockReturnValue({
126
+ channel: {
127
+ media: {
128
+ readRemoteMediaBuffer,
129
+ },
130
+ },
131
+ });
132
+ uploadGoogleChatAttachment.mockResolvedValue({
133
+ attachmentUploadToken: "token-1",
134
+ });
135
+ sendGoogleChatMessage.mockResolvedValue({
136
+ messageName: "spaces/AAA/messages/msg-1",
137
+ });
138
+
139
+ if (!googlechatMessageActions.handleAction) {
140
+ throw new Error("Expected googlechatMessageActions.handleAction to be defined");
141
+ }
142
+ const result = await googlechatMessageActions.handleAction({
143
+ action: "send",
144
+ params: {
145
+ to: "spaces/AAA",
146
+ message: "caption",
147
+ media: "https://example.com/file.png",
148
+ threadId: "thread-1",
149
+ },
150
+ cfg: {},
151
+ accountId: "default",
152
+ } as never);
153
+
154
+ expect(resolveGoogleChatOutboundSpace).toHaveBeenCalledWith({
155
+ account,
156
+ target: "spaces/AAA",
157
+ });
158
+ expect(readRemoteMediaBuffer).toHaveBeenCalledWith({
159
+ url: "https://example.com/file.png",
160
+ maxBytes: 5 * 1024 * 1024,
161
+ });
162
+ expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
163
+ account,
164
+ space: "spaces/AAA",
165
+ filename: "remote.png",
166
+ buffer: Buffer.from("remote-bytes"),
167
+ contentType: "image/png",
168
+ });
169
+ expect(sendGoogleChatMessage).toHaveBeenCalledWith({
170
+ account,
171
+ space: "spaces/AAA",
172
+ text: "caption",
173
+ thread: "thread-1",
174
+ attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
175
+ });
176
+ expectJsonResult(result, { ok: true, to: "spaces/AAA" });
177
+ });
178
+
179
+ it("routes upload-file through the same attachment upload path with filename override", async () => {
180
+ const account = buildAccount({
181
+ config: { mediaMaxMb: 5 },
182
+ });
183
+ resolveGoogleChatAccount.mockReturnValue(account);
184
+ resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
185
+ const localRoot = "/tmp/googlechat-action-test";
186
+ const localPath = path.join(localRoot, "local.md");
187
+ const readFile = vi.fn(async () => Buffer.from("local-bytes"));
188
+ getGoogleChatRuntime.mockReturnValue({
189
+ channel: {
190
+ media: {
191
+ readRemoteMediaBuffer: vi.fn(),
192
+ },
193
+ },
194
+ });
195
+ uploadGoogleChatAttachment.mockResolvedValue({
196
+ attachmentUploadToken: "token-2",
197
+ });
198
+ sendGoogleChatMessage.mockResolvedValue({
199
+ messageName: "spaces/BBB/messages/msg-2",
200
+ });
201
+
202
+ if (!googlechatMessageActions.handleAction) {
203
+ throw new Error("Expected googlechatMessageActions.handleAction to be defined");
204
+ }
205
+ const result = await googlechatMessageActions.handleAction({
206
+ action: "upload-file",
207
+ params: {
208
+ to: "spaces/BBB",
209
+ path: localPath,
210
+ message: "notes",
211
+ filename: "renamed.txt",
212
+ },
213
+ cfg: {},
214
+ accountId: "default",
215
+ mediaLocalRoots: [localRoot],
216
+ mediaReadFile: readFile,
217
+ } as never);
218
+
219
+ expect(readFile).toHaveBeenCalledWith(localPath);
220
+ expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({
221
+ account,
222
+ space: "spaces/BBB",
223
+ filename: "renamed.txt",
224
+ buffer: Buffer.from("local-bytes"),
225
+ contentType: "text/markdown",
226
+ });
227
+ expect(sendGoogleChatMessage).toHaveBeenCalledWith({
228
+ account,
229
+ space: "spaces/BBB",
230
+ text: "notes",
231
+ thread: undefined,
232
+ attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
233
+ });
234
+ expectJsonResult(result, { ok: true, to: "spaces/BBB" });
235
+ });
236
+
237
+ it("removes only matching app reactions on react remove", async () => {
238
+ const account = buildAccount({
239
+ config: { botUser: "users/app-bot" },
240
+ });
241
+ resolveGoogleChatAccount.mockReturnValue(account);
242
+ listGoogleChatReactions.mockResolvedValue([
243
+ {
244
+ name: "reactions/1",
245
+ emoji: { unicode: "👍" },
246
+ user: { name: "users/app" },
247
+ },
248
+ {
249
+ name: "reactions/2",
250
+ emoji: { unicode: "👍" },
251
+ user: { name: "users/app-bot" },
252
+ },
253
+ {
254
+ name: "reactions/3",
255
+ emoji: { unicode: "👍" },
256
+ user: { name: "users/other" },
257
+ },
258
+ ]);
259
+
260
+ if (!googlechatMessageActions.handleAction) {
261
+ throw new Error("Expected googlechatMessageActions.handleAction to be defined");
262
+ }
263
+ const result = await googlechatMessageActions.handleAction({
264
+ action: "react",
265
+ params: {
266
+ messageId: "spaces/AAA/messages/msg-1",
267
+ emoji: "👍",
268
+ remove: true,
269
+ },
270
+ cfg: {},
271
+ accountId: "default",
272
+ } as never);
273
+
274
+ expect(listGoogleChatReactions).toHaveBeenCalledWith({
275
+ account,
276
+ messageName: "spaces/AAA/messages/msg-1",
277
+ });
278
+ expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2);
279
+ expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, {
280
+ account,
281
+ reactionName: "reactions/1",
282
+ });
283
+ expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, {
284
+ account,
285
+ reactionName: "reactions/2",
286
+ });
287
+ expectJsonResult(result, { ok: true, removed: 2 });
288
+ });
289
+ });