mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
86
apps/docs/content/docs/en/tools/google_forms.mdx
Normal file
86
apps/docs/content/docs/en/tools/google_forms.mdx
Normal 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`
|
||||
@@ -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>) || {}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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'
|
||||
: '',
|
||||
|
||||
94
apps/sim/blocks/blocks/google_form.ts
Normal file
94
apps/sim/blocks/blocks/google_form.ts
Normal 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'],
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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'
|
||||
|
||||
166
apps/sim/tools/google_form/get_responses.ts
Normal file
166
apps/sim/tools/google_form/get_responses.ts
Normal 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>,
|
||||
}
|
||||
},
|
||||
}
|
||||
3
apps/sim/tools/google_form/index.ts
Normal file
3
apps/sim/tools/google_form/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { getResponsesTool } from '@/tools/google_form/get_responses'
|
||||
|
||||
export const googleFormsGetResponsesTool = getResponsesTool
|
||||
21
apps/sim/tools/google_form/types.ts
Normal file
21
apps/sim/tools/google_form/types.ts
Normal 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
|
||||
}
|
||||
24
apps/sim/tools/google_form/utils.ts
Normal file
24
apps/sim/tools/google_form/utils.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
82
apps/sim/triggers/googleforms/webhook.ts
Normal file
82
apps/sim/triggers/googleforms/webhook.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user