@actagent/googlechat 2026.6.2

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 (73) hide show
  1. package/README.md +11 -0
  2. package/actagent.plugin.json +17 -0
  3. package/api.ts +4 -0
  4. package/channel-config-api.ts +2 -0
  5. package/channel-plugin-api.ts +2 -0
  6. package/config-api.ts +3 -0
  7. package/contract-api.ts +6 -0
  8. package/directory-contract-api.ts +7 -0
  9. package/doctor-contract-api.ts +2 -0
  10. package/index.ts +21 -0
  11. package/npm-shrinkwrap.json +314 -0
  12. package/package.json +88 -0
  13. package/runtime-api.ts +61 -0
  14. package/secret-contract-api.ts +6 -0
  15. package/setup-entry.ts +14 -0
  16. package/setup-plugin-api.ts +3 -0
  17. package/src/accounts.ts +185 -0
  18. package/src/actions.test.ts +312 -0
  19. package/src/actions.ts +228 -0
  20. package/src/api.ts +346 -0
  21. package/src/approval-auth.test.ts +25 -0
  22. package/src/approval-auth.ts +38 -0
  23. package/src/approval-card-actions.test.ts +113 -0
  24. package/src/approval-card-actions.ts +307 -0
  25. package/src/approval-card-click.test.ts +279 -0
  26. package/src/approval-card-click.ts +94 -0
  27. package/src/approval-handler.runtime.test.ts +388 -0
  28. package/src/approval-handler.runtime.ts +413 -0
  29. package/src/approval-native.test.ts +399 -0
  30. package/src/approval-native.ts +246 -0
  31. package/src/auth.ts +219 -0
  32. package/src/channel-base.ts +123 -0
  33. package/src/channel-config.test.ts +174 -0
  34. package/src/channel.adapters.ts +363 -0
  35. package/src/channel.deps.runtime.ts +30 -0
  36. package/src/channel.runtime.ts +18 -0
  37. package/src/channel.setup.ts +7 -0
  38. package/src/channel.test.ts +845 -0
  39. package/src/channel.ts +214 -0
  40. package/src/config-schema.test.ts +32 -0
  41. package/src/config-schema.ts +4 -0
  42. package/src/doctor-contract.test.ts +76 -0
  43. package/src/doctor-contract.ts +181 -0
  44. package/src/doctor.ts +58 -0
  45. package/src/gateway.ts +84 -0
  46. package/src/google-auth.runtime.test.ts +571 -0
  47. package/src/google-auth.runtime.ts +570 -0
  48. package/src/group-policy.ts +18 -0
  49. package/src/monitor-access.test.ts +492 -0
  50. package/src/monitor-access.ts +466 -0
  51. package/src/monitor-durable.test.ts +40 -0
  52. package/src/monitor-durable.ts +24 -0
  53. package/src/monitor-reply-delivery.ts +162 -0
  54. package/src/monitor-routing.ts +66 -0
  55. package/src/monitor-types.ts +34 -0
  56. package/src/monitor-webhook.test.ts +670 -0
  57. package/src/monitor-webhook.ts +361 -0
  58. package/src/monitor.reply-delivery.test.ts +145 -0
  59. package/src/monitor.test.ts +389 -0
  60. package/src/monitor.ts +530 -0
  61. package/src/monitor.webhook-routing.test.ts +258 -0
  62. package/src/runtime.ts +10 -0
  63. package/src/secret-contract.test.ts +61 -0
  64. package/src/secret-contract.ts +162 -0
  65. package/src/setup-core.ts +41 -0
  66. package/src/setup-surface.ts +244 -0
  67. package/src/setup.test.ts +620 -0
  68. package/src/targets.test.ts +562 -0
  69. package/src/targets.ts +67 -0
  70. package/src/types.config.ts +4 -0
  71. package/src/types.ts +139 -0
  72. package/test-api.ts +3 -0
  73. package/tsconfig.json +16 -0
