feat(google-forms): added google forms block (#1343)

* fix(sidebar): draggable cursor on sidebar when switching workflows (#1276)

* added google form tool to read forms

* added trigger mode and block docs

* updated docs

* removed file

* reverted diff

* greptile comments

* Reverted bun file

* remove outdated code for old webhook modal

* restore ui changes to webhooks

* removed provider specific logic

* fix lint

---------

Co-authored-by: Waleed Latif <walif6@gmail.com>
Co-authored-by: Vikhyath Mondreti <vikhyathvikku@gmail.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
Co-authored-by: Adam Gough <adamgough@Mac.attlocal.net>
Co-authored-by: Siddharth Ganesan <33737564+Sg312@users.noreply.github.com>
This commit is contained in:
Adam Gough
2025-09-16 21:20:59 -07:00
committed by GitHub
parent bd402cdda5
commit 4b5c2b43e9
22 changed files with 828 additions and 92 deletions

View File

@@ -0,0 +1,86 @@
---
title: Google Forms
description: Read responses from a Google Form
---
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="google_forms"
color="#E0E0E0"
icon={true}
iconSvg={`<svg className="block-icon" xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 65' fill='none'>
<path
d='M29.583 0H4.438C1.997 0 0 1.997 0 4.438v56.208C0 63.086 1.997 65.083 4.438 65.083h38.458c2.44 0 4.437-1.997 4.437-4.437V17.75L36.979 10.354 29.583 0Z'
fill='#673AB7'
/>
<path
d='M29.583 0v10.354c0 2.45 1.986 4.438 4.438 4.438h13.312L36.979 10.354 29.583 0Z'
fill='#B39DDB'
/>
<path
d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z'
fill='#F1F1F1'
/>
<defs>
<linearGradient
id='gf-gradient'
x1='30.881'
y1='16.452'
x2='47.333'
y2='32.9'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9575CD' />
<stop offset='1' stopColor='#7E57C2' />
</linearGradient>
</defs>
</svg>`}
/>
{/* MANUAL-CONTENT-START:intro */}
[Google Forms](https://forms.google.com) is Google's online survey and form tool that allows users to create forms, collect responses, and analyze results. As part of Google's productivity suite, Google Forms makes it easy to gather information, feedback, and data from users.
Learn how to integrate the Google Forms tool in Sim to automatically read and process form responses in your workflows. This tutorial walks you through connecting Google Forms, retrieving responses, and using collected data to power automation. Perfect for syncing survey results, registrations, or feedback with your agents in real-time.
With Google Forms, you can:
- **Create surveys and forms**: Design custom forms for feedback, registration, quizzes, and more
- **Collect responses automatically**: Gather data from users in real-time
- **Analyze results**: View responses in Google Forms or export to Google Sheets for further analysis
- **Collaborate easily**: Share forms and work with others to build and review questions
- **Integrate with other Google services**: Connect with Google Sheets, Drive, and more
In Sim, the Google Forms integration enables your agents to programmatically access form responses. This allows for powerful automation scenarios such as processing survey data, triggering workflows based on new submissions, and syncing form results with other tools. Your agents can fetch all responses for a form, retrieve a specific response, and use the data to drive intelligent automation. By connecting Sim with Google Forms, you can automate data collection, streamline feedback processing, and incorporate form responses into your agent's capabilities.
{/* MANUAL-CONTENT-END */}
## Usage Instructions
Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.
## Tools
### `google_forms_get_responses`
#### Input
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| formId | string | Yes | The ID of the Google Form |
| responseId | string | No | If provided, returns this specific response |
| pageSize | number | No | Max responses to return (service may return fewer). Defaults to 5000 |
#### Output
| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `data` | json | Response or list of responses |
## Notes
- Category: `tools`
- Type: `google_forms`

View File

@@ -201,6 +201,37 @@ export async function POST(
}
}
// Handle Google Forms shared-secret authentication (Apps Script forwarder)
if (foundWebhook.provider === 'google_forms') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
const expectedToken = providerConfig.token as string | undefined
const secretHeaderName = providerConfig.secretHeaderName as string | undefined
if (expectedToken) {
let isTokenValid = false
if (secretHeaderName) {
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
if (headerValue === expectedToken) {
isTokenValid = true
}
} else {
const authHeader = request.headers.get('authorization')
if (authHeader?.toLowerCase().startsWith('bearer ')) {
const token = authHeader.substring(7)
if (token === expectedToken) {
isTokenValid = true
}
}
}
if (!isTokenValid) {
logger.warn(`[${requestId}] Google Forms webhook authentication failed for path: ${path}`)
return new NextResponse('Unauthorized - Invalid secret', { status: 401 })
}
}
}
// Handle generic webhook authentication if enabled
if (foundWebhook.provider === 'generic') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}

View File

