diff --git a/services/slackbotv2/src/index.ts b/services/slackbotv2/src/index.ts index 08c175a07..fc1253814 100644 --- a/services/slackbotv2/src/index.ts +++ b/services/slackbotv2/src/index.ts @@ -349,7 +349,7 @@ async function syncThreadMessageToSession( } const serializeStartedAtMs = nowMs() - const serializedMessage = await serializeMessage(message) + const serializedMessage = await serializeSlackMessage(message) const overrides = extractMessageOverrides(serializedMessage.text) serializedMessage.text = overrides.cleanedText if (overrides.harnessType || overrides.model || overrides.reasoning) { @@ -1527,6 +1527,12 @@ function isSlackThreadReply(message: ChatMessage): boolean { return Boolean(threadTs && ts && threadTs !== ts) } +async function serializeSlackMessage(message: ChatMessage): Promise { + const serialized = await serializeMessage(message) + serialized.text = slackTextWithNativeTables(serialized.text, slackRawRecord(message)) + return serialized +} + async function collectSlackThreadContext( options: SlackbotV2Options, currentMessage: ChatMessage @@ -1535,7 +1541,7 @@ async function collectSlackThreadContext( const channel = stringField(raw.channel) const threadTs = stringField(raw.thread_ts) const currentTs = stringField(raw.ts) || currentMessage.id - if (!channel || !threadTs) return [await serializeMessage(currentMessage)] + if (!channel || !threadTs) return [await serializeSlackMessage(currentMessage)] const messages: SlackbotV2ApiMessage[] = [] let cursor: string | undefined @@ -1560,7 +1566,7 @@ async function collectSlackThreadContext( } while (cursor) const currentIndex = messages.findIndex(message => message.id === currentMessage.id) - const serializedCurrent = await serializeMessage(currentMessage) + const serializedCurrent = await serializeSlackMessage(currentMessage) if (currentIndex >= 0) { messages[currentIndex] = serializedCurrent } else { @@ -1595,7 +1601,7 @@ async function slackApiMessageFromSlack( || stringField(message.team_id) || stringField(rawCurrent.team) || stringField(rawCurrent.team_id), - text: normalizeSlackText(stringField(message.text)), + text: slackTextWithNativeTables(normalizeSlackText(stringField(message.text)), message), threadId: currentMessage.threadId, timestamp: slackTimestampToIso(id) } @@ -1683,6 +1689,100 @@ function slackRawRecord(message: ChatMessage): Record { : {} } +function slackTextWithNativeTables(text: string, message: Record): string { + const tableText = slackNativeTablesText(message) + if (!tableText) return text + const normalizedText = text.trim() + return normalizedText ? `${normalizedText}\n\n${tableText}` : tableText +} + +function slackNativeTablesText(message: Record): string { + const tableBlocks = [ + ...slackBlockRecords(message.blocks), + ...slackAttachmentRecords(message.attachments).flatMap(attachment => + slackBlockRecords(attachment.blocks) + ) + ].filter(block => stringField(block.type) === 'table') + const tables = tableBlocks + .map((block, index) => slackNativeTableText(block, index + 1)) + .filter(Boolean) + return tables.join('\n\n') +} + +function slackNativeTableText(block: Record, index: number): string { + const rows = Array.isArray(block.rows) ? block.rows : [] + const normalizedRows = rows + .map(row => slackNativeTableRow(row)) + .filter(row => row.some(cell => cell)) + if (normalizedRows.length === 0) return '' + + const columnCount = Math.max(...normalizedRows.map(row => row.length)) + const paddedRows = normalizedRows.map(row => + Array.from({ length: columnCount }, (_value, columnIndex) => + markdownTableCell(row[columnIndex] ?? '') + ) + ) + const [header = [], ...body] = paddedRows + const lines = [`Slack table${index > 1 ? ` ${index}` : ''}:`] + lines.push(`| ${header.join(' | ')} |`) + lines.push(`| ${Array.from({ length: columnCount }, () => '---').join(' | ')} |`) + for (const row of body) lines.push(`| ${row.join(' | ')} |`) + return lines.join('\n') +} + +function slackNativeTableRow(value: unknown): string[] { + return Array.isArray(value) ? value.map(cell => slackNativeTableCellText(cell)) : [] +} + +function slackNativeTableCellText(cell: unknown): string { + return normalizeSlackText(slackRichTextObjectText(cell).replace(/\s+/g, ' ')).trim() +} + +function slackRichTextObjectText(value: unknown): string { + if (typeof value === 'string') return value + if (!isPlainRecord(value)) return '' + + const type = stringField(value.type) + if (type === 'raw_text' || type === 'text') return stringField(value.text) + if (type === 'link') { + const label = stringField(value.text) + const url = stringField(value.url) + if (label && url && label !== url) return `${label} (${url})` + return label || url + } + if (type === 'user') return `@${stringField(value.user_id) || stringField(value.user)}` + if (type === 'channel') return `#${stringField(value.channel_id) || stringField(value.channel)}` + if (type === 'emoji') return `:${stringField(value.name)}:` + if (type === 'broadcast') return `@${stringField(value.range)}` + + const nestedElements = ['elements', 'blocks', 'children'] + .flatMap(key => recordElements(value[key])) + .map(element => slackRichTextObjectText(element)) + .filter(Boolean) + if (nestedElements.length > 0) return nestedElements.join('') + return stringField(value.text) +} + +function markdownTableCell(text: string): string { + return text.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim() +} + +function slackAttachmentRecords(value: unknown): Record[] { + return recordElements(value) +} + +function slackBlockRecords(value: unknown): Record[] { + return recordElements(value) +} + +function recordElements(value: unknown): Record[] { + return Array.isArray(value) ? value.filter(isPlainRecord) : [] +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} + function slackActorId(message: Record): string { const profile = message.bot_profile if (profile && typeof profile === 'object' && !Array.isArray(profile)) { diff --git a/services/slackbotv2/test/chat-sdk-emulate.test.ts b/services/slackbotv2/test/chat-sdk-emulate.test.ts index 46f0a6f0a..38e7dfa0b 100644 --- a/services/slackbotv2/test/chat-sdk-emulate.test.ts +++ b/services/slackbotv2/test/chat-sdk-emulate.test.ts @@ -508,6 +508,85 @@ describe('slackbotv2', () => { ) }) + it('includes native Slack table attachments from the current mention', async () => { + const parent = await postUserMessage('Root context before a native table.') + const mention = await postUserMessage(`<@${BOT_USER_ID}> summarize this table`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-current-native-table', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> summarize this table`, + attachments: [nativeSlackTableAttachment()] + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await Promise.all(waits) + + const texts = sessionMessageTexts(codexApi.appends[0]!.body.messages) + const mentionText = texts.find(text => text.includes('summarize this table')) ?? '' + expect(mentionText).toContain('Slack table:') + expect(mentionText).toContain('| Business Name | Contact Person | Website |') + expect(mentionText).toContain( + '| Exclusive Timepieces NYC Inc. | Roma Yusupov | https://exclusive.example |' + ) + const executeInput = JSON.stringify(JSON.parse(codexApi.executes[0]!.body.input_lines.at(-1)!)) + expect(executeInput).toContain('Exclusive Timepieces NYC Inc.') + expect(executeInput).toContain('Roma Yusupov') + }) + + it('includes native Slack table attachments from preceding Slack thread messages', async () => { + const parent = await postUserMessage('Root context before native tables.') + const priorReply = await postUserMessage('Leads are in the Slack table below.', parent.ts) + slackApi.addAttachmentToMessage(CHANNEL_ID, priorReply.ts, nativeSlackTableAttachment()) + + const mention = await postUserMessage(`<@${BOT_USER_ID}> inspect the earlier table`, parent.ts) + const waits: Promise[] = [] + const response = await bot.app.request( + '/api/webhooks/slack', + signedSlackEvent({ + event_id: 'Ev-slackbotv2-history-native-table', + event: { + type: 'app_mention', + user: USER_ID, + channel: CHANNEL_ID, + team: TEAM_ID, + ts: mention.ts, + thread_ts: parent.ts, + text: `<@${BOT_USER_ID}> inspect the earlier table` + } + }), + {}, + waitUntilContext(waits) + ) + + expect(response.status).toBe(200) + await Promise.all(waits) + + const texts = sessionMessageTexts(codexApi.appends[0]!.body.messages) + const priorReplyText = texts.find(text => text.includes('Leads are in the Slack table')) ?? '' + expect(priorReplyText).toContain('Slack table:') + expect(priorReplyText).toContain('| Business Name | Contact Person | Website |') + expect(priorReplyText).toContain( + '| Exclusive Timepieces NYC Inc. | Roma Yusupov | https://exclusive.example |' + ) + const executeInput = JSON.stringify(JSON.parse(codexApi.executes[0]!.body.input_lines.at(-1)!)) + expect(executeInput).toContain('Leads are in the Slack table below.') + expect(executeInput).toContain('Exclusive Timepieces NYC Inc.') + expect(executeInput).toContain('Roma Yusupov') + }) + it('injects Slack requester identity and verified GitHub handle into Codex input', async () => { slackApi.setUserProfile(USER_ID, { name: 'akshaan', @@ -3364,6 +3443,36 @@ function apiMessageFromSlackEvent(input: { } } +function nativeSlackTableAttachment(): Record { + return { + blocks: [ + { + type: 'table', + rows: [ + [ + { type: 'raw_text', text: 'Business Name' }, + { type: 'raw_text', text: 'Contact Person' }, + { type: 'raw_text', text: 'Website' } + ], + [ + { type: 'raw_text', text: 'Exclusive Timepieces NYC Inc.' }, + { + type: 'rich_text', + elements: [ + { + type: 'rich_text_section', + elements: [{ type: 'text', text: 'Roma Yusupov' }] + } + ] + }, + { type: 'raw_text', text: 'https://exclusive.example' } + ] + ] + } + ] + } +} + async function postUserMessage( text: string, threadTs?: string, @@ -3796,6 +3905,7 @@ function writeMockSseEvent(stream: ServerResponse, event: MockSessionEvent): voi } type PatchedSlackApi = { + addAttachmentToMessage(channel: string, ts: string, attachment: Record): void addFileToMessage(channel: string, ts: string, file: Record): void calls: StreamCall[] close(): Promise @@ -3840,6 +3950,7 @@ type SlackStreamTranscript = { async function startPatchedSlackApi(emulatorUrl: string): Promise { const upstreamUrl = loopbackUrl(emulatorUrl) const calls: StreamCall[] = [] + const threadMessageAttachments = new Map[]>() const threadMessageFiles = new Map[]>() const userProfiles = new Map>() const userProfileRequests = new Map() @@ -3857,6 +3968,7 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise) { + const key = slackReplyKey(channel, ts) + threadMessageAttachments.set(key, [ + ...(threadMessageAttachments.get(key) ?? []), + attachment + ]) + }, addFileToMessage(channel: string, ts: string, file: Record) { const key = slackReplyKey(channel, ts) threadMessageFiles.set(key, [...(threadMessageFiles.get(key) ?? []), file]) @@ -3895,6 +4014,7 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise + threadMessageAttachments: Map[]> threadNotFoundReplies: Set threadMessageFiles: Map[]> userProfiles: Map> @@ -4034,7 +4155,7 @@ async function handlePatchedSlackRequest( await sendWebResponse(res, Response.json({ ok: false, error: 'thread_not_found' })) return } - if (input.threadMessageFiles.size > 0) { + if (input.threadMessageFiles.size > 0 || input.threadMessageAttachments.size > 0) { const rawBody = await request.arrayBuffer() const proxied = await fetch(slackApiProxyUrl(path, url.search, input.upstreamUrl), { method: request.method, @@ -4049,7 +4170,17 @@ async function handlePatchedSlackRequest( const files = input.threadMessageFiles.get( slackReplyKey(stringField(body.channel), stringField(item.ts)) ) - return files ? { ...item, files: [...slackFileArray(item.files), ...files] } : item + const attachments = input.threadMessageAttachments.get( + slackReplyKey(stringField(body.channel), stringField(item.ts)) + ) + if (!files && !attachments) return item + return { + ...item, + ...(files ? { files: [...slackRecordArray(item.files), ...files] } : {}), + ...(attachments + ? { attachments: [...slackRecordArray(item.attachments), ...attachments] } + : {}) + } }) } await sendWebResponse(res, Response.json(payload, { status: proxied.status })) @@ -4507,7 +4638,7 @@ function stringField(value: unknown): string { return typeof value === 'string' ? value : '' } -function slackFileArray(value: unknown): Record[] { +function slackRecordArray(value: unknown): Record[] { return Array.isArray(value) ? (value.filter(item => item && typeof item === 'object' && !Array.isArray(item)