@@ -0,0 +1,413 @@
1
+ import type {
2
+ ChannelApprovalCapabilityHandlerContext,
3
+ ExpiredApprovalView,
4
+ PendingApprovalView,
5
+ ResolvedApprovalView,
6
+ } from "actagent/plugin-sdk/approval-handler-runtime";
7
+ import { createChannelApprovalNativeRuntimeAdapter } from "actagent/plugin-sdk/approval-handler-runtime";
8
+ import { buildChannelApprovalNativeTargetKey } from "actagent/plugin-sdk/approval-native-runtime";
9
+ import type { ExecApprovalDecision } from "actagent/plugin-sdk/approval-runtime";
10
+ import { createSubsystemLogger } from "actagent/plugin-sdk/runtime-env";
11
+ import { normalizeOptionalString } from "actagent/plugin-sdk/string-coerce-runtime";
12
+ import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
13
+ import { sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js";
14
+ import {
15
+ buildGoogleChatApprovalActionParameters,
16
+ createGoogleChatApprovalToken,
17
+ GOOGLECHAT_APPROVAL_ACTION,
18
+ registerGoogleChatApprovalCardBinding,
19
+ registerGoogleChatManualApprovalFollowupSuppression,
20
+ unregisterGoogleChatManualApprovalFollowupSuppression,
21
+ unregisterGoogleChatApprovalCardBindings,
22
+ } from "./approval-card-actions.js";
23
+ import {
24
+ isGoogleChatNativeApprovalClientEnabled,
25
+ shouldHandleGoogleChatNativeApprovalRequest,
26
+ } from "./approval-native.js";
27
+ import { resolveGoogleChatOutboundSpace } from "./targets.js";
28
+ import type { GoogleChatCardV2 } from "./types.js";
29
+
30
+ const log = createSubsystemLogger("googlechat/approvals");
31
+ const GOOGLECHAT_APPROVAL_CARD_ID = "actagent-approval";
32
+ const MAX_TEXT_PARAGRAPH_CHARS = 1800;
33
+
34
+ type GoogleChatApprovalHandlerContext = {
35
+ account?: ResolvedGoogleChatAccount;
36
+ };
37
+
38
+ type GoogleChatApprovalActionToken = {
39
+ token: string;
40
+ decision: ExecApprovalDecision;
41
+ };
42
+
43
+ type GoogleChatPendingDelivery = {
44
+ approvalId: string;
45
+ approvalKind: "exec" | "plugin";
46
+ expiresAtMs: number;
47
+ cardsV2: GoogleChatCardV2[];
48
+ actionTokens: GoogleChatApprovalActionToken[];
49
+ allowedDecisions: readonly ExecApprovalDecision[];
50
+ };
51
+
52
+ type PreparedGoogleChatTarget = {
53
+ to: string;
54
+ threadName?: string;
55
+ };
56
+
57
+ type GoogleChatPendingEntry = {
58
+ accountId: string;
59
+ spaceName: string;
60
+ messageName: string;
61
+ threadName?: string;
62
+ actionTokens: GoogleChatApprovalActionToken[];
63
+ };
64
+
65
+ type GoogleChatFinalDelivery = {
66
+ cardsV2: GoogleChatCardV2[];
67
+ };
68
+
69
+ function resolveHandlerAccount(
70
+ params: ChannelApprovalCapabilityHandlerContext,
71
+ ): ResolvedGoogleChatAccount | null {
72
+ const context = params.context as GoogleChatApprovalHandlerContext | undefined;
73
+ const account =
74
+ context?.account ??
75
+ resolveGoogleChatAccount({
76
+ cfg: params.cfg,
77
+ accountId: params.accountId,
78
+ });
79
+ if (!account.enabled || account.credentialSource === "none") {
80
+ return null;
81
+ }
82
+ return account;
83
+ }
84
+
85
+ function escapeGoogleChatText(text: string): string {
86
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
87
+ }
88
+
89
+ function truncateText(text: string, maxChars = MAX_TEXT_PARAGRAPH_CHARS): string {
90
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars - 3)}...`;
91
+ }
92
+
93
+ function buildMetadataText(metadata: readonly { label: string; value: string }[]): string {
94
+ return metadata
95
+ .map(
96
+ (item) => `<b>${escapeGoogleChatText(item.label)}:</b> ${escapeGoogleChatText(item.value)}`,
97
+ )
98
+ .join("<br>");
99
+ }
100
+
101
+ function formatDecision(decision: ExecApprovalDecision): string {
102
+ return decision === "allow-once"
103
+ ? "Allowed once"
104
+ : decision === "allow-always"
105
+ ? "Allowed always"
106
+ : "Denied";
107
+ }
108
+
109
+ function buildMainTextWidget(text: string) {
110
+ return {
111
+ textParagraph: {
112
+ text: escapeGoogleChatText(truncateText(text)),
113
+ },
114
+ };
115
+ }
116
+
117
+ function buildHtmlTextWidget(text: string) {
118
+ return {
119
+ textParagraph: {
120
+ text: truncateText(text),
121
+ },
122
+ };
123
+ }
124
+
125
+ function buildExecPendingSections(view: PendingApprovalView) {
126
+ if (view.approvalKind !== "exec") {
127
+ return [];
128
+ }
129
+ return [
130
+ {
131
+ header: "Command",
132
+ widgets: [buildMainTextWidget(view.commandText)],
133
+ },
134
+ ...(view.commandPreview && view.commandPreview !== view.commandText
135
+ ? [
136
+ {
137
+ header: "Preview",
138
+ widgets: [buildMainTextWidget(view.commandPreview)],
139
+ },
140
+ ]
141
+ : []),
142
+ ];
143
+ }
144
+
145
+ function buildPluginPendingSections(view: PendingApprovalView) {
146
+ if (view.approvalKind !== "plugin") {
147
+ return [];
148
+ }
149
+ return [
150
+ {
151
+ header: "Request",
152
+ widgets: [
153
+ buildHtmlTextWidget(
154
+ `<b>${escapeGoogleChatText(view.title)}</b>${
155
+ view.description ? `<br>${escapeGoogleChatText(view.description)}` : ""
156
+ }`,
157
+ ),
158
+ ],
159
+ },
160
+ ];
161
+ }
162
+
163
+ function buildMetadataSection(
164
+ view: PendingApprovalView | ResolvedApprovalView | ExpiredApprovalView,
165
+ ) {
166
+ const metadata = [{ label: "Approval ID", value: view.approvalId }, ...view.metadata];
167
+ return metadata.length > 0
168
+ ? [
169
+ {
170
+ header: "Details",
171
+ widgets: [buildHtmlTextWidget(buildMetadataText(metadata))],
172
+ },
173
+ ]
174
+ : [];
175
+ }
176
+
177
+ function buildActionSection(params: { actionFunction: string; view: PendingApprovalView }): {
178
+ section: NonNullable<GoogleChatCardV2["card"]["sections"]>[number];
179
+ actionTokens: GoogleChatApprovalActionToken[];
180
+ } {
181
+ const { actionFunction, view } = params;
182
+ const actionTokens = view.actions.map((action) => ({
183
+ token: createGoogleChatApprovalToken(),
184
+ decision: action.decision,
185
+ }));
186
+ return {
187
+ actionTokens,
188
+ section: {
189
+ widgets: [
190
+ {
191
+ buttonList: {
192
+ buttons: view.actions.map((action, index) => {
193
+ const actionToken = actionTokens[index];
194
+ if (!actionToken) {
195
+ throw new Error("Google Chat approval action token missing.");
196
+ }
197
+ return {
198
+ text: action.label,
199
+ onClick: {
200
+ action: {
201
+ function: actionFunction,
202
+ parameters: buildGoogleChatApprovalActionParameters(actionToken.token),
203
+ loadIndicator: "SPINNER" as const,
204
+ },
205
+ },
206
+ };
207
+ }),
208
+ },
209
+ },
210
+ ],
211
+ },
212
+ };
213
+ }
214
+
215
+ function buildPendingPayload(params: {
216
+ actionFunction: string;
217
+ nowMs: number;
218
+ view: PendingApprovalView;
219
+ }): GoogleChatPendingDelivery {
220
+ const { actionFunction, nowMs, view } = params;
221
+ const { section: actionSection, actionTokens } = buildActionSection({ actionFunction, view });
222
+ const title =
223
+ view.approvalKind === "plugin" ? "Plugin Approval Required" : "Exec Approval Required";
224
+ const subtitle = `Expires in ${Math.max(0, Math.ceil((view.expiresAtMs - nowMs) / 1000))}s`;
225
+ const card: GoogleChatCardV2 = {
226
+ cardId: GOOGLECHAT_APPROVAL_CARD_ID,
227
+ card: {
228
+ header: { title, subtitle },
229
+ sections: [
230
+ ...buildExecPendingSections(view),
231
+ ...buildPluginPendingSections(view),
232
+ ...buildMetadataSection(view),
233
+ actionSection,
234
+ ],
235
+ },
236
+ };
237
+ return {
238
+ approvalId: view.approvalId,
239
+ approvalKind: view.approvalKind,
240
+ expiresAtMs: view.expiresAtMs,
241
+ cardsV2: [card],
242
+ actionTokens,
243
+ allowedDecisions: view.actions.map((action) => action.decision),
244
+ };
245
+ }
246
+
247
+ function resolveApprovalActionFunction(params: ChannelApprovalCapabilityHandlerContext): string {
248
+ const account = resolveHandlerAccount(params);
249
+ const audience = normalizeOptionalString(account?.config.audience);
250
+ const appPrincipal = normalizeOptionalString(account?.config.appPrincipal);
251
+ return account?.config.audienceType === "app-url" && audience && appPrincipal
252
+ ? audience
253
+ : GOOGLECHAT_APPROVAL_ACTION;
254
+ }
255
+
256
+ function buildResolvedPayload(view: ResolvedApprovalView): GoogleChatFinalDelivery {
257
+ const resolvedBy = normalizeOptionalString(view.resolvedBy);
258
+ const card: GoogleChatCardV2 = {
259
+ cardId: GOOGLECHAT_APPROVAL_CARD_ID,
260
+ card: {
261
+ header: {
262
+ title: `${view.approvalKind === "plugin" ? "Plugin" : "Exec"} Approval: ${formatDecision(
263
+ view.decision,
264
+ )}`,
265
+ subtitle: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
266
+ },
267
+ sections: buildMetadataSection(view),
268
+ },
269
+ };
270
+ return {
271
+ cardsV2: [card],
272
+ };
273
+ }
274
+
275
+ function buildExpiredPayload(view: ExpiredApprovalView): GoogleChatFinalDelivery {
276
+ const card: GoogleChatCardV2 = {
277
+ cardId: GOOGLECHAT_APPROVAL_CARD_ID,
278
+ card: {
279
+ header: {
280
+ title: `${view.approvalKind === "plugin" ? "Plugin" : "Exec"} Approval Expired`,
281
+ subtitle: "This approval request expired before it was resolved.",
282
+ },
283
+ sections: buildMetadataSection(view),
284
+ },
285
+ };
286
+ return {
287
+ cardsV2: [card],
288
+ };
289
+ }
290
+
291
+ export const googleChatApprovalNativeRuntime = createChannelApprovalNativeRuntimeAdapter<
292
+ GoogleChatPendingDelivery,
293
+ PreparedGoogleChatTarget,
294
+ GoogleChatPendingEntry,
295
+ readonly string[],
296
+ GoogleChatFinalDelivery
297
+ >({
298
+ eventKinds: ["exec", "plugin"],
299
+ availability: {
300
+ isConfigured: ({ cfg, accountId }) =>
301
+ isGoogleChatNativeApprovalClientEnabled({ cfg, accountId }),
302
+ shouldHandle: ({ cfg, accountId, request }) =>
303
+ shouldHandleGoogleChatNativeApprovalRequest({ cfg, accountId, request }),
304
+ },
305
+ presentation: {
306
+ buildPendingPayload: ({ cfg, accountId, context, nowMs, view }) =>
307
+ buildPendingPayload({
308
+ actionFunction: resolveApprovalActionFunction({ cfg, accountId, context }),
309
+ nowMs,
310
+ view,
311
+ }),
312
+ buildResolvedResult: ({ view }) => ({ kind: "update", payload: buildResolvedPayload(view) }),
313
+ buildExpiredResult: ({ view }) => ({ kind: "update", payload: buildExpiredPayload(view) }),
314
+ },
315
+ transport: {
316
+ prepareTarget: ({ plannedTarget }) => ({
317
+ dedupeKey: buildChannelApprovalNativeTargetKey(plannedTarget.target),
318
+ target: {
319
+ to: plannedTarget.target.to,
320
+ threadName:
321
+ plannedTarget.target.threadId != null ? String(plannedTarget.target.threadId) : undefined,
322
+ },
323
+ }),
324
+ deliverPending: async ({ cfg, accountId, context, preparedTarget, pendingPayload }) => {
325
+ const account = resolveHandlerAccount({ cfg, accountId, context });
326
+ if (!account) {
327
+ return null;
328
+ }
329
+ const spaceName = await resolveGoogleChatOutboundSpace({
330
+ account,
331
+ target: preparedTarget.to,
332
+ });
333
+ // Native delivery can race the model's message tool follow-up; register before
334
+ // the send awaits so the channel-local outbound filter can suppress duplicates.
335
+ registerGoogleChatManualApprovalFollowupSuppression({
336
+ approvalId: pendingPayload.approvalId,
337
+ approvalKind: pendingPayload.approvalKind,
338
+ allowedDecisions: pendingPayload.allowedDecisions,
339
+ expiresAtMs: pendingPayload.expiresAtMs,
340
+ });
341
+ let sent: Awaited<ReturnType<typeof sendGoogleChatMessage>>;
342
+ try {
343
+ sent = await sendGoogleChatMessage({
344
+ account,
345
+ space: spaceName,
346
+ cardsV2: pendingPayload.cardsV2,
347
+ thread: preparedTarget.threadName,
348
+ });
349
+ } catch (error) {
350
+ unregisterGoogleChatManualApprovalFollowupSuppression(pendingPayload.approvalId);
351
+ throw error;
352
+ }
353
+ if (!sent?.messageName) {
354
+ unregisterGoogleChatManualApprovalFollowupSuppression(pendingPayload.approvalId);
355
+ return null;
356
+ }
357
+ return {
358
+ accountId: account.accountId,
359
+ spaceName,
360
+ messageName: sent.messageName,
361
+ ...(preparedTarget.threadName ? { threadName: preparedTarget.threadName } : {}),
362
+ actionTokens: pendingPayload.actionTokens,
363
+ };
364
+ },
365
+ updateEntry: async ({ cfg, accountId, context, entry, payload }) => {
366
+ const account = resolveHandlerAccount({ cfg, accountId, context });
367
+ if (!account) {
368
+ return;
369
+ }
370
+ await updateGoogleChatMessage({
371
+ account,
372
+ messageName: entry.messageName,
373
+ cardsV2: payload.cardsV2,
374
+ });
375
+ },
376
+ },
377
+ interactions: {
378
+ bindPending: ({ entry, request, approvalKind, view, pendingPayload }) => {
379
+ const tokens: string[] = [];
380
+ for (const actionToken of entry.actionTokens) {
381
+ const ok = registerGoogleChatApprovalCardBinding({
382
+ token: actionToken.token,
383
+ accountId: entry.accountId,
384
+ approvalId: request.id,
385
+ approvalKind,
386
+ decision: actionToken.decision,
387
+ allowedDecisions: pendingPayload.allowedDecisions,
388
+ spaceName: entry.spaceName,
389
+ messageName: entry.messageName,
390
+ threadName: entry.threadName ?? null,
391
+ expiresAtMs: view.expiresAtMs,
392
+ });
393
+ if (ok) {
394
+ tokens.push(actionToken.token);
395
+ }
396
+ }
397
+ return tokens.length > 0 ? tokens : null;
398
+ },
399
+ unbindPending: ({ binding }) => {
400
+ unregisterGoogleChatApprovalCardBindings(binding);
401
+ },
402
+ cancelDelivered: ({ entry }) => {
403
+ unregisterGoogleChatApprovalCardBindings(
404
+ entry.actionTokens.map((actionToken) => actionToken.token),
405
+ );
406
+ },
407
+ },
408
+ observe: {
409
+ onDeliveryError: ({ error, request }) => {
410
+ log.error(`googlechat approvals: failed to send request ${request.id}: ${String(error)}`);
411
+ },
412
+ },
413
+ });