@@ -144,8 +144,7 @@ export function AuthSelector({
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'hover:bg-muted/50 hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
@@ -162,8 +161,7 @@ export function AuthSelector({
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'hover:bg-muted/50 hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-30',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
@@ -171,7 +169,7 @@ export function AuthSelector({
{copySuccess ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Copy className='h-3.5 w-3.5 ' />
)}
<span className='sr-only'>Copy password</span>
</Button>
@@ -184,15 +182,14 @@ export function AuthSelector({
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
{showPassword ? (
<EyeOff className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<EyeOff className='h-3.5 w-3.5 ' />
) : (
<Eye className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Eye className='h-3.5 w-3.5 ' />
)}
<span className='sr-only'>
{showPassword ? 'Hide password' : 'Show password'}

View File

@@ -605,7 +605,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
{copySuccess[webhook.id] ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy webhook URL</span>
</Button>
@@ -643,7 +643,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
<Play className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Play className='h-3.5 w-3.5' />
<span className='sr-only'>Test webhook</span>
</Button>
</TooltipTrigger>
@@ -665,7 +665,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
<Pencil className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Pencil className='h-3.5 w-3.5' />
<span className='sr-only'>Edit webhook</span>
</Button>
</TooltipTrigger>
@@ -687,7 +687,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
<Trash2 className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Trash2 className='h-3.5 w-3.5' />
<span className='sr-only'>Delete webhook</span>
</Button>
</TooltipTrigger>
@@ -874,8 +874,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'hover:bg-muted/50 hover:text-foreground',
'disabled:cursor-not-allowed disabled:opacity-30',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
@@ -883,7 +882,7 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
{copySuccess.form ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Copy className='h-3.5 w-3.5' />
)}
<span className='sr-only'>Copy secret</span>
</Button>
@@ -901,16 +900,15 @@ export function WebhookSettings({ workflowId, open, onOpenChange }: WebhookSetti
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => setShowSecret(!showSecret)}
>
{showSecret ? (
<EyeOff className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<EyeOff className='h-3.5 w-3.5' />
) : (
<Eye className='h-3.5 w-3.5 transition-transform duration-200 group-hover:scale-110' />
<Eye className='h-3.5 w-3.5' />
)}
<span className='sr-only'>
{showSecret ? 'Hide secret' : 'Show secret'}

View File

@@ -366,7 +366,7 @@ export function ShortInput({
<Input
ref={inputRef}
className={cn(
'allow-scroll w-full overflow-auto text-transparent caret-foreground placeholder:text-muted-foreground/50',
'allow-scroll w-full overflow-auto text-transparent caret-foreground [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground/50 [&::-webkit-scrollbar]:hidden',
isConnecting &&
config?.connectionDroppable !== false &&
'ring-2 ring-blue-500 ring-offset-2 focus-visible:ring-blue-500'
@@ -403,13 +403,13 @@ export function ShortInput({
onWheel={handleWheel}
onKeyDown={handleKeyDown}
autoComplete='off'
style={{ overflowX: 'auto' }}
style={{ overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
disabled={disabled}
/>
<div
ref={overlayRef}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-3 text-sm'
style={{ overflowX: 'auto' }}
className='pointer-events-none absolute inset-0 flex items-center overflow-x-auto bg-transparent px-3 text-sm [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
style={{ overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div
className='w-full whitespace-pre'

View File

@@ -125,7 +125,7 @@ export function TriggerConfigSection({
<Button
variant='outline'
role='combobox'
className='h-10 w-full justify-between text-left font-normal'
className='h-9 w-full justify-between rounded-[8px] text-left font-normal'
>
<div className='flex w-full items-center justify-between'>
{selectedValues.length > 0 ? (
@@ -209,6 +209,7 @@ export function TriggerConfigSection({
placeholder={fieldDef.placeholder}
value={value}
onChange={(e) => onChange(fieldId, Number(e.target.value))}
className='h-9 rounded-[8px]'
/>
{fieldDef.description && (
<p className='text-muted-foreground text-sm'>{fieldDef.description}</p>
@@ -247,52 +248,61 @@ export function TriggerConfigSection({
</Tooltip>
)}
</div>
<div className='flex'>
<div className='relative flex-1'>
<Input
id={fieldId}
type={isSecret && !showSecret ? 'password' : 'text'}
placeholder={fieldDef.placeholder}
value={value}
onChange={(e) => onChange(fieldId, e.target.value)}
className={cn(
'h-10 flex-1',
isSecret ? 'pr-10' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
/>
{isSecret && (
<div className='relative'>
<Input
id={fieldId}
type={isSecret && !showSecret ? 'password' : 'text'}
placeholder={fieldDef.placeholder}
value={value}
onChange={(e) => onChange(fieldId, e.target.value)}
className={cn(
'h-9 rounded-[8px]',
isSecret ? 'pr-32' : '',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
/>
{isSecret && (
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Button
type='button'
variant='ghost'
size='icon'
size='sm'
className={cn(
'-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 text-muted-foreground',
'transition-colors hover:bg-transparent hover:text-foreground'
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => toggleSecretVisibility(fieldId)}
aria-label={showSecret ? 'Hide secret' : 'Show secret'}
>
{showSecret ? <EyeOff className='h-4 w-4' /> : <Eye className='h-4 w-4' />}
{showSecret ? (
<EyeOff className='h-3.5 w-3.5 ' />
) : (
<Eye className='h-3.5 w-3.5 ' />
)}
<span className='sr-only'>{showSecret ? 'Hide secret' : 'Show secret'}</span>
</Button>
)}
</div>
{isSecret && (
<Button
type='button'
size='icon'
variant='outline'
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(value, fieldId)}
disabled={!value}
>
{copied === fieldId ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
)}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => copyToClipboard(value, fieldId)}
disabled={!value}
>
{copied === fieldId ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 ' />
)}
</Button>
</div>
)}
</div>
</div>
@@ -329,31 +339,37 @@ export function TriggerConfigSection({
</Tooltip>
</TooltipProvider>
</div>
<div className='flex'>
<div className='relative flex-1'>
<Input
value={webhookUrl}
readOnly
className={cn(
'h-10 flex-1 cursor-text font-mono text-xs',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
</div>
<Button
type='button'
size='icon'
variant='outline'
className={cn('ml-2 h-10 w-10', 'hover:bg-primary/5', 'transition-colors')}
onClick={() => copyToClipboard(webhookUrl, 'url')}
>
{copied === 'url' ? (
<Check className='h-4 w-4 text-green-500' />
) : (
<Copy className='h-4 w-4' />
<div className='relative'>
<Input
value={webhookUrl}
readOnly
className={cn(
'h-9 cursor-text rounded-[8px] pr-10 font-mono text-xs',
'focus-visible:ring-2 focus-visible:ring-primary/20'
)}
</Button>
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<div className='absolute top-0.5 right-0.5 flex h-8 items-center gap-1 pr-1'>
<Button
type='button'
variant='ghost'
size='sm'
className={cn(
'group h-7 w-7 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:scale-105 hover:bg-muted/50 hover:text-foreground',
'active:scale-95',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
onClick={() => copyToClipboard(webhookUrl, 'url')}
>
{copied === 'url' ? (
<Check className='h-3.5 w-3.5 text-foreground' />
) : (
<Copy className='h-3.5 w-3.5 ' />
)}
</Button>
</div>
</div>
</div>
)}

View File

@@ -1,4 +1,6 @@
import { Notice } from '@/components/ui'
import { useState } from 'react'
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'
import { Button, Notice } from '@/components/ui'
import { cn } from '@/lib/utils'
import { JSONView } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/console/components'
import type { TriggerConfig } from '@/triggers/types'
@@ -8,6 +10,7 @@ interface TriggerInstructionsProps {
webhookUrl: string
samplePayload: any
triggerDef: TriggerConfig
config?: Record<string, any>
}
export function TriggerInstructions({
@@ -15,7 +18,30 @@ export function TriggerInstructions({
webhookUrl,
samplePayload,
triggerDef,
config = {},
}: TriggerInstructionsProps) {
const [copied, setCopied] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const token = (config as any)?.token as string | undefined
const secretHeaderName = (config as any)?.secretHeaderName as string | undefined
const formId = (config as any)?.formId || '<YOUR_FORM_ID>'
const headerLine = secretHeaderName
? `{ '${secretHeaderName}': TOKEN }`
: "{ Authorization: 'Bearer ' + TOKEN }"
const googleFormsSnippet = token
? `const WEBHOOK_URL = '${webhookUrl || '<WEBHOOK URL>'}';\nconst TOKEN = '${token}'; // from Sim Trigger Configuration\nconst FORM_ID = '${formId}'; // optional but recommended\n\nfunction onFormSubmit(e) {\n var answers = {};\n var formResponse = e && e.response;\n if (formResponse && typeof formResponse.getItemResponses === 'function') {\n var itemResponses = formResponse.getItemResponses() || [];\n for (var i = 0; i < itemResponses.length; i++) {\n var ir = itemResponses[i];\n var question = ir.getItem().getTitle();\n var value = ir.getResponse();\n if (Array.isArray(value)) {\n value = value.length === 1 ? value[0] : value;\n }\n answers[question] = value;\n }\n } else if (e && e.namedValues) {\n var namedValues = e.namedValues || {};\n for (var k in namedValues) {\n var v = namedValues[k];\n answers[k] = Array.isArray(v) ? (v.length === 1 ? v[0] : v) : v;\n }\n }\n\n var payload = {\n provider: 'googleforms',\n formId: FORM_ID || undefined,\n responseId: Utilities.getUuid(),\n createTime: new Date().toISOString(),\n lastSubmittedTime: new Date().toISOString(),\n answers: answers,\n raw: e || {}\n };\n\n UrlFetchApp.fetch(WEBHOOK_URL, {\n method: 'post',\n contentType: 'application/json',\n payload: JSON.stringify(payload),\n headers: ${headerLine},\n muteHttpExceptions: true\n });\n}`
: `const WEBHOOK_URL = '${webhookUrl || '<WEBHOOK URL>'}';\nconst FORM_ID = '${formId}'; // optional but recommended\n\nfunction onFormSubmit(e) {\n var answers = {};\n var formResponse = e && e.response;\n if (formResponse && typeof formResponse.getItemResponses === 'function') {\n var itemResponses = formResponse.getItemResponses() || [];\n for (var i = 0; i < itemResponses.length; i++) {\n var ir = itemResponses[i];\n var question = ir.getItem().getTitle();\n var value = ir.getResponse();\n if (Array.isArray(value)) {\n value = value.length === 1 ? value[0] : value;\n }\n answers[question] = value;\n }\n } else if (e && e.namedValues) {\n var namedValues = e.namedValues || {};\n for (var k in namedValues) {\n var v = namedValues[k];\n answers[k] = Array.isArray(v) ? (v.length === 1 ? v[0] : v) : v;\n }\n }\n\n var payload = {\n provider: 'googleforms',\n formId: FORM_ID || undefined,\n responseId: Utilities.getUuid(),\n createTime: new Date().toISOString(),\n lastSubmittedTime: new Date().toISOString(),\n answers: answers,\n raw: e || {}\n };\n\n UrlFetchApp.fetch(WEBHOOK_URL, {\n method: 'post',\n contentType: 'application/json',\n payload: JSON.stringify(payload),\n muteHttpExceptions: true\n });\n}`
const copySnippet = async () => {
try {
await navigator.clipboard.writeText(googleFormsSnippet)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {}
}
return (
<div className='space-y-4'>
<div className={cn('mt-4 rounded-md border border-border bg-card/50 p-4 shadow-sm')}>
@@ -27,6 +53,59 @@ export function TriggerInstructions({
))}
</ol>
</div>
{triggerDef.provider === 'google_forms' && (
<div className='mt-4'>
<div className='relative overflow-hidden rounded-lg border border-border bg-card shadow-sm'>
<div
className='relative flex cursor-pointer items-center border-border/60 border-b bg-muted/30 px-4 py-3 transition-colors hover:bg-muted/40'
onClick={() => setIsExpanded(!isExpanded)}
>
<div className='flex items-center gap-2'>
{isExpanded ? (
<ChevronDown className='h-4 w-4 text-muted-foreground' />
) : (
<ChevronRight className='h-4 w-4 text-muted-foreground' />
)}
{triggerDef.icon && (
<triggerDef.icon className='h-4 w-4 text-[#611f69] dark:text-[#e01e5a]' />
)}
<h5 className='font-medium text-sm'>Apps Script snippet</h5>
</div>
{isExpanded && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
copySnippet()
}}
aria-label='Copy snippet'
className={cn(
'group -translate-y-1/2 absolute top-1/2 right-3 h-6 w-6 rounded-md p-0',
'text-muted-foreground/60 transition-all duration-200',
'hover:bg-muted/50 hover:text-foreground',
'focus-visible:ring-2 focus-visible:ring-muted-foreground/20 focus-visible:ring-offset-1'
)}
>
{copied ? (
<Check className='h-3 w-3 text-foreground' />
) : (
<Copy className='h-3 w-3' />
)}
</Button>
)}
</div>
{isExpanded && (
<div className='overflow-auto p-4'>
<pre className='whitespace-pre-wrap font-mono text-foreground text-xs leading-5'>
{googleFormsSnippet}
</pre>
</div>
)}
</div>
</div>
)}
</div>
<Notice
@@ -37,7 +116,7 @@ export function TriggerInstructions({
<triggerDef.icon className='mt-0.5 mr-3.5 h-5 w-5 flex-shrink-0 text-[#611f69] dark:text-[#e01e5a]' />
) : null
}
title={`${triggerDef.provider.charAt(0).toUpperCase() + triggerDef.provider.slice(1)} Event Payload Example`}
title={`${triggerDef.provider.charAt(0).toUpperCase() + triggerDef.provider.slice(1).replace(/_/g, ' ')} Event Payload Example`}
>
Your workflow will receive a payload similar to this when a subscribed event occurs.
<div className='overflow-wrap-anywhere mt-2 whitespace-normal break-normal font-mono text-sm'>

View File

@@ -336,7 +336,7 @@ export function TriggerModal({
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[600px]'
className='flex max-h-[90vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[800px]'
hideCloseButton
onOpenAutoFocus={(e) => e.preventDefault()}
>
@@ -419,7 +419,7 @@ export function TriggerModal({
onClick={handleDelete}
disabled={isDeleting || isSaving}
size='default'
className='h-10'
className='h-9 rounded-[8px]'
>
{isDeleting ? (
<div className='mr-2 h-4 w-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent' />
@@ -431,7 +431,12 @@ export function TriggerModal({
)}
</div>
<div className='flex gap-2'>
<Button variant='outline' onClick={onClose} size='default' className='h-10'>
<Button
variant='outline'
onClick={onClose}
size='default'
className='h-9 rounded-[8px]'
>
Cancel
</Button>
<Button
@@ -442,7 +447,7 @@ export function TriggerModal({
(!(hasConfigChanged || hasCredentialChanged) && !!triggerId)
}
className={cn(
'h-10',
'h-9 rounded-[8px]',
isConfigValid() && (hasConfigChanged || hasCredentialChanged || !triggerId)
? 'bg-primary hover:bg-primary/90'
: '',

View File

@@ -0,0 +1,94 @@
import { GoogleFormsIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
export const GoogleFormsBlock: BlockConfig = {
type: 'google_forms',
name: 'Google Forms',
description: 'Read responses from a Google Form',
longDescription:
'Integrate Google Forms into your workflow. Provide a Form ID to list responses, or specify a Response ID to fetch a single response. Requires OAuth.',
docsLink: 'https://docs.sim.ai/tools/google_forms',
category: 'tools',
bgColor: '#E0E0E0',
icon: GoogleFormsIcon,
subBlocks: [
{
id: 'credential',
title: 'Google Account',
type: 'oauth-input',
layout: 'full',
required: true,
provider: 'google-forms',
serviceId: 'google-forms',
requiredScopes: [],
placeholder: 'Select Google account',
},
{
id: 'formId',
title: 'Form ID',
type: 'short-input',
layout: 'full',
required: true,
placeholder: 'Enter the Google Form ID',
dependsOn: ['credential'],
},
{
id: 'responseId',
title: 'Response ID',
type: 'short-input',
layout: 'full',
placeholder: 'Enter a specific response ID',
},
{
id: 'pageSize',
title: 'Page Size',
type: 'short-input',
layout: 'full',
placeholder: 'Max responses to retrieve (default 5000)',
},
// Trigger configuration (shown when block is in trigger mode)
{
id: 'triggerConfig',
title: 'Trigger Configuration',
type: 'trigger-config',
layout: 'full',
triggerProvider: 'google_forms',
availableTriggers: ['google_forms_webhook'],
},
],
tools: {
access: ['google_forms_get_responses'],
config: {
tool: () => 'google_forms_get_responses',
params: (params) => {
const { credential, formId, responseId, pageSize, ...rest } = params
const effectiveFormId = String(formId || '').trim()
if (!effectiveFormId) {
throw new Error('Form ID is required.')
}
return {
...rest,
formId: effectiveFormId,
responseId: responseId ? String(responseId).trim() : undefined,
pageSize: pageSize ? Number(pageSize) : undefined,
credential,
}
},
},
},
inputs: {
credential: { type: 'string', description: 'Google OAuth credential' },
formId: { type: 'string', description: 'Google Form ID' },
responseId: { type: 'string', description: 'Specific response ID' },
pageSize: { type: 'string', description: 'Max responses to retrieve (default 5000)' },
},
outputs: {
data: { type: 'json', description: 'Response or list of responses' },
},
triggers: {
enabled: true,
available: ['google_forms_webhook'],
},
}

View File

@@ -25,6 +25,7 @@ import { GoogleSearchBlock } from '@/blocks/blocks/google'
import { GoogleCalendarBlock } from '@/blocks/blocks/google_calendar'
import { GoogleDocsBlock } from '@/blocks/blocks/google_docs'
import { GoogleDriveBlock } from '@/blocks/blocks/google_drive'
import { GoogleFormsBlock } from '@/blocks/blocks/google_form'
import { GoogleSheetsBlock } from '@/blocks/blocks/google_sheets'
import { HuggingFaceBlock } from '@/blocks/blocks/huggingface'
import { HunterBlock } from '@/blocks/blocks/hunter'
@@ -104,6 +105,7 @@ export const registry: Record<string, BlockConfig> = {
google_calendar: GoogleCalendarBlock,
google_docs: GoogleDocsBlock,
google_drive: GoogleDriveBlock,
google_forms: GoogleFormsBlock,
google_search: GoogleSearchBlock,
google_sheets: GoogleSheetsBlock,
huggingface: HuggingFaceBlock,

View File

@@ -3651,6 +3651,38 @@ export const HIPAABadgeIcon = (props: SVGProps<SVGSVGElement>) => (
</svg>
)
export function GoogleFormsIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg {...props} xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 65' fill='none'>
<path
d='M29.583 0H4.438C1.997 0 0 1.997 0 4.438v56.208C0 63.086 1.997 65.083 4.438 65.083h38.458c2.44 0 4.437-1.997 4.437-4.437V17.75L36.979 10.354 29.583 0Z'
fill='#673AB7'
/>
<path
d='M29.583 0v10.354c0 2.45 1.986 4.438 4.438 4.438h13.312L36.979 10.354 29.583 0Z'
fill='#B39DDB'
/>
<path
d='M19.229 50.292h16.271v-2.959H19.229v2.959Zm0-17.75v2.958h16.271v-2.958H19.229Zm-3.698 1.479c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm0 7.396c0 1.224-0.995 2.219-2.219 2.219s-2.219-0.995-2.219-2.219c0-1.224 0.995-2.219 2.219-2.219s2.219 0.995 2.219 2.219Zm3.698-5.917h16.271v-2.959H19.229v2.959Z'
fill='#F1F1F1'
/>
<defs>
<linearGradient
id='gf-gradient'
x1='30.881'
y1='16.452'
x2='47.333'
y2='32.9'
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#9575CD' />
<stop offset='1' stopColor='#7E57C2' />
</linearGradient>
</defs>
</svg>
)
}
export const SMSIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}

View File

@@ -31,7 +31,12 @@ export class TriggerBlockHandler implements BlockHandler {
// (e.g., webhook payload injected at init), return it as-is to preserve the raw shape.
const existingState = context.blockStates.get(block.id)
if (existingState?.output && Object.keys(existingState.output).length > 0) {
return existingState.output
const existingOutput = existingState.output as any
const existingProvider = existingOutput?.webhook?.data?.provider
// Provider-specific output shaping should be handled upstream per trigger's webhook formatter
return existingOutput
}
// For trigger blocks, return the starter block's output which contains the workflow input

View File

@@ -441,6 +441,21 @@ export const auth = betterAuth({
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-sheets`,
},
{
providerId: 'google-forms',
clientId: env.GOOGLE_CLIENT_ID as string,
clientSecret: env.GOOGLE_CLIENT_SECRET as string,
discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration',
accessType: 'offline',
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
prompt: 'consent',
redirectURI: `${env.NEXT_PUBLIC_APP_URL}/api/auth/oauth2/callback/google-forms`,
},
{
providerId: 'microsoft-teams',
clientId: env.MICROSOFT_CLIENT_ID as string,

View File

@@ -8,6 +8,7 @@ import {
GoogleCalendarIcon,
GoogleDocsIcon,
GoogleDriveIcon,
GoogleFormsIcon,
GoogleIcon,
GoogleSheetsIcon,
JiraIcon,
@@ -55,6 +56,7 @@ export type OAuthService =
| 'google-docs'
| 'google-sheets'
| 'google-calendar'
| 'google-forms'
| 'github'
| 'x'
| 'supabase'
@@ -138,6 +140,19 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderConfig> = {
baseProviderIcon: (props) => GoogleIcon(props),
scopes: ['https://www.googleapis.com/auth/drive.file'],
},
'google-forms': {
id: 'google-forms',
name: 'Google Forms',
description: 'Retrieve Google Form responses.',
providerId: 'google-forms',
icon: (props) => GoogleFormsIcon(props),
baseProviderIcon: (props) => GoogleIcon(props),
scopes: [
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
'https://www.googleapis.com/auth/forms.responses.readonly',
],
},
'google-calendar': {
id: 'google-calendar',
name: 'Google Calendar',
@@ -515,6 +530,9 @@ export function getServiceIdFromScopes(provider: OAuthProvider, scopes: string[]
if (scopes.some((scope) => scope.includes('calendar'))) {
return 'google-calendar'
}
if (scopes.some((scope) => scope.includes('forms'))) {
return 'google-forms'
}
} else if (provider === 'microsoft-teams') {
return 'microsoft-teams'
} else if (provider === 'outlook') {

View File

@@ -512,6 +512,64 @@ export function formatWebhookInput(
}
}
if (foundWebhook.provider === 'google_forms') {
const providerConfig = (foundWebhook.providerConfig as Record<string, any>) || {}
// Normalize answers: if value is an array with single element, collapse to scalar; keep multi-select arrays
const normalizeAnswers = (src: unknown): Record<string, unknown> => {
if (!src || typeof src !== 'object') return {}
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(src as Record<string, unknown>)) {
if (Array.isArray(v)) {
out[k] = v.length === 1 ? v[0] : v
} else {
out[k] = v as unknown
}
}
return out
}
const responseId = body?.responseId || body?.id || ''
const createTime = body?.createTime || body?.timestamp || new Date().toISOString()
const lastSubmittedTime = body?.lastSubmittedTime || createTime
const formId = body?.formId || providerConfig.formId || ''
const includeRaw = providerConfig.includeRawPayload !== false
const normalizedAnswers = normalizeAnswers(body?.answers)
const summaryCount = Object.keys(normalizedAnswers).length
const input = `Google Form response${responseId ? ` ${responseId}` : ''} (${summaryCount} answers)`
return {
input,
responseId,
createTime,
lastSubmittedTime,
formId,
answers: normalizedAnswers,
...(includeRaw ? { raw: body?.raw ?? body } : {}),
google_forms: {
responseId,
createTime,
lastSubmittedTime,
formId,
answers: normalizedAnswers,
...(includeRaw ? { raw: body?.raw ?? body } : {}),
},
webhook: {
data: {
provider: 'google_forms',
path: foundWebhook.path,
providerConfig: foundWebhook.providerConfig,
payload: includeRaw ? body : undefined,
headers: Object.fromEntries(request.headers.entries()),
method: request.method,
},
},
workflowId: foundWorkflow.id,
}
}
if (foundWebhook.provider === 'github') {
// GitHub webhook input formatting logic
const eventType = request.headers.get('x-github-event') || 'unknown'

View File

@@ -0,0 +1,166 @@
import type {
GoogleFormsGetResponsesParams,
GoogleFormsResponse,
GoogleFormsResponseList,
} from '@/tools/google_form/types'
import { buildGetResponseUrl, buildListResponsesUrl } from '@/tools/google_form/utils'
import type { ToolConfig } from '@/tools/types'
export const getResponsesTool: ToolConfig<GoogleFormsGetResponsesParams> = {
id: 'google_forms_get_responses',
name: 'Google Forms: Get Responses',
description: 'Retrieve a single response or list responses from a Google Form',
version: '1.0.0',
oauth: {
required: true,
provider: 'google-forms',
additionalScopes: [],
},
params: {
accessToken: {
type: 'string',
required: true,
visibility: 'hidden',
description: 'OAuth2 access token',
},
formId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'The ID of the Google Form',
},
responseId: {
type: 'string',
required: false,
visibility: 'user-only',
description: 'If provided, returns this specific response',
},
pageSize: {
type: 'number',
required: false,
visibility: 'user-only',
description:
'Maximum number of responses to return (service may return fewer). Defaults to 5000.',
},
},
request: {
url: (params: GoogleFormsGetResponsesParams) =>
params.responseId
? buildGetResponseUrl({ formId: params.formId, responseId: params.responseId })
: buildListResponsesUrl({ formId: params.formId, pageSize: params.pageSize }),
method: 'GET',
headers: (params: GoogleFormsGetResponsesParams) => ({
Authorization: `Bearer ${params.accessToken}`,
'Content-Type': 'application/json',
}),
},
transformResponse: async (response: Response, params?: GoogleFormsGetResponsesParams) => {
const data = (await response.json()) as unknown
if (!response.ok) {
let errorMessage = response.statusText || 'Failed to fetch responses'
if (data && typeof data === 'object') {
const record = data as Record<string, unknown>
const error = record.error as { message?: string } | undefined
if (error?.message) {
errorMessage = error.message
}
}
return {
success: false,
output: (data as Record<string, unknown>) || {},
error: errorMessage,
}
}
// Normalize answers into a flat key/value map per response
const normalizeAnswerContainer = (container: unknown): unknown => {
if (!container || typeof container !== 'object') return container
const record = container as Record<string, unknown>
const answers = record.answers as unknown[] | undefined
if (Array.isArray(answers)) {
const values = answers.map((entry) => {
if (entry && typeof entry === 'object') {
const er = entry as Record<string, unknown>
if (typeof er.value !== 'undefined') return er.value
}
return entry
})
return values.length === 1 ? values[0] : values
}
return container
}
const normalizeAnswers = (answers: unknown): Record<string, unknown> => {
if (!answers || typeof answers !== 'object') return {}
const src = answers as Record<string, unknown>
const out: Record<string, unknown> = {}
for (const [questionId, answerObj] of Object.entries(src)) {
if (answerObj && typeof answerObj === 'object') {
const aRec = answerObj as Record<string, unknown>
// Find first *Answers property that contains an answers array
const key = Object.keys(aRec).find(
(k) => k.toLowerCase().endsWith('answers') && Array.isArray((aRec[k] as any)?.answers)
)
if (key) {
out[questionId] = normalizeAnswerContainer(aRec[key])
continue
}
}
out[questionId] = answerObj as unknown
}
return out
}
const normalizeResponse = (r: GoogleFormsResponse): Record<string, unknown> => ({
responseId: r.responseId,
createTime: r.createTime,
lastSubmittedTime: r.lastSubmittedTime,
answers: normalizeAnswers(r.answers as unknown),
})
// Distinguish single vs list response shapes
const isList = (obj: unknown): obj is GoogleFormsResponseList =>
!!obj && typeof obj === 'object' && Array.isArray((obj as GoogleFormsResponseList).responses)
if (isList(data)) {
const listData = data as GoogleFormsResponseList
const toTimestamp = (s?: string): number => {
if (!s) return 0
const t = Date.parse(s)
return Number.isNaN(t) ? 0 : t
}
const sorted = (listData.responses || [])
.slice()
.sort(
(a, b) =>
toTimestamp(b.lastSubmittedTime || b.createTime) -
toTimestamp(a.lastSubmittedTime || a.createTime)
)
const normalized = sorted.map((r) => normalizeResponse(r))
return {
success: true,
output: {
responses: normalized,
raw: listData,
} as unknown as Record<string, unknown>,
}
}
const single = data as GoogleFormsResponse
const normalizedSingle = normalizeResponse(single)
return {
success: true,
output: {
response: normalizedSingle,
raw: single,
} as unknown as Record<string, unknown>,
}
},
}

View File

@@ -0,0 +1,3 @@
import { getResponsesTool } from '@/tools/google_form/get_responses'
export const googleFormsGetResponsesTool = getResponsesTool

View File

@@ -0,0 +1,21 @@
export interface GoogleFormsResponse {
responseId?: string
createTime?: string
lastSubmittedTime?: string
answers?: Record<string, unknown>
respondentEmail?: string
totalScore?: number
[key: string]: unknown
}
export interface GoogleFormsResponseList {
responses?: GoogleFormsResponse[]
nextPageToken?: string
}
export interface GoogleFormsGetResponsesParams {
accessToken: string
formId: string
responseId?: string
pageSize?: number
}

View File

@@ -0,0 +1,24 @@
import { createLogger } from '@/lib/logs/console/logger'
export const FORMS_API_BASE = 'https://forms.googleapis.com/v1'
const logger = createLogger('GoogleFormsUtils')
export function buildListResponsesUrl(params: { formId: string; pageSize?: number }): string {
const { formId, pageSize } = params
const url = new URL(`${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses`)
if (pageSize && pageSize > 0) {
const limited = Math.min(pageSize, 5000)
url.searchParams.set('pageSize', String(limited))
}
const finalUrl = url.toString()
logger.debug('Built Google Forms list responses URL', { finalUrl })
return finalUrl
}
export function buildGetResponseUrl(params: { formId: string; responseId: string }): string {
const { formId, responseId } = params
const finalUrl = `${FORMS_API_BASE}/forms/${encodeURIComponent(formId)}/responses/${encodeURIComponent(responseId)}`
logger.debug('Built Google Forms get response URL', { finalUrl })
return finalUrl
}

View File

@@ -48,6 +48,7 @@ import {
googleDriveListTool,
googleDriveUploadTool,
} from '@/tools/google_drive'
import { googleFormsGetResponsesTool } from '@/tools/google_form'
import {
googleSheetsAppendTool,
googleSheetsReadTool,
@@ -339,6 +340,7 @@ export const tools: Record<string, ToolConfig> = {
google_calendar_list: googleCalendarListTool,
google_calendar_quick_add: googleCalendarQuickAddTool,
google_calendar_invite: googleCalendarInviteTool,
google_forms_get_responses: googleFormsGetResponsesTool,
workflow_executor: workflowExecutorTool,
wealthbox_read_contact: wealthboxReadContactTool,
wealthbox_write_contact: wealthboxWriteContactTool,

View File

@@ -0,0 +1,82 @@
import { GoogleFormsIcon } from '@/components/icons'
import type { TriggerConfig } from '../types'
export const googleFormsWebhookTrigger: TriggerConfig = {
id: 'google_forms_webhook',
name: 'Google Forms Webhook',
provider: 'google_forms',
description: 'Trigger workflow from Google Form submissions (via Apps Script forwarder)',
version: '1.0.0',
icon: GoogleFormsIcon,
configFields: {
token: {
type: 'string',
label: 'Shared Secret',
placeholder: 'Enter a secret used by your Apps Script forwarder',
description:
'We validate requests using this secret. Send it as Authorization: Bearer <token> or a custom header.',
required: true,
isSecret: true,
},
secretHeaderName: {
type: 'string',
label: 'Custom Secret Header (optional)',
placeholder: 'X-GForms-Secret',
description:
'If set, the webhook will validate this header equals your Shared Secret instead of Authorization.',
required: false,
},
formId: {
type: 'string',
label: 'Form ID (optional)',
placeholder: '1FAIpQLSd... (Google Form ID)',
description:
'Optional, for clarity and matching in workflows. Not required for webhook to work.',
},
includeRawPayload: {
type: 'boolean',
label: 'Include Raw Payload',
description: 'Include the original payload from Apps Script in the workflow input.',
defaultValue: true,
},
},
outputs: {
// Expose flattened fields at the root; nested google_forms exists at runtime for back-compat
responseId: { type: 'string', description: 'Unique response identifier (if available)' },
createTime: { type: 'string', description: 'Response creation timestamp' },
lastSubmittedTime: { type: 'string', description: 'Last submitted timestamp' },
formId: { type: 'string', description: 'Google Form ID' },
answers: { type: 'object', description: 'Normalized map of question -> answer' },
raw: { type: 'object', description: 'Original payload (when enabled)' },
},
instructions: [
'Open your Google Form → More (⋮) → Script editor.',
'Paste the Apps Script snippet from below into <code>Code.gs</code> → Save.',
'Triggers (clock icon) → Add Trigger → Function: <code>onFormSubmit</code> → Event source: <code>From form</code> → Event type: <code>On form submit</code> → Save.',
'Authorize when prompted. Submit a test response and verify the run in Sim → Logs.',
],
samplePayload: {
provider: 'google_forms',
formId: '1FAIpQLSdEXAMPLE',
responseId: 'R_12345',
createTime: '2025-01-01T12:00:00.000Z',
lastSubmittedTime: '2025-01-01T12:00:00.000Z',
answers: {
'What is your name?': 'Ada Lovelace',
Languages: ['TypeScript', 'Python'],
'Subscribed?': true,
},
raw: { any: 'original payload from Apps Script if included' },
},
webhook: {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
}

View File

@@ -4,6 +4,7 @@ import { airtableWebhookTrigger } from './airtable'
import { genericWebhookTrigger } from './generic'
import { githubWebhookTrigger } from './github'
import { gmailPollingTrigger } from './gmail'
import { googleFormsWebhookTrigger } from './googleforms/webhook'
import { microsoftTeamsWebhookTrigger } from './microsoftteams'
import { outlookPollingTrigger } from './outlook'
import { slackWebhookTrigger } from './slack'
@@ -24,6 +25,7 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
stripe_webhook: stripeWebhookTrigger,
telegram_webhook: telegramWebhookTrigger,
whatsapp_webhook: whatsappWebhookTrigger,
google_forms_webhook: googleFormsWebhookTrigger,
}
// Utility functions for working with triggers