@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/src/channel.ts ADDED
@@ -0,0 +1,277 @@
1
+ import { describeAccountSnapshot } from "klaw/plugin-sdk/account-helpers";
2
+ import { formatNormalizedAllowFromEntries } from "klaw/plugin-sdk/allow-from";
3
+ import {
4
+ adaptScopedAccountAccessor,
5
+ createScopedChannelConfigAdapter,
6
+ } from "klaw/plugin-sdk/channel-config-helpers";
7
+ import type { ChannelMessageActionName } from "klaw/plugin-sdk/channel-contract";
8
+ import { createChatChannelPlugin } from "klaw/plugin-sdk/channel-core";
9
+ import { buildPassiveProbedChannelStatusSummary } from "klaw/plugin-sdk/extension-shared";
10
+ import { createLazyRuntimeNamedExport } from "klaw/plugin-sdk/lazy-runtime";
11
+ import {
12
+ createComputedAccountStatusAdapter,
13
+ createDefaultChannelRuntimeState,
14
+ } from "klaw/plugin-sdk/status-helpers";
15
+ import { extractToolSend } from "klaw/plugin-sdk/tool-send";
16
+ import { googleChatApprovalAuth } from "./approval-auth.js";
17
+ import {
18
+ formatAllowFromEntry,
19
+ googlechatDirectoryAdapter,
20
+ googlechatGroupsAdapter,
21
+ googlechatMessageAdapter,
22
+ googlechatOutboundAdapter,
23
+ googlechatPairingTextAdapter,
24
+ googlechatSecurityAdapter,
25
+ googlechatThreadingAdapter,
26
+ } from "./channel.adapters.js";
27
+ import {
28
+ buildChannelConfigSchema,
29
+ DEFAULT_ACCOUNT_ID,
30
+ GoogleChatConfigSchema,
31
+ isGoogleChatSpaceTarget,
32
+ isGoogleChatUserTarget,
33
+ listGoogleChatAccountIds,
34
+ normalizeGoogleChatTarget,
35
+ type GoogleChatConfigAccessorAccount,
36
+ resolveGoogleChatConfigAccessorAccount,
37
+ resolveDefaultGoogleChatAccountId,
38
+ resolveGoogleChatAccount,
39
+ type ChannelMessageActionAdapter,
40
+ type ChannelStatusIssue,
41
+ type ResolvedGoogleChatAccount,
42
+ } from "./channel.deps.runtime.js";
43
+ import {
44
+ legacyConfigRules as GOOGLECHAT_LEGACY_CONFIG_RULES,
45
+ normalizeCompatibilityConfig as normalizeGoogleChatCompatibilityConfig,
46
+ } from "./doctor-contract.js";
47
+ import { collectGoogleChatMutableAllowlistWarnings } from "./doctor.js";
48
+ import { startGoogleChatGatewayAccount } from "./gateway.js";
49
+ import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
50
+ import { googlechatSetupAdapter } from "./setup-core.js";
51
+ import { googlechatSetupWizard } from "./setup-surface.js";
52
+
53
+ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
54
+ () => import("./channel.runtime.js"),
55
+ "googleChatChannelRuntime",
56
+ );
57
+
58
+ const meta = {
59
+ id: "googlechat",
60
+ label: "Google Chat",
61
+ selectionLabel: "Google Chat (Chat API)",
62
+ docsPath: "/channels/googlechat",
63
+ docsLabel: "googlechat",
64
+ blurb: "Google Workspace Chat app with HTTP webhook.",
65
+ aliases: ["gchat", "google-chat"],
66
+ order: 55,
67
+ detailLabel: "Google Chat",
68
+ systemImage: "message.badge",
69
+ markdownCapable: true,
70
+ };
71
+
72
+ const googleChatConfigAdapter = createScopedChannelConfigAdapter<
73
+ ResolvedGoogleChatAccount,
74
+ GoogleChatConfigAccessorAccount
75
+ >({
76
+ sectionKey: "googlechat",
77
+ listAccountIds: listGoogleChatAccountIds,
78
+ resolveAccount: adaptScopedAccountAccessor(resolveGoogleChatAccount),
79
+ resolveAccessorAccount: resolveGoogleChatConfigAccessorAccount,
80
+ defaultAccountId: resolveDefaultGoogleChatAccountId,
81
+ clearBaseFields: [
82
+ "serviceAccount",
83
+ "serviceAccountFile",
84
+ "audienceType",
85
+ "audience",
86
+ "webhookPath",
87
+ "webhookUrl",
88
+ "botUser",
89
+ "name",
90
+ ],
91
+ resolveAllowFrom: (account) => account.config.dm?.allowFrom,
92
+ formatAllowFrom: (allowFrom) =>
93
+ formatNormalizedAllowFromEntries({
94
+ allowFrom,
95
+ normalizeEntry: formatAllowFromEntry,
96
+ }),
97
+ resolveDefaultTo: (account) => account.config.defaultTo,
98
+ });
99
+
100
+ const googlechatActions: ChannelMessageActionAdapter = {
101
+ describeMessageTool: ({ cfg, accountId }) => {
102
+ const accounts = accountId
103
+ ? [resolveGoogleChatAccount({ cfg, accountId })].filter(
104
+ (account) => account.enabled && account.credentialSource !== "none",
105
+ )
106
+ : listGoogleChatAccountIds(cfg)
107
+ .map((id) => resolveGoogleChatAccount({ cfg, accountId: id }))
108
+ .filter((account) => account.enabled && account.credentialSource !== "none");
109
+ if (accounts.length === 0) {
110
+ return null;
111
+ }
112
+ const actions = new Set<ChannelMessageActionName>(["send", "upload-file"]);
113
+ if (accounts.some((account) => account.config.actions?.reactions !== false)) {
114
+ actions.add("react");
115
+ actions.add("reactions");
116
+ }
117
+ return { actions: Array.from(actions) };
118
+ },
119
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
120
+ handleAction: async (ctx) => {
121
+ const { googlechatMessageActions } = await import("./actions.js");
122
+ if (!googlechatMessageActions.handleAction) {
123
+ throw new Error("Google Chat actions are not available.");
124
+ }
125
+ return await googlechatMessageActions.handleAction(ctx);
126
+ },
127
+ };
128
+
129
+ export const googlechatPlugin = createChatChannelPlugin({
130
+ base: {
131
+ id: "googlechat",
132
+ meta: { ...meta },
133
+ setup: googlechatSetupAdapter,
134
+ setupWizard: googlechatSetupWizard,
135
+ capabilities: {
136
+ chatTypes: ["direct", "group", "thread"],
137
+ reactions: true,
138
+ threads: true,
139
+ media: true,
140
+ nativeCommands: false,
141
+ blockStreaming: true,
142
+ },
143
+ streaming: {
144
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
145
+ },
146
+ reload: { configPrefixes: ["channels.googlechat"] },
147
+ configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
148
+ config: {
149
+ ...googleChatConfigAdapter,
150
+ isConfigured: (account) => account.credentialSource !== "none",
151
+ describeAccount: (account) =>
152
+ describeAccountSnapshot({
153
+ account,
154
+ configured: account.credentialSource !== "none",
155
+ extra: {
156
+ credentialSource: account.credentialSource,
157
+ },
158
+ }),
159
+ },
160
+ approvalCapability: googleChatApprovalAuth,
161
+ secrets: {
162
+ secretTargetRegistryEntries,
163
+ collectRuntimeConfigAssignments,
164
+ },
165
+ groups: googlechatGroupsAdapter,
166
+ messaging: {
167
+ targetPrefixes: ["googlechat", "google-chat", "gchat"],
168
+ normalizeTarget: normalizeGoogleChatTarget,
169
+ targetResolver: {
170
+ looksLikeId: (raw, normalized) => {
171
+ const value = normalized ?? raw.trim();
172
+ return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
173
+ },
174
+ hint: "<spaces/{space}|users/{user}>",
175
+ },
176
+ },
177
+ directory: googlechatDirectoryAdapter,
178
+ message: googlechatMessageAdapter,
179
+ resolver: {
180
+ resolveTargets: async ({ inputs, kind }) => {
181
+ const resolved = inputs.map((input) => {
182
+ const normalized = normalizeGoogleChatTarget(input);
183
+ if (!normalized) {
184
+ return { input, resolved: false, note: "empty target" };
185
+ }
186
+ if (kind === "user" && isGoogleChatUserTarget(normalized)) {
187
+ return { input, resolved: true, id: normalized };
188
+ }
189
+ if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
190
+ return { input, resolved: true, id: normalized };
191
+ }
192
+ return {
193
+ input,
194
+ resolved: false,
195
+ note: "use spaces/{space} or users/{user}",
196
+ };
197
+ });
198
+ return resolved;
199
+ },
200
+ },
201
+ actions: googlechatActions,
202
+ doctor: {
203
+ dmAllowFromMode: "nestedOnly",
204
+ groupModel: "route",
205
+ groupAllowFromFallbackToAllowFrom: false,
206
+ warnOnEmptyGroupSenderAllowlist: false,
207
+ legacyConfigRules: GOOGLECHAT_LEGACY_CONFIG_RULES,
208
+ normalizeCompatibilityConfig: normalizeGoogleChatCompatibilityConfig,
209
+ collectMutableAllowlistWarnings: collectGoogleChatMutableAllowlistWarnings,
210
+ },
211
+ status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
212
+ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
213
+ collectStatusIssues: (accounts): ChannelStatusIssue[] =>
214
+ accounts.flatMap((entry) => {
215
+ const accountId = entry.accountId ?? DEFAULT_ACCOUNT_ID;
216
+ const enabled = entry.enabled !== false;
217
+ const configured = entry.configured === true;
218
+ if (!enabled || !configured) {
219
+ return [];
220
+ }
221
+ const issues: ChannelStatusIssue[] = [];
222
+ if (!entry.audience) {
223
+ issues.push({
224
+ channel: "googlechat",
225
+ accountId,
226
+ kind: "config",
227
+ message: "Google Chat audience is missing (set channels.googlechat.audience).",
228
+ fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
229
+ });
230
+ }
231
+ if (!entry.audienceType) {
232
+ issues.push({
233
+ channel: "googlechat",
234
+ accountId,
235
+ kind: "config",
236
+ message: "Google Chat audienceType is missing (app-url or project-number).",
237
+ fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
238
+ });
239
+ }
240
+ return issues;
241
+ }),
242
+ buildChannelSummary: ({ snapshot }) =>
243
+ buildPassiveProbedChannelStatusSummary(snapshot, {
244
+ credentialSource: snapshot.credentialSource ?? "none",
245
+ audienceType: snapshot.audienceType ?? null,
246
+ audience: snapshot.audience ?? null,
247
+ webhookPath: snapshot.webhookPath ?? null,
248
+ webhookUrl: snapshot.webhookUrl ?? null,
249
+ }),
250
+ probeAccount: async ({ account }) =>
251
+ (await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
252
+ resolveAccountSnapshot: ({ account }) => ({
253
+ accountId: account.accountId,
254
+ name: account.name,
255
+ enabled: account.enabled,
256
+ configured: account.credentialSource !== "none",
257
+ extra: {
258
+ credentialSource: account.credentialSource,
259
+ audienceType: account.config.audienceType,
260
+ audience: account.config.audience,
261
+ webhookPath: account.config.webhookPath,
262
+ webhookUrl: account.config.webhookUrl,
263
+ dmPolicy: account.config.dm?.policy ?? "pairing",
264
+ },
265
+ }),
266
+ }),
267
+ gateway: {
268
+ startAccount: startGoogleChatGatewayAccount,
269
+ },
270
+ },
271
+ pairing: {
272
+ text: googlechatPairingTextAdapter,
273
+ },
274
+ security: googlechatSecurityAdapter,
275
+ threading: googlechatThreadingAdapter,
276
+ outbound: googlechatOutboundAdapter,
277
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { GoogleChatConfigSchema } from "../runtime-api.js";
3
+
4
+ describe("googlechat config schema", () => {
5
+ it("accepts serviceAccount refs", () => {
6
+ const result = GoogleChatConfigSchema.safeParse({
7
+ serviceAccountRef: {
8
+ source: "file",
9
+ provider: "filemain",
10
+ id: "/channels/googlechat/serviceAccount",
11
+ },
12
+ });
13
+
14
+ expect(result.success).toBe(true);
15
+ });
16
+
17
+ it("accepts the documented group config shape", () => {
18
+ const result = GoogleChatConfigSchema.safeParse({
19
+ groups: {
20
+ "spaces/AAAA": {
21
+ enabled: true,
22
+ requireMention: true,
23
+ users: ["users/1234567890"],
24
+ systemPrompt: "Short answers only.",
25
+ },
26
+ },
27
+ });
28
+
29
+ expect(result.success).toBe(true);
30
+ });
31
+ });
@@ -0,0 +1,3 @@
1
+ import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../config-api.js";
2
+
3
+ export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { normalizeCompatibilityConfig } from "./doctor-contract.js";
3
+
4
+ describe("googlechat doctor contract", () => {
5
+ it("removes legacy streamMode keys", () => {
6
+ const result = normalizeCompatibilityConfig({
7
+ cfg: {
8
+ channels: {
9
+ googlechat: {
10
+ streamMode: "append",
11
+ accounts: {
12
+ work: {
13
+ streamMode: "replace",
14
+ },
15
+ },
16
+ },
17
+ },
18
+ } as never,
19
+ });
20
+
21
+ expect(result.changes).toEqual([
22
+ "Removed channels.googlechat.streamMode (legacy key no longer used).",
23
+ "Removed channels.googlechat.accounts.work.streamMode (legacy key no longer used).",
24
+ ]);
25
+ expect(result.config.channels?.googlechat).toEqual({
26
+ accounts: {
27
+ work: {},
28
+ },
29
+ });
30
+ });
31
+
32
+ it("moves legacy group allow toggles into enabled", () => {
33
+ const result = normalizeCompatibilityConfig({
34
+ cfg: {
35
+ channels: {
36
+ googlechat: {
37
+ groups: {
38
+ "spaces/aaa": {
39
+ allow: false,
40
+ },
41
+ "spaces/bbb": {
42
+ allow: true,
43
+ enabled: false,
44
+ },
45
+ },
46
+ accounts: {
47
+ work: {
48
+ groups: {
49
+ "spaces/ccc": {
50
+ allow: true,
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ },
57
+ } as never,
58
+ });
59
+
60
+ expect(result.changes).toEqual([
61
+ "Moved channels.googlechat.groups.spaces/aaa.allow → channels.googlechat.groups.spaces/aaa.enabled.",
62
+ "Removed channels.googlechat.groups.spaces/bbb.allow (channels.googlechat.groups.spaces/bbb.enabled already set).",
63
+ "Moved channels.googlechat.accounts.work.groups.spaces/ccc.allow → channels.googlechat.accounts.work.groups.spaces/ccc.enabled.",
64
+ ]);
65
+ expect(result.config.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
66
+ enabled: false,
67
+ });
68
+ expect(result.config.channels?.googlechat?.groups?.["spaces/bbb"]).toEqual({
69
+ enabled: false,
70
+ });
71
+ expect(result.config.channels?.googlechat?.accounts?.work?.groups?.["spaces/ccc"]).toEqual({
72
+ enabled: true,
73
+ });
74
+ });
75
+ });
@@ -0,0 +1,182 @@
1
+ import type {
2
+ ChannelDoctorConfigMutation,
3
+ ChannelDoctorLegacyConfigRule,
4
+ } from "klaw/plugin-sdk/channel-contract";
5
+ import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
6
+ import { asObjectRecord } from "klaw/plugin-sdk/runtime-doctor";
7
+
8
+ type GoogleChatChannelsConfig = NonNullable<KlawConfig["channels"]>;
9
+
10
+ function hasLegacyGoogleChatStreamMode(value: unknown): boolean {
11
+ return asObjectRecord(value)?.streamMode !== undefined;
12
+ }
13
+
14
+ function hasLegacyGoogleChatGroupAllowAlias(value: unknown): boolean {
15
+ const groups = asObjectRecord(asObjectRecord(value)?.groups);
16
+ if (!groups) {
17
+ return false;
18
+ }
19
+ return Object.values(groups).some((group) =>
20
+ Object.prototype.hasOwnProperty.call(asObjectRecord(group) ?? {}, "allow"),
21
+ );
22
+ }
23
+
24
+ function hasLegacyAccountAliases(value: unknown, match: (entry: unknown) => boolean): boolean {
25
+ const accounts = asObjectRecord(value);
26
+ if (!accounts) {
27
+ return false;
28
+ }
29
+ return Object.values(accounts).some((account) => match(account));
30
+ }
31
+
32
+ function normalizeGoogleChatGroups(params: {
33
+ groups: Record<string, unknown>;
34
+ pathPrefix: string;
35
+ changes: string[];
36
+ }): { groups: Record<string, unknown>; changed: boolean } {
37
+ let changed = false;
38
+ const nextGroups = { ...params.groups };
39
+ for (const [groupId, groupValue] of Object.entries(params.groups)) {
40
+ const group = asObjectRecord(groupValue);
41
+ if (!group || !Object.prototype.hasOwnProperty.call(group, "allow")) {
42
+ continue;
43
+ }
44
+ const nextGroup = { ...group };
45
+ if (nextGroup.enabled === undefined) {
46
+ nextGroup.enabled = group.allow;
47
+ params.changes.push(
48
+ `Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled.`,
49
+ );
50
+ } else {
51
+ params.changes.push(
52
+ `Removed ${params.pathPrefix}.${groupId}.allow (${params.pathPrefix}.${groupId}.enabled already set).`,
53
+ );
54
+ }
55
+ delete nextGroup.allow;
56
+ nextGroups[groupId] = nextGroup;
57
+ changed = true;
58
+ }
59
+ return { groups: nextGroups, changed };
60
+ }
61
+
62
+ function normalizeGoogleChatEntry(params: {
63
+ entry: Record<string, unknown>;
64
+ pathPrefix: string;
65
+ changes: string[];
66
+ }): { entry: Record<string, unknown>; changed: boolean } {
67
+ let updated = params.entry;
68
+ let changed = false;
69
+
70
+ if (updated.streamMode !== undefined) {
71
+ updated = { ...updated };
72
+ delete updated.streamMode;
73
+ params.changes.push(`Removed ${params.pathPrefix}.streamMode (legacy key no longer used).`);
74
+ changed = true;
75
+ }
76
+
77
+ const groups = asObjectRecord(updated.groups);
78
+ if (groups) {
79
+ const normalized = normalizeGoogleChatGroups({
80
+ groups,
81
+ pathPrefix: `${params.pathPrefix}.groups`,
82
+ changes: params.changes,
83
+ });
84
+ if (normalized.changed) {
85
+ updated = { ...updated, groups: normalized.groups };
86
+ changed = true;
87
+ }
88
+ }
89
+
90
+ return { entry: updated, changed };
91
+ }
92
+
93
+ export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
94
+ {
95
+ path: ["channels", "googlechat"],
96
+ message: "channels.googlechat.streamMode is legacy and no longer used; it is removed on load.",
97
+ match: hasLegacyGoogleChatStreamMode,
98
+ },
99
+ {
100
+ path: ["channels", "googlechat", "accounts"],
101
+ message:
102
+ "channels.googlechat.accounts.<id>.streamMode is legacy and no longer used; it is removed on load.",
103
+ match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatStreamMode),
104
+ },
105
+ {
106
+ path: ["channels", "googlechat"],
107
+ message:
108
+ 'channels.googlechat.groups.<id>.allow is legacy; use channels.googlechat.groups.<id>.enabled instead. Run "klaw doctor --fix".',
109
+ match: hasLegacyGoogleChatGroupAllowAlias,
110
+ },
111
+ {
112
+ path: ["channels", "googlechat", "accounts"],
113
+ message:
114
+ 'channels.googlechat.accounts.<id>.groups.<id>.allow is legacy; use channels.googlechat.accounts.<id>.groups.<id>.enabled instead. Run "klaw doctor --fix".',
115
+ match: (value) => hasLegacyAccountAliases(value, hasLegacyGoogleChatGroupAllowAlias),
116
+ },
117
+ ];
118
+
119
+ export function normalizeCompatibilityConfig({
120
+ cfg,
121
+ }: {
122
+ cfg: KlawConfig;
123
+ }): ChannelDoctorConfigMutation {
124
+ const rawEntry = asObjectRecord(
125
+ (cfg.channels as Record<string, unknown> | undefined)?.googlechat,
126
+ );
127
+ if (!rawEntry) {
128
+ return { config: cfg, changes: [] };
129
+ }
130
+
131
+ const changes: string[] = [];
132
+ let updated = rawEntry;
133
+ let changed = false;
134
+
135
+ const root = normalizeGoogleChatEntry({
136
+ entry: updated,
137
+ pathPrefix: "channels.googlechat",
138
+ changes,
139
+ });
140
+ updated = root.entry;
141
+ changed = root.changed;
142
+
143
+ const accounts = asObjectRecord(updated.accounts);
144
+ if (accounts) {
145
+ let accountsChanged = false;
146
+ const nextAccounts = { ...accounts };
147
+ for (const [accountId, accountValue] of Object.entries(accounts)) {
148
+ const account = asObjectRecord(accountValue);
149
+ if (!account) {
150
+ continue;
151
+ }
152
+ const normalized = normalizeGoogleChatEntry({
153
+ entry: account,
154
+ pathPrefix: `channels.googlechat.accounts.${accountId}`,
155
+ changes,
156
+ });
157
+ if (!normalized.changed) {
158
+ continue;
159
+ }
160
+ nextAccounts[accountId] = normalized.entry;
161
+ accountsChanged = true;
162
+ }
163
+ if (accountsChanged) {
164
+ updated = { ...updated, accounts: nextAccounts };
165
+ changed = true;
166
+ }
167
+ }
168
+
169
+ if (!changed) {
170
+ return { config: cfg, changes: [] };
171
+ }
172
+ return {
173
+ config: {
174
+ ...cfg,
175
+ channels: {
176
+ ...cfg.channels,
177
+ googlechat: updated as GoogleChatChannelsConfig["googlechat"],
178
+ },
179
+ },
180
+ changes,
181
+ };
182
+ }
package/src/doctor.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "klaw/plugin-sdk/channel-policy";
2
+
3
+ function asObjectRecord(value: unknown): Record<string, unknown> | null {
4
+ return value && typeof value === "object" && !Array.isArray(value)
5
+ ? (value as Record<string, unknown>)
6
+ : null;
7
+ }
8
+
9
+ function isGoogleChatMutableAllowEntry(raw: string): boolean {
10
+ const text = raw.trim();
11
+ if (!text || text === "*") {
12
+ return false;
13
+ }
14
+
15
+ const withoutPrefix = text.replace(/^(googlechat|google-chat|gchat):/i, "").trim();
16
+ if (!withoutPrefix) {
17
+ return false;
18
+ }
19
+
20
+ const withoutUsers = withoutPrefix.replace(/^users\//i, "");
21
+ return withoutUsers.includes("@");
22
+ }
23
+
24
+ export const collectGoogleChatMutableAllowlistWarnings =
25
+ createDangerousNameMatchingMutableAllowlistWarningCollector({
26
+ channel: "googlechat",
27
+ detector: isGoogleChatMutableAllowEntry,
28
+ collectLists: (scope) => {
29
+ const lists = [
30
+ {
31
+ pathLabel: `${scope.prefix}.groupAllowFrom`,
32
+ list: scope.account.groupAllowFrom,
33
+ },
34
+ ];
35
+ const dm = asObjectRecord(scope.account.dm);
36
+ if (dm) {
37
+ lists.push({
38
+ pathLabel: `${scope.prefix}.dm.allowFrom`,
39
+ list: dm.allowFrom,
40
+ });
41
+ }
42
+ const groups = asObjectRecord(scope.account.groups);
43
+ if (groups) {
44
+ for (const [groupKey, groupRaw] of Object.entries(groups)) {
45
+ const group = asObjectRecord(groupRaw);
46
+ if (!group) {
47
+ continue;
48
+ }
49
+ lists.push({
50
+ pathLabel: `${scope.prefix}.groups.${groupKey}.users`,
51
+ list: group.users,
52
+ });
53
+ }
54
+ }
55
+ return lists;
56
+ },
57
+ });
package/src/gateway.ts ADDED
@@ -0,0 +1,63 @@
1
+ import {
2
+ createAccountStatusSink,
3
+ runPassiveAccountLifecycle,
4
+ } from "klaw/plugin-sdk/channel-lifecycle";
5
+ import type { KlawConfig } from "klaw/plugin-sdk/config-contracts";
6
+ import { createLazyRuntimeNamedExport } from "klaw/plugin-sdk/lazy-runtime";
7
+ import type { ChannelAccountSnapshot } from "klaw/plugin-sdk/status-helpers";
8
+ import type { ResolvedGoogleChatAccount } from "./accounts.js";
9
+ import type { GoogleChatRuntimeEnv } from "./monitor-types.js";
10
+
11
+ const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport(
12
+ () => import("./channel.runtime.js"),
13
+ "googleChatChannelRuntime",
14
+ );
15
+
16
+ export async function startGoogleChatGatewayAccount(ctx: {
17
+ account: ResolvedGoogleChatAccount;
18
+ cfg: KlawConfig;
19
+ runtime: GoogleChatRuntimeEnv;
20
+ abortSignal: AbortSignal;
21
+ setStatus: (next: ChannelAccountSnapshot) => void;
22
+ log?: {
23
+ info?: (message: string) => void;
24
+ };
25
+ }): Promise<void> {
26
+ const account = ctx.account;
27
+ const statusSink = createAccountStatusSink({
28
+ accountId: account.accountId,
29
+ setStatus: ctx.setStatus,
30
+ });
31
+ ctx.log?.info?.(`[${account.accountId}] starting Google Chat webhook`);
32
+ const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
33
+ await loadGoogleChatChannelRuntime();
34
+ statusSink({
35
+ running: true,
36
+ lastStartAt: Date.now(),
37
+ webhookPath: resolveGoogleChatWebhookPath({ account }),
38
+ audienceType: account.config.audienceType,
39
+ audience: account.config.audience,
40
+ });
41
+ await runPassiveAccountLifecycle({
42
+ abortSignal: ctx.abortSignal,
43
+ start: async () =>
44
+ await startGoogleChatMonitor({
45
+ account,
46
+ config: ctx.cfg,
47
+ runtime: ctx.runtime,
48
+ abortSignal: ctx.abortSignal,
49
+ webhookPath: account.config.webhookPath,
50
+ webhookUrl: account.config.webhookUrl,
51
+ statusSink,
52
+ }),
53
+ stop: async (unregister) => {
54
+ unregister?.();
55
+ },
56
+ onStop: async () => {
57
+ statusSink({
58
+ running: false,
59
+ lastStopAt: Date.now(),
60
+ });
61
+ },
62
+ });
63
+ }