Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 104 additions & 4 deletions services/slackbotv2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@
}

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) {
Expand Down Expand Up @@ -1527,6 +1527,12 @@
return Boolean(threadTs && ts && threadTs !== ts)
}

async function serializeSlackMessage(message: ChatMessage): Promise<SlackbotV2ApiMessage> {
const serialized = await serializeMessage(message)
serialized.text = slackTextWithNativeTables(serialized.text, slackRawRecord(message))
return serialized
}

async function collectSlackThreadContext(
options: SlackbotV2Options,
currentMessage: ChatMessage
Expand All @@ -1535,7 +1541,7 @@
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
Expand All @@ -1560,7 +1566,7 @@
} 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 {
Expand Down Expand Up @@ -1595,7 +1601,7 @@
|| 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)
}
Expand Down Expand Up @@ -1683,6 +1689,100 @@
: {}
}

function slackTextWithNativeTables(text: string, message: Record<string, unknown>): 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, unknown>): 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<string, unknown>, 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<string, unknown>[] {
return recordElements(value)
}

function slackBlockRecords(value: unknown): Record<string, unknown>[] {
return recordElements(value)
}

function recordElements(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter(isPlainRecord) : []
}

function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}

function slackActorId(message: Record<string, unknown>): string {
const profile = message.bot_profile
if (profile && typeof profile === 'object' && !Array.isArray(profile)) {
Expand Down
137 changes: 134 additions & 3 deletions services/slackbotv2/test/chat-sdk-emulate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[] = []
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<unknown>[] = []
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',
Expand Down Expand Up @@ -3364,6 +3443,36 @@ function apiMessageFromSlackEvent(input: {
}
}

function nativeSlackTableAttachment(): Record<string, unknown> {
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,
Expand Down Expand Up @@ -3796,6 +3905,7 @@ function writeMockSseEvent(stream: ServerResponse, event: MockSessionEvent): voi
}

type PatchedSlackApi = {
addAttachmentToMessage(channel: string, ts: string, attachment: Record<string, unknown>): void
addFileToMessage(channel: string, ts: string, file: Record<string, unknown>): void
calls: StreamCall[]
close(): Promise<void>
Expand Down Expand Up @@ -3840,6 +3950,7 @@ type SlackStreamTranscript = {
async function startPatchedSlackApi(emulatorUrl: string): Promise<PatchedSlackApi> {
const upstreamUrl = loopbackUrl(emulatorUrl)
const calls: StreamCall[] = []
const threadMessageAttachments = new Map<string, Record<string, unknown>[]>()
const threadMessageFiles = new Map<string, Record<string, unknown>[]>()
const userProfiles = new Map<string, Record<string, unknown>>()
const userProfileRequests = new Map<string, number>()
Expand All @@ -3857,6 +3968,7 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise<PatchedSlackAp
maxStreamStopChars,
port,
streams,
threadMessageAttachments,
threadNotFoundReplies,
threadMessageFiles,
userProfiles,
Expand All @@ -3869,6 +3981,13 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise<PatchedSlackAp
})
await listen(server, port)
return {
addAttachmentToMessage(channel: string, ts: string, attachment: Record<string, unknown>) {
const key = slackReplyKey(channel, ts)
threadMessageAttachments.set(key, [
...(threadMessageAttachments.get(key) ?? []),
attachment
])
},
addFileToMessage(channel: string, ts: string, file: Record<string, unknown>) {
const key = slackReplyKey(channel, ts)
threadMessageFiles.set(key, [...(threadMessageFiles.get(key) ?? []), file])
Expand All @@ -3895,6 +4014,7 @@ async function startPatchedSlackApi(emulatorUrl: string): Promise<PatchedSlackAp
appendFailure.error = ''
chatPostMessageSocketFailures.remaining = 0
threadNotFoundReplies.clear()
threadMessageAttachments.clear()
threadMessageFiles.clear()
streams.clear()
userProfiles.clear()
Expand Down Expand Up @@ -3923,6 +4043,7 @@ async function handlePatchedSlackRequest(
maxStreamStopChars: number | null
port: number
streams: Map<string, StreamRecord>
threadMessageAttachments: Map<string, Record<string, unknown>[]>
threadNotFoundReplies: Set<string>
threadMessageFiles: Map<string, Record<string, unknown>[]>
userProfiles: Map<string, Record<string, unknown>>
Expand Down Expand Up @@ -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,
Expand All @@ -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 }))
Expand Down Expand Up @@ -4507,7 +4638,7 @@ function stringField(value: unknown): string {
return typeof value === 'string' ? value : ''
}

function slackFileArray(value: unknown): Record<string, unknown>[] {
function slackRecordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value)
? (value.filter(item =>
item && typeof item === 'object' && !Array.isArray(item)
Expand Down
Loading