From d2a9373a53ed427e30ad43cc5378562aa4e6d26f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:28:02 -0700 Subject: [PATCH 01/13] feat(telegram): add edit, forward, copy, location, contact, poll, pin, reaction, chat-action, and chat-info tools --- apps/sim/blocks/blocks/telegram.ts | 411 ++++++++++++++++-- apps/sim/tools/registry.ts | 24 + apps/sim/tools/telegram/copy_message.ts | 96 ++++ apps/sim/tools/telegram/edit_message_text.ts | 91 ++++ apps/sim/tools/telegram/forward_message.ts | 89 ++++ apps/sim/tools/telegram/get_chat.ts | 92 ++++ apps/sim/tools/telegram/get_chat_member.ts | 99 +++++ apps/sim/tools/telegram/index.ts | 30 +- apps/sim/tools/telegram/pin_message.ts | 90 ++++ apps/sim/tools/telegram/send_chat_action.ts | 82 ++++ apps/sim/tools/telegram/send_contact.ts | 105 +++++ apps/sim/tools/telegram/send_location.ts | 88 ++++ apps/sim/tools/telegram/send_poll.ts | 105 +++++ .../tools/telegram/set_message_reaction.ts | 101 +++++ apps/sim/tools/telegram/types.ts | 123 ++++++ apps/sim/tools/telegram/unpin_message.ts | 84 ++++ apps/sim/tools/telegram/utils.ts | 7 + 17 files changed, 1683 insertions(+), 34 deletions(-) create mode 100644 apps/sim/tools/telegram/copy_message.ts create mode 100644 apps/sim/tools/telegram/edit_message_text.ts create mode 100644 apps/sim/tools/telegram/forward_message.ts create mode 100644 apps/sim/tools/telegram/get_chat.ts create mode 100644 apps/sim/tools/telegram/get_chat_member.ts create mode 100644 apps/sim/tools/telegram/pin_message.ts create mode 100644 apps/sim/tools/telegram/send_chat_action.ts create mode 100644 apps/sim/tools/telegram/send_contact.ts create mode 100644 apps/sim/tools/telegram/send_location.ts create mode 100644 apps/sim/tools/telegram/send_poll.ts create mode 100644 apps/sim/tools/telegram/set_message_reaction.ts create mode 100644 apps/sim/tools/telegram/unpin_message.ts diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index 6236a93ffe9..b8754cb617c 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -11,7 +11,7 @@ export const TelegramBlock: BlockConfig = { description: 'Interact with Telegram', authMode: AuthMode.BotToken, longDescription: - 'Integrate Telegram into the workflow. Can send and delete messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.', + 'Integrate Telegram into the workflow. Send, edit, forward, copy, pin, and delete messages; send media, locations, contacts, and polls; react to messages; show chat actions; and look up chat and member info. Can be used in trigger mode to start a workflow when a message is sent to a chat.', docsLink: 'https://docs.sim.ai/integrations/telegram', category: 'tools', integrationType: IntegrationType.Communication, @@ -30,7 +30,19 @@ export const TelegramBlock: BlockConfig = { { label: 'Send Audio', id: 'telegram_send_audio' }, { label: 'Send Animation', id: 'telegram_send_animation' }, { label: 'Send Document', id: 'telegram_send_document' }, + { label: 'Send Location', id: 'telegram_send_location' }, + { label: 'Send Contact', id: 'telegram_send_contact' }, + { label: 'Send Poll', id: 'telegram_send_poll' }, + { label: 'Send Chat Action', id: 'telegram_send_chat_action' }, + { label: 'Edit Message Text', id: 'telegram_edit_message_text' }, + { label: 'Forward Message', id: 'telegram_forward_message' }, + { label: 'Copy Message', id: 'telegram_copy_message' }, { label: 'Delete Message', id: 'telegram_delete_message' }, + { label: 'Pin Message', id: 'telegram_pin_message' }, + { label: 'Unpin Message', id: 'telegram_unpin_message' }, + { label: 'Set Message Reaction', id: 'telegram_set_message_reaction' }, + { label: 'Get Chat', id: 'telegram_get_chat' }, + { label: 'Get Chat Member', id: 'telegram_get_chat_member' }, ], value: () => 'telegram_message', }, @@ -64,8 +76,8 @@ export const TelegramBlock: BlockConfig = { title: 'Message', type: 'long-input', placeholder: 'Enter the message to send', - required: true, - condition: { field: 'operation', value: 'telegram_message' }, + required: { field: 'operation', value: ['telegram_message', 'telegram_edit_message_text'] }, + condition: { field: 'operation', value: ['telegram_message', 'telegram_edit_message_text'] }, }, { id: 'photoFile', @@ -194,6 +206,7 @@ export const TelegramBlock: BlockConfig = { 'telegram_send_audio', 'telegram_send_animation', 'telegram_send_document', + 'telegram_copy_message', ], }, }, @@ -201,10 +214,191 @@ export const TelegramBlock: BlockConfig = { id: 'messageId', title: 'Message ID', type: 'short-input', - placeholder: 'Enter the message ID to delete', - description: 'The unique identifier of the message you want to delete', - required: true, - condition: { field: 'operation', value: 'telegram_delete_message' }, + placeholder: 'Enter the message ID', + description: 'The unique identifier of the target message', + required: { + field: 'operation', + value: [ + 'telegram_delete_message', + 'telegram_edit_message_text', + 'telegram_forward_message', + 'telegram_copy_message', + 'telegram_pin_message', + 'telegram_set_message_reaction', + ], + }, + condition: { + field: 'operation', + value: [ + 'telegram_delete_message', + 'telegram_edit_message_text', + 'telegram_forward_message', + 'telegram_copy_message', + 'telegram_pin_message', + 'telegram_unpin_message', + 'telegram_set_message_reaction', + ], + }, + }, + { + id: 'fromChatId', + title: 'From Chat ID', + type: 'short-input', + placeholder: 'Enter the source chat ID', + description: 'The chat ID where the original message currently lives', + required: { + field: 'operation', + value: ['telegram_forward_message', 'telegram_copy_message'], + }, + condition: { + field: 'operation', + value: ['telegram_forward_message', 'telegram_copy_message'], + }, + }, + { + id: 'latitude', + title: 'Latitude', + type: 'short-input', + placeholder: 'e.g., 37.7749', + required: { field: 'operation', value: 'telegram_send_location' }, + condition: { field: 'operation', value: 'telegram_send_location' }, + }, + { + id: 'longitude', + title: 'Longitude', + type: 'short-input', + placeholder: 'e.g., -122.4194', + required: { field: 'operation', value: 'telegram_send_location' }, + condition: { field: 'operation', value: 'telegram_send_location' }, + }, + { + id: 'phoneNumber', + title: 'Phone Number', + type: 'short-input', + placeholder: "Contact's phone number", + required: { field: 'operation', value: 'telegram_send_contact' }, + condition: { field: 'operation', value: 'telegram_send_contact' }, + }, + { + id: 'firstName', + title: 'First Name', + type: 'short-input', + placeholder: "Contact's first name", + required: { field: 'operation', value: 'telegram_send_contact' }, + condition: { field: 'operation', value: 'telegram_send_contact' }, + }, + { + id: 'lastName', + title: 'Last Name', + type: 'short-input', + placeholder: "Contact's last name (optional)", + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_send_contact' }, + }, + { + id: 'vcard', + title: 'vCard', + type: 'long-input', + placeholder: 'Additional contact data as a vCard (optional)', + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_send_contact' }, + }, + { + id: 'question', + title: 'Poll Question', + type: 'long-input', + placeholder: 'Enter the poll question', + required: { field: 'operation', value: 'telegram_send_poll' }, + condition: { field: 'operation', value: 'telegram_send_poll' }, + }, + { + id: 'pollOptions', + title: 'Poll Options', + type: 'long-input', + placeholder: 'One answer option per line (2-10 options)', + description: 'Enter each answer option on its own line', + required: { field: 'operation', value: 'telegram_send_poll' }, + condition: { field: 'operation', value: 'telegram_send_poll' }, + }, + { + id: 'isAnonymous', + title: 'Anonymous Poll', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_send_poll' }, + }, + { + id: 'allowsMultipleAnswers', + title: 'Allow Multiple Answers', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_send_poll' }, + }, + { + id: 'action', + title: 'Chat Action', + type: 'dropdown', + options: [ + { label: 'Typing', id: 'typing' }, + { label: 'Upload Photo', id: 'upload_photo' }, + { label: 'Record Video', id: 'record_video' }, + { label: 'Upload Video', id: 'upload_video' }, + { label: 'Record Voice', id: 'record_voice' }, + { label: 'Upload Voice', id: 'upload_voice' }, + { label: 'Upload Document', id: 'upload_document' }, + { label: 'Choose Sticker', id: 'choose_sticker' }, + { label: 'Find Location', id: 'find_location' }, + { label: 'Record Video Note', id: 'record_video_note' }, + { label: 'Upload Video Note', id: 'upload_video_note' }, + ], + value: () => 'typing', + required: { field: 'operation', value: 'telegram_send_chat_action' }, + condition: { field: 'operation', value: 'telegram_send_chat_action' }, + }, + { + id: 'reactionEmoji', + title: 'Reaction Emoji', + type: 'short-input', + placeholder: 'e.g., 👍 (leave empty to remove the reaction)', + condition: { field: 'operation', value: 'telegram_set_message_reaction' }, + }, + { + id: 'isBig', + title: 'Big Reaction', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_set_message_reaction' }, + }, + { + id: 'disableNotification', + title: 'Silent Pin', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'telegram_pin_message' }, + }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'Enter the target user ID', + required: { field: 'operation', value: 'telegram_get_chat_member' }, + condition: { field: 'operation', value: 'telegram_get_chat_member' }, }, ...getTrigger('telegram_webhook').subBlocks, ], @@ -217,28 +411,21 @@ export const TelegramBlock: BlockConfig = { 'telegram_send_audio', 'telegram_send_animation', 'telegram_send_document', + 'telegram_edit_message_text', + 'telegram_forward_message', + 'telegram_copy_message', + 'telegram_send_location', + 'telegram_send_contact', + 'telegram_send_poll', + 'telegram_pin_message', + 'telegram_unpin_message', + 'telegram_set_message_reaction', + 'telegram_send_chat_action', + 'telegram_get_chat', + 'telegram_get_chat_member', ], config: { - tool: (params) => { - switch (params.operation) { - case 'telegram_message': - return 'telegram_message' - case 'telegram_delete_message': - return 'telegram_delete_message' - case 'telegram_send_photo': - return 'telegram_send_photo' - case 'telegram_send_video': - return 'telegram_send_video' - case 'telegram_send_audio': - return 'telegram_send_audio' - case 'telegram_send_animation': - return 'telegram_send_animation' - case 'telegram_send_document': - return 'telegram_send_document' - default: - return 'telegram_message' - } - }, + tool: (params) => params.operation || 'telegram_message', params: (params) => { if (!params.botToken) throw new Error('Bot token required for this operation') @@ -252,6 +439,19 @@ export const TelegramBlock: BlockConfig = { chatId, } + /** Coerce string/number input to a finite number, throwing with a labeled message. */ + const requireNumber = (value: unknown, label: string): number => { + const num = Number(value) + if (value === undefined || value === null || value === '' || Number.isNaN(num)) { + throw new Error(`${label} is required and must be a number.`) + } + return num + } + + /** Coerce agent-supplied "true"/"false" strings (or booleans) to a real boolean. */ + const toBoolean = (value: unknown): boolean => + value === true || String(value).toLowerCase() === 'true' + switch (params.operation) { case 'telegram_message': if (!params.text) { @@ -262,12 +462,9 @@ export const TelegramBlock: BlockConfig = { text: params.text, } case 'telegram_delete_message': - if (!params.messageId) { - throw new Error('Message ID is required for delete operation.') - } return { ...commonParams, - messageId: params.messageId, + messageId: requireNumber(params.messageId, 'Message ID'), } case 'telegram_send_photo': { // photo is the canonical param for both basic (photoFile) and advanced modes @@ -333,6 +530,125 @@ export const TelegramBlock: BlockConfig = { caption: params.caption, } } + case 'telegram_edit_message_text': + if (!params.text) { + throw new Error('Message text is required.') + } + return { + ...commonParams, + messageId: requireNumber(params.messageId, 'Message ID'), + text: params.text, + } + case 'telegram_forward_message': { + const fromChatId = (params.fromChatId || '').trim() + if (!fromChatId) { + throw new Error('Source chat ID is required.') + } + return { + ...commonParams, + fromChatId, + messageId: requireNumber(params.messageId, 'Message ID'), + } + } + case 'telegram_copy_message': { + const fromChatId = (params.fromChatId || '').trim() + if (!fromChatId) { + throw new Error('Source chat ID is required.') + } + return { + ...commonParams, + fromChatId, + messageId: requireNumber(params.messageId, 'Message ID'), + caption: params.caption, + } + } + case 'telegram_send_location': + return { + ...commonParams, + latitude: requireNumber(params.latitude, 'Latitude'), + longitude: requireNumber(params.longitude, 'Longitude'), + } + case 'telegram_send_contact': + if (!params.phoneNumber || !params.firstName) { + throw new Error('Phone number and first name are required.') + } + return { + ...commonParams, + phoneNumber: params.phoneNumber, + firstName: params.firstName, + lastName: params.lastName, + vcard: params.vcard, + } + case 'telegram_send_poll': { + if (!params.question) { + throw new Error('Poll question is required.') + } + const rawOptions = params.pollOptions + const options = Array.isArray(rawOptions) + ? rawOptions.map((option) => String(option).trim()).filter(Boolean) + : String(rawOptions ?? '') + .split('\n') + .map((option) => option.trim()) + .filter(Boolean) + if (options.length < 2) { + throw new Error('At least 2 poll options are required.') + } + const pollParams: Record = { + ...commonParams, + question: params.question, + options, + } + if (params.isAnonymous !== undefined && params.isAnonymous !== '') { + pollParams.isAnonymous = toBoolean(params.isAnonymous) + } + if (params.allowsMultipleAnswers !== undefined && params.allowsMultipleAnswers !== '') { + pollParams.allowsMultipleAnswers = toBoolean(params.allowsMultipleAnswers) + } + return pollParams + } + case 'telegram_pin_message': { + const pinParams: Record = { + ...commonParams, + messageId: requireNumber(params.messageId, 'Message ID'), + } + if (params.disableNotification !== undefined && params.disableNotification !== '') { + pinParams.disableNotification = toBoolean(params.disableNotification) + } + return pinParams + } + case 'telegram_unpin_message': { + const unpinParams: Record = { ...commonParams } + if (params.messageId !== undefined && params.messageId !== '') { + unpinParams.messageId = requireNumber(params.messageId, 'Message ID') + } + return unpinParams + } + case 'telegram_set_message_reaction': { + const reactionParams: Record = { + ...commonParams, + messageId: requireNumber(params.messageId, 'Message ID'), + reaction: params.reactionEmoji, + } + if (params.isBig !== undefined && params.isBig !== '') { + reactionParams.isBig = toBoolean(params.isBig) + } + return reactionParams + } + case 'telegram_send_chat_action': + if (!params.action) { + throw new Error('Chat action is required.') + } + return { + ...commonParams, + action: params.action, + } + case 'telegram_get_chat': + return { ...commonParams } + case 'telegram_get_chat_member': + return { + ...commonParams, + userId: requireNumber(params.userId, 'User ID'), + } default: return { ...commonParams, @@ -353,7 +669,26 @@ export const TelegramBlock: BlockConfig = { animation: { type: 'json', description: 'Animation (UserFile or URL/file_id)' }, files: { type: 'array', description: 'Files to attach (UserFile array)' }, caption: { type: 'string', description: 'Caption for media' }, - messageId: { type: 'string', description: 'Message ID to delete' }, + messageId: { type: 'string', description: 'Target message ID' }, + fromChatId: { type: 'string', description: 'Source chat ID for forward/copy' }, + latitude: { type: 'string', description: 'Latitude of the location' }, + longitude: { type: 'string', description: 'Longitude of the location' }, + phoneNumber: { type: 'string', description: "Contact's phone number" }, + firstName: { type: 'string', description: "Contact's first name" }, + lastName: { type: 'string', description: "Contact's last name" }, + vcard: { type: 'string', description: 'Contact vCard data' }, + question: { type: 'string', description: 'Poll question' }, + pollOptions: { type: 'string', description: 'Poll answer options (one per line)' }, + isAnonymous: { type: 'string', description: 'Whether the poll is anonymous' }, + allowsMultipleAnswers: { + type: 'string', + description: 'Whether the poll allows multiple answers', + }, + action: { type: 'string', description: 'Chat action to broadcast' }, + reactionEmoji: { type: 'string', description: 'Emoji to react with' }, + isBig: { type: 'string', description: 'Whether to show a big reaction animation' }, + disableNotification: { type: 'string', description: 'Pin the message silently' }, + userId: { type: 'string', description: 'Target user ID for chat member lookup' }, }, outputs: { // Send message operation outputs @@ -519,5 +854,17 @@ export const TelegramBlockMeta = { content: '# Route an Incoming Telegram Message\n\nUse Telegram as a trigger so the workflow runs whenever a user messages the bot.\n\n## Steps\n1. Enable the Telegram webhook trigger so incoming messages start the workflow.\n2. Read the trigger outputs: text, from_username, chat_id, and chat_type.\n3. Branch on the message content (for example detect a command or a support question) to decide the next action.\n4. Reply with the Send Message operation using the chat_id from the trigger.\n\n## Output\nReturn the parsed incoming message fields and confirm the reply that was sent back to the user.', }, + { + name: 'run-a-poll', + description: 'Post a poll to a Telegram chat to collect a quick vote from members.', + content: + '# Run a Telegram Poll\n\nGather a fast decision or sentiment check from a chat or channel.\n\n## Steps\n1. Use the Send Poll operation with your Bot Token and the target Chat ID.\n2. Set the Poll Question and add one answer option per line (2-10 options).\n3. In advanced settings, choose whether the poll is anonymous and whether multiple answers are allowed.\n4. Send the poll, then read replies from the chat to act on the outcome.\n\n## Output\nReturn the sent message ID so the poll post can be referenced or pinned later.', + }, + { + name: 'pin-and-react-to-messages', + description: 'Pin an important message and add an emoji reaction in a Telegram chat.', + content: + '# Pin and React to a Telegram Message\n\nHighlight a key message and acknowledge it with a reaction.\n\n## Steps\n1. Capture the message ID to act on, for example from the Send Message output or the incoming message trigger.\n2. Use the Pin Message operation with the Bot Token, Chat ID, and Message ID to pin it for the whole chat.\n3. Use the Set Message Reaction operation with an emoji to acknowledge a message, or send an empty reaction to remove one.\n4. Use the Unpin Message operation later to clear the pinned message when it is no longer relevant.\n\n## Output\nConfirm the pin and reaction succeeded so the chat keeps the important context surfaced.', + }, ], } as const satisfies BlockMeta diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 37583a9fed0..f6cc4ef1b4a 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3645,13 +3645,25 @@ import { } from '@/tools/tailscale' import { tavilyCrawlTool, tavilyExtractTool, tavilyMapTool, tavilySearchTool } from '@/tools/tavily' import { + telegramCopyMessageTool, telegramDeleteMessageTool, + telegramEditMessageTextTool, + telegramForwardMessageTool, + telegramGetChatMemberTool, + telegramGetChatTool, telegramMessageTool, + telegramPinMessageTool, telegramSendAnimationTool, telegramSendAudioTool, + telegramSendChatActionTool, + telegramSendContactTool, telegramSendDocumentTool, + telegramSendLocationTool, telegramSendPhotoTool, + telegramSendPollTool, telegramSendVideoTool, + telegramSetMessageReactionTool, + telegramUnpinMessageTool, } from '@/tools/telegram' import { temporalCancelWorkflowTool, @@ -6725,6 +6737,18 @@ export const tools: Record = { telegram_send_photo: telegramSendPhotoTool, telegram_send_video: telegramSendVideoTool, telegram_send_document: telegramSendDocumentTool, + telegram_edit_message_text: telegramEditMessageTextTool, + telegram_forward_message: telegramForwardMessageTool, + telegram_copy_message: telegramCopyMessageTool, + telegram_send_location: telegramSendLocationTool, + telegram_send_contact: telegramSendContactTool, + telegram_send_poll: telegramSendPollTool, + telegram_pin_message: telegramPinMessageTool, + telegram_unpin_message: telegramUnpinMessageTool, + telegram_set_message_reaction: telegramSetMessageReactionTool, + telegram_send_chat_action: telegramSendChatActionTool, + telegram_get_chat: telegramGetChatTool, + telegram_get_chat_member: telegramGetChatMemberTool, temporal_start_workflow: temporalStartWorkflowTool, temporal_signal_workflow: temporalSignalWorkflowTool, temporal_signal_with_start: temporalSignalWithStartTool, diff --git a/apps/sim/tools/telegram/copy_message.ts b/apps/sim/tools/telegram/copy_message.ts new file mode 100644 index 00000000000..67b196fffef --- /dev/null +++ b/apps/sim/tools/telegram/copy_message.ts @@ -0,0 +1,96 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { TelegramCopyMessageParams, TelegramCopyMessageResponse } from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramCopyMessageTool: ToolConfig< + TelegramCopyMessageParams, + TelegramCopyMessageResponse +> = { + id: 'telegram_copy_message', + name: 'Telegram Copy Message', + description: + 'Copy a message to another Telegram chat without a forward header through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Destination chat ID (numeric, can be negative for groups)', + }, + fromChatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source chat ID the original message belongs to', + }, + messageId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Identifier of the message to copy in the source chat', + }, + caption: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'New caption for the copied media (keeps the original if omitted)', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'copyMessage'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + from_chat_id: params.fromChatId, + message_id: params.messageId, + } + if (params.caption) body.caption = params.caption + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to copy message' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message copied successfully', + data: { + message_id: data.result?.message_id, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Copied message identifier', + properties: { + message_id: { type: 'number', description: 'Identifier of the new copied message' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/edit_message_text.ts b/apps/sim/tools/telegram/edit_message_text.ts new file mode 100644 index 00000000000..45acef5108c --- /dev/null +++ b/apps/sim/tools/telegram/edit_message_text.ts @@ -0,0 +1,91 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramEditMessageTextParams, + TelegramMessage, + TelegramSendMessageResponse, +} from '@/tools/telegram/types' +import { convertMarkdownToHTML, telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramEditMessageTextTool: ToolConfig< + TelegramEditMessageTextParams, + TelegramSendMessageResponse +> = { + id: 'telegram_edit_message_text', + name: 'Telegram Edit Message Text', + description: + 'Edit the text of an existing message in a Telegram chat or channel through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + messageId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Identifier of the message to edit', + }, + text: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'New text of the message', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'editMessageText'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + message_id: params.messageId, + text: convertMarkdownToHTML(params.text), + parse_mode: 'HTML', + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to edit message' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message edited successfully', + data: data.result as TelegramMessage, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Edited Telegram message data', + properties: { + message_id: { type: 'number', description: 'Unique Telegram message identifier' }, + date: { type: 'number', description: 'Unix timestamp when message was sent' }, + text: { type: 'string', description: 'Text content of the edited message' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/forward_message.ts b/apps/sim/tools/telegram/forward_message.ts new file mode 100644 index 00000000000..99f31cabe44 --- /dev/null +++ b/apps/sim/tools/telegram/forward_message.ts @@ -0,0 +1,89 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramForwardMessageParams, + TelegramMessage, + TelegramSendMessageResponse, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramForwardMessageTool: ToolConfig< + TelegramForwardMessageParams, + TelegramSendMessageResponse +> = { + id: 'telegram_forward_message', + name: 'Telegram Forward Message', + description: 'Forward a message from one Telegram chat to another through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Destination chat ID (numeric, can be negative for groups)', + }, + fromChatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Source chat ID the original message belongs to', + }, + messageId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Identifier of the message to forward in the source chat', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'forwardMessage'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + from_chat_id: params.fromChatId, + message_id: params.messageId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to forward message' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message forwarded successfully', + data: data.result as TelegramMessage, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Forwarded Telegram message data', + properties: { + message_id: { type: 'number', description: 'Identifier of the forwarded message' }, + date: { type: 'number', description: 'Unix timestamp when message was sent' }, + text: { type: 'string', description: 'Text content of the forwarded message' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/get_chat.ts b/apps/sim/tools/telegram/get_chat.ts new file mode 100644 index 00000000000..59b4f1747a4 --- /dev/null +++ b/apps/sim/tools/telegram/get_chat.ts @@ -0,0 +1,92 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramChatFullInfo, + TelegramGetChatParams, + TelegramGetChatResponse, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramGetChatTool: ToolConfig = { + id: 'telegram_get_chat', + name: 'Telegram Get Chat', + description: 'Get up-to-date information about a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID or @username (numeric, can be negative for groups)', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'getChat'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to get chat' + throw new Error(errorMessage) + } + + const result = data.result + + return { + success: true, + output: { + message: 'Chat info retrieved successfully', + data: { + id: result.id, + type: result.type, + title: result.title ?? null, + username: result.username ?? null, + first_name: result.first_name ?? null, + last_name: result.last_name ?? null, + description: result.description ?? null, + bio: result.bio ?? null, + invite_link: result.invite_link ?? null, + linked_chat_id: result.linked_chat_id ?? null, + } as TelegramChatFullInfo, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Telegram chat information', + properties: { + id: { type: 'number', description: 'Unique chat identifier' }, + type: { type: 'string', description: 'Chat type (private, group, supergroup, channel)' }, + title: { type: 'string', description: 'Chat title for groups and channels' }, + username: { type: 'string', description: 'Chat username, if available' }, + first_name: { type: 'string', description: 'First name for private chats' }, + last_name: { type: 'string', description: 'Last name for private chats' }, + description: { type: 'string', description: 'Chat description' }, + bio: { type: 'string', description: 'Bio of the other party in a private chat' }, + invite_link: { type: 'string', description: 'Primary invite link for the chat' }, + linked_chat_id: { type: 'number', description: 'Linked discussion or channel chat ID' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/get_chat_member.ts b/apps/sim/tools/telegram/get_chat_member.ts new file mode 100644 index 00000000000..7bfd7f2d34f --- /dev/null +++ b/apps/sim/tools/telegram/get_chat_member.ts @@ -0,0 +1,99 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramChatMember, + TelegramGetChatMemberParams, + TelegramGetChatMemberResponse, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramGetChatMemberTool: ToolConfig< + TelegramGetChatMemberParams, + TelegramGetChatMemberResponse +> = { + id: 'telegram_get_chat_member', + name: 'Telegram Get Chat Member', + description: 'Get information about a member of a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID or @username (numeric, can be negative for groups)', + }, + userId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Unique identifier of the target user', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'getChatMember'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + user_id: params.userId, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to get chat member' + throw new Error(errorMessage) + } + + const result = data.result + + return { + success: true, + output: { + message: 'Chat member retrieved successfully', + data: { + status: result.status, + user: result.user, + } as TelegramChatMember, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Telegram chat member information', + properties: { + status: { + type: 'string', + description: "Member's status (creator, administrator, member, restricted, left, kicked)", + }, + user: { + type: 'object', + description: 'Information about the user', + properties: { + id: { type: 'number', description: 'Unique user identifier' }, + is_bot: { type: 'boolean', description: 'Whether the user is a bot' }, + first_name: { type: 'string', description: "User's first name" }, + last_name: { type: 'string', description: "User's last name" }, + username: { type: 'string', description: "User's username" }, + }, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/index.ts b/apps/sim/tools/telegram/index.ts index abb955851b5..553f9d69fd3 100644 --- a/apps/sim/tools/telegram/index.ts +++ b/apps/sim/tools/telegram/index.ts @@ -1,17 +1,43 @@ +import { telegramCopyMessageTool } from '@/tools/telegram/copy_message' import { telegramDeleteMessageTool } from '@/tools/telegram/delete_message' +import { telegramEditMessageTextTool } from '@/tools/telegram/edit_message_text' +import { telegramForwardMessageTool } from '@/tools/telegram/forward_message' +import { telegramGetChatTool } from '@/tools/telegram/get_chat' +import { telegramGetChatMemberTool } from '@/tools/telegram/get_chat_member' import { telegramMessageTool } from '@/tools/telegram/message' +import { telegramPinMessageTool } from '@/tools/telegram/pin_message' import { telegramSendAnimationTool } from '@/tools/telegram/send_animation' import { telegramSendAudioTool } from '@/tools/telegram/send_audio' +import { telegramSendChatActionTool } from '@/tools/telegram/send_chat_action' +import { telegramSendContactTool } from '@/tools/telegram/send_contact' import { telegramSendDocumentTool } from '@/tools/telegram/send_document' +import { telegramSendLocationTool } from '@/tools/telegram/send_location' import { telegramSendPhotoTool } from '@/tools/telegram/send_photo' +import { telegramSendPollTool } from '@/tools/telegram/send_poll' import { telegramSendVideoTool } from '@/tools/telegram/send_video' +import { telegramSetMessageReactionTool } from '@/tools/telegram/set_message_reaction' +import { telegramUnpinMessageTool } from '@/tools/telegram/unpin_message' export { - telegramSendAnimationTool, - telegramSendAudioTool, + telegramCopyMessageTool, telegramDeleteMessageTool, + telegramEditMessageTextTool, + telegramForwardMessageTool, + telegramGetChatTool, + telegramGetChatMemberTool, telegramMessageTool, + telegramPinMessageTool, + telegramSendAnimationTool, + telegramSendAudioTool, + telegramSendChatActionTool, + telegramSendContactTool, telegramSendDocumentTool, + telegramSendLocationTool, telegramSendPhotoTool, + telegramSendPollTool, telegramSendVideoTool, + telegramSetMessageReactionTool, + telegramUnpinMessageTool, } + +export * from './types' diff --git a/apps/sim/tools/telegram/pin_message.ts b/apps/sim/tools/telegram/pin_message.ts new file mode 100644 index 00000000000..08692f9f82b --- /dev/null +++ b/apps/sim/tools/telegram/pin_message.ts @@ -0,0 +1,90 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { TelegramBooleanResponse, TelegramPinMessageParams } from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramPinMessageTool: ToolConfig = + { + id: 'telegram_pin_message', + name: 'Telegram Pin Message', + description: 'Pin a message in a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + messageId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Identifier of the message to pin', + }, + disableNotification: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Pass true to pin silently without notifying chat members', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'pinChatMessage'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + message_id: params.messageId, + } + if (params.disableNotification !== undefined) { + body.disable_notification = params.disableNotification + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to pin message' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message pinned successfully', + data: { + ok: data.ok, + result: data.result, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Pin operation result', + properties: { + ok: { type: 'boolean', description: 'API response success status' }, + result: { type: 'boolean', description: 'Whether the message was pinned' }, + }, + }, + }, + } diff --git a/apps/sim/tools/telegram/send_chat_action.ts b/apps/sim/tools/telegram/send_chat_action.ts new file mode 100644 index 00000000000..65dddfae232 --- /dev/null +++ b/apps/sim/tools/telegram/send_chat_action.ts @@ -0,0 +1,82 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { TelegramBooleanResponse, TelegramSendChatActionParams } from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramSendChatActionTool: ToolConfig< + TelegramSendChatActionParams, + TelegramBooleanResponse +> = { + id: 'telegram_send_chat_action', + name: 'Telegram Send Chat Action', + description: + 'Show a status action such as a typing indicator in a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + action: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Type of action to broadcast (e.g. typing, upload_photo, record_video, upload_document, find_location)', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'sendChatAction'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + action: params.action, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to send chat action' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Chat action sent successfully', + data: { + ok: data.ok, + result: data.result, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Chat action result', + properties: { + ok: { type: 'boolean', description: 'API response success status' }, + result: { type: 'boolean', description: 'Whether the action was broadcast' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/send_contact.ts b/apps/sim/tools/telegram/send_contact.ts new file mode 100644 index 00000000000..242bdf8638a --- /dev/null +++ b/apps/sim/tools/telegram/send_contact.ts @@ -0,0 +1,105 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramMessage, + TelegramSendContactParams, + TelegramSendMessageResponse, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramSendContactTool: ToolConfig< + TelegramSendContactParams, + TelegramSendMessageResponse +> = { + id: 'telegram_send_contact', + name: 'Telegram Send Contact', + description: 'Send a phone contact to a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + phoneNumber: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Contact's phone number", + }, + firstName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: "Contact's first name", + }, + lastName: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: "Contact's last name", + }, + vcard: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Additional data about the contact in the form of a vCard', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'sendContact'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + phone_number: params.phoneNumber, + first_name: params.firstName, + } + if (params.lastName) body.last_name = params.lastName + if (params.vcard) body.vcard = params.vcard + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to send contact' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Contact sent successfully', + data: data.result as TelegramMessage, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Telegram message data for the sent contact', + properties: { + message_id: { type: 'number', description: 'Unique Telegram message identifier' }, + date: { type: 'number', description: 'Unix timestamp when message was sent' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/send_location.ts b/apps/sim/tools/telegram/send_location.ts new file mode 100644 index 00000000000..ab2e0559373 --- /dev/null +++ b/apps/sim/tools/telegram/send_location.ts @@ -0,0 +1,88 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramMessage, + TelegramSendLocationParams, + TelegramSendMessageResponse, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramSendLocationTool: ToolConfig< + TelegramSendLocationParams, + TelegramSendMessageResponse +> = { + id: 'telegram_send_location', + name: 'Telegram Send Location', + description: 'Send a point on the map to a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + latitude: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Latitude of the location', + }, + longitude: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Longitude of the location', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'sendLocation'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + chat_id: params.chatId, + latitude: params.latitude, + longitude: params.longitude, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to send location' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Location sent successfully', + data: data.result as TelegramMessage, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Telegram message data for the sent location', + properties: { + message_id: { type: 'number', description: 'Unique Telegram message identifier' }, + date: { type: 'number', description: 'Unix timestamp when message was sent' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/send_poll.ts b/apps/sim/tools/telegram/send_poll.ts new file mode 100644 index 00000000000..182b711d49b --- /dev/null +++ b/apps/sim/tools/telegram/send_poll.ts @@ -0,0 +1,105 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramMessage, + TelegramSendMessageResponse, + TelegramSendPollParams, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramSendPollTool: ToolConfig = + { + id: 'telegram_send_poll', + name: 'Telegram Send Poll', + description: 'Send a native poll to a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + question: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Poll question (1-300 characters)', + }, + options: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'List of 2-10 answer options as text strings', + }, + isAnonymous: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the poll needs to be anonymous (defaults to true)', + }, + allowsMultipleAnswers: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the poll allows multiple answers', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'sendPoll'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + question: params.question, + options: (params.options ?? []).map((option) => ({ text: option })), + } + if (params.isAnonymous !== undefined) body.is_anonymous = params.isAnonymous + if (params.allowsMultipleAnswers !== undefined) { + body.allows_multiple_answers = params.allowsMultipleAnswers + } + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to send poll' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Poll sent successfully', + data: data.result as TelegramMessage, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Telegram message data for the sent poll', + properties: { + message_id: { type: 'number', description: 'Unique Telegram message identifier' }, + date: { type: 'number', description: 'Unix timestamp when message was sent' }, + }, + }, + }, + } diff --git a/apps/sim/tools/telegram/set_message_reaction.ts b/apps/sim/tools/telegram/set_message_reaction.ts new file mode 100644 index 00000000000..7ab7f923f56 --- /dev/null +++ b/apps/sim/tools/telegram/set_message_reaction.ts @@ -0,0 +1,101 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { + TelegramBooleanResponse, + TelegramSetMessageReactionParams, +} from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramSetMessageReactionTool: ToolConfig< + TelegramSetMessageReactionParams, + TelegramBooleanResponse +> = { + id: 'telegram_set_message_reaction', + name: 'Telegram Set Message Reaction', + description: + 'Set or remove an emoji reaction on a message in a Telegram chat through the Telegram Bot API.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + messageId: { + type: 'number', + required: true, + visibility: 'user-or-llm', + description: 'Identifier of the target message', + }, + reaction: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Emoji to react with (leave empty to remove the reaction)', + }, + isBig: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Pass true to show the reaction with a big animation', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'setMessageReaction'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + message_id: params.messageId, + reaction: params.reaction ? [{ type: 'emoji', emoji: params.reaction }] : [], + } + if (params.isBig !== undefined) body.is_big = params.isBig + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to set message reaction' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message reaction set successfully', + data: { + ok: data.ok, + result: data.result, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Reaction operation result', + properties: { + ok: { type: 'boolean', description: 'API response success status' }, + result: { type: 'boolean', description: 'Whether the reaction was set' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/types.ts b/apps/sim/tools/telegram/types.ts index 9ae794067a4..ff25da5ddc6 100644 --- a/apps/sim/tools/telegram/types.ts +++ b/apps/sim/tools/telegram/types.ts @@ -125,6 +125,92 @@ export interface TelegramDeleteMessageParams extends TelegramAuthParams { messageId: number } +export interface TelegramEditMessageTextParams extends TelegramAuthParams { + messageId: number + text: string +} + +export interface TelegramForwardMessageParams extends TelegramAuthParams { + fromChatId: string + messageId: number +} + +export interface TelegramCopyMessageParams extends TelegramAuthParams { + fromChatId: string + messageId: number + caption?: string +} + +export interface TelegramSendLocationParams extends TelegramAuthParams { + latitude: number + longitude: number +} + +export interface TelegramSendContactParams extends TelegramAuthParams { + phoneNumber: string + firstName: string + lastName?: string + vcard?: string +} + +export interface TelegramSendPollParams extends TelegramAuthParams { + question: string + options: string[] + isAnonymous?: boolean + allowsMultipleAnswers?: boolean +} + +export interface TelegramPinMessageParams extends TelegramAuthParams { + messageId: number + disableNotification?: boolean +} + +export interface TelegramUnpinMessageParams extends TelegramAuthParams { + messageId?: number +} + +export interface TelegramSetMessageReactionParams extends TelegramAuthParams { + messageId: number + reaction?: string + isBig?: boolean +} + +export interface TelegramSendChatActionParams extends TelegramAuthParams { + action: string +} + +export type TelegramGetChatParams = TelegramAuthParams + +export interface TelegramGetChatMemberParams extends TelegramAuthParams { + userId: number +} + +export interface TelegramChatFullInfo { + id: number + type: string + title?: string + username?: string + first_name?: string + last_name?: string + description?: string + bio?: string + invite_link?: string + linked_chat_id?: number +} + +export interface TelegramUser { + id: number + is_bot: boolean + first_name?: string + last_name?: string + username?: string +} + +export interface TelegramChatMember { + status: string + user: TelegramUser +} + export interface TelegramSendMessageResponse extends ToolResponse { output: { message: string @@ -171,6 +257,39 @@ export interface TelegramSendDocumentResponse extends ToolResponse { } } +export interface TelegramCopyMessageResponse extends ToolResponse { + output: { + message: string + data?: { + message_id: number + } + } +} + +export interface TelegramBooleanResponse extends ToolResponse { + output: { + message: string + data?: { + ok: boolean + result: boolean + } + } +} + +export interface TelegramGetChatResponse extends ToolResponse { + output: { + message: string + data?: TelegramChatFullInfo + } +} + +export interface TelegramGetChatMemberResponse extends ToolResponse { + output: { + message: string + data?: TelegramChatMember + } +} + export type TelegramResponse = | TelegramSendMessageResponse | TelegramSendPhotoResponse @@ -178,6 +297,10 @@ export type TelegramResponse = | TelegramSendMediaResponse | TelegramSendDocumentResponse | TelegramDeleteMessageResponse + | TelegramCopyMessageResponse + | TelegramBooleanResponse + | TelegramGetChatResponse + | TelegramGetChatMemberResponse // Legacy type for backwards compatibility interface TelegramMessageParams { diff --git a/apps/sim/tools/telegram/unpin_message.ts b/apps/sim/tools/telegram/unpin_message.ts new file mode 100644 index 00000000000..1e09f9bcf25 --- /dev/null +++ b/apps/sim/tools/telegram/unpin_message.ts @@ -0,0 +1,84 @@ +import { ErrorExtractorId } from '@/tools/error-extractors' +import type { TelegramBooleanResponse, TelegramUnpinMessageParams } from '@/tools/telegram/types' +import { telegramApiUrl } from '@/tools/telegram/utils' +import type { ToolConfig } from '@/tools/types' + +export const telegramUnpinMessageTool: ToolConfig< + TelegramUnpinMessageParams, + TelegramBooleanResponse +> = { + id: 'telegram_unpin_message', + name: 'Telegram Unpin Message', + description: + 'Unpin a pinned message in a Telegram chat through the Telegram Bot API. Unpins the most recent pinned message when no message ID is given.', + version: '1.0.0', + errorExtractor: ErrorExtractorId.TELEGRAM_DESCRIPTION, + + params: { + botToken: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Telegram Bot API Token', + }, + chatId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Telegram chat ID (numeric, can be negative for groups)', + }, + messageId: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Identifier of the message to unpin (omit to unpin the most recent one)', + }, + }, + + request: { + url: (params) => telegramApiUrl(params.botToken, 'unpinChatMessage'), + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => { + const body: Record = { + chat_id: params.chatId, + } + if (params.messageId !== undefined) body.message_id = params.messageId + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + + if (!data.ok) { + const errorMessage = data.description || data.error || 'Failed to unpin message' + throw new Error(errorMessage) + } + + return { + success: true, + output: { + message: 'Message unpinned successfully', + data: { + ok: data.ok, + result: data.result, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + data: { + type: 'object', + description: 'Unpin operation result', + properties: { + ok: { type: 'boolean', description: 'API response success status' }, + result: { type: 'boolean', description: 'Whether the message was unpinned' }, + }, + }, + }, +} diff --git a/apps/sim/tools/telegram/utils.ts b/apps/sim/tools/telegram/utils.ts index 52e3298b2c2..531671d0027 100644 --- a/apps/sim/tools/telegram/utils.ts +++ b/apps/sim/tools/telegram/utils.ts @@ -1,3 +1,10 @@ +/** + * Build a Telegram Bot API method URL for the given bot token. + */ +export function telegramApiUrl(botToken: string, method: string): string { + return `https://api.telegram.org/bot${botToken}/${method}` +} + export function convertMarkdownToHTML(text: string): string { return ( text From 3be1cf9a6f685546a1d77b895e50cda81f080f4f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:28:03 -0700 Subject: [PATCH 02/13] feat(outlook): add reply, reply-all, folders, attachments, search, and message-update tools --- apps/sim/blocks/blocks/outlook.ts | 217 +++++++++++++++++++-- apps/sim/tools/outlook/create_folder.ts | 91 +++++++++ apps/sim/tools/outlook/get_attachment.ts | 122 ++++++++++++ apps/sim/tools/outlook/index.ts | 24 ++- apps/sim/tools/outlook/list_attachments.ts | 98 ++++++++++ apps/sim/tools/outlook/list_folders.ts | 110 +++++++++++ apps/sim/tools/outlook/reply.ts | 97 +++++++++ apps/sim/tools/outlook/reply_all.ts | 97 +++++++++ apps/sim/tools/outlook/search.ts | 137 +++++++++++++ apps/sim/tools/outlook/types.ts | 182 +++++++++++++++++ apps/sim/tools/outlook/update_message.ts | 142 ++++++++++++++ apps/sim/tools/registry.ts | 16 ++ 12 files changed, 1317 insertions(+), 16 deletions(-) create mode 100644 apps/sim/tools/outlook/create_folder.ts create mode 100644 apps/sim/tools/outlook/get_attachment.ts create mode 100644 apps/sim/tools/outlook/list_attachments.ts create mode 100644 apps/sim/tools/outlook/list_folders.ts create mode 100644 apps/sim/tools/outlook/reply.ts create mode 100644 apps/sim/tools/outlook/reply_all.ts create mode 100644 apps/sim/tools/outlook/search.ts create mode 100644 apps/sim/tools/outlook/update_message.ts diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index 1426ce4fe01..b4dd530d6e1 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -9,10 +9,10 @@ import { getTrigger } from '@/triggers' export const OutlookBlock: BlockConfig = { type: 'outlook', name: 'Outlook', - description: 'Send, read, draft, forward, and move Outlook email messages', + description: 'Send, read, search, reply, organize, and manage Outlook email', authMode: AuthMode.OAuth, longDescription: - 'Integrate Outlook into the workflow. Can read, draft, send, forward, and move email messages. Can be used in trigger mode to trigger a workflow when a new email is received.', + 'Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received.', docsLink: 'https://docs.sim.ai/integrations/outlook', category: 'tools', integrationType: IntegrationType.Email, @@ -28,12 +28,20 @@ export const OutlookBlock: BlockConfig = { { label: 'Send Email', id: 'send_outlook' }, { label: 'Draft Email', id: 'draft_outlook' }, { label: 'Read Email', id: 'read_outlook' }, + { label: 'Search Email', id: 'search_outlook' }, + { label: 'Reply to Email', id: 'reply_outlook' }, + { label: 'Reply All', id: 'reply_all_outlook' }, { label: 'Forward Email', id: 'forward_outlook' }, { label: 'Move Email', id: 'move_outlook' }, + { label: 'Copy Email', id: 'copy_outlook' }, { label: 'Mark as Read', id: 'mark_read_outlook' }, { label: 'Mark as Unread', id: 'mark_unread_outlook' }, + { label: 'Set Categories & Flag', id: 'update_message_outlook' }, { label: 'Delete Email', id: 'delete_outlook' }, - { label: 'Copy Email', id: 'copy_outlook' }, + { label: 'List Folders', id: 'list_folders_outlook' }, + { label: 'Create Folder', id: 'create_folder_outlook' }, + { label: 'List Attachments', id: 'list_attachments_outlook' }, + { label: 'Get Attachment', id: 'get_attachment_outlook' }, ], value: () => 'send_outlook', }, @@ -80,8 +88,11 @@ export const OutlookBlock: BlockConfig = { id: 'comment', title: 'Comment', type: 'long-input', - placeholder: 'Optional comment to include when forwarding', - condition: { field: 'operation', value: ['forward_outlook'] }, + placeholder: 'Message to include when forwarding or replying', + condition: { + field: 'operation', + value: ['forward_outlook', 'reply_outlook', 'reply_all_outlook'], + }, required: false, }, { @@ -191,10 +202,13 @@ export const OutlookBlock: BlockConfig = { }, { id: 'maxResults', - title: 'Number of Emails', + title: 'Number of Results', type: 'short-input', - placeholder: 'Number of emails to retrieve (default: 1, max: 10)', - condition: { field: 'operation', value: 'read_outlook' }, + placeholder: 'Number of results to retrieve', + condition: { + field: 'operation', + value: ['read_outlook', 'search_outlook', 'list_folders_outlook'], + }, }, { id: 'includeAttachments', @@ -238,7 +252,7 @@ export const OutlookBlock: BlockConfig = { condition: { field: 'operation', value: 'move_outlook' }, required: true, }, - // Mark as Read/Unread, Delete - Message ID field + // Single-message operations - Message ID field { id: 'actionMessageId', title: 'Message ID', @@ -246,7 +260,16 @@ export const OutlookBlock: BlockConfig = { placeholder: 'ID of the email', condition: { field: 'operation', - value: ['mark_read_outlook', 'mark_unread_outlook', 'delete_outlook'], + value: [ + 'mark_read_outlook', + 'mark_unread_outlook', + 'delete_outlook', + 'reply_outlook', + 'reply_all_outlook', + 'update_message_outlook', + 'list_attachments_outlook', + 'get_attachment_outlook', + ], }, required: true, }, @@ -286,6 +309,85 @@ export const OutlookBlock: BlockConfig = { condition: { field: 'operation', value: 'copy_outlook' }, required: true, }, + // Search Email - Query field + { + id: 'searchQuery', + title: 'Search Query', + type: 'short-input', + placeholder: 'Text to search across subject, body, sender, and recipients', + condition: { field: 'operation', value: 'search_outlook' }, + required: true, + }, + // List Folders - Include hidden folders toggle + { + id: 'includeHiddenFolders', + title: 'Include Hidden Folders', + type: 'switch', + condition: { field: 'operation', value: 'list_folders_outlook' }, + mode: 'advanced', + }, + // Create Folder - Folder name field + { + id: 'folderName', + title: 'Folder Name', + type: 'short-input', + placeholder: 'Name of the new folder', + condition: { field: 'operation', value: 'create_folder_outlook' }, + required: true, + }, + // Create Folder - Hidden toggle + { + id: 'folderIsHidden', + title: 'Hidden Folder', + type: 'switch', + condition: { field: 'operation', value: 'create_folder_outlook' }, + mode: 'advanced', + }, + // Get Attachment - Attachment ID field + { + id: 'attachmentId', + title: 'Attachment ID', + type: 'short-input', + placeholder: 'ID of the attachment to retrieve', + condition: { field: 'operation', value: 'get_attachment_outlook' }, + required: true, + }, + // Set Categories & Flag - Categories field + { + id: 'categories', + title: 'Categories', + type: 'short-input', + placeholder: 'Comma-separated category names (replaces existing categories)', + condition: { field: 'operation', value: 'update_message_outlook' }, + required: false, + }, + // Set Categories & Flag - Flag status + { + id: 'flagStatus', + title: 'Flag Status', + type: 'dropdown', + options: [ + { label: 'Not Flagged', id: 'notFlagged' }, + { label: 'Flagged', id: 'flagged' }, + { label: 'Complete', id: 'complete' }, + ], + condition: { field: 'operation', value: 'update_message_outlook' }, + required: false, + }, + // Set Categories & Flag - Importance + { + id: 'importance', + title: 'Importance', + type: 'dropdown', + options: [ + { label: 'Low', id: 'low' }, + { label: 'Normal', id: 'normal' }, + { label: 'High', id: 'high' }, + ], + condition: { field: 'operation', value: 'update_message_outlook' }, + mode: 'advanced', + required: false, + }, ...getTrigger('outlook_poller').subBlocks, ], tools: { @@ -293,12 +395,20 @@ export const OutlookBlock: BlockConfig = { 'outlook_send', 'outlook_draft', 'outlook_read', + 'outlook_search', + 'outlook_reply', + 'outlook_reply_all', 'outlook_forward', 'outlook_move', + 'outlook_copy', 'outlook_mark_read', 'outlook_mark_unread', + 'outlook_update_message', 'outlook_delete', - 'outlook_copy', + 'outlook_list_folders', + 'outlook_create_folder', + 'outlook_list_attachments', + 'outlook_get_attachment', ], config: { tool: (params) => { @@ -321,6 +431,22 @@ export const OutlookBlock: BlockConfig = { return 'outlook_delete' case 'copy_outlook': return 'outlook_copy' + case 'search_outlook': + return 'outlook_search' + case 'reply_outlook': + return 'outlook_reply' + case 'reply_all_outlook': + return 'outlook_reply_all' + case 'update_message_outlook': + return 'outlook_update_message' + case 'list_folders_outlook': + return 'outlook_list_folders' + case 'create_folder_outlook': + return 'outlook_create_folder' + case 'list_attachments_outlook': + return 'outlook_list_attachments' + case 'get_attachment_outlook': + return 'outlook_get_attachment' default: throw new Error(`Invalid Outlook operation: ${params.operation}`) } @@ -335,9 +461,18 @@ export const OutlookBlock: BlockConfig = { moveMessageId, actionMessageId, copyMessageId, + searchQuery, + folderName, + folderIsHidden, + includeHiddenFolders, + categories, + maxResults, ...rest } = params + // Agent calls may deliver booleans as the strings "true"/"false" + const toBool = (value: unknown): boolean => value === true || value === 'true' + // folder is already the canonical param - use it directly const effectiveFolder = folder ? String(folder).trim() : '' @@ -347,10 +482,29 @@ export const OutlookBlock: BlockConfig = { rest.attachments = normalizedAttachments } + if (maxResults != null && maxResults !== '') { + rest.maxResults = Number(maxResults) + } + if (rest.operation === 'read_outlook') { rest.folder = effectiveFolder || 'INBOX' } + if (rest.operation === 'search_outlook' && searchQuery) { + rest.query = String(searchQuery).trim() + } + + if (rest.operation === 'list_folders_outlook') { + rest.includeHiddenFolders = toBool(includeHiddenFolders) + } + + if (rest.operation === 'create_folder_outlook') { + if (folderName) { + rest.displayName = String(folderName).trim() + } + rest.isHidden = toBool(folderIsHidden) + } + // Handle move operation if (rest.operation === 'move_outlook') { if (moveMessageId) { @@ -364,13 +518,35 @@ export const OutlookBlock: BlockConfig = { } if ( - ['mark_read_outlook', 'mark_unread_outlook', 'delete_outlook'].includes(rest.operation) + [ + 'mark_read_outlook', + 'mark_unread_outlook', + 'delete_outlook', + 'reply_outlook', + 'reply_all_outlook', + 'update_message_outlook', + 'list_attachments_outlook', + 'get_attachment_outlook', + ].includes(rest.operation) ) { if (actionMessageId) { rest.messageId = actionMessageId } } + if ( + rest.operation === 'update_message_outlook' && + categories != null && + categories !== '' + ) { + const categoryList = Array.isArray(categories) + ? categories + : String(categories).split(',') + rest.categories = categoryList + .map((category) => String(category).trim()) + .filter((category) => category.length > 0) + } + if (rest.operation === 'copy_outlook') { if (copyMessageId) { rest.messageId = copyMessageId @@ -417,6 +593,18 @@ export const OutlookBlock: BlockConfig = { type: 'string', description: 'Destination folder ID for copy (canonical param)', }, + // Search operation inputs + searchQuery: { type: 'string', description: 'Free-text search query' }, + // Folder operation inputs + folderName: { type: 'string', description: 'Name of the new folder' }, + folderIsHidden: { type: 'boolean', description: 'Whether the new folder is hidden' }, + includeHiddenFolders: { type: 'boolean', description: 'Include hidden folders when listing' }, + // Attachment operation inputs + attachmentId: { type: 'string', description: 'ID of the attachment to retrieve' }, + // Update message operation inputs + categories: { type: 'string', description: 'Comma-separated category names' }, + flagStatus: { type: 'string', description: 'Follow-up flag status' }, + importance: { type: 'string', description: 'Message importance level' }, }, outputs: { // Common outputs @@ -447,6 +635,11 @@ export const OutlookBlock: BlockConfig = { }, isRead: { type: 'boolean', description: 'Whether email is read' }, importance: { type: 'string', description: 'Email importance level' }, + // Folder operation outputs + folders: { type: 'json', description: 'Array of mail folder objects' }, + // Update message operation outputs + categories: { type: 'json', description: 'Categories assigned to the message' }, + flagStatus: { type: 'string', description: 'Follow-up flag status of the message' }, // Trigger outputs email: { type: 'json', description: 'Email data from trigger' }, rawEmail: { type: 'json', description: 'Complete raw email data from Microsoft Graph API' }, diff --git a/apps/sim/tools/outlook/create_folder.ts b/apps/sim/tools/outlook/create_folder.ts new file mode 100644 index 00000000000..efde129b17c --- /dev/null +++ b/apps/sim/tools/outlook/create_folder.ts @@ -0,0 +1,91 @@ +import type { OutlookCreateFolderParams, OutlookCreateFolderResponse } from '@/tools/outlook/types' +import { OUTLOOK_FOLDER_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookCreateFolderTool: ToolConfig< + OutlookCreateFolderParams, + OutlookCreateFolderResponse +> = { + id: 'outlook_create_folder', + name: 'Outlook Create Folder', + description: 'Create a new mail folder in the root of the Outlook mailbox', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + displayName: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The display name of the new folder', + }, + isHidden: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the new folder is hidden (cannot be changed after creation)', + }, + }, + + request: { + url: () => 'https://graph.microsoft.com/v1.0/me/mailFolders', + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const displayName = params.displayName?.trim() + if (!displayName) { + throw new Error('A folder display name is required') + } + return { + displayName, + ...(params.isHidden ? { isHidden: true } : {}), + } + }, + }, + + transformResponse: async (response: Response) => { + const folder = await response.json() + return { + success: true, + output: { + message: `Successfully created folder "${folder.displayName ?? ''}".`, + results: { + id: folder.id, + displayName: folder.displayName ?? null, + parentFolderId: folder.parentFolderId ?? null, + childFolderCount: folder.childFolderCount ?? null, + unreadItemCount: folder.unreadItemCount ?? null, + totalItemCount: folder.totalItemCount ?? null, + isHidden: folder.isHidden ?? null, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'The newly created mail folder', + properties: OUTLOOK_FOLDER_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/outlook/get_attachment.ts b/apps/sim/tools/outlook/get_attachment.ts new file mode 100644 index 00000000000..8520708804d --- /dev/null +++ b/apps/sim/tools/outlook/get_attachment.ts @@ -0,0 +1,122 @@ +import type { + OutlookAttachment, + OutlookGetAttachmentParams, + OutlookGetAttachmentResponse, +} from '@/tools/outlook/types' +import { + OUTLOOK_ATTACHMENT_METADATA_OUTPUT_PROPERTIES, + OUTLOOK_ATTACHMENT_OUTPUT_PROPERTIES, +} from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +interface OutlookAttachmentApi { + '@odata.type'?: string + id: string + name?: string + contentType?: string + size?: number + isInline?: boolean + lastModifiedDateTime?: string + contentBytes?: string +} + +export const outlookGetAttachmentTool: ToolConfig< + OutlookGetAttachmentParams, + OutlookGetAttachmentResponse +> = { + id: 'outlook_get_attachment', + name: 'Outlook Get Attachment', + description: 'Get a single attachment on an Outlook message, including its file contents', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message that owns the attachment', + }, + attachmentId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the attachment to retrieve', + }, + }, + + request: { + url: (params) => + `https://graph.microsoft.com/v1.0/me/messages/${params.messageId.trim()}/attachments/${params.attachmentId.trim()}`, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const attachment: OutlookAttachmentApi = await response.json() + + const files: OutlookAttachment[] = [] + if ( + attachment['@odata.type'] === '#microsoft.graph.fileAttachment' && + attachment.contentBytes + ) { + files.push({ + name: attachment.name ?? 'attachment', + data: attachment.contentBytes, + contentType: attachment.contentType ?? 'application/octet-stream', + size: attachment.size ?? 0, + }) + } + + return { + success: true, + output: { + message: `Successfully retrieved attachment "${attachment.name ?? ''}".`, + results: { + id: attachment.id, + name: attachment.name ?? null, + contentType: attachment.contentType ?? null, + size: attachment.size ?? null, + isInline: attachment.isInline ?? null, + attachmentType: attachment['@odata.type'] ?? null, + lastModifiedDateTime: attachment.lastModifiedDateTime ?? null, + }, + attachments: files, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'object', + description: 'Attachment metadata', + properties: OUTLOOK_ATTACHMENT_METADATA_OUTPUT_PROPERTIES, + }, + attachments: { + type: 'file[]', + description: 'The downloaded file attachment (empty for non-file attachment types)', + items: { + type: 'object', + properties: OUTLOOK_ATTACHMENT_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/index.ts b/apps/sim/tools/outlook/index.ts index dbb135f48e8..f8cc4934374 100644 --- a/apps/sim/tools/outlook/index.ts +++ b/apps/sim/tools/outlook/index.ts @@ -1,21 +1,37 @@ import { outlookCopyTool } from '@/tools/outlook/copy' +import { outlookCreateFolderTool } from '@/tools/outlook/create_folder' import { outlookDeleteTool } from '@/tools/outlook/delete' import { outlookDraftTool } from '@/tools/outlook/draft' import { outlookForwardTool } from '@/tools/outlook/forward' +import { outlookGetAttachmentTool } from '@/tools/outlook/get_attachment' +import { outlookListAttachmentsTool } from '@/tools/outlook/list_attachments' +import { outlookListFoldersTool } from '@/tools/outlook/list_folders' import { outlookMarkReadTool } from '@/tools/outlook/mark_read' import { outlookMarkUnreadTool } from '@/tools/outlook/mark_unread' import { outlookMoveTool } from '@/tools/outlook/move' import { outlookReadTool } from '@/tools/outlook/read' +import { outlookReplyTool } from '@/tools/outlook/reply' +import { outlookReplyAllTool } from '@/tools/outlook/reply_all' +import { outlookSearchTool } from '@/tools/outlook/search' import { outlookSendTool } from '@/tools/outlook/send' +import { outlookUpdateMessageTool } from '@/tools/outlook/update_message' export { + outlookCopyTool, + outlookCreateFolderTool, + outlookDeleteTool, outlookDraftTool, outlookForwardTool, + outlookGetAttachmentTool, + outlookListAttachmentsTool, + outlookListFoldersTool, + outlookMarkReadTool, + outlookMarkUnreadTool, outlookMoveTool, outlookReadTool, + outlookReplyTool, + outlookReplyAllTool, + outlookSearchTool, outlookSendTool, - outlookMarkReadTool, - outlookMarkUnreadTool, - outlookDeleteTool, - outlookCopyTool, + outlookUpdateMessageTool, } diff --git a/apps/sim/tools/outlook/list_attachments.ts b/apps/sim/tools/outlook/list_attachments.ts new file mode 100644 index 00000000000..01c50667912 --- /dev/null +++ b/apps/sim/tools/outlook/list_attachments.ts @@ -0,0 +1,98 @@ +import type { + CleanedOutlookAttachmentMetadata, + OutlookListAttachmentsParams, + OutlookListAttachmentsResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_ATTACHMENT_METADATA_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +interface OutlookAttachmentApi { + '@odata.type'?: string + id: string + name?: string + contentType?: string + size?: number + isInline?: boolean + lastModifiedDateTime?: string +} + +export const outlookListAttachmentsTool: ToolConfig< + OutlookListAttachmentsParams, + OutlookListAttachmentsResponse +> = { + id: 'outlook_list_attachments', + name: 'Outlook List Attachments', + description: 'List the attachments on an Outlook message (metadata only, without contents)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message whose attachments to list', + }, + }, + + request: { + url: (params) => + `https://graph.microsoft.com/v1.0/me/messages/${params.messageId.trim()}/attachments?$select=id,name,contentType,size,isInline,lastModifiedDateTime`, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + const attachments: OutlookAttachmentApi[] = data.value || [] + + const cleanedAttachments: CleanedOutlookAttachmentMetadata[] = attachments.map( + (attachment) => ({ + id: attachment.id, + name: attachment.name ?? null, + contentType: attachment.contentType ?? null, + size: attachment.size ?? null, + isInline: attachment.isInline ?? null, + attachmentType: attachment['@odata.type'] ?? null, + lastModifiedDateTime: attachment.lastModifiedDateTime ?? null, + }) + ) + + return { + success: true, + output: { + message: `Successfully retrieved ${cleanedAttachments.length} attachment(s).`, + results: cleanedAttachments, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'array', + description: 'Array of attachment metadata objects', + items: { + type: 'object', + properties: OUTLOOK_ATTACHMENT_METADATA_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/list_folders.ts b/apps/sim/tools/outlook/list_folders.ts new file mode 100644 index 00000000000..0c4f31ebf0b --- /dev/null +++ b/apps/sim/tools/outlook/list_folders.ts @@ -0,0 +1,110 @@ +import type { + CleanedOutlookFolder, + OutlookListFoldersParams, + OutlookListFoldersResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_FOLDER_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +interface OutlookFolderApi { + id: string + displayName?: string + parentFolderId?: string + childFolderCount?: number + unreadItemCount?: number + totalItemCount?: number + isHidden?: boolean +} + +export const outlookListFoldersTool: ToolConfig< + OutlookListFoldersParams, + OutlookListFoldersResponse +> = { + id: 'outlook_list_folders', + name: 'Outlook List Folders', + description: 'List mail folders in the root of the Outlook mailbox', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of folders to retrieve (default: 50, max: 100)', + }, + includeHiddenFolders: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to include hidden folders in the results', + }, + }, + + request: { + url: (params) => { + const maxResults = params.maxResults + ? Math.max(1, Math.min(Math.abs(Number(params.maxResults)), 100)) + : 50 + const query = new URLSearchParams({ $top: String(maxResults) }) + if (params.includeHiddenFolders) { + query.set('includeHiddenFolders', 'true') + } + return `https://graph.microsoft.com/v1.0/me/mailFolders?${query.toString()}` + }, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + const folders: OutlookFolderApi[] = data.value || [] + + const cleanedFolders: CleanedOutlookFolder[] = folders.map((folder) => ({ + id: folder.id, + displayName: folder.displayName ?? null, + parentFolderId: folder.parentFolderId ?? null, + childFolderCount: folder.childFolderCount ?? null, + unreadItemCount: folder.unreadItemCount ?? null, + totalItemCount: folder.totalItemCount ?? null, + isHidden: folder.isHidden ?? null, + })) + + return { + success: true, + output: { + message: `Successfully retrieved ${cleanedFolders.length} folder(s).`, + results: cleanedFolders, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'array', + description: 'Array of mail folder objects', + items: { + type: 'object', + properties: OUTLOOK_FOLDER_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/reply.ts b/apps/sim/tools/outlook/reply.ts new file mode 100644 index 00000000000..f5383a37df9 --- /dev/null +++ b/apps/sim/tools/outlook/reply.ts @@ -0,0 +1,97 @@ +import type { OutlookReplyParams, OutlookReplyResponse } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookReplyTool: ToolConfig = { + id: 'outlook_reply', + name: 'Outlook Reply', + description: 'Reply to the sender of an Outlook message with a comment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message to reply to', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The reply text to include above the original message', + }, + }, + + request: { + url: (params) => + `https://graph.microsoft.com/v1.0/me/messages/${params.messageId.trim()}/reply`, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + comment: params.comment ?? '', + }), + }, + + transformResponse: async (response: Response) => { + const status = response.status + const requestId = + response.headers?.get('request-id') || response.headers?.get('x-ms-request-id') || undefined + + return { + success: true, + output: { + message: + status === 202 || status === 200 + ? 'Reply sent successfully' + : `Reply sent (HTTP ${status})`, + results: { + status: 'replied', + timestamp: new Date().toISOString(), + httpStatus: status, + requestId, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + results: { + type: 'object', + description: 'Reply result details', + properties: { + status: { type: 'string', description: 'Reply status' }, + timestamp: { type: 'string', description: 'Timestamp when the reply was sent' }, + httpStatus: { + type: 'number', + description: 'HTTP status code returned by the API', + optional: true, + }, + requestId: { + type: 'string', + description: 'Microsoft Graph request-id header for tracing', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/reply_all.ts b/apps/sim/tools/outlook/reply_all.ts new file mode 100644 index 00000000000..cb42edd9386 --- /dev/null +++ b/apps/sim/tools/outlook/reply_all.ts @@ -0,0 +1,97 @@ +import type { OutlookReplyParams, OutlookReplyResponse } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookReplyAllTool: ToolConfig = { + id: 'outlook_reply_all', + name: 'Outlook Reply All', + description: 'Reply to all recipients of an Outlook message with a comment', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message to reply to', + }, + comment: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'The reply text to include above the original message', + }, + }, + + request: { + url: (params) => + `https://graph.microsoft.com/v1.0/me/messages/${params.messageId.trim()}/replyAll`, + method: 'POST', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => ({ + comment: params.comment ?? '', + }), + }, + + transformResponse: async (response: Response) => { + const status = response.status + const requestId = + response.headers?.get('request-id') || response.headers?.get('x-ms-request-id') || undefined + + return { + success: true, + output: { + message: + status === 202 || status === 200 + ? 'Reply all sent successfully' + : `Reply all sent (HTTP ${status})`, + results: { + status: 'repliedAll', + timestamp: new Date().toISOString(), + httpStatus: status, + requestId, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + results: { + type: 'object', + description: 'Reply-all result details', + properties: { + status: { type: 'string', description: 'Reply status' }, + timestamp: { type: 'string', description: 'Timestamp when the reply was sent' }, + httpStatus: { + type: 'number', + description: 'HTTP status code returned by the API', + optional: true, + }, + requestId: { + type: 'string', + description: 'Microsoft Graph request-id header for tracing', + optional: true, + }, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/search.ts b/apps/sim/tools/outlook/search.ts new file mode 100644 index 00000000000..234216215f3 --- /dev/null +++ b/apps/sim/tools/outlook/search.ts @@ -0,0 +1,137 @@ +import type { + CleanedOutlookMessage, + OutlookMessage, + OutlookMessagesResponse, + OutlookSearchParams, + OutlookSearchResponse, +} from '@/tools/outlook/types' +import { OUTLOOK_MESSAGE_OUTPUT_PROPERTIES } from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +export const outlookSearchTool: ToolConfig = { + id: 'outlook_search', + name: 'Outlook Search', + description: 'Search Outlook messages using a free-text query (Microsoft Graph $search)', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + query: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Search text matched against the subject, body, sender, and recipients of messages', + }, + maxResults: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum number of messages to retrieve (default: 10, max: 25)', + }, + }, + + request: { + url: (params) => { + const query = params.query?.trim() + if (!query) { + throw new Error('A search query is required') + } + const maxResults = params.maxResults + ? Math.max(1, Math.min(Math.abs(Number(params.maxResults)), 25)) + : 10 + const searchParams = new URLSearchParams({ + $search: `"${query}"`, + $top: String(maxResults), + }) + return `https://graph.microsoft.com/v1.0/me/messages?${searchParams.toString()}` + }, + method: 'GET', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + } + }, + }, + + transformResponse: async (response: Response) => { + const data: OutlookMessagesResponse = await response.json() + const messages = data.value || [] + + if (messages.length === 0) { + return { + success: true, + output: { + message: 'No matching messages found.', + results: [], + }, + } + } + + const cleanedMessages: CleanedOutlookMessage[] = messages.map((message: OutlookMessage) => ({ + id: message.id, + subject: message.subject, + bodyPreview: message.bodyPreview, + body: { + contentType: message.body?.contentType, + content: message.body?.content, + }, + sender: { + name: message.sender?.emailAddress?.name, + address: message.sender?.emailAddress?.address, + }, + from: { + name: message.from?.emailAddress?.name, + address: message.from?.emailAddress?.address, + }, + toRecipients: + message.toRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + ccRecipients: + message.ccRecipients?.map((recipient) => ({ + name: recipient.emailAddress?.name, + address: recipient.emailAddress?.address, + })) || [], + receivedDateTime: message.receivedDateTime, + sentDateTime: message.sentDateTime, + hasAttachments: message.hasAttachments, + isRead: message.isRead, + importance: message.importance, + })) + + return { + success: true, + output: { + message: `Found ${cleanedMessages.length} matching message(s).`, + results: cleanedMessages, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or status message' }, + results: { + type: 'array', + description: 'Array of matching email message objects', + items: { + type: 'object', + properties: OUTLOOK_MESSAGE_OUTPUT_PROPERTIES, + }, + }, + }, +} diff --git a/apps/sim/tools/outlook/types.ts b/apps/sim/tools/outlook/types.ts index 77ac94f03d0..31856966d7d 100644 --- a/apps/sim/tools/outlook/types.ts +++ b/apps/sim/tools/outlook/types.ts @@ -340,3 +340,185 @@ export type OutlookExtendedResponse = | OutlookMarkReadResponse | OutlookDeleteResponse | OutlookCopyResponse + +/** + * Output definition for mail folder objects. + * @see https://learn.microsoft.com/en-us/graph/api/resources/mailfolder + */ +export const OUTLOOK_FOLDER_OUTPUT_PROPERTIES = { + id: { type: 'string', description: 'Unique folder identifier' }, + displayName: { type: 'string', description: 'Display name of the folder', optional: true }, + parentFolderId: { + type: 'string', + description: 'Identifier of the parent folder', + optional: true, + }, + childFolderCount: { + type: 'number', + description: 'Number of immediate child folders', + optional: true, + }, + unreadItemCount: { + type: 'number', + description: 'Number of unread items in the folder', + optional: true, + }, + totalItemCount: { + type: 'number', + description: 'Total number of items in the folder', + optional: true, + }, + isHidden: { type: 'boolean', description: 'Whether the folder is hidden', optional: true }, +} as const satisfies Record + +/** + * Output definition for attachment metadata returned by list/get attachment tools. + */ +export const OUTLOOK_ATTACHMENT_METADATA_OUTPUT_PROPERTIES = { + id: { type: 'string', description: 'Unique attachment identifier' }, + name: { type: 'string', description: 'Attachment filename', optional: true }, + contentType: { type: 'string', description: 'MIME type of the attachment', optional: true }, + size: { type: 'number', description: 'Attachment size in bytes', optional: true }, + isInline: { + type: 'boolean', + description: 'Whether the attachment is rendered inline in the message body', + optional: true, + }, + attachmentType: { + type: 'string', + description: 'Microsoft Graph attachment type (e.g. #microsoft.graph.fileAttachment)', + optional: true, + }, + lastModifiedDateTime: { + type: 'string', + description: 'When the attachment was last modified (ISO 8601)', + optional: true, + }, +} as const satisfies Record + +/** Cleaned mail folder returned by our tools. */ +export interface CleanedOutlookFolder { + id: string + displayName?: string | null + parentFolderId?: string | null + childFolderCount?: number | null + unreadItemCount?: number | null + totalItemCount?: number | null + isHidden?: boolean | null +} + +/** Cleaned attachment metadata returned by our tools. */ +export interface CleanedOutlookAttachmentMetadata { + id: string + name?: string | null + contentType?: string | null + size?: number | null + isInline?: boolean | null + attachmentType?: string | null + lastModifiedDateTime?: string | null +} + +export interface OutlookReplyParams { + accessToken: string + messageId: string + comment?: string +} + +export interface OutlookReplyResponse extends ToolResponse { + output: { + message: string + results: { + status: string + timestamp: string + httpStatus?: number + requestId?: string + } + } +} + +export interface OutlookListFoldersParams { + accessToken: string + maxResults?: number + includeHiddenFolders?: boolean +} + +export interface OutlookListFoldersResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookFolder[] + } +} + +export interface OutlookCreateFolderParams { + accessToken: string + displayName: string + isHidden?: boolean +} + +export interface OutlookCreateFolderResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookFolder + } +} + +export interface OutlookListAttachmentsParams { + accessToken: string + messageId: string +} + +export interface OutlookListAttachmentsResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookAttachmentMetadata[] + } +} + +export interface OutlookGetAttachmentParams { + accessToken: string + messageId: string + attachmentId: string +} + +export interface OutlookGetAttachmentResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookAttachmentMetadata + attachments: OutlookAttachment[] + } +} + +export interface OutlookSearchParams { + accessToken: string + query: string + maxResults?: number +} + +export interface OutlookSearchResponse extends ToolResponse { + output: { + message: string + results: CleanedOutlookMessage[] + } +} + +export interface OutlookUpdateMessageParams { + accessToken: string + messageId: string + categories?: string[] + flagStatus?: 'notFlagged' | 'flagged' | 'complete' + importance?: 'low' | 'normal' | 'high' +} + +export interface OutlookUpdateMessageResponse extends ToolResponse { + output: { + message: string + results: { + messageId: string + subject?: string | null + categories: string[] + flagStatus?: string | null + importance?: string | null + isRead?: boolean | null + } + } +} diff --git a/apps/sim/tools/outlook/update_message.ts b/apps/sim/tools/outlook/update_message.ts new file mode 100644 index 00000000000..225e0291c8a --- /dev/null +++ b/apps/sim/tools/outlook/update_message.ts @@ -0,0 +1,142 @@ +import type { + OutlookUpdateMessageParams, + OutlookUpdateMessageResponse, +} from '@/tools/outlook/types' +import type { ToolConfig } from '@/tools/types' + +interface OutlookMessageUpdateApi { + id: string + subject?: string + categories?: string[] + flag?: { flagStatus?: string } + importance?: string + isRead?: boolean +} + +export const outlookUpdateMessageTool: ToolConfig< + OutlookUpdateMessageParams, + OutlookUpdateMessageResponse +> = { + id: 'outlook_update_message', + name: 'Outlook Update Message', + description: 'Set the categories, follow-up flag, and importance on an Outlook message', + version: '1.0.0', + + oauth: { + required: true, + provider: 'outlook', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'OAuth access token for Outlook', + }, + messageId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The ID of the message to update', + }, + categories: { + type: 'json', + required: false, + visibility: 'user-or-llm', + description: + 'Array of category names to assign to the message (replaces existing categories)', + }, + flagStatus: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Follow-up flag status: notFlagged, flagged, or complete', + }, + importance: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Message importance: low, normal, or high', + }, + }, + + request: { + url: (params) => `https://graph.microsoft.com/v1.0/me/messages/${params.messageId.trim()}`, + method: 'PATCH', + headers: (params) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + return { + Authorization: `Bearer ${params.accessToken}`, + 'Content-Type': 'application/json', + } + }, + body: (params) => { + const body: Record = {} + + if (Array.isArray(params.categories)) { + body.categories = params.categories + .map((category) => String(category).trim()) + .filter((category) => category.length > 0) + } + + if (params.flagStatus) { + body.flag = { flagStatus: params.flagStatus } + } + + if (params.importance) { + body.importance = params.importance + } + + if (Object.keys(body).length === 0) { + throw new Error('Provide at least one of categories, flagStatus, or importance to update') + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const message: OutlookMessageUpdateApi = await response.json() + return { + success: true, + output: { + message: 'Message updated successfully', + results: { + messageId: message.id, + subject: message.subject ?? null, + categories: message.categories ?? [], + flagStatus: message.flag?.flagStatus ?? null, + importance: message.importance ?? null, + isRead: message.isRead ?? null, + }, + }, + } + }, + + outputs: { + message: { type: 'string', description: 'Success or error message' }, + results: { + type: 'object', + description: 'Updated message details', + properties: { + messageId: { type: 'string', description: 'ID of the updated message' }, + subject: { type: 'string', description: 'Subject of the message', optional: true }, + categories: { + type: 'array', + description: 'Categories assigned to the message', + items: { type: 'string' }, + }, + flagStatus: { + type: 'string', + description: 'Follow-up flag status of the message', + optional: true, + }, + importance: { type: 'string', description: 'Importance of the message', optional: true }, + isRead: { type: 'boolean', description: 'Whether the message is read', optional: true }, + }, + }, + }, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index f6cc4ef1b4a..47bf299b0a0 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2342,14 +2342,22 @@ import { import { openAIEmbeddingsTool, openAIImageTool } from '@/tools/openai' import { outlookCopyTool, + outlookCreateFolderTool, outlookDeleteTool, outlookDraftTool, outlookForwardTool, + outlookGetAttachmentTool, + outlookListAttachmentsTool, + outlookListFoldersTool, outlookMarkReadTool, outlookMarkUnreadTool, outlookMoveTool, outlookReadTool, + outlookReplyAllTool, + outlookReplyTool, + outlookSearchTool, outlookSendTool, + outlookUpdateMessageTool, } from '@/tools/outlook' import { pagerdutyAddNoteTool, @@ -6928,6 +6936,14 @@ export const tools: Record = { outlook_mark_unread: outlookMarkUnreadTool, outlook_delete: outlookDeleteTool, outlook_copy: outlookCopyTool, + outlook_reply: outlookReplyTool, + outlook_reply_all: outlookReplyAllTool, + outlook_list_folders: outlookListFoldersTool, + outlook_create_folder: outlookCreateFolderTool, + outlook_list_attachments: outlookListAttachmentsTool, + outlook_get_attachment: outlookGetAttachmentTool, + outlook_search: outlookSearchTool, + outlook_update_message: outlookUpdateMessageTool, pagerduty_list_incidents: pagerdutyListIncidentsTool, pagerduty_create_incident: pagerdutyCreateIncidentTool, pagerduty_update_incident: pagerdutyUpdateIncidentTool, From af1aac9abd7302f9a5ff142b3b67c613e7083d56 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:28:03 -0700 Subject: [PATCH 03/13] feat(notion): add block children CRUD, comments, and users tools (v1 + v2) --- apps/sim/blocks/blocks/notion.ts | 339 +++++++++++++++++- apps/sim/tools/notion/append_blocks.ts | 125 +++++++ apps/sim/tools/notion/create_comment.ts | 130 +++++++ apps/sim/tools/notion/delete_block.ts | 87 +++++ apps/sim/tools/notion/index.ts | 29 +- apps/sim/tools/notion/list_comments.ts | 109 ++++++ apps/sim/tools/notion/list_users.ts | 96 +++++ .../tools/notion/retrieve_block_children.ts | 108 ++++++ apps/sim/tools/notion/retrieve_user.ts | 102 ++++++ apps/sim/tools/notion/types.ts | 111 ++++++ apps/sim/tools/notion/update_block.ts | 131 +++++++ apps/sim/tools/registry.ts | 32 ++ 12 files changed, 1388 insertions(+), 11 deletions(-) create mode 100644 apps/sim/tools/notion/append_blocks.ts create mode 100644 apps/sim/tools/notion/create_comment.ts create mode 100644 apps/sim/tools/notion/delete_block.ts create mode 100644 apps/sim/tools/notion/list_comments.ts create mode 100644 apps/sim/tools/notion/list_users.ts create mode 100644 apps/sim/tools/notion/retrieve_block_children.ts create mode 100644 apps/sim/tools/notion/retrieve_user.ts create mode 100644 apps/sim/tools/notion/update_block.ts diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index fd11428f839..343e9285eb0 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -33,6 +33,14 @@ export const NotionBlock: BlockConfig = { { label: 'Create Database', id: 'notion_create_database' }, { label: 'Add Database Row', id: 'notion_add_database_row' }, { label: 'Append Content', id: 'notion_write' }, + { label: 'Append Block Children', id: 'notion_append_blocks' }, + { label: 'Retrieve Block Children', id: 'notion_retrieve_block_children' }, + { label: 'Update Block', id: 'notion_update_block' }, + { label: 'Delete Block', id: 'notion_delete_block' }, + { label: 'Create Comment', id: 'notion_create_comment' }, + { label: 'List Comments', id: 'notion_list_comments' }, + { label: 'List Users', id: 'notion_list_users' }, + { label: 'Retrieve User', id: 'notion_retrieve_user' }, { label: 'Query Database', id: 'notion_query_database' }, { label: 'Search Workspace', id: 'notion_search' }, ], @@ -227,7 +235,16 @@ export const NotionBlock: BlockConfig = { title: 'Page Size', type: 'short-input', placeholder: 'Number of results (default: 100, max: 100)', - condition: { field: 'operation', value: 'notion_query_database' }, + mode: 'advanced', + condition: { + field: 'operation', + value: [ + 'notion_query_database', + 'notion_retrieve_block_children', + 'notion_list_comments', + 'notion_list_users', + ], + }, }, // Search Fields { @@ -301,6 +318,122 @@ export const NotionBlock: BlockConfig = { generationType: 'json-object', }, }, + { + id: 'blockId', + title: 'Page or Block ID', + type: 'short-input', + placeholder: 'Enter Notion page or block ID', + dependsOn: ['credential'], + condition: { + field: 'operation', + value: [ + 'notion_append_blocks', + 'notion_retrieve_block_children', + 'notion_update_block', + 'notion_delete_block', + 'notion_list_comments', + ], + }, + required: true, + }, + { + id: 'children', + title: 'Block Children', + type: 'code', + placeholder: 'Enter an array of Notion block objects as JSON', + condition: { field: 'operation', value: 'notion_append_blocks' }, + required: true, + wandConfig: { + enabled: true, + prompt: + 'Generate an array of Notion block objects in JSON format based on the user\'s description. Each block has "object": "block", a "type", and a type-specific field. Examples: paragraph {"object":"block","type":"paragraph","paragraph":{"rich_text":[{"type":"text","text":{"content":"Hello"}}]}}, heading_2 {"object":"block","type":"heading_2","heading_2":{"rich_text":[{"type":"text","text":{"content":"Section"}}]}}, bulleted_list_item, numbered_list_item, to_do {"object":"block","type":"to_do","to_do":{"rich_text":[{"type":"text","text":{"content":"Task"}}],"checked":false}}. Return ONLY a valid JSON array - no explanations.', + placeholder: 'Describe the content blocks to append...', + generationType: 'json-object', + }, + }, + { + id: 'after', + title: 'Append After Block ID', + type: 'short-input', + placeholder: 'UUID of the block to append after (optional)', + mode: 'advanced', + condition: { field: 'operation', value: 'notion_append_blocks' }, + }, + { + id: 'block', + title: 'Block Update', + type: 'code', + placeholder: 'Enter the block-type fields to update as JSON', + condition: { field: 'operation', value: 'notion_update_block' }, + required: true, + wandConfig: { + enabled: true, + prompt: + 'Generate a Notion block-type update object in JSON format based on the user\'s description. The object contains the block type as a key with its updatable fields. Examples: paragraph {"paragraph":{"rich_text":[{"type":"text","text":{"content":"Updated text"}}]}}, to_do {"to_do":{"rich_text":[{"type":"text","text":{"content":"Task"}}],"checked":true}}, heading_1 {"heading_1":{"rich_text":[{"type":"text","text":{"content":"New heading"}}]}}. Return ONLY valid JSON - no explanations.', + placeholder: 'Describe how to update the block...', + generationType: 'json-object', + }, + }, + { + id: 'archived', + title: 'Archive Block', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: 'notion_update_block' }, + }, + { + id: 'commentParentId', + title: 'Page ID', + type: 'short-input', + placeholder: 'UUID of the page to comment on', + dependsOn: ['credential'], + condition: { field: 'operation', value: 'notion_create_comment' }, + }, + { + id: 'discussionId', + title: 'Discussion ID', + type: 'short-input', + placeholder: 'UUID of an existing discussion thread (optional)', + mode: 'advanced', + condition: { field: 'operation', value: 'notion_create_comment' }, + }, + { + id: 'commentContent', + title: 'Comment', + type: 'long-input', + placeholder: 'Enter the comment text', + condition: { field: 'operation', value: 'notion_create_comment' }, + required: true, + wandConfig: { + enabled: true, + prompt: + "Generate a concise, professional comment to post on a Notion page based on the user's description. Return ONLY the comment text - no explanations, no quotes.", + placeholder: 'Describe what the comment should say...', + }, + }, + { + id: 'userId', + title: 'User ID', + type: 'short-input', + placeholder: 'UUID of the Notion user to retrieve', + condition: { field: 'operation', value: 'notion_retrieve_user' }, + required: true, + }, + { + id: 'startCursor', + title: 'Start Cursor', + type: 'short-input', + placeholder: 'Pagination cursor from a previous response (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['notion_retrieve_block_children', 'notion_list_comments', 'notion_list_users'], + }, + }, ], tools: { access: [ @@ -311,6 +444,16 @@ export const NotionBlock: BlockConfig = { 'notion_query_database', 'notion_search', 'notion_create_database', + 'notion_add_database_row', + 'notion_update_page', + 'notion_append_blocks', + 'notion_retrieve_block_children', + 'notion_update_block', + 'notion_delete_block', + 'notion_create_comment', + 'notion_list_comments', + 'notion_list_users', + 'notion_retrieve_user', ], config: { tool: (params) => { @@ -323,18 +466,51 @@ export const NotionBlock: BlockConfig = { return 'notion_write' case 'notion_create_page': return 'notion_create_page' + case 'notion_update_page': + return 'notion_update_page' case 'notion_query_database': return 'notion_query_database' case 'notion_search': return 'notion_search' case 'notion_create_database': return 'notion_create_database' + case 'notion_add_database_row': + return 'notion_add_database_row' + case 'notion_append_blocks': + return 'notion_append_blocks' + case 'notion_retrieve_block_children': + return 'notion_retrieve_block_children' + case 'notion_update_block': + return 'notion_update_block' + case 'notion_delete_block': + return 'notion_delete_block' + case 'notion_create_comment': + return 'notion_create_comment' + case 'notion_list_comments': + return 'notion_list_comments' + case 'notion_list_users': + return 'notion_list_users' + case 'notion_retrieve_user': + return 'notion_retrieve_user' default: return 'notion_read' } }, params: (params) => { - const { oauthCredential, operation, properties, filter, sorts, ...rest } = params + const { + oauthCredential, + operation, + properties, + filter, + sorts, + children, + block, + archived, + pageSize, + commentParentId, + commentContent, + ...rest + } = params // Parse properties from JSON string for create/add operations let parsedProperties @@ -375,12 +551,62 @@ export const NotionBlock: BlockConfig = { } } + // Parse block children array for append operations + let parsedChildren + if (operation === 'notion_append_blocks' && children) { + if (typeof children === 'string') { + try { + parsedChildren = JSON.parse(children) + } catch (error) { + throw new Error(`Invalid JSON for children: ${toError(error).message}`) + } + } else { + parsedChildren = children + } + } + + // Parse block-type payload for update block operations + let parsedBlock + if (operation === 'notion_update_block' && block) { + if (typeof block === 'string') { + try { + parsedBlock = JSON.parse(block) + } catch (error) { + throw new Error(`Invalid JSON for block: ${toError(error).message}`) + } + } else { + parsedBlock = block + } + } + + // Coerce archived flag — agent calls deliver "true"/"false" strings + let coercedArchived + if (operation === 'notion_update_block' && archived !== undefined && archived !== '') { + coercedArchived = archived === true || archived === 'true' + } + + // Coerce numeric page size for paginated operations + let coercedPageSize + if (pageSize !== undefined && pageSize !== null && pageSize !== '') { + coercedPageSize = Number(pageSize) + } + return { ...rest, oauthCredential, ...(parsedProperties ? { properties: parsedProperties } : {}), ...(parsedFilter ? { filter: JSON.stringify(parsedFilter) } : {}), ...(parsedSorts ? { sorts: JSON.stringify(parsedSorts) } : {}), + ...(parsedChildren ? { children: parsedChildren } : {}), + ...(parsedBlock ? { block: parsedBlock } : {}), + ...(coercedArchived !== undefined ? { archived: coercedArchived } : {}), + ...(coercedPageSize !== undefined ? { pageSize: coercedPageSize } : {}), + ...(operation === 'notion_create_comment' && commentParentId + ? { pageId: commentParentId } + : {}), + ...(operation === 'notion_create_comment' && commentContent + ? { content: commentContent } + : {}), } }, }, @@ -401,6 +627,19 @@ export const NotionBlock: BlockConfig = { // Search inputs query: { type: 'string', description: 'Search query' }, filterType: { type: 'string', description: 'Filter type' }, + // Block content inputs + blockId: { type: 'string', description: 'Page or block identifier' }, + children: { type: 'json', description: 'Array of block objects to append' }, + after: { type: 'string', description: 'Block ID to append after' }, + block: { type: 'json', description: 'Block-type fields to update' }, + archived: { type: 'boolean', description: 'Whether to archive the block' }, + startCursor: { type: 'string', description: 'Pagination cursor' }, + // Comment inputs + commentParentId: { type: 'string', description: 'Page identifier to comment on' }, + discussionId: { type: 'string', description: 'Discussion thread identifier' }, + commentContent: { type: 'string', description: 'Comment text' }, + // User inputs + userId: { type: 'string', description: 'User identifier' }, }, outputs: { // Common outputs across all Notion operations @@ -472,6 +711,14 @@ export const NotionV2Block: BlockConfig = { 'notion_search_v2', 'notion_create_database_v2', 'notion_add_database_row_v2', + 'notion_append_blocks_v2', + 'notion_retrieve_block_children_v2', + 'notion_update_block_v2', + 'notion_delete_block_v2', + 'notion_create_comment_v2', + 'notion_list_comments_v2', + 'notion_list_users_v2', + 'notion_retrieve_user_v2', ], config: { tool: createVersionedToolSelector({ @@ -487,8 +734,8 @@ export const NotionV2Block: BlockConfig = { // Read page outputs content: { type: 'string', - description: 'Page content in markdown format', - condition: { field: 'operation', value: 'notion_read' }, + description: 'Page content in markdown format, or comment text for create comment', + condition: { field: 'operation', value: ['notion_read', 'notion_create_comment'] }, }, title: { type: 'string', @@ -500,7 +747,7 @@ export const NotionV2Block: BlockConfig = { }, id: { type: 'string', - description: 'Page or database ID', + description: 'Page, database, block, comment, or user ID', condition: { field: 'operation', value: [ @@ -509,6 +756,10 @@ export const NotionV2Block: BlockConfig = { 'notion_add_database_row', 'notion_read_database', 'notion_update_page', + 'notion_update_block', + 'notion_delete_block', + 'notion_create_comment', + 'notion_retrieve_user', ], }, }, @@ -520,21 +771,51 @@ export const NotionV2Block: BlockConfig = { type: 'string', description: 'Last edit timestamp', }, - // Database query/search outputs + // List/query/search outputs results: { type: 'array', - description: 'Array of results from query or search', - condition: { field: 'operation', value: ['notion_query_database', 'notion_search'] }, + description: 'Array of results (pages, blocks, comments, or users)', + condition: { + field: 'operation', + value: [ + 'notion_query_database', + 'notion_search', + 'notion_append_blocks', + 'notion_retrieve_block_children', + 'notion_list_comments', + 'notion_list_users', + ], + }, }, has_more: { type: 'boolean', description: 'Whether more results are available', - condition: { field: 'operation', value: ['notion_query_database', 'notion_search'] }, + condition: { + field: 'operation', + value: [ + 'notion_query_database', + 'notion_search', + 'notion_append_blocks', + 'notion_retrieve_block_children', + 'notion_list_comments', + 'notion_list_users', + ], + }, }, next_cursor: { type: 'string', description: 'Cursor for pagination', - condition: { field: 'operation', value: ['notion_query_database', 'notion_search'] }, + condition: { + field: 'operation', + value: [ + 'notion_query_database', + 'notion_search', + 'notion_append_blocks', + 'notion_retrieve_block_children', + 'notion_list_comments', + 'notion_list_users', + ], + }, }, total_results: { type: 'number', @@ -553,6 +834,44 @@ export const NotionV2Block: BlockConfig = { description: 'Whether content was successfully appended', condition: { field: 'operation', value: 'notion_write' }, }, + // Block update/delete outputs + type: { + type: 'string', + description: 'Block type', + condition: { field: 'operation', value: 'notion_update_block' }, + }, + block: { + type: 'json', + description: 'The full updated Notion block object', + condition: { field: 'operation', value: 'notion_update_block' }, + }, + archived: { + type: 'boolean', + description: 'Whether the block was archived', + condition: { field: 'operation', value: ['notion_update_block', 'notion_delete_block'] }, + }, + // Comment outputs + discussion_id: { + type: 'string', + description: 'Discussion thread ID', + condition: { field: 'operation', value: 'notion_create_comment' }, + }, + // User outputs + name: { + type: 'string', + description: 'User display name', + condition: { field: 'operation', value: 'notion_retrieve_user' }, + }, + avatar_url: { + type: 'string', + description: 'User avatar image URL', + condition: { field: 'operation', value: 'notion_retrieve_user' }, + }, + email: { + type: 'string', + description: 'User email address (person users only)', + condition: { field: 'operation', value: 'notion_retrieve_user' }, + }, }, } diff --git a/apps/sim/tools/notion/append_blocks.ts b/apps/sim/tools/notion/append_blocks.ts new file mode 100644 index 00000000000..335a3ebdfc8 --- /dev/null +++ b/apps/sim/tools/notion/append_blocks.ts @@ -0,0 +1,125 @@ +import type { NotionAppendBlocksParams } from '@/tools/notion/types' +import { BLOCK_LIST_RESULTS_OUTPUT, PAGINATION_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionAppendBlocksResponse { + success: boolean + output: { + results: any[] + has_more: boolean + next_cursor: string | null + } +} + +/** + * Coerce the children param into a block array, accepting either a JSON string + * (when called directly by an agent) or an already-parsed array. + */ +function parseChildren(children: any[] | string): any[] { + if (Array.isArray(children)) return children + if (typeof children === 'string') { + const parsed = JSON.parse(children) + if (!Array.isArray(parsed)) { + throw new Error('children must be a JSON array of Notion block objects') + } + return parsed + } + throw new Error('children must be a JSON array of Notion block objects') +} + +export const notionAppendBlocksTool: ToolConfig< + NotionAppendBlocksParams, + NotionAppendBlocksResponse +> = { + id: 'notion_append_blocks', + name: 'Notion Append Block Children', + description: 'Append new block children (content) to a Notion page or block', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + blockId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the page or block to append children to', + }, + children: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: 'Array of Notion block objects to append (max 100)', + }, + after: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of an existing block to append the new children after', + }, + }, + + request: { + url: (params: NotionAppendBlocksParams) => + `https://api.notion.com/v1/blocks/${params.blockId.trim()}/children`, + method: 'PATCH', + headers: (params: NotionAppendBlocksParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + body: (params: NotionAppendBlocksParams) => { + const body: any = { children: parseChildren(params.children) } + if (params.after) body.after = params.after.trim() + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + results: data.results ?? [], + has_more: data.has_more ?? false, + next_cursor: data.next_cursor ?? null, + }, + } + }, + + outputs: { + results: BLOCK_LIST_RESULTS_OUTPUT, + has_more: PAGINATION_OUTPUT_PROPERTIES.has_more, + next_cursor: PAGINATION_OUTPUT_PROPERTIES.next_cursor, + }, +} + +export const notionAppendBlocksV2Tool: ToolConfig< + NotionAppendBlocksParams, + NotionAppendBlocksResponse +> = { + id: 'notion_append_blocks_v2', + name: 'Notion Append Block Children', + description: 'Append new block children (content) to a Notion page or block', + version: '2.0.0', + oauth: notionAppendBlocksTool.oauth, + params: notionAppendBlocksTool.params, + request: notionAppendBlocksTool.request, + transformResponse: notionAppendBlocksTool.transformResponse, + outputs: notionAppendBlocksTool.outputs, +} diff --git a/apps/sim/tools/notion/create_comment.ts b/apps/sim/tools/notion/create_comment.ts new file mode 100644 index 00000000000..94bb7018201 --- /dev/null +++ b/apps/sim/tools/notion/create_comment.ts @@ -0,0 +1,130 @@ +import type { NotionCreateCommentParams } from '@/tools/notion/types' +import { RICH_TEXT_ARRAY_OUTPUT } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionCreateCommentResponse { + success: boolean + output: { + id: string + discussion_id: string + created_time: string + content: string + rich_text: any[] + } +} + +export const notionCreateCommentTool: ToolConfig< + NotionCreateCommentParams, + NotionCreateCommentResponse +> = { + id: 'notion_create_comment', + name: 'Notion Create Comment', + description: 'Create a comment on a Notion page or within an existing discussion thread', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + pageId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of the page to comment on (provide either pageId or discussionId)', + }, + discussionId: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'UUID of an existing discussion thread to reply to', + }, + content: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The text content of the comment', + }, + }, + + request: { + url: () => 'https://api.notion.com/v1/comments', + method: 'POST', + headers: (params: NotionCreateCommentParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + body: (params: NotionCreateCommentParams) => { + const pageId = params.pageId?.trim() + const discussionId = params.discussionId?.trim() + + if (!pageId && !discussionId) { + throw new Error('Either pageId or discussionId is required to create a comment') + } + + const body: any = { + rich_text: [{ type: 'text', text: { content: params.content } }], + } + + if (discussionId) { + body.discussion_id = discussionId + } else { + body.parent = { page_id: pageId } + } + + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + const richText = data.rich_text ?? [] + return { + success: response.ok, + output: { + id: data.id, + discussion_id: data.discussion_id ?? '', + created_time: data.created_time ?? '', + content: richText.map((t: any) => t.plain_text ?? '').join(''), + rich_text: richText, + }, + } + }, + + outputs: { + id: { type: 'string', description: 'Comment UUID' }, + discussion_id: { type: 'string', description: 'UUID of the discussion thread' }, + created_time: { type: 'string', description: 'ISO 8601 creation timestamp' }, + content: { type: 'string', description: 'Plain text content of the comment' }, + rich_text: RICH_TEXT_ARRAY_OUTPUT, + }, +} + +export const notionCreateCommentV2Tool: ToolConfig< + NotionCreateCommentParams, + NotionCreateCommentResponse +> = { + id: 'notion_create_comment_v2', + name: 'Notion Create Comment', + description: 'Create a comment on a Notion page or within an existing discussion thread', + version: '2.0.0', + oauth: notionCreateCommentTool.oauth, + params: notionCreateCommentTool.params, + request: notionCreateCommentTool.request, + transformResponse: notionCreateCommentTool.transformResponse, + outputs: notionCreateCommentTool.outputs, +} diff --git a/apps/sim/tools/notion/delete_block.ts b/apps/sim/tools/notion/delete_block.ts new file mode 100644 index 00000000000..e0968e6ccae --- /dev/null +++ b/apps/sim/tools/notion/delete_block.ts @@ -0,0 +1,87 @@ +import type { NotionDeleteBlockParams } from '@/tools/notion/types' +import { BLOCK_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionDeleteBlockResponse { + success: boolean + output: { + id: string + archived: boolean + } +} + +export const notionDeleteBlockTool: ToolConfig = + { + id: 'notion_delete_block', + name: 'Notion Delete Block', + description: 'Delete (move to trash) a single Notion block', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + blockId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the block to delete', + }, + }, + + request: { + url: (params: NotionDeleteBlockParams) => + `https://api.notion.com/v1/blocks/${params.blockId.trim()}`, + method: 'DELETE', + headers: (params: NotionDeleteBlockParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + id: data.id, + archived: data.archived ?? true, + }, + } + }, + + outputs: { + id: BLOCK_OUTPUT_PROPERTIES.id, + archived: { type: 'boolean', description: 'Whether the block was archived (moved to trash)' }, + }, + } + +export const notionDeleteBlockV2Tool: ToolConfig< + NotionDeleteBlockParams, + NotionDeleteBlockResponse +> = { + id: 'notion_delete_block_v2', + name: 'Notion Delete Block', + description: 'Delete (move to trash) a single Notion block', + version: '2.0.0', + oauth: notionDeleteBlockTool.oauth, + params: notionDeleteBlockTool.params, + request: notionDeleteBlockTool.request, + transformResponse: notionDeleteBlockTool.transformResponse, + outputs: notionDeleteBlockTool.outputs, +} diff --git a/apps/sim/tools/notion/index.ts b/apps/sim/tools/notion/index.ts index f30b9a09c1f..0f93db304b2 100644 --- a/apps/sim/tools/notion/index.ts +++ b/apps/sim/tools/notion/index.ts @@ -2,15 +2,26 @@ import { notionAddDatabaseRowTool, notionAddDatabaseRowV2Tool, } from '@/tools/notion/add_database_row' +import { notionAppendBlocksTool, notionAppendBlocksV2Tool } from '@/tools/notion/append_blocks' +import { notionCreateCommentTool, notionCreateCommentV2Tool } from '@/tools/notion/create_comment' import { notionCreateDatabaseTool, notionCreateDatabaseV2Tool, } from '@/tools/notion/create_database' import { notionCreatePageTool, notionCreatePageV2Tool } from '@/tools/notion/create_page' +import { notionDeleteBlockTool, notionDeleteBlockV2Tool } from '@/tools/notion/delete_block' +import { notionListCommentsTool, notionListCommentsV2Tool } from '@/tools/notion/list_comments' +import { notionListUsersTool, notionListUsersV2Tool } from '@/tools/notion/list_users' import { notionQueryDatabaseTool, notionQueryDatabaseV2Tool } from '@/tools/notion/query_database' import { notionReadTool, notionReadV2Tool } from '@/tools/notion/read' import { notionReadDatabaseTool, notionReadDatabaseV2Tool } from '@/tools/notion/read_database' +import { + notionRetrieveBlockChildrenTool, + notionRetrieveBlockChildrenV2Tool, +} from '@/tools/notion/retrieve_block_children' +import { notionRetrieveUserTool, notionRetrieveUserV2Tool } from '@/tools/notion/retrieve_user' import { notionSearchTool, notionSearchV2Tool } from '@/tools/notion/search' +import { notionUpdateBlockTool, notionUpdateBlockV2Tool } from '@/tools/notion/update_block' import { notionUpdatePageTool, notionUpdatePageV2Tool } from '@/tools/notion/update_page' import { notionWriteTool, notionWriteV2Tool } from '@/tools/notion/write' @@ -24,6 +35,15 @@ export { notionQueryDatabaseTool, notionSearchTool, notionCreateDatabaseTool, + notionAddDatabaseRowTool, + notionAppendBlocksTool, + notionRetrieveBlockChildrenTool, + notionUpdateBlockTool, + notionDeleteBlockTool, + notionCreateCommentTool, + notionListCommentsTool, + notionListUsersTool, + notionRetrieveUserTool, // V2 tools notionReadV2Tool, notionReadDatabaseV2Tool, @@ -33,6 +53,13 @@ export { notionQueryDatabaseV2Tool, notionSearchV2Tool, notionCreateDatabaseV2Tool, - notionAddDatabaseRowTool, notionAddDatabaseRowV2Tool, + notionAppendBlocksV2Tool, + notionRetrieveBlockChildrenV2Tool, + notionUpdateBlockV2Tool, + notionDeleteBlockV2Tool, + notionCreateCommentV2Tool, + notionListCommentsV2Tool, + notionListUsersV2Tool, + notionRetrieveUserV2Tool, } diff --git a/apps/sim/tools/notion/list_comments.ts b/apps/sim/tools/notion/list_comments.ts new file mode 100644 index 00000000000..25757153f4a --- /dev/null +++ b/apps/sim/tools/notion/list_comments.ts @@ -0,0 +1,109 @@ +import type { NotionListCommentsParams } from '@/tools/notion/types' +import { COMMENT_LIST_RESULTS_OUTPUT, PAGINATION_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionListCommentsResponse { + success: boolean + output: { + results: any[] + has_more: boolean + next_cursor: string | null + } +} + +export const notionListCommentsTool: ToolConfig< + NotionListCommentsParams, + NotionListCommentsResponse +> = { + id: 'notion_list_comments', + name: 'Notion List Comments', + description: 'List unresolved comments on a Notion page or block', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + blockId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the page or block whose comments to list', + }, + startCursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned by a previous request', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to return (1-100, default 100)', + }, + }, + + request: { + url: (params: NotionListCommentsParams) => { + const url = new URL('https://api.notion.com/v1/comments') + url.searchParams.set('block_id', params.blockId.trim()) + if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) + if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + return url.toString() + }, + method: 'GET', + headers: (params: NotionListCommentsParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + results: data.results ?? [], + has_more: data.has_more ?? false, + next_cursor: data.next_cursor ?? null, + }, + } + }, + + outputs: { + results: COMMENT_LIST_RESULTS_OUTPUT, + has_more: PAGINATION_OUTPUT_PROPERTIES.has_more, + next_cursor: PAGINATION_OUTPUT_PROPERTIES.next_cursor, + }, +} + +export const notionListCommentsV2Tool: ToolConfig< + NotionListCommentsParams, + NotionListCommentsResponse +> = { + id: 'notion_list_comments_v2', + name: 'Notion List Comments', + description: 'List unresolved comments on a Notion page or block', + version: '2.0.0', + oauth: notionListCommentsTool.oauth, + params: notionListCommentsTool.params, + request: notionListCommentsTool.request, + transformResponse: notionListCommentsTool.transformResponse, + outputs: notionListCommentsTool.outputs, +} diff --git a/apps/sim/tools/notion/list_users.ts b/apps/sim/tools/notion/list_users.ts new file mode 100644 index 00000000000..5877c57bd40 --- /dev/null +++ b/apps/sim/tools/notion/list_users.ts @@ -0,0 +1,96 @@ +import type { NotionListUsersParams } from '@/tools/notion/types' +import { PAGINATION_OUTPUT_PROPERTIES, USER_LIST_RESULTS_OUTPUT } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionListUsersResponse { + success: boolean + output: { + results: any[] + has_more: boolean + next_cursor: string | null + } +} + +export const notionListUsersTool: ToolConfig = { + id: 'notion_list_users', + name: 'Notion List Users', + description: 'List all users (members and bots) in the Notion workspace', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + startCursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned by a previous request', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to return (1-100, default 100)', + }, + }, + + request: { + url: (params: NotionListUsersParams) => { + const url = new URL('https://api.notion.com/v1/users') + if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) + if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + return url.toString() + }, + method: 'GET', + headers: (params: NotionListUsersParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + results: data.results ?? [], + has_more: data.has_more ?? false, + next_cursor: data.next_cursor ?? null, + }, + } + }, + + outputs: { + results: USER_LIST_RESULTS_OUTPUT, + has_more: PAGINATION_OUTPUT_PROPERTIES.has_more, + next_cursor: PAGINATION_OUTPUT_PROPERTIES.next_cursor, + }, +} + +export const notionListUsersV2Tool: ToolConfig = { + id: 'notion_list_users_v2', + name: 'Notion List Users', + description: 'List all users (members and bots) in the Notion workspace', + version: '2.0.0', + oauth: notionListUsersTool.oauth, + params: notionListUsersTool.params, + request: notionListUsersTool.request, + transformResponse: notionListUsersTool.transformResponse, + outputs: notionListUsersTool.outputs, +} diff --git a/apps/sim/tools/notion/retrieve_block_children.ts b/apps/sim/tools/notion/retrieve_block_children.ts new file mode 100644 index 00000000000..0ee8bdbef4c --- /dev/null +++ b/apps/sim/tools/notion/retrieve_block_children.ts @@ -0,0 +1,108 @@ +import type { NotionRetrieveBlockChildrenParams } from '@/tools/notion/types' +import { BLOCK_LIST_RESULTS_OUTPUT, PAGINATION_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionRetrieveBlockChildrenResponse { + success: boolean + output: { + results: any[] + has_more: boolean + next_cursor: string | null + } +} + +export const notionRetrieveBlockChildrenTool: ToolConfig< + NotionRetrieveBlockChildrenParams, + NotionRetrieveBlockChildrenResponse +> = { + id: 'notion_retrieve_block_children', + name: 'Notion Retrieve Block Children', + description: 'Retrieve the block children (content) of a Notion page or block', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + blockId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the page or block whose children to retrieve', + }, + startCursor: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Pagination cursor returned by a previous request', + }, + pageSize: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Number of results to return (1-100, default 100)', + }, + }, + + request: { + url: (params: NotionRetrieveBlockChildrenParams) => { + const url = new URL(`https://api.notion.com/v1/blocks/${params.blockId.trim()}/children`) + if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) + if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + return url.toString() + }, + method: 'GET', + headers: (params: NotionRetrieveBlockChildrenParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + results: data.results ?? [], + has_more: data.has_more ?? false, + next_cursor: data.next_cursor ?? null, + }, + } + }, + + outputs: { + results: BLOCK_LIST_RESULTS_OUTPUT, + has_more: PAGINATION_OUTPUT_PROPERTIES.has_more, + next_cursor: PAGINATION_OUTPUT_PROPERTIES.next_cursor, + }, +} + +export const notionRetrieveBlockChildrenV2Tool: ToolConfig< + NotionRetrieveBlockChildrenParams, + NotionRetrieveBlockChildrenResponse +> = { + id: 'notion_retrieve_block_children_v2', + name: 'Notion Retrieve Block Children', + description: 'Retrieve the block children (content) of a Notion page or block', + version: '2.0.0', + oauth: notionRetrieveBlockChildrenTool.oauth, + params: notionRetrieveBlockChildrenTool.params, + request: notionRetrieveBlockChildrenTool.request, + transformResponse: notionRetrieveBlockChildrenTool.transformResponse, + outputs: notionRetrieveBlockChildrenTool.outputs, +} diff --git a/apps/sim/tools/notion/retrieve_user.ts b/apps/sim/tools/notion/retrieve_user.ts new file mode 100644 index 00000000000..ce425b835ad --- /dev/null +++ b/apps/sim/tools/notion/retrieve_user.ts @@ -0,0 +1,102 @@ +import type { NotionRetrieveUserParams } from '@/tools/notion/types' +import { USER_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionRetrieveUserResponse { + success: boolean + output: { + id: string + type: string | null + name: string | null + avatar_url: string | null + email: string | null + } +} + +export const notionRetrieveUserTool: ToolConfig< + NotionRetrieveUserParams, + NotionRetrieveUserResponse +> = { + id: 'notion_retrieve_user', + name: 'Notion Retrieve User', + description: 'Retrieve a single Notion user by their UUID', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + userId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the user to retrieve', + }, + }, + + request: { + url: (params: NotionRetrieveUserParams) => + `https://api.notion.com/v1/users/${params.userId.trim()}`, + method: 'GET', + headers: (params: NotionRetrieveUserParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + id: data.id, + type: data.type ?? null, + name: data.name ?? null, + avatar_url: data.avatar_url ?? null, + email: data.person?.email ?? null, + }, + } + }, + + outputs: { + id: USER_OUTPUT_PROPERTIES.id, + type: USER_OUTPUT_PROPERTIES.type, + name: USER_OUTPUT_PROPERTIES.name, + avatar_url: USER_OUTPUT_PROPERTIES.avatar_url, + email: { + type: 'string', + description: 'User email address (person users only)', + optional: true, + }, + }, +} + +export const notionRetrieveUserV2Tool: ToolConfig< + NotionRetrieveUserParams, + NotionRetrieveUserResponse +> = { + id: 'notion_retrieve_user_v2', + name: 'Notion Retrieve User', + description: 'Retrieve a single Notion user by their UUID', + version: '2.0.0', + oauth: notionRetrieveUserTool.oauth, + params: notionRetrieveUserTool.params, + request: notionRetrieveUserTool.request, + transformResponse: notionRetrieveUserTool.transformResponse, + outputs: notionRetrieveUserTool.outputs, +} diff --git a/apps/sim/tools/notion/types.ts b/apps/sim/tools/notion/types.ts index 5db2224f8ae..448d7f8635d 100644 --- a/apps/sim/tools/notion/types.ts +++ b/apps/sim/tools/notion/types.ts @@ -559,6 +559,66 @@ export const WRITE_OUTPUT_PROPERTIES = { appended: { type: 'boolean', description: 'Whether content was successfully appended' }, } as const satisfies Record +/** + * Comment object properties from Notion API. + * @see https://developers.notion.com/reference/comment-object + */ +export const COMMENT_OUTPUT_PROPERTIES = { + object: { type: 'string', description: 'Always "comment"' }, + id: { type: 'string', description: 'Comment UUID' }, + parent: PARENT_OUTPUT, + discussion_id: { type: 'string', description: 'UUID of the discussion thread' }, + created_time: { type: 'string', description: 'ISO 8601 creation timestamp' }, + last_edited_time: { type: 'string', description: 'ISO 8601 last edit timestamp' }, + created_by: PARTIAL_USER_OUTPUT, + rich_text: RICH_TEXT_ARRAY_OUTPUT, +} as const satisfies Record + +/** + * Complete comment output definition for array items + */ +export const COMMENT_OUTPUT: OutputProperty = { + type: 'object', + description: 'Notion comment object', + properties: COMMENT_OUTPUT_PROPERTIES, +} + +/** + * Array of block objects (used by append/retrieve block children). + */ +export const BLOCK_LIST_RESULTS_OUTPUT: OutputProperty = { + type: 'array', + description: 'Array of Notion block objects', + items: { + type: 'object', + properties: BLOCK_OUTPUT_PROPERTIES, + }, +} + +/** + * Array of comment objects (used by list comments). + */ +export const COMMENT_LIST_RESULTS_OUTPUT: OutputProperty = { + type: 'array', + description: 'Array of Notion comment objects', + items: { + type: 'object', + properties: COMMENT_OUTPUT_PROPERTIES, + }, +} + +/** + * Array of user objects (used by list users). + */ +export const USER_LIST_RESULTS_OUTPUT: OutputProperty = { + type: 'array', + description: 'Array of Notion user objects', + items: { + type: 'object', + properties: USER_OUTPUT_PROPERTIES, + }, +} + export interface NotionReadParams { pageId: string accessToken: string @@ -635,3 +695,54 @@ export interface NotionAddDatabaseRowParams { properties: Record accessToken: string } + +export interface NotionAppendBlocksParams { + blockId: string + children: any[] | string + after?: string + accessToken: string +} + +export interface NotionRetrieveBlockChildrenParams { + blockId: string + startCursor?: string + pageSize?: number + accessToken: string +} + +export interface NotionUpdateBlockParams { + blockId: string + block: Record | string + archived?: boolean + accessToken: string +} + +export interface NotionDeleteBlockParams { + blockId: string + accessToken: string +} + +export interface NotionCreateCommentParams { + pageId?: string + discussionId?: string + content: string + accessToken: string +} + +export interface NotionListCommentsParams { + blockId: string + startCursor?: string + pageSize?: number + accessToken: string +} + +export interface NotionListUsersParams { + startCursor?: string + pageSize?: number + accessToken: string +} + +export interface NotionRetrieveUserParams { + userId: string + accessToken: string +} diff --git a/apps/sim/tools/notion/update_block.ts b/apps/sim/tools/notion/update_block.ts new file mode 100644 index 00000000000..52a7cb52e11 --- /dev/null +++ b/apps/sim/tools/notion/update_block.ts @@ -0,0 +1,131 @@ +import type { NotionUpdateBlockParams } from '@/tools/notion/types' +import { BLOCK_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import type { ToolConfig } from '@/tools/types' + +interface NotionUpdateBlockResponse { + success: boolean + output: { + id: string + type: string + archived: boolean + block: Record + } +} + +/** + * Coerce the block param into an object, accepting either a JSON string + * (when called directly by an agent) or an already-parsed object. + */ +function parseBlock(block: Record | string): Record { + if (typeof block === 'string') { + const parsed = JSON.parse(block) + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('block must be a JSON object describing the block-type fields to update') + } + return parsed + } + if (typeof block === 'object' && block !== null && !Array.isArray(block)) return block + throw new Error('block must be a JSON object describing the block-type fields to update') +} + +export const notionUpdateBlockTool: ToolConfig = + { + id: 'notion_update_block', + name: 'Notion Update Block', + description: 'Update the content or archived state of a single Notion block', + version: '1.0.0', + + oauth: { + required: true, + provider: 'notion', + }, + + params: { + accessToken: { + type: 'string', + required: true, + visibility: 'hidden', + description: 'Notion OAuth access token', + }, + blockId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The UUID of the block to update', + }, + block: { + type: 'json', + required: true, + visibility: 'user-or-llm', + description: + 'Block-type object with the fields to update, e.g. {"paragraph": {"rich_text": [...]}}', + }, + archived: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Set to true to archive (delete) the block, or false to restore it', + }, + }, + + request: { + url: (params: NotionUpdateBlockParams) => + `https://api.notion.com/v1/blocks/${params.blockId.trim()}`, + method: 'PATCH', + headers: (params: NotionUpdateBlockParams) => { + if (!params.accessToken) { + throw new Error('Access token is required') + } + + return { + Authorization: `Bearer ${params.accessToken}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + } + }, + body: (params: NotionUpdateBlockParams) => { + const body: Record = parseBlock(params.block) + if (params.archived != null) body.archived = params.archived + return body + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + return { + success: response.ok, + output: { + id: data.id, + type: data.type ?? '', + archived: data.archived ?? false, + block: data, + }, + } + }, + + outputs: { + id: BLOCK_OUTPUT_PROPERTIES.id, + type: BLOCK_OUTPUT_PROPERTIES.type, + archived: BLOCK_OUTPUT_PROPERTIES.archived, + block: { + type: 'object', + description: 'The full updated Notion block object', + properties: BLOCK_OUTPUT_PROPERTIES, + }, + }, + } + +export const notionUpdateBlockV2Tool: ToolConfig< + NotionUpdateBlockParams, + NotionUpdateBlockResponse +> = { + id: 'notion_update_block_v2', + name: 'Notion Update Block', + description: 'Update the content or archived state of a single Notion block', + version: '2.0.0', + oauth: notionUpdateBlockTool.oauth, + params: notionUpdateBlockTool.params, + request: notionUpdateBlockTool.request, + transformResponse: notionUpdateBlockTool.transformResponse, + outputs: notionUpdateBlockTool.outputs, +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index 47bf299b0a0..3bbf132fc4d 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -2267,18 +2267,34 @@ import { import { notionAddDatabaseRowTool, notionAddDatabaseRowV2Tool, + notionAppendBlocksTool, + notionAppendBlocksV2Tool, + notionCreateCommentTool, + notionCreateCommentV2Tool, notionCreateDatabaseTool, notionCreateDatabaseV2Tool, notionCreatePageTool, notionCreatePageV2Tool, + notionDeleteBlockTool, + notionDeleteBlockV2Tool, + notionListCommentsTool, + notionListCommentsV2Tool, + notionListUsersTool, + notionListUsersV2Tool, notionQueryDatabaseTool, notionQueryDatabaseV2Tool, notionReadDatabaseTool, notionReadDatabaseV2Tool, notionReadTool, notionReadV2Tool, + notionRetrieveBlockChildrenTool, + notionRetrieveBlockChildrenV2Tool, + notionRetrieveUserTool, + notionRetrieveUserV2Tool, notionSearchTool, notionSearchV2Tool, + notionUpdateBlockTool, + notionUpdateBlockV2Tool, notionUpdatePageTool, notionUpdatePageV2Tool, notionWriteTool, @@ -5140,6 +5156,14 @@ export const tools: Record = { notion_create_database: notionCreateDatabaseTool, notion_add_database_row: notionAddDatabaseRowTool, notion_update_page: notionUpdatePageTool, + notion_append_blocks: notionAppendBlocksTool, + notion_retrieve_block_children: notionRetrieveBlockChildrenTool, + notion_update_block: notionUpdateBlockTool, + notion_delete_block: notionDeleteBlockTool, + notion_create_comment: notionCreateCommentTool, + notion_list_comments: notionListCommentsTool, + notion_list_users: notionListUsersTool, + notion_retrieve_user: notionRetrieveUserTool, notion_read_v2: notionReadV2Tool, notion_read_database_v2: notionReadDatabaseV2Tool, notion_write_v2: notionWriteV2Tool, @@ -5149,6 +5173,14 @@ export const tools: Record = { notion_create_database_v2: notionCreateDatabaseV2Tool, notion_update_page_v2: notionUpdatePageV2Tool, notion_add_database_row_v2: notionAddDatabaseRowV2Tool, + notion_append_blocks_v2: notionAppendBlocksV2Tool, + notion_retrieve_block_children_v2: notionRetrieveBlockChildrenV2Tool, + notion_update_block_v2: notionUpdateBlockV2Tool, + notion_delete_block_v2: notionDeleteBlockV2Tool, + notion_create_comment_v2: notionCreateCommentV2Tool, + notion_list_comments_v2: notionListCommentsV2Tool, + notion_list_users_v2: notionListUsersV2Tool, + notion_retrieve_user_v2: notionRetrieveUserV2Tool, obsidian_append_active: obsidianAppendActiveTool, obsidian_append_note: obsidianAppendNoteTool, obsidian_append_periodic_note: obsidianAppendPeriodicNoteTool, From 9b508bd6b70250342c5d9cba41324a243db922ee Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:31:04 -0700 Subject: [PATCH 04/13] docs(integrations): regenerate docs + catalog for telegram, outlook, notion tools --- .../content/docs/en/integrations/notion.mdx | 283 ++++++++++++++++++ .../content/docs/en/integrations/outlook.mdx | 233 +++++++++++++- .../content/docs/en/integrations/telegram.mdx | 281 ++++++++++++++++- apps/sim/lib/integrations/integrations.json | 94 +++++- 4 files changed, 872 insertions(+), 19 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/notion.mdx b/apps/docs/content/docs/en/integrations/notion.mdx index 4ddf64db96c..af14a062afc 100644 --- a/apps/docs/content/docs/en/integrations/notion.mdx +++ b/apps/docs/content/docs/en/integrations/notion.mdx @@ -273,6 +273,289 @@ Create a new database in Notion with custom properties | `last_edited_time` | string | ISO 8601 last edit timestamp | | `title` | string | Row title | +### `notion_append_blocks` + +Append new block children (content) to a Notion page or block + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `blockId` | string | Yes | The UUID of the page or block to append children to | +| `children` | json | Yes | Array of Notion block objects to append \(max 100\) | +| `after` | string | No | UUID of an existing block to append the new children after | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_retrieve_block_children` + +Retrieve the block children (content) of a Notion page or block + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `blockId` | string | Yes | The UUID of the page or block whose children to retrieve | +| `startCursor` | string | No | Pagination cursor returned by a previous request | +| `pageSize` | number | No | Number of results to return \(1-100, default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_update_block` + +Update the content or archived state of a single Notion block + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `blockId` | string | Yes | The UUID of the block to update | +| `block` | json | Yes | Block-type object with the fields to update, e.g. \{"paragraph": \{"rich_text": \[...\]\}\} | +| `archived` | boolean | No | Set to true to archive \(delete\) the block, or false to restore it | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_delete_block` + +Delete (move to trash) a single Notion block + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `blockId` | string | Yes | The UUID of the block to delete | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_create_comment` + +Create a comment on a Notion page or within an existing discussion thread + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `pageId` | string | No | UUID of the page to comment on \(provide either pageId or discussionId\) | +| `discussionId` | string | No | UUID of an existing discussion thread to reply to | +| `content` | string | Yes | The text content of the comment | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_list_comments` + +List unresolved comments on a Notion page or block + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `blockId` | string | Yes | The UUID of the page or block whose comments to list | +| `startCursor` | string | No | Pagination cursor returned by a previous request | +| `pageSize` | number | No | Number of results to return \(1-100, default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_list_users` + +List all users (members and bots) in the Notion workspace + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `startCursor` | string | No | Pagination cursor returned by a previous request | +| `pageSize` | number | No | Number of results to return \(1-100, default 100\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + +### `notion_retrieve_user` + +Retrieve a single Notion user by their UUID + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `userId` | string | Yes | The UUID of the user to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `content` | string | Page content in markdown format, or comment text for create comment | +| `title` | string | Page or database title | +| `url` | string | Notion URL | +| `id` | string | Page, database, block, comment, or user ID | +| `created_time` | string | Creation timestamp | +| `last_edited_time` | string | Last edit timestamp | +| `results` | array | Array of results \(pages, blocks, comments, or users\) | +| `has_more` | boolean | Whether more results are available | +| `next_cursor` | string | Cursor for pagination | +| `total_results` | number | Number of results returned | +| `properties` | json | Database properties schema | +| `appended` | boolean | Whether content was successfully appended | +| `type` | string | Block type | +| `block` | json | The full updated Notion block object | +| `archived` | boolean | Whether the block was archived | +| `discussion_id` | string | Discussion thread ID | +| `name` | string | User display name | +| `avatar_url` | string | User avatar image URL | +| `email` | string | User email address \(person users only\) | + ## Triggers diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index d99b8b83839..1784f30eade 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -1,6 +1,6 @@ --- title: Outlook -description: Send, read, draft, forward, and move Outlook email messages +description: Send, read, search, reply, organize, and manage Outlook email --- import { BlockInfoCard } from "@/components/ui/block-info-card" @@ -37,7 +37,7 @@ By connecting Sim with Microsoft Outlook, you enable intelligent agents to autom ## Usage Instructions -Integrate Outlook into the workflow. Can read, draft, send, forward, and move email messages. Can be used in trigger mode to trigger a workflow when a new email is received. +Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received. @@ -139,6 +139,91 @@ Read emails from Outlook | ↳ `importance` | string | Message importance \(low, normal, high\) | | `attachments` | file[] | All email attachments flattened from all emails | +### `outlook_search` + +Search Outlook messages using a free-text query (Microsoft Graph $search) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `query` | string | Yes | Search text matched against the subject, body, sender, and recipients of messages | +| `maxResults` | number | No | Maximum number of messages to retrieve \(default: 10, max: 25\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | array | Array of matching email message objects | +| ↳ `id` | string | Unique message identifier | +| ↳ `subject` | string | Email subject | +| ↳ `bodyPreview` | string | Preview of the message body | +| ↳ `body` | object | Message body | +| ↳ `contentType` | string | Body content type \(text or html\) | +| ↳ `content` | string | Body content | +| ↳ `sender` | object | Sender information | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `from` | object | From address information | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `toRecipients` | array | To recipients | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `ccRecipients` | array | CC recipients | +| ↳ `name` | string | Display name of the person or entity | +| ↳ `address` | string | Email address | +| ↳ `receivedDateTime` | string | When the message was received \(ISO 8601\) | +| ↳ `sentDateTime` | string | When the message was sent \(ISO 8601\) | +| ↳ `hasAttachments` | boolean | Whether the message has attachments | +| ↳ `isRead` | boolean | Whether the message has been read | +| ↳ `importance` | string | Message importance \(low, normal, high\) | + +### `outlook_reply` + +Reply to the sender of an Outlook message with a comment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | The ID of the message to reply to | +| `comment` | string | No | The reply text to include above the original message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `results` | object | Reply result details | +| ↳ `status` | string | Reply status | +| ↳ `timestamp` | string | Timestamp when the reply was sent | +| ↳ `httpStatus` | number | HTTP status code returned by the API | +| ↳ `requestId` | string | Microsoft Graph request-id header for tracing | + +### `outlook_reply_all` + +Reply to all recipients of an Outlook message with a comment + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | The ID of the message to reply to | +| `comment` | string | No | The reply text to include above the original message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `results` | object | Reply-all result details | +| ↳ `status` | string | Reply status | +| ↳ `timestamp` | string | Timestamp when the reply was sent | +| ↳ `httpStatus` | number | HTTP status code returned by the API | +| ↳ `requestId` | string | Microsoft Graph request-id header for tracing | + ### `outlook_forward` Forward an existing Outlook message to specified recipients @@ -184,6 +269,27 @@ Move emails between Outlook folders | `messageId` | string | ID of the moved message | | `newFolderId` | string | ID of the destination folder | +### `outlook_copy` + +Copy an Outlook message to another folder + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | ID of the message to copy | +| `destinationId` | string | Yes | ID of the destination folder | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Email copy success status | +| `message` | string | Success or error message | +| `originalMessageId` | string | ID of the original message | +| `copiedMessageId` | string | ID of the copied message | +| `destinationFolderId` | string | ID of the destination folder | + ### `outlook_mark_read` Mark an Outlook message as read @@ -222,6 +328,32 @@ Mark an Outlook message as unread | `messageId` | string | ID of the message | | `isRead` | boolean | Read status of the message | +### `outlook_update_message` + +Set the categories, follow-up flag, and importance on an Outlook message + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | The ID of the message to update | +| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories\) | +| `flagStatus` | string | No | Follow-up flag status: notFlagged, flagged, or complete | +| `importance` | string | No | Message importance: low, normal, or high | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `results` | object | Updated message details | +| ↳ `messageId` | string | ID of the updated message | +| ↳ `subject` | string | Subject of the message | +| ↳ `categories` | array | Categories assigned to the message | +| ↳ `flagStatus` | string | Follow-up flag status of the message | +| ↳ `importance` | string | Importance of the message | +| ↳ `isRead` | boolean | Whether the message is read | + ### `outlook_delete` Delete an Outlook message (move to Deleted Items) @@ -241,26 +373,105 @@ Delete an Outlook message (move to Deleted Items) | `messageId` | string | ID of the deleted message | | `status` | string | Deletion status | -### `outlook_copy` +### `outlook_list_folders` -Copy an Outlook message to another folder +List mail folders in the root of the Outlook mailbox #### Input | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `messageId` | string | Yes | ID of the message to copy | -| `destinationId` | string | Yes | ID of the destination folder | +| `maxResults` | number | No | Maximum number of folders to retrieve \(default: 50, max: 100\) | +| `includeHiddenFolders` | boolean | No | Whether to include hidden folders in the results | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `success` | boolean | Email copy success status | -| `message` | string | Success or error message | -| `originalMessageId` | string | ID of the original message | -| `copiedMessageId` | string | ID of the copied message | -| `destinationFolderId` | string | ID of the destination folder | +| `message` | string | Success or status message | +| `results` | array | Array of mail folder objects | +| ↳ `id` | string | Unique folder identifier | +| ↳ `displayName` | string | Display name of the folder | +| ↳ `parentFolderId` | string | Identifier of the parent folder | +| ↳ `childFolderCount` | number | Number of immediate child folders | +| ↳ `unreadItemCount` | number | Number of unread items in the folder | +| ↳ `totalItemCount` | number | Total number of items in the folder | +| ↳ `isHidden` | boolean | Whether the folder is hidden | + +### `outlook_create_folder` + +Create a new mail folder in the root of the Outlook mailbox + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `displayName` | string | Yes | The display name of the new folder | +| `isHidden` | boolean | No | Whether the new folder is hidden \(cannot be changed after creation\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | The newly created mail folder | +| ↳ `id` | string | Unique folder identifier | +| ↳ `displayName` | string | Display name of the folder | +| ↳ `parentFolderId` | string | Identifier of the parent folder | +| ↳ `childFolderCount` | number | Number of immediate child folders | +| ↳ `unreadItemCount` | number | Number of unread items in the folder | +| ↳ `totalItemCount` | number | Total number of items in the folder | +| ↳ `isHidden` | boolean | Whether the folder is hidden | + +### `outlook_list_attachments` + +List the attachments on an Outlook message (metadata only, without contents) + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | The ID of the message whose attachments to list | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | array | Array of attachment metadata objects | +| ↳ `id` | string | Unique attachment identifier | +| ↳ `name` | string | Attachment filename | +| ↳ `contentType` | string | MIME type of the attachment | +| ↳ `size` | number | Attachment size in bytes | +| ↳ `isInline` | boolean | Whether the attachment is rendered inline in the message body | +| ↳ `attachmentType` | string | Microsoft Graph attachment type \(e.g. #microsoft.graph.fileAttachment\) | +| ↳ `lastModifiedDateTime` | string | When the attachment was last modified \(ISO 8601\) | + +### `outlook_get_attachment` + +Get a single attachment on an Outlook message, including its file contents + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `messageId` | string | Yes | The ID of the message that owns the attachment | +| `attachmentId` | string | Yes | The ID of the attachment to retrieve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or status message | +| `results` | object | Attachment metadata | +| ↳ `id` | string | Unique attachment identifier | +| ↳ `name` | string | Attachment filename | +| ↳ `contentType` | string | MIME type of the attachment | +| ↳ `size` | number | Attachment size in bytes | +| ↳ `isInline` | boolean | Whether the attachment is rendered inline in the message body | +| ↳ `attachmentType` | string | Microsoft Graph attachment type \(e.g. #microsoft.graph.fileAttachment\) | +| ↳ `lastModifiedDateTime` | string | When the attachment was last modified \(ISO 8601\) | +| `attachments` | file[] | The downloaded file attachment \(empty for non-file attachment types\) | diff --git a/apps/docs/content/docs/en/integrations/telegram.mdx b/apps/docs/content/docs/en/integrations/telegram.mdx index d167edcea02..98dd2d12246 100644 --- a/apps/docs/content/docs/en/integrations/telegram.mdx +++ b/apps/docs/content/docs/en/integrations/telegram.mdx @@ -53,7 +53,7 @@ Learn how to use the Telegram Tool in Sim to seamlessly automate message deliver ## Usage Instructions -Integrate Telegram into the workflow. Can send and delete messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat. +Integrate Telegram into the workflow. Send, edit, forward, copy, pin, and delete messages; send media, locations, contacts, and polls; react to messages; show chat actions; and look up chat and member info. Can be used in trigger mode to start a workflow when a message is sent to a chat. @@ -376,6 +376,285 @@ Send documents (PDF, ZIP, DOC, etc.) to Telegram channels or users through the T | ↳ `file_unique_id` | string | Unique document file identifier | | ↳ `file_size` | number | Size of document file in bytes | +### `telegram_edit_message_text` + +Edit the text of an existing message in a Telegram chat or channel through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `messageId` | number | Yes | Identifier of the message to edit | +| `text` | string | Yes | New text of the message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Edited Telegram message data | +| ↳ `message_id` | number | Unique Telegram message identifier | +| ↳ `date` | number | Unix timestamp when message was sent | +| ↳ `text` | string | Text content of the edited message | + +### `telegram_forward_message` + +Forward a message from one Telegram chat to another through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Destination chat ID \(numeric, can be negative for groups\) | +| `fromChatId` | string | Yes | Source chat ID the original message belongs to | +| `messageId` | number | Yes | Identifier of the message to forward in the source chat | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Forwarded Telegram message data | +| ↳ `message_id` | number | Identifier of the forwarded message | +| ↳ `date` | number | Unix timestamp when message was sent | +| ↳ `text` | string | Text content of the forwarded message | + +### `telegram_copy_message` + +Copy a message to another Telegram chat without a forward header through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Destination chat ID \(numeric, can be negative for groups\) | +| `fromChatId` | string | Yes | Source chat ID the original message belongs to | +| `messageId` | number | Yes | Identifier of the message to copy in the source chat | +| `caption` | string | No | New caption for the copied media \(keeps the original if omitted\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Copied message identifier | +| ↳ `message_id` | number | Identifier of the new copied message | + +### `telegram_send_location` + +Send a point on the map to a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `latitude` | number | Yes | Latitude of the location | +| `longitude` | number | Yes | Longitude of the location | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Telegram message data for the sent location | +| ↳ `message_id` | number | Unique Telegram message identifier | +| ↳ `date` | number | Unix timestamp when message was sent | + +### `telegram_send_contact` + +Send a phone contact to a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `phoneNumber` | string | Yes | Contact's phone number | +| `firstName` | string | Yes | Contact's first name | +| `lastName` | string | No | Contact's last name | +| `vcard` | string | No | Additional data about the contact in the form of a vCard | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Telegram message data for the sent contact | +| ↳ `message_id` | number | Unique Telegram message identifier | +| ↳ `date` | number | Unix timestamp when message was sent | + +### `telegram_send_poll` + +Send a native poll to a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `question` | string | Yes | Poll question \(1-300 characters\) | +| `options` | json | Yes | List of 2-10 answer options as text strings | +| `isAnonymous` | boolean | No | Whether the poll needs to be anonymous \(defaults to true\) | +| `allowsMultipleAnswers` | boolean | No | Whether the poll allows multiple answers | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Telegram message data for the sent poll | +| ↳ `message_id` | number | Unique Telegram message identifier | +| ↳ `date` | number | Unix timestamp when message was sent | + +### `telegram_pin_message` + +Pin a message in a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `messageId` | number | Yes | Identifier of the message to pin | +| `disableNotification` | boolean | No | Pass true to pin silently without notifying chat members | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Pin operation result | +| ↳ `ok` | boolean | API response success status | +| ↳ `result` | boolean | Whether the message was pinned | + +### `telegram_unpin_message` + +Unpin a pinned message in a Telegram chat through the Telegram Bot API. Unpins the most recent pinned message when no message ID is given. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `messageId` | number | No | Identifier of the message to unpin \(omit to unpin the most recent one\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Unpin operation result | +| ↳ `ok` | boolean | API response success status | +| ↳ `result` | boolean | Whether the message was unpinned | + +### `telegram_set_message_reaction` + +Set or remove an emoji reaction on a message in a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `messageId` | number | Yes | Identifier of the target message | +| `reaction` | string | No | Emoji to react with \(leave empty to remove the reaction\) | +| `isBig` | boolean | No | Pass true to show the reaction with a big animation | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Reaction operation result | +| ↳ `ok` | boolean | API response success status | +| ↳ `result` | boolean | Whether the reaction was set | + +### `telegram_send_chat_action` + +Show a status action such as a typing indicator in a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID \(numeric, can be negative for groups\) | +| `action` | string | Yes | Type of action to broadcast \(e.g. typing, upload_photo, record_video, upload_document, find_location\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Chat action result | +| ↳ `ok` | boolean | API response success status | +| ↳ `result` | boolean | Whether the action was broadcast | + +### `telegram_get_chat` + +Get up-to-date information about a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID or @username \(numeric, can be negative for groups\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Telegram chat information | +| ↳ `id` | number | Unique chat identifier | +| ↳ `type` | string | Chat type \(private, group, supergroup, channel\) | +| ↳ `title` | string | Chat title for groups and channels | +| ↳ `username` | string | Chat username, if available | +| ↳ `first_name` | string | First name for private chats | +| ↳ `last_name` | string | Last name for private chats | +| ↳ `description` | string | Chat description | +| ↳ `bio` | string | Bio of the other party in a private chat | +| ↳ `invite_link` | string | Primary invite link for the chat | +| ↳ `linked_chat_id` | number | Linked discussion or channel chat ID | + +### `telegram_get_chat_member` + +Get information about a member of a Telegram chat through the Telegram Bot API. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `botToken` | string | Yes | Your Telegram Bot API Token | +| `chatId` | string | Yes | Telegram chat ID or @username \(numeric, can be negative for groups\) | +| `userId` | number | Yes | Unique identifier of the target user | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `message` | string | Success or error message | +| `data` | object | Telegram chat member information | +| ↳ `status` | string | Member's status \(creator, administrator, member, restricted, left, kicked\) | +| ↳ `user` | object | Information about the user | +| ↳ `id` | number | Unique user identifier | +| ↳ `is_bot` | boolean | Whether the user is a bot | +| ↳ `first_name` | string | User's first name | +| ↳ `last_name` | string | User's last name | +| ↳ `username` | string | User's username | + ## Triggers diff --git a/apps/sim/lib/integrations/integrations.json b/apps/sim/lib/integrations/integrations.json index 7f9f88a0c49..2868235c8a7 100644 --- a/apps/sim/lib/integrations/integrations.json +++ b/apps/sim/lib/integrations/integrations.json @@ -11447,8 +11447,8 @@ "type": "outlook", "slug": "outlook", "name": "Outlook", - "description": "Send, read, draft, forward, and move Outlook email messages", - "longDescription": "Integrate Outlook into the workflow. Can read, draft, send, forward, and move email messages. Can be used in trigger mode to trigger a workflow when a new email is received.", + "description": "Send, read, search, reply, organize, and manage Outlook email", + "longDescription": "Integrate Outlook into the workflow. Can send, draft, read, search, reply, forward, move, copy, and delete email; manage mail folders and attachments; and set categories and flags on messages. Can be used in trigger mode to trigger a workflow when a new email is received.", "bgColor": "#FFFFFF", "iconName": "OutlookIcon", "docsUrl": "https://docs.sim.ai/integrations/outlook", @@ -11465,6 +11465,18 @@ "name": "Read Email", "description": "Read emails from Outlook" }, + { + "name": "Search Email", + "description": "Search Outlook messages using a free-text query (Microsoft Graph $search)" + }, + { + "name": "Reply to Email", + "description": "Reply to the sender of an Outlook message with a comment" + }, + { + "name": "Reply All", + "description": "Reply to all recipients of an Outlook message with a comment" + }, { "name": "Forward Email", "description": "Forward an existing Outlook message to specified recipients" @@ -11473,6 +11485,10 @@ "name": "Move Email", "description": "Move emails between Outlook folders" }, + { + "name": "Copy Email", + "description": "Copy an Outlook message to another folder" + }, { "name": "Mark as Read", "description": "Mark an Outlook message as read" @@ -11481,16 +11497,32 @@ "name": "Mark as Unread", "description": "Mark an Outlook message as unread" }, + { + "name": "Set Categories & Flag", + "description": "Set the categories, follow-up flag, and importance on an Outlook message" + }, { "name": "Delete Email", "description": "Delete an Outlook message (move to Deleted Items)" }, { - "name": "Copy Email", - "description": "Copy an Outlook message to another folder" + "name": "List Folders", + "description": "List mail folders in the root of the Outlook mailbox" + }, + { + "name": "Create Folder", + "description": "Create a new mail folder in the root of the Outlook mailbox" + }, + { + "name": "List Attachments", + "description": "List the attachments on an Outlook message (metadata only, without contents)" + }, + { + "name": "Get Attachment", + "description": "Get a single attachment on an Outlook message, including its file contents" } ], - "operationCount": 9, + "operationCount": 17, "triggers": [ { "id": "outlook_poller", @@ -16998,7 +17030,7 @@ "slug": "telegram", "name": "Telegram", "description": "Interact with Telegram", - "longDescription": "Integrate Telegram into the workflow. Can send and delete messages. Can be used in trigger mode to trigger a workflow when a message is sent to a chat.", + "longDescription": "Integrate Telegram into the workflow. Send, edit, forward, copy, pin, and delete messages; send media, locations, contacts, and polls; react to messages; show chat actions; and look up chat and member info. Can be used in trigger mode to start a workflow when a message is sent to a chat.", "bgColor": "#FFFFFF", "iconName": "TelegramIcon", "docsUrl": "https://docs.sim.ai/integrations/telegram", @@ -17027,12 +17059,60 @@ "name": "Send Document", "description": "Send documents (PDF, ZIP, DOC, etc.) to Telegram channels or users through the Telegram Bot API." }, + { + "name": "Send Location", + "description": "Send a point on the map to a Telegram chat through the Telegram Bot API." + }, + { + "name": "Send Contact", + "description": "Send a phone contact to a Telegram chat through the Telegram Bot API." + }, + { + "name": "Send Poll", + "description": "Send a native poll to a Telegram chat through the Telegram Bot API." + }, + { + "name": "Send Chat Action", + "description": "Show a status action such as a typing indicator in a Telegram chat through the Telegram Bot API." + }, + { + "name": "Edit Message Text", + "description": "Edit the text of an existing message in a Telegram chat or channel through the Telegram Bot API." + }, + { + "name": "Forward Message", + "description": "Forward a message from one Telegram chat to another through the Telegram Bot API." + }, + { + "name": "Copy Message", + "description": "Copy a message to another Telegram chat without a forward header through the Telegram Bot API." + }, { "name": "Delete Message", "description": "Delete messages in Telegram channels or chats through the Telegram Bot API. Requires the message ID of the message to delete." + }, + { + "name": "Pin Message", + "description": "Pin a message in a Telegram chat through the Telegram Bot API." + }, + { + "name": "Unpin Message", + "description": "Unpin a pinned message in a Telegram chat through the Telegram Bot API. Unpins the most recent pinned message when no message ID is given." + }, + { + "name": "Set Message Reaction", + "description": "Set or remove an emoji reaction on a message in a Telegram chat through the Telegram Bot API." + }, + { + "name": "Get Chat", + "description": "Get up-to-date information about a Telegram chat through the Telegram Bot API." + }, + { + "name": "Get Chat Member", + "description": "Get information about a member of a Telegram chat through the Telegram Bot API." } ], - "operationCount": 7, + "operationCount": 19, "triggers": [ { "id": "telegram_webhook", From fa5a2c4a3664b6be2c293e9355cca68199619b59 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:39:38 -0700 Subject: [PATCH 05/13] fix(notion): guard pageSize coercion with Number.isFinite so non-numeric input isn't forwarded as NaN --- apps/sim/blocks/blocks/notion.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 343e9285eb0..99c684b60b9 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -585,10 +585,10 @@ export const NotionBlock: BlockConfig = { coercedArchived = archived === true || archived === 'true' } - // Coerce numeric page size for paginated operations - let coercedPageSize + let coercedPageSize: number | undefined if (pageSize !== undefined && pageSize !== null && pageSize !== '') { - coercedPageSize = Number(pageSize) + const parsedPageSize = Number(pageSize) + if (Number.isFinite(parsedPageSize)) coercedPageSize = parsedPageSize } return { From 8bc7addd9bdb545c4c53de8e57c3991c1a8fd1d7 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:48:59 -0700 Subject: [PATCH 06/13] fix(telegram): normalize send_poll options (array, JSON string, or newlines) so json-typed input can't crash on .map --- apps/sim/tools/telegram/send_poll.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/sim/tools/telegram/send_poll.ts b/apps/sim/tools/telegram/send_poll.ts index 182b711d49b..4cc9effa3b3 100644 --- a/apps/sim/tools/telegram/send_poll.ts +++ b/apps/sim/tools/telegram/send_poll.ts @@ -7,6 +7,27 @@ import type { import { telegramApiUrl } from '@/tools/telegram/utils' import type { ToolConfig } from '@/tools/types' +/** + * Normalize poll options into a trimmed string array. Accepts an array, a JSON + * array string, or a newline-separated string (the `json`-typed param can arrive + * in any of these forms from block inputs or agent tool-calls). + */ +function normalizePollOptions(value: unknown): string[] { + let items: unknown[] = [] + if (Array.isArray(value)) { + items = value + } else if (typeof value === 'string') { + const trimmed = value.trim() + try { + const parsed = JSON.parse(trimmed) + items = Array.isArray(parsed) ? parsed : trimmed.split('\n') + } catch { + items = trimmed.split('\n') + } + } + return items.map((item) => String(item).trim()).filter(Boolean) +} + export const telegramSendPollTool: ToolConfig = { id: 'telegram_send_poll', @@ -61,10 +82,14 @@ export const telegramSendPollTool: ToolConfig { + const optionList = normalizePollOptions(params.options) + if (optionList.length < 2) { + throw new Error('A poll requires at least 2 options') + } const body: Record = { chat_id: params.chatId, question: params.question, - options: (params.options ?? []).map((option) => ({ text: option })), + options: optionList.map((text) => ({ text })), } if (params.isAnonymous !== undefined) body.is_anonymous = params.isAnonymous if (params.allowsMultipleAnswers !== undefined) { From 3f16505e8cca9f2cba06a77da9532d58e51ac30a Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 11:57:51 -0700 Subject: [PATCH 07/13] fix(integrations): harden outlook search quoting, notion archive default, and telegram poll options - outlook: strip embedded double quotes from the $search term so they can't break the quoted KQL query - notion: default the update_block Archive dropdown to 'Leave unchanged' so updates don't send an unintended archived:false restore flag - telegram: pass raw poll options through to the tool's normalizePollOptions (handles array/JSON-string/newlines) so the block layer no longer mishandles JSON-string input --- apps/sim/blocks/blocks/notion.ts | 13 ++++++++++--- apps/sim/blocks/blocks/telegram.ts | 12 +----------- apps/sim/tools/outlook/search.ts | 6 +++++- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 99c684b60b9..d4810457452 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -379,9 +379,11 @@ export const NotionBlock: BlockConfig = { title: 'Archive Block', type: 'dropdown', options: [ - { label: 'No', id: 'false' }, - { label: 'Yes', id: 'true' }, + { label: 'Leave unchanged', id: 'unchanged' }, + { label: 'No (restore)', id: 'false' }, + { label: 'Yes (archive)', id: 'true' }, ], + value: () => 'unchanged', mode: 'advanced', condition: { field: 'operation', value: 'notion_update_block' }, }, @@ -581,7 +583,12 @@ export const NotionBlock: BlockConfig = { // Coerce archived flag — agent calls deliver "true"/"false" strings let coercedArchived - if (operation === 'notion_update_block' && archived !== undefined && archived !== '') { + if ( + operation === 'notion_update_block' && + archived !== undefined && + archived !== '' && + archived !== 'unchanged' + ) { coercedArchived = archived === true || archived === 'true' } diff --git a/apps/sim/blocks/blocks/telegram.ts b/apps/sim/blocks/blocks/telegram.ts index b8754cb617c..296d818cbef 100644 --- a/apps/sim/blocks/blocks/telegram.ts +++ b/apps/sim/blocks/blocks/telegram.ts @@ -583,20 +583,10 @@ export const TelegramBlock: BlockConfig = { if (!params.question) { throw new Error('Poll question is required.') } - const rawOptions = params.pollOptions - const options = Array.isArray(rawOptions) - ? rawOptions.map((option) => String(option).trim()).filter(Boolean) - : String(rawOptions ?? '') - .split('\n') - .map((option) => option.trim()) - .filter(Boolean) - if (options.length < 2) { - throw new Error('At least 2 poll options are required.') - } const pollParams: Record = { ...commonParams, question: params.question, - options, + options: params.pollOptions, } if (params.isAnonymous !== undefined && params.isAnonymous !== '') { pollParams.isAnonymous = toBoolean(params.isAnonymous) diff --git a/apps/sim/tools/outlook/search.ts b/apps/sim/tools/outlook/search.ts index 234216215f3..4fd0fa3e19c 100644 --- a/apps/sim/tools/outlook/search.ts +++ b/apps/sim/tools/outlook/search.ts @@ -43,7 +43,11 @@ export const outlookSearchTool: ToolConfig { - const query = params.query?.trim() + const rawQuery = params.query?.trim() + if (!rawQuery) { + throw new Error('A search query is required') + } + const query = rawQuery.replace(/"/g, ' ').trim() if (!query) { throw new Error('A search query is required') } From 8c84970ae4cf71d4c03a16898f3524533c52af11 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:06:04 -0700 Subject: [PATCH 08/13] fix(integrations): normalize outlook categories input and clamp notion pageSize - outlook: normalize update_message categories (array, JSON string, or comma/newline) so a JSON-string value isn't silently dropped - notion: clamp pageSize to Notion's 1-100 range (truncated) so out-of-range values don't hit an API error --- apps/sim/blocks/blocks/notion.ts | 4 +++- apps/sim/tools/outlook/update_message.ts | 28 ++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index d4810457452..03bfa2b8257 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -595,7 +595,9 @@ export const NotionBlock: BlockConfig = { let coercedPageSize: number | undefined if (pageSize !== undefined && pageSize !== null && pageSize !== '') { const parsedPageSize = Number(pageSize) - if (Number.isFinite(parsedPageSize)) coercedPageSize = parsedPageSize + if (Number.isFinite(parsedPageSize)) { + coercedPageSize = Math.min(Math.max(Math.trunc(parsedPageSize), 1), 100) + } } return { diff --git a/apps/sim/tools/outlook/update_message.ts b/apps/sim/tools/outlook/update_message.ts index 225e0291c8a..12e50122b98 100644 --- a/apps/sim/tools/outlook/update_message.ts +++ b/apps/sim/tools/outlook/update_message.ts @@ -13,6 +13,27 @@ interface OutlookMessageUpdateApi { isRead?: boolean } +/** + * Normalize message categories into a trimmed string array. Accepts an array, a + * JSON-array string, or a comma/newline-separated string (the `json`-typed param + * can arrive in any of these forms from block inputs or agent tool-calls). + */ +function normalizeCategories(value: unknown): string[] { + let items: unknown[] = [] + if (Array.isArray(value)) { + items = value + } else if (typeof value === 'string') { + const trimmed = value.trim() + try { + const parsed = JSON.parse(trimmed) + items = Array.isArray(parsed) ? parsed : trimmed.split(/[,\n]/) + } catch { + items = trimmed.split(/[,\n]/) + } + } + return items.map((item) => String(item).trim()).filter(Boolean) +} + export const outlookUpdateMessageTool: ToolConfig< OutlookUpdateMessageParams, OutlookUpdateMessageResponse @@ -76,10 +97,9 @@ export const outlookUpdateMessageTool: ToolConfig< body: (params) => { const body: Record = {} - if (Array.isArray(params.categories)) { - body.categories = params.categories - .map((category) => String(category).trim()) - .filter((category) => category.length > 0) + const categories = normalizeCategories(params.categories) + if (categories.length > 0) { + body.categories = categories } if (params.flagStatus) { From 1aaa0949f59cb20319658dad04f3e8e7bfe58a9f Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:14:07 -0700 Subject: [PATCH 09/13] fix(outlook): pass raw categories to the tool's normalizeCategories so the block handles JSON-string input too --- apps/sim/blocks/blocks/outlook.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/sim/blocks/blocks/outlook.ts b/apps/sim/blocks/blocks/outlook.ts index b4dd530d6e1..5c75331f9da 100644 --- a/apps/sim/blocks/blocks/outlook.ts +++ b/apps/sim/blocks/blocks/outlook.ts @@ -539,12 +539,7 @@ export const OutlookBlock: BlockConfig = { categories != null && categories !== '' ) { - const categoryList = Array.isArray(categories) - ? categories - : String(categories).split(',') - rest.categories = categoryList - .map((category) => String(category).trim()) - .filter((category) => category.length > 0) + rest.categories = categories } if (rest.operation === 'copy_outlook') { From 65ddd741b19e392f6c39395b4a3be6464e74772b Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:22:32 -0700 Subject: [PATCH 10/13] fix(outlook): allow clearing message categories by passing an empty array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An explicit empty array now sends categories:[] to clear all labels, a non-empty value replaces, and an absent/empty value leaves them untouched — matching the documented replace semantics. --- apps/docs/content/docs/en/integrations/outlook.mdx | 2 +- apps/sim/tools/outlook/update_message.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index 1784f30eade..acee3ae4dcc 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -337,7 +337,7 @@ Set the categories, follow-up flag, and importance on an Outlook message | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | The ID of the message to update | -| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories\) | +| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories; pass an empty array to clear all\) | | `flagStatus` | string | No | Follow-up flag status: notFlagged, flagged, or complete | | `importance` | string | No | Message importance: low, normal, or high | diff --git a/apps/sim/tools/outlook/update_message.ts b/apps/sim/tools/outlook/update_message.ts index 12e50122b98..77fd86723d0 100644 --- a/apps/sim/tools/outlook/update_message.ts +++ b/apps/sim/tools/outlook/update_message.ts @@ -66,7 +66,7 @@ export const outlookUpdateMessageTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Array of category names to assign to the message (replaces existing categories)', + 'Array of category names to assign to the message (replaces existing categories; pass an empty array to clear all)', }, flagStatus: { type: 'string', @@ -97,9 +97,11 @@ export const outlookUpdateMessageTool: ToolConfig< body: (params) => { const body: Record = {} - const categories = normalizeCategories(params.categories) - if (categories.length > 0) { - body.categories = categories + const rawCategories: unknown = params.categories + if (Array.isArray(rawCategories)) { + body.categories = normalizeCategories(rawCategories) + } else if (typeof rawCategories === 'string' && rawCategories.trim() !== '') { + body.categories = normalizeCategories(rawCategories) } if (params.flagStatus) { From 786c6461d8b33d9b1edc4b25d38204f44722e1ff Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:28:01 -0700 Subject: [PATCH 11/13] fix(outlook): only clear categories on an explicit empty array, not a delimiter-only string A string that normalizes to no categories (e.g. just commas) is now a no-op rather than clearing all labels; clearing requires an explicit empty array. --- apps/sim/tools/outlook/update_message.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/sim/tools/outlook/update_message.ts b/apps/sim/tools/outlook/update_message.ts index 77fd86723d0..45cc18836b7 100644 --- a/apps/sim/tools/outlook/update_message.ts +++ b/apps/sim/tools/outlook/update_message.ts @@ -100,8 +100,11 @@ export const outlookUpdateMessageTool: ToolConfig< const rawCategories: unknown = params.categories if (Array.isArray(rawCategories)) { body.categories = normalizeCategories(rawCategories) - } else if (typeof rawCategories === 'string' && rawCategories.trim() !== '') { - body.categories = normalizeCategories(rawCategories) + } else if (typeof rawCategories === 'string') { + const normalizedCategories = normalizeCategories(rawCategories) + if (normalizedCategories.length > 0) { + body.categories = normalizedCategories + } } if (params.flagStatus) { From 19d0208decc0a7f3c842dbe6fdc6b0d2073b8599 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:36:47 -0700 Subject: [PATCH 12/13] fix(notion): clamp page_size to 1-100 at the tool layer for list comments/users and block children Adds a shared clampNotionPageSize helper so the agent-direct path is bounded to Notion's range, not just the block path. --- apps/sim/tools/notion/list_comments.ts | 4 +++- apps/sim/tools/notion/list_users.ts | 4 +++- apps/sim/tools/notion/retrieve_block_children.ts | 4 +++- apps/sim/tools/notion/utils.ts | 11 +++++++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/sim/tools/notion/list_comments.ts b/apps/sim/tools/notion/list_comments.ts index 25757153f4a..23c56c2c310 100644 --- a/apps/sim/tools/notion/list_comments.ts +++ b/apps/sim/tools/notion/list_comments.ts @@ -1,5 +1,6 @@ import type { NotionListCommentsParams } from '@/tools/notion/types' import { COMMENT_LIST_RESULTS_OUTPUT, PAGINATION_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import { clampNotionPageSize } from '@/tools/notion/utils' import type { ToolConfig } from '@/tools/types' interface NotionListCommentsResponse { @@ -57,7 +58,8 @@ export const notionListCommentsTool: ToolConfig< const url = new URL('https://api.notion.com/v1/comments') url.searchParams.set('block_id', params.blockId.trim()) if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) - if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + const pageSize = clampNotionPageSize(params.pageSize) + if (pageSize != null) url.searchParams.set('page_size', String(pageSize)) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/notion/list_users.ts b/apps/sim/tools/notion/list_users.ts index 5877c57bd40..4c5de68160a 100644 --- a/apps/sim/tools/notion/list_users.ts +++ b/apps/sim/tools/notion/list_users.ts @@ -1,5 +1,6 @@ import type { NotionListUsersParams } from '@/tools/notion/types' import { PAGINATION_OUTPUT_PROPERTIES, USER_LIST_RESULTS_OUTPUT } from '@/tools/notion/types' +import { clampNotionPageSize } from '@/tools/notion/utils' import type { ToolConfig } from '@/tools/types' interface NotionListUsersResponse { @@ -47,7 +48,8 @@ export const notionListUsersTool: ToolConfig { const url = new URL('https://api.notion.com/v1/users') if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) - if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + const pageSize = clampNotionPageSize(params.pageSize) + if (pageSize != null) url.searchParams.set('page_size', String(pageSize)) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/notion/retrieve_block_children.ts b/apps/sim/tools/notion/retrieve_block_children.ts index 0ee8bdbef4c..4a1e16f7428 100644 --- a/apps/sim/tools/notion/retrieve_block_children.ts +++ b/apps/sim/tools/notion/retrieve_block_children.ts @@ -1,5 +1,6 @@ import type { NotionRetrieveBlockChildrenParams } from '@/tools/notion/types' import { BLOCK_LIST_RESULTS_OUTPUT, PAGINATION_OUTPUT_PROPERTIES } from '@/tools/notion/types' +import { clampNotionPageSize } from '@/tools/notion/utils' import type { ToolConfig } from '@/tools/types' interface NotionRetrieveBlockChildrenResponse { @@ -56,7 +57,8 @@ export const notionRetrieveBlockChildrenTool: ToolConfig< url: (params: NotionRetrieveBlockChildrenParams) => { const url = new URL(`https://api.notion.com/v1/blocks/${params.blockId.trim()}/children`) if (params.startCursor) url.searchParams.set('start_cursor', params.startCursor.trim()) - if (params.pageSize != null) url.searchParams.set('page_size', String(params.pageSize)) + const pageSize = clampNotionPageSize(params.pageSize) + if (pageSize != null) url.searchParams.set('page_size', String(pageSize)) return url.toString() }, method: 'GET', diff --git a/apps/sim/tools/notion/utils.ts b/apps/sim/tools/notion/utils.ts index d85e3c7fc96..60288d97551 100644 --- a/apps/sim/tools/notion/utils.ts +++ b/apps/sim/tools/notion/utils.ts @@ -1,3 +1,14 @@ +/** + * Clamp a Notion `page_size` value to the API's supported 1-100 range. Returns + * undefined when the input isn't a finite number so callers can omit the param. + */ +export function clampNotionPageSize(value: unknown): number | undefined { + if (value == null || value === '') return undefined + const parsed = Number(value) + if (!Number.isFinite(parsed)) return undefined + return Math.min(Math.max(Math.trunc(parsed), 1), 100) +} + export function formatPropertyValue(property: any): string { switch (property.type) { case 'title': From 628f5cd706095a372ad461c2e1e540f1238b4629 Mon Sep 17 00:00:00 2001 From: waleed Date: Mon, 29 Jun 2026 12:44:29 -0700 Subject: [PATCH 13/13] fix(outlook): use consistent set/replace semantics for message categories Categories are replaced with the provided non-empty list and left unchanged when empty, consistent across the block and agent paths. Drops the ambiguous empty-array clear (clearing all categories isn't expressible unambiguously from the comma-separated field) and updates the description to match. --- apps/docs/content/docs/en/integrations/outlook.mdx | 2 +- apps/sim/tools/outlook/update_message.ts | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/docs/en/integrations/outlook.mdx b/apps/docs/content/docs/en/integrations/outlook.mdx index acee3ae4dcc..f7341289fe2 100644 --- a/apps/docs/content/docs/en/integrations/outlook.mdx +++ b/apps/docs/content/docs/en/integrations/outlook.mdx @@ -337,7 +337,7 @@ Set the categories, follow-up flag, and importance on an Outlook message | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `messageId` | string | Yes | The ID of the message to update | -| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories; pass an empty array to clear all\) | +| `categories` | json | No | Array of category names to assign to the message \(replaces existing categories; leave empty to keep them unchanged\) | | `flagStatus` | string | No | Follow-up flag status: notFlagged, flagged, or complete | | `importance` | string | No | Message importance: low, normal, or high | diff --git a/apps/sim/tools/outlook/update_message.ts b/apps/sim/tools/outlook/update_message.ts index 45cc18836b7..92c5ad36736 100644 --- a/apps/sim/tools/outlook/update_message.ts +++ b/apps/sim/tools/outlook/update_message.ts @@ -66,7 +66,7 @@ export const outlookUpdateMessageTool: ToolConfig< required: false, visibility: 'user-or-llm', description: - 'Array of category names to assign to the message (replaces existing categories; pass an empty array to clear all)', + 'Array of category names to assign to the message (replaces existing categories; leave empty to keep them unchanged)', }, flagStatus: { type: 'string', @@ -97,14 +97,9 @@ export const outlookUpdateMessageTool: ToolConfig< body: (params) => { const body: Record = {} - const rawCategories: unknown = params.categories - if (Array.isArray(rawCategories)) { - body.categories = normalizeCategories(rawCategories) - } else if (typeof rawCategories === 'string') { - const normalizedCategories = normalizeCategories(rawCategories) - if (normalizedCategories.length > 0) { - body.categories = normalizedCategories - } + const normalizedCategories = normalizeCategories(params.categories) + if (normalizedCategories.length > 0) { + body.categories = normalizedCategories } if (params.flagStatus) {