Skip to content

Commit 89bb7a0

Browse files
committed
fix(slack): fold the channel header into the content hash so a rename or topic edit re-indexes
1 parent d995615 commit 89bb7a0

2 files changed

Lines changed: 39 additions & 8 deletions

File tree

apps/sim/connectors/slack/slack.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ let listNextCursor = ''
3838
let history: Record<string, unknown>[] = MESSAGES
3939
/** Whether `conversations.info` reports the channel as missing; per-test overridable. */
4040
let channelMissing = false
41+
/** The channel `conversations.info` returns; per-test overridable. */
42+
let infoChannel: Record<string, unknown> = GENERAL
4143

4244
beforeEach(() => {
4345
requestedUrls.length = 0
4446
listedChannels = [GENERAL, PLATFORM]
4547
listNextCursor = ''
4648
history = MESSAGES
4749
channelMissing = false
50+
infoChannel = GENERAL
4851
fetchMock.mockReset()
4952
fetchMock.mockImplementation(async (input) => {
5053
const url = new URL(String(input))
@@ -61,7 +64,7 @@ beforeEach(() => {
6164
case '/api/conversations.info':
6265
return channelMissing
6366
? jsonResponse({ ok: false, error: 'channel_not_found' })
64-
: jsonResponse({ ok: true, channel: GENERAL })
67+
: jsonResponse({ ok: true, channel: infoChannel })
6568
case '/api/conversations.history':
6669
return jsonResponse({ ok: true, messages: history, response_metadata: {} })
6770
case '/api/users.info': {
@@ -163,7 +166,9 @@ describe('getDocument', () => {
163166
expect(doc).toMatchObject({
164167
externalId: 'C0GENERAL',
165168
title: '#general',
166-
contentHash: 'slack-v3:C0GENERAL:1700000000.000100:1700000200.000100:3:noedit:noreply:0',
169+
contentHash: expect.stringMatching(
170+
/^slack-v3:C0GENERAL:[0-9a-f]{16}:1700000000\.000100:1700000200\.000100:3:noedit:noreply:0$/
171+
),
167172
metadata: expect.objectContaining({ channelName: 'general', messageCount: 2 }),
168173
})
169174
expect(doc?.content).toBe(
@@ -177,6 +182,22 @@ describe('getDocument', () => {
177182
)
178183
})
179184

185+
it('moves the hash when the header changes without any message changing', async () => {
186+
const before = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
187+
188+
infoChannel = { ...GENERAL, name: 'general-renamed' }
189+
const renamed = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
190+
expect(renamed?.contentHash).not.toBe(before?.contentHash)
191+
192+
infoChannel = { ...GENERAL, topic: { value: 'A new topic' } }
193+
const retopiced = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
194+
expect(retopiced?.contentHash).not.toBe(before?.contentHash)
195+
196+
infoChannel = GENERAL
197+
const again = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})
198+
expect(again?.contentHash).toBe(before?.contentHash)
199+
})
200+
180201
it('keeps a channel with no messages as a live document', async () => {
181202
history = []
182203
const doc = await slackConnector.getDocument('token', {}, 'C0GENERAL', {})

apps/sim/connectors/slack/slack.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createHash } from 'node:crypto'
12
import { createLogger } from '@sim/logger'
23
import { toError } from '@sim/utils/errors'
34
import { generateId } from '@sim/utils/id'
@@ -602,13 +603,15 @@ async function buildSlackChannelDocument(
602603
maxMessages
603604
)
604605

605-
const lines = new BoundedLines(CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, 'last')
606-
lines.pin(`Channel: #${channel.name}`)
606+
const header = [`Channel: #${channel.name}`]
607607
const topic = channel.topic?.value?.trim()
608-
if (topic) lines.pin(`Topic: ${topic}`)
608+
if (topic) header.push(`Topic: ${topic}`)
609609
const purpose = channel.purpose?.value?.trim()
610-
if (purpose) lines.pin(`Purpose: ${purpose}`)
611-
lines.pin('')
610+
if (purpose) header.push(`Purpose: ${purpose}`)
611+
612+
/** The newest messages survive when the window does not fit; the header always does. */
613+
const lines = new BoundedLines(CONNECTOR_TEXT_DOCUMENT_MAX_BYTES, 'last')
614+
lines.pin(...header, '')
612615
await appendMessages(accessToken, lines, messages, syncContext)
613616
const messageCount = lines.count
614617

@@ -633,13 +636,20 @@ async function buildSlackChannelDocument(
633636
* in catches deletes (count drops) but still cannot detect reply edits
634637
* without fetching `conversations.replies` for each parent.
635638
*
639+
* The header is digested into the hash because it is part of the document:
640+
* renaming a channel or editing its topic changes the indexed text without
641+
* touching a single message, and the sync engine drops a refresh whose hash
642+
* matches the stored one. A digest keeps the hash bounded and free of the
643+
* delimiter collisions raw topic text would bring.
644+
*
636645
* The `slack-v3` prefix forces a one-time re-index of channels indexed
637646
* before the document gained its header and size ceiling; `slack-v2` did the
638647
* same when attachment and Block Kit content started being extracted.
639648
* Per-message `ts` and the window are unchanged by either, so without the
640649
* bump the hash would match and the richer content would never be embedded.
641650
*/
642-
const contentHash = `slack-v3:${channel.id}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messages.length}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}`
651+
const headerDigest = createHash('sha256').update(header.join('\n')).digest('hex').slice(0, 16)
652+
const contentHash = `slack-v3:${channel.id}:${headerDigest}:${oldestTs ?? 'empty'}:${lastActivityTs ?? 'empty'}:${messages.length}:${maxEditTs || 'noedit'}:${maxReplyTs || 'noreply'}:${totalReplies}`
643653

644654
return { content: lines.join(), contentHash, messageCount, lastActivityTs }
645655
}

0 commit comments

Comments
 (0)