@openclaw/googlechat 2026.5.2-beta.1
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.
- package/api.ts +3 -0
- package/channel-config-api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +20 -0
- package/openclaw.plugin.json +884 -0
- package/package.json +88 -0
- package/runtime-api.ts +60 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/setup-plugin-api.ts +3 -0
- package/src/accounts.ts +169 -0
- package/src/actions.test.ts +265 -0
- package/src/actions.ts +227 -0
- package/src/api.ts +322 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +32 -0
- package/src/auth.ts +209 -0
- package/src/channel-config.test.ts +39 -0
- package/src/channel.adapters.ts +293 -0
- package/src/channel.deps.runtime.ts +28 -0
- package/src/channel.runtime.ts +17 -0
- package/src/channel.setup.ts +98 -0
- package/src/channel.test.ts +695 -0
- package/src/channel.ts +256 -0
- package/src/config-schema.test.ts +16 -0
- package/src/config-schema.ts +3 -0
- package/src/doctor-contract.test.ts +75 -0
- package/src/doctor-contract.ts +182 -0
- package/src/doctor.ts +57 -0
- package/src/gateway.ts +63 -0
- package/src/google-auth.runtime.test.ts +462 -0
- package/src/google-auth.runtime.ts +539 -0
- package/src/group-policy.ts +17 -0
- package/src/monitor-access.test.ts +443 -0
- package/src/monitor-access.ts +405 -0
- package/src/monitor-reply-delivery.ts +156 -0
- package/src/monitor-routing.ts +55 -0
- package/src/monitor-types.ts +33 -0
- package/src/monitor-webhook.test.ts +446 -0
- package/src/monitor-webhook.ts +285 -0
- package/src/monitor.reply-delivery.test.ts +139 -0
- package/src/monitor.ts +428 -0
- package/src/monitor.webhook-routing.test.ts +252 -0
- package/src/runtime.ts +9 -0
- package/src/secret-contract.test.ts +60 -0
- package/src/secret-contract.ts +161 -0
- package/src/sender-allow.ts +46 -0
- package/src/setup-core.ts +40 -0
- package/src/setup-surface.ts +236 -0
- package/src/setup.test.ts +560 -0
- package/src/targets.test.ts +421 -0
- package/src/targets.ts +66 -0
- package/src/types.config.ts +3 -0
- package/src/types.ts +73 -0
- package/test-api.ts +2 -0
- package/tsconfig.json +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openclaw/googlechat",
|
|
3
|
+
"version": "2026.5.2-beta.1",
|
|
4
|
+
"description": "OpenClaw Google Chat channel plugin",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/openclaw/openclaw"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"gaxios": "7.1.4",
|
|
12
|
+
"google-auth-library": "10.6.2",
|
|
13
|
+
"zod": "^4.4.1"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@openclaw/plugin-sdk": "workspace:*",
|
|
17
|
+
"openclaw": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"openclaw": ">=2026.5.2-beta.1"
|
|
21
|
+
},
|
|
22
|
+
"peerDependenciesMeta": {
|
|
23
|
+
"openclaw": {
|
|
24
|
+
"optional": true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"openclaw": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./index.ts"
|
|
30
|
+
],
|
|
31
|
+
"setupEntry": "./setup-entry.ts",
|
|
32
|
+
"channel": {
|
|
33
|
+
"id": "googlechat",
|
|
34
|
+
"label": "Google Chat",
|
|
35
|
+
"selectionLabel": "Google Chat (Chat API)",
|
|
36
|
+
"detailLabel": "Google Chat",
|
|
37
|
+
"docsPath": "/channels/googlechat",
|
|
38
|
+
"docsLabel": "googlechat",
|
|
39
|
+
"blurb": "Google Workspace Chat app with HTTP webhook.",
|
|
40
|
+
"aliases": [
|
|
41
|
+
"gchat",
|
|
42
|
+
"google-chat"
|
|
43
|
+
],
|
|
44
|
+
"order": 55,
|
|
45
|
+
"systemImage": "message.badge",
|
|
46
|
+
"markdownCapable": true,
|
|
47
|
+
"doctorCapabilities": {
|
|
48
|
+
"dmAllowFromMode": "nestedOnly",
|
|
49
|
+
"groupModel": "route",
|
|
50
|
+
"groupAllowFromFallbackToAllowFrom": false,
|
|
51
|
+
"warnOnEmptyGroupSenderAllowlist": false
|
|
52
|
+
},
|
|
53
|
+
"cliAddOptions": [
|
|
54
|
+
{
|
|
55
|
+
"flags": "--webhook-path <path>",
|
|
56
|
+
"description": "Google Chat webhook path"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"flags": "--webhook-url <url>",
|
|
60
|
+
"description": "Google Chat webhook URL"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"flags": "--audience-type <type>",
|
|
64
|
+
"description": "Google Chat audience type (app-url|project-number)"
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"flags": "--audience <value>",
|
|
68
|
+
"description": "Google Chat audience value (app URL or project number)"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"install": {
|
|
73
|
+
"npmSpec": "@openclaw/googlechat",
|
|
74
|
+
"defaultChoice": "npm",
|
|
75
|
+
"minHostVersion": ">=2026.4.10"
|
|
76
|
+
},
|
|
77
|
+
"compat": {
|
|
78
|
+
"pluginApi": ">=2026.5.2-beta.1"
|
|
79
|
+
},
|
|
80
|
+
"build": {
|
|
81
|
+
"openclawVersion": "2026.5.2-beta.1"
|
|
82
|
+
},
|
|
83
|
+
"release": {
|
|
84
|
+
"publishToClawHub": true,
|
|
85
|
+
"publishToNpm": true
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
package/runtime-api.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
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 "openclaw/plugin-sdk/account-id";
|
|
5
|
+
export {
|
|
6
|
+
createActionGate,
|
|
7
|
+
jsonResult,
|
|
8
|
+
readNumberParam,
|
|
9
|
+
readReactionParams,
|
|
10
|
+
readStringParam,
|
|
11
|
+
} from "openclaw/plugin-sdk/channel-actions";
|
|
12
|
+
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
|
13
|
+
export type {
|
|
14
|
+
ChannelMessageActionAdapter,
|
|
15
|
+
ChannelMessageActionName,
|
|
16
|
+
ChannelStatusIssue,
|
|
17
|
+
} from "openclaw/plugin-sdk/channel-contract";
|
|
18
|
+
export { missingTargetError } from "openclaw/plugin-sdk/channel-feedback";
|
|
19
|
+
export {
|
|
20
|
+
createAccountStatusSink,
|
|
21
|
+
runPassiveAccountLifecycle,
|
|
22
|
+
} from "openclaw/plugin-sdk/channel-lifecycle";
|
|
23
|
+
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
24
|
+
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
25
|
+
export {
|
|
26
|
+
evaluateGroupRouteAccessForPolicy,
|
|
27
|
+
resolveDmGroupAccessWithLists,
|
|
28
|
+
resolveSenderScopedGroupPolicy,
|
|
29
|
+
} from "openclaw/plugin-sdk/channel-policy";
|
|
30
|
+
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
|
31
|
+
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
|
32
|
+
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
33
|
+
export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
|
34
|
+
export {
|
|
35
|
+
GROUP_POLICY_BLOCKED_LABEL,
|
|
36
|
+
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
37
|
+
resolveDefaultGroupPolicy,
|
|
38
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
39
|
+
} from "openclaw/plugin-sdk/runtime-group-policy";
|
|
40
|
+
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
|
41
|
+
export { fetchRemoteMedia, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
|
42
|
+
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
|
43
|
+
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
|
44
|
+
export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
45
|
+
export type { GoogleChatAccountConfig, GoogleChatConfig } from "openclaw/plugin-sdk/config-types";
|
|
46
|
+
export { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
|
47
|
+
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
|
48
|
+
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
49
|
+
export { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-path";
|
|
50
|
+
export {
|
|
51
|
+
registerWebhookTargetWithPluginRoute,
|
|
52
|
+
resolveWebhookTargetWithAuthOrReject,
|
|
53
|
+
withResolvedWebhookRequestPipeline,
|
|
54
|
+
} from "openclaw/plugin-sdk/webhook-targets";
|
|
55
|
+
export {
|
|
56
|
+
createWebhookInFlightLimiter,
|
|
57
|
+
readJsonWebhookBodyOrReject,
|
|
58
|
+
type WebhookInFlightLimiter,
|
|
59
|
+
} from "openclaw/plugin-sdk/webhook-request-guards";
|
|
60
|
+
export { setGoogleChatRuntime } from "./src/runtime.js";
|
package/setup-entry.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineBundledChannelSetupEntry } from "openclaw/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
|
+
});
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAccountListHelpers,
|
|
3
|
+
DEFAULT_ACCOUNT_ID,
|
|
4
|
+
normalizeAccountId,
|
|
5
|
+
type OpenClawConfig,
|
|
6
|
+
resolveAccountEntry,
|
|
7
|
+
resolveMergedAccountConfig,
|
|
8
|
+
} from "openclaw/plugin-sdk/account-resolution";
|
|
9
|
+
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
|
|
10
|
+
import { isSecretRef } from "openclaw/plugin-sdk/secret-input";
|
|
11
|
+
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import type { GoogleChatAccountConfig } from "./types.config.js";
|
|
14
|
+
|
|
15
|
+
type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
|
|
16
|
+
|
|
17
|
+
export type ResolvedGoogleChatAccount = {
|
|
18
|
+
accountId: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
config: GoogleChatAccountConfig;
|
|
22
|
+
credentialSource: GoogleChatCredentialSource;
|
|
23
|
+
credentials?: Record<string, unknown>;
|
|
24
|
+
credentialsFile?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type GoogleChatConfigAccessorAccount = {
|
|
28
|
+
config: GoogleChatAccountConfig;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
|
|
32
|
+
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
|
33
|
+
const JsonRecordSchema = z.record(z.string(), z.unknown());
|
|
34
|
+
|
|
35
|
+
const {
|
|
36
|
+
listAccountIds: listGoogleChatAccountIds,
|
|
37
|
+
resolveDefaultAccountId: resolveDefaultGoogleChatAccountId,
|
|
38
|
+
} = createAccountListHelpers("googlechat");
|
|
39
|
+
export { listGoogleChatAccountIds, resolveDefaultGoogleChatAccountId };
|
|
40
|
+
|
|
41
|
+
function mergeGoogleChatAccountConfig(
|
|
42
|
+
cfg: OpenClawConfig,
|
|
43
|
+
accountId: string,
|
|
44
|
+
): GoogleChatAccountConfig {
|
|
45
|
+
const raw = cfg.channels?.["googlechat"] ?? {};
|
|
46
|
+
const base = resolveMergedAccountConfig<GoogleChatAccountConfig>({
|
|
47
|
+
channelConfig: raw as GoogleChatAccountConfig,
|
|
48
|
+
accounts: raw.accounts as Record<string, Partial<GoogleChatAccountConfig>> | undefined,
|
|
49
|
+
accountId,
|
|
50
|
+
omitKeys: ["defaultAccount"],
|
|
51
|
+
});
|
|
52
|
+
const defaultAccountConfig = resolveAccountEntry(raw.accounts, DEFAULT_ACCOUNT_ID) ?? {};
|
|
53
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
54
|
+
return base;
|
|
55
|
+
}
|
|
56
|
+
const {
|
|
57
|
+
enabled: _ignoredEnabled,
|
|
58
|
+
dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching,
|
|
59
|
+
serviceAccount: _ignoredServiceAccount,
|
|
60
|
+
serviceAccountRef: _ignoredServiceAccountRef,
|
|
61
|
+
serviceAccountFile: _ignoredServiceAccountFile,
|
|
62
|
+
...defaultAccountShared
|
|
63
|
+
} = defaultAccountConfig;
|
|
64
|
+
// In multi-account setups, allow accounts.default to provide shared defaults
|
|
65
|
+
// (for example webhook/audience fields) while preserving top-level and account overrides.
|
|
66
|
+
return { ...defaultAccountShared, ...base } as GoogleChatAccountConfig;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveGoogleChatConfigAccessorAccount(params: {
|
|
70
|
+
cfg: OpenClawConfig;
|
|
71
|
+
accountId?: string | null;
|
|
72
|
+
}): GoogleChatConfigAccessorAccount {
|
|
73
|
+
const accountId = normalizeAccountId(
|
|
74
|
+
params.accountId ?? params.cfg.channels?.googlechat?.defaultAccount,
|
|
75
|
+
);
|
|
76
|
+
return { config: mergeGoogleChatAccountConfig(params.cfg, accountId) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
|
|
80
|
+
if (isSecretRef(value)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
const trimmed = value.trim();
|
|
86
|
+
if (!trimmed) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return safeParseJsonWithSchema(JsonRecordSchema, trimmed);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return safeParseWithSchema(JsonRecordSchema, value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveCredentialsFromConfig(params: {
|
|
96
|
+
accountId: string;
|
|
97
|
+
account: GoogleChatAccountConfig;
|
|
98
|
+
}): {
|
|
99
|
+
credentials?: Record<string, unknown>;
|
|
100
|
+
credentialsFile?: string;
|
|
101
|
+
source: GoogleChatCredentialSource;
|
|
102
|
+
} {
|
|
103
|
+
const { account, accountId } = params;
|
|
104
|
+
const inline = parseServiceAccount(account.serviceAccount);
|
|
105
|
+
if (inline) {
|
|
106
|
+
return { credentials: inline, source: "inline" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isSecretRef(account.serviceAccount)) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`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.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (isSecretRef(account.serviceAccountRef)) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`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.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const file = normalizeOptionalString(account.serviceAccountFile);
|
|
122
|
+
if (file) {
|
|
123
|
+
return { credentialsFile: file, source: "file" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
127
|
+
const envJson = process.env[ENV_SERVICE_ACCOUNT];
|
|
128
|
+
const envInline = parseServiceAccount(envJson);
|
|
129
|
+
if (envInline) {
|
|
130
|
+
return { credentials: envInline, source: "env" };
|
|
131
|
+
}
|
|
132
|
+
const envFile = normalizeOptionalString(process.env[ENV_SERVICE_ACCOUNT_FILE]);
|
|
133
|
+
if (envFile) {
|
|
134
|
+
return { credentialsFile: envFile, source: "env" };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { source: "none" };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function resolveGoogleChatAccount(params: {
|
|
142
|
+
cfg: OpenClawConfig;
|
|
143
|
+
accountId?: string | null;
|
|
144
|
+
}): ResolvedGoogleChatAccount {
|
|
145
|
+
const accountId = normalizeAccountId(
|
|
146
|
+
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
|
|
147
|
+
);
|
|
148
|
+
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
|
|
149
|
+
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
|
150
|
+
const accountEnabled = merged.enabled !== false;
|
|
151
|
+
const enabled = baseEnabled && accountEnabled;
|
|
152
|
+
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
accountId,
|
|
156
|
+
name: normalizeOptionalString(merged.name),
|
|
157
|
+
enabled,
|
|
158
|
+
config: merged,
|
|
159
|
+
credentialSource: credentials.source,
|
|
160
|
+
credentials: credentials.credentials,
|
|
161
|
+
credentialsFile: credentials.credentialsFile,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function listEnabledGoogleChatAccounts(cfg: OpenClawConfig): ResolvedGoogleChatAccount[] {
|
|
166
|
+
return listGoogleChatAccountIds(cfg)
|
|
167
|
+
.map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
|
|
168
|
+
.filter((account) => account.enabled);
|
|
169
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { 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
|
+
it("describes send and reaction actions only when enabled accounts exist", async () => {
|
|
47
|
+
listEnabledGoogleChatAccounts.mockReturnValueOnce([]);
|
|
48
|
+
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull();
|
|
49
|
+
|
|
50
|
+
listEnabledGoogleChatAccounts.mockReturnValueOnce([
|
|
51
|
+
{
|
|
52
|
+
enabled: true,
|
|
53
|
+
credentialSource: "service-account",
|
|
54
|
+
config: { actions: { reactions: true } },
|
|
55
|
+
},
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({
|
|
59
|
+
actions: ["send", "upload-file", "react", "reactions"],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("honors account-scoped reaction gates during discovery", () => {
|
|
64
|
+
resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({
|
|
65
|
+
enabled: true,
|
|
66
|
+
credentialSource: "service-account",
|
|
67
|
+
config: {
|
|
68
|
+
actions: { reactions: accountId === "work" },
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
expect(
|
|
73
|
+
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }),
|
|
74
|
+
).toEqual({
|
|
75
|
+
actions: ["send", "upload-file"],
|
|
76
|
+
});
|
|
77
|
+
expect(
|
|
78
|
+
googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }),
|
|
79
|
+
).toEqual({
|
|
80
|
+
actions: ["send", "upload-file", "react", "reactions"],
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends messages with uploaded media through the resolved space", async () => {
|
|
85
|
+
resolveGoogleChatAccount.mockReturnValue({
|
|
86
|
+
credentialSource: "service-account",
|
|
87
|
+
config: { mediaMaxMb: 5 },
|
|
88
|
+
});
|
|
89
|
+
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA");
|
|
90
|
+
getGoogleChatRuntime.mockReturnValue({
|
|
91
|
+
channel: {
|
|
92
|
+
media: {
|
|
93
|
+
fetchRemoteMedia: vi.fn(async () => ({
|
|
94
|
+
buffer: Buffer.from("remote-bytes"),
|
|
95
|
+
fileName: "remote.png",
|
|
96
|
+
contentType: "image/png",
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
uploadGoogleChatAttachment.mockResolvedValue({
|
|
102
|
+
attachmentUploadToken: "token-1",
|
|
103
|
+
});
|
|
104
|
+
sendGoogleChatMessage.mockResolvedValue({
|
|
105
|
+
messageName: "spaces/AAA/messages/msg-1",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
if (!googlechatMessageActions.handleAction) {
|
|
109
|
+
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
|
110
|
+
}
|
|
111
|
+
const result = await googlechatMessageActions.handleAction({
|
|
112
|
+
action: "send",
|
|
113
|
+
params: {
|
|
114
|
+
to: "spaces/AAA",
|
|
115
|
+
message: "caption",
|
|
116
|
+
media: "https://example.com/file.png",
|
|
117
|
+
threadId: "thread-1",
|
|
118
|
+
},
|
|
119
|
+
cfg: {},
|
|
120
|
+
accountId: "default",
|
|
121
|
+
} as never);
|
|
122
|
+
|
|
123
|
+
expect(resolveGoogleChatOutboundSpace).toHaveBeenCalledWith(
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
target: "spaces/AAA",
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
space: "spaces/AAA",
|
|
131
|
+
filename: "remote.png",
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
|
135
|
+
expect.objectContaining({
|
|
136
|
+
space: "spaces/AAA",
|
|
137
|
+
text: "caption",
|
|
138
|
+
thread: "thread-1",
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
expect(result).toMatchObject({
|
|
142
|
+
details: {
|
|
143
|
+
ok: true,
|
|
144
|
+
to: "spaces/AAA",
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("routes upload-file through the same attachment upload path with filename override", async () => {
|
|
150
|
+
resolveGoogleChatAccount.mockReturnValue({
|
|
151
|
+
credentialSource: "service-account",
|
|
152
|
+
config: { mediaMaxMb: 5 },
|
|
153
|
+
});
|
|
154
|
+
resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB");
|
|
155
|
+
const localRoot = "/tmp/googlechat-action-test";
|
|
156
|
+
const localPath = path.join(localRoot, "local.md");
|
|
157
|
+
const readFile = vi.fn(async () => Buffer.from("local-bytes"));
|
|
158
|
+
getGoogleChatRuntime.mockReturnValue({
|
|
159
|
+
channel: {
|
|
160
|
+
media: {
|
|
161
|
+
fetchRemoteMedia: vi.fn(),
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
uploadGoogleChatAttachment.mockResolvedValue({
|
|
166
|
+
attachmentUploadToken: "token-2",
|
|
167
|
+
});
|
|
168
|
+
sendGoogleChatMessage.mockResolvedValue({
|
|
169
|
+
messageName: "spaces/BBB/messages/msg-2",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
if (!googlechatMessageActions.handleAction) {
|
|
173
|
+
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
|
174
|
+
}
|
|
175
|
+
const result = await googlechatMessageActions.handleAction({
|
|
176
|
+
action: "upload-file",
|
|
177
|
+
params: {
|
|
178
|
+
to: "spaces/BBB",
|
|
179
|
+
path: localPath,
|
|
180
|
+
message: "notes",
|
|
181
|
+
filename: "renamed.txt",
|
|
182
|
+
},
|
|
183
|
+
cfg: {},
|
|
184
|
+
accountId: "default",
|
|
185
|
+
mediaLocalRoots: [localRoot],
|
|
186
|
+
mediaReadFile: readFile,
|
|
187
|
+
} as never);
|
|
188
|
+
|
|
189
|
+
expect(readFile).toHaveBeenCalledWith(localPath);
|
|
190
|
+
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
|
191
|
+
expect.objectContaining({
|
|
192
|
+
space: "spaces/BBB",
|
|
193
|
+
filename: "renamed.txt",
|
|
194
|
+
buffer: Buffer.from("local-bytes"),
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({
|
|
199
|
+
space: "spaces/BBB",
|
|
200
|
+
text: "notes",
|
|
201
|
+
attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }],
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
expect(result).toMatchObject({
|
|
205
|
+
details: {
|
|
206
|
+
ok: true,
|
|
207
|
+
to: "spaces/BBB",
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("removes only matching app reactions on react remove", async () => {
|
|
213
|
+
resolveGoogleChatAccount.mockReturnValue({
|
|
214
|
+
credentialSource: "service-account",
|
|
215
|
+
config: { botUser: "users/app-bot" },
|
|
216
|
+
});
|
|
217
|
+
listGoogleChatReactions.mockResolvedValue([
|
|
218
|
+
{
|
|
219
|
+
name: "reactions/1",
|
|
220
|
+
emoji: { unicode: "👍" },
|
|
221
|
+
user: { name: "users/app" },
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "reactions/2",
|
|
225
|
+
emoji: { unicode: "👍" },
|
|
226
|
+
user: { name: "users/app-bot" },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: "reactions/3",
|
|
230
|
+
emoji: { unicode: "👍" },
|
|
231
|
+
user: { name: "users/other" },
|
|
232
|
+
},
|
|
233
|
+
]);
|
|
234
|
+
|
|
235
|
+
if (!googlechatMessageActions.handleAction) {
|
|
236
|
+
throw new Error("Expected googlechatMessageActions.handleAction to be defined");
|
|
237
|
+
}
|
|
238
|
+
const result = await googlechatMessageActions.handleAction({
|
|
239
|
+
action: "react",
|
|
240
|
+
params: {
|
|
241
|
+
messageId: "spaces/AAA/messages/msg-1",
|
|
242
|
+
emoji: "👍",
|
|
243
|
+
remove: true,
|
|
244
|
+
},
|
|
245
|
+
cfg: {},
|
|
246
|
+
accountId: "default",
|
|
247
|
+
} as never);
|
|
248
|
+
|
|
249
|
+
expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2);
|
|
250
|
+
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, {
|
|
251
|
+
account: expect.anything(),
|
|
252
|
+
reactionName: "reactions/1",
|
|
253
|
+
});
|
|
254
|
+
expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, {
|
|
255
|
+
account: expect.anything(),
|
|
256
|
+
reactionName: "reactions/2",
|
|
257
|
+
});
|
|
258
|
+
expect(result).toMatchObject({
|
|
259
|
+
details: {
|
|
260
|
+
ok: true,
|
|
261
|
+
removed: 2,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|