mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(email-footer) Add "sent with sim ai" for free users (#3515)
* Add "sent with sim ai" for free users * Only add prompt injection on free tier * Add try catch around billing info fetch --------- Co-authored-by: Theodore Li <theo@sim.ai>
This commit is contained in:
@@ -43,7 +43,7 @@ export async function POST(req: NextRequest) {
|
||||
const effectiveChatId = chatId || crypto.randomUUID()
|
||||
const [workspaceContext, integrationTools, userPermission] = await Promise.all([
|
||||
generateWorkspaceContext(workspaceId, userId),
|
||||
buildIntegrationToolSchemas(),
|
||||
buildIntegrationToolSchemas(userId),
|
||||
getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
|
||||
])
|
||||
|
||||
|
||||
103
apps/sim/lib/copilot/chat-payload.test.ts
Normal file
103
apps/sim/lib/copilot/chat-payload.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: vi.fn(() => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/billing/core/subscription', () => ({
|
||||
getUserSubscriptionState: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/copilot/chat-context', () => ({
|
||||
processFileAttachments: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/core/config/feature-flags', () => ({
|
||||
isHosted: false,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/mcp/utils', () => ({
|
||||
createMcpToolId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/utils', () => ({
|
||||
getWorkflowById: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/registry', () => ({
|
||||
tools: {
|
||||
gmail_send: {
|
||||
id: 'gmail_send',
|
||||
name: 'Gmail Send',
|
||||
description: 'Send emails using Gmail',
|
||||
},
|
||||
brandfetch_search: {
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/utils', () => ({
|
||||
getLatestVersionTools: vi.fn((input) => input),
|
||||
stripVersionSuffix: vi.fn((toolId: string) => toolId),
|
||||
}))
|
||||
|
||||
vi.mock('@/tools/params', () => ({
|
||||
createUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
|
||||
}))
|
||||
|
||||
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
|
||||
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload'
|
||||
|
||||
const mockedGetUserSubscriptionState = getUserSubscriptionState as unknown as {
|
||||
mockResolvedValue: (value: unknown) => void
|
||||
mockRejectedValue: (value: unknown) => void
|
||||
mockClear: () => void
|
||||
}
|
||||
|
||||
describe('buildIntegrationToolSchemas', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('appends the email footer prompt for free users', async () => {
|
||||
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: true })
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-free')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
|
||||
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-free')
|
||||
expect(gmailTool?.description).toContain('sent with sim ai')
|
||||
})
|
||||
|
||||
it('does not append the email footer prompt for paid users', async () => {
|
||||
mockedGetUserSubscriptionState.mockResolvedValue({ isFree: false })
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-paid')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
|
||||
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-paid')
|
||||
expect(gmailTool?.description).toBe('Send emails using Gmail')
|
||||
})
|
||||
|
||||
it('still builds integration tools when subscription lookup fails', async () => {
|
||||
mockedGetUserSubscriptionState.mockRejectedValue(new Error('db unavailable'))
|
||||
|
||||
const toolSchemas = await buildIntegrationToolSchemas('user-error')
|
||||
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
|
||||
const brandfetchTool = toolSchemas.find((tool) => tool.name === 'brandfetch_search')
|
||||
|
||||
expect(getUserSubscriptionState).toHaveBeenCalledWith('user-error')
|
||||
expect(gmailTool?.description).toBe('Send emails using Gmail')
|
||||
expect(brandfetchTool?.description).toBe('Search for brands by company name')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getUserSubscriptionState } from '@/lib/billing/core/subscription'
|
||||
import { processFileAttachments } from '@/lib/copilot/chat-context'
|
||||
import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions'
|
||||
import { isHosted } from '@/lib/core/config/feature-flags'
|
||||
@@ -44,11 +45,22 @@ export interface ToolSchema {
|
||||
* Shared by the interactive chat payload builder and the non-interactive
|
||||
* block execution route so both paths send the same tool definitions to Go.
|
||||
*/
|
||||
export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
|
||||
export async function buildIntegrationToolSchemas(userId: string): Promise<ToolSchema[]> {
|
||||
const integrationTools: ToolSchema[] = []
|
||||
try {
|
||||
const { createUserToolSchema } = await import('@/tools/params')
|
||||
const latestTools = getLatestVersionTools(tools)
|
||||
let shouldAppendEmailTagline = false
|
||||
|
||||
try {
|
||||
const subscriptionState = await getUserSubscriptionState(userId)
|
||||
shouldAppendEmailTagline = subscriptionState.isFree
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load subscription state for copilot tool descriptions', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
for (const [toolId, toolConfig] of Object.entries(latestTools)) {
|
||||
try {
|
||||
@@ -59,6 +71,7 @@ export async function buildIntegrationToolSchemas(): Promise<ToolSchema[]> {
|
||||
description: getCopilotToolDescription(toolConfig, {
|
||||
isHosted,
|
||||
fallbackName: strippedName,
|
||||
appendEmailTagline: shouldAppendEmailTagline,
|
||||
}),
|
||||
input_schema: userSchema as unknown as Record<string, unknown>,
|
||||
defer_loading: true,
|
||||
@@ -118,7 +131,7 @@ export async function buildCopilotRequestPayload(
|
||||
let integrationTools: ToolSchema[] = []
|
||||
|
||||
if (effectiveMode === 'build') {
|
||||
integrationTools = await buildIntegrationToolSchemas()
|
||||
integrationTools = await buildIntegrationToolSchemas(userId)
|
||||
|
||||
// Discover MCP tools from workspace servers and include as deferred tools
|
||||
if (workflowId) {
|
||||
|
||||
@@ -6,6 +6,7 @@ describe('getCopilotToolDescription', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
hosting: { apiKeyParam: 'apiKey' } as never,
|
||||
@@ -19,6 +20,7 @@ describe('getCopilotToolDescription', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
hosting: { apiKeyParam: 'apiKey' } as never,
|
||||
@@ -32,6 +34,7 @@ describe('getCopilotToolDescription', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'brandfetch_search',
|
||||
name: '',
|
||||
description: '',
|
||||
hosting: { apiKeyParam: 'apiKey' } as never,
|
||||
@@ -40,4 +43,60 @@ describe('getCopilotToolDescription', () => {
|
||||
)
|
||||
).toBe('brandfetch_search <note>API key is hosted by Sim.</note>')
|
||||
})
|
||||
|
||||
it.concurrent('appends the email tagline instruction for Gmail tools when enabled', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'gmail_send',
|
||||
name: 'Gmail Send',
|
||||
description: 'Send emails using Gmail',
|
||||
},
|
||||
{ appendEmailTagline: true }
|
||||
)
|
||||
).toBe(
|
||||
'Send emails using Gmail <important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('appends the email tagline instruction for Outlook tools when enabled', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'outlook_send',
|
||||
name: 'Outlook Send',
|
||||
description: 'Send emails using Outlook',
|
||||
},
|
||||
{ appendEmailTagline: true }
|
||||
)
|
||||
).toBe(
|
||||
'Send emails using Outlook <important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
|
||||
)
|
||||
})
|
||||
|
||||
it.concurrent('does not append the email tagline instruction for non-email tools', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'brandfetch_search',
|
||||
name: 'Brandfetch Search',
|
||||
description: 'Search for brands by company name',
|
||||
},
|
||||
{ appendEmailTagline: true }
|
||||
)
|
||||
).toBe('Search for brands by company name')
|
||||
})
|
||||
|
||||
it.concurrent('does not append the email tagline instruction when disabled', () => {
|
||||
expect(
|
||||
getCopilotToolDescription(
|
||||
{
|
||||
id: 'gmail_send_v2',
|
||||
name: 'Gmail Send',
|
||||
description: 'Send emails using Gmail',
|
||||
},
|
||||
{ appendEmailTagline: false }
|
||||
)
|
||||
).toBe('Send emails using Gmail')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const HOSTED_API_KEY_NOTE = '<note>API key is hosted by Sim.</note>'
|
||||
const EMAIL_TAGLINE_NOTE =
|
||||
'<important>Always add the footer "sent with sim ai" to the end of the email body. Add 3 line breaks before the footer.</important>'
|
||||
const EMAIL_TAGLINE_TOOL_IDS = new Set(['gmail_send', 'gmail_send_v2', 'outlook_send'])
|
||||
|
||||
export function getCopilotToolDescription(
|
||||
tool: Pick<ToolConfig, 'description' | 'hosting' | 'name'>,
|
||||
tool: Pick<ToolConfig, 'description' | 'hosting' | 'id' | 'name'>,
|
||||
options?: {
|
||||
isHosted?: boolean
|
||||
fallbackName?: string
|
||||
appendEmailTagline?: boolean
|
||||
}
|
||||
): string {
|
||||
const baseDescription = tool.description || tool.name || options?.fallbackName || ''
|
||||
const notes: string[] = []
|
||||
|
||||
if (!options?.isHosted || !tool.hosting) {
|
||||
if (options?.isHosted && tool.hosting && !baseDescription.includes(HOSTED_API_KEY_NOTE)) {
|
||||
notes.push(HOSTED_API_KEY_NOTE)
|
||||
}
|
||||
|
||||
if (
|
||||
options?.appendEmailTagline &&
|
||||
EMAIL_TAGLINE_TOOL_IDS.has(tool.id) &&
|
||||
!baseDescription.includes(EMAIL_TAGLINE_NOTE)
|
||||
) {
|
||||
notes.push(EMAIL_TAGLINE_NOTE)
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
return baseDescription
|
||||
}
|
||||
|
||||
if (baseDescription.includes(HOSTED_API_KEY_NOTE)) {
|
||||
return baseDescription
|
||||
}
|
||||
|
||||
return baseDescription ? `${baseDescription} ${HOSTED_API_KEY_NOTE}` : HOSTED_API_KEY_NOTE
|
||||
return [baseDescription, ...notes].filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user