@rokrokss/claude-slack-channel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ export function fixSlackMrkdwn(text: string): string {
2
+ return text.replace(/\*([^*]+)\*/g, '\u200B*$1*\u200B')
3
+ }
4
+
5
+ export function extractMessageText(msg: Record<string, any>): string {
6
+ const parts: string[] = []
7
+
8
+ if (msg.blocks) {
9
+ for (const block of msg.blocks) {
10
+ if (block.type === 'rich_text' && block.elements) {
11
+ for (const elem of block.elements) {
12
+ if (elem.elements) {
13
+ parts.push(elem.elements.map((e: any) => e.text ?? '').join(''))
14
+ }
15
+ }
16
+ } else if (block.type === 'section') {
17
+ if (block.text?.text) parts.push(block.text.text)
18
+ if (block.fields) {
19
+ parts.push(block.fields.map((f: any) => f.text ?? '').join(' '))
20
+ }
21
+ } else if (block.type === 'header') {
22
+ if (block.text?.text) parts.push(`*${block.text.text}*`)
23
+ } else if (block.type === 'context' && block.elements) {
24
+ const texts = block.elements.map((e: any) => e.text ?? '').filter(Boolean)
25
+ if (texts.length) parts.push(texts.join(' '))
26
+ } else if (block.type === 'divider') {
27
+ parts.push('---')
28
+ } else if (block.type === 'image') {
29
+ parts.push(block.alt_text || block.title?.text || '[image]')
30
+ } else if (block.text?.text) {
31
+ parts.push(block.text.text)
32
+ }
33
+ }
34
+ }
35
+
36
+ if (parts.length > 0) return parts.join('\n')
37
+
38
+ if (msg.text) return msg.text
39
+
40
+ if (msg.attachments) {
41
+ for (const att of msg.attachments) {
42
+ const attParts: string[] = []
43
+ if (att.blocks) {
44
+ const inner = extractMessageText({ blocks: att.blocks })
45
+ if (inner) attParts.push(inner)
46
+ }
47
+ if (att.pretext) attParts.push(att.pretext)
48
+ if (att.title && att.title_link) {
49
+ attParts.push(`<${att.title_link}|${att.title}>`)
50
+ } else if (att.title) {
51
+ attParts.push(att.title)
52
+ }
53
+ if (att.text) attParts.push(att.text)
54
+ if (att.fields) {
55
+ for (const f of att.fields) {
56
+ if (f.title || f.value) attParts.push(`${f.title ?? ''}: ${f.value ?? ''}`)
57
+ }
58
+ }
59
+ if (att.image_url) attParts.push(`[image: ${att.image_url}]`)
60
+ if (attParts.length === 0 && att.from_url) attParts.push(att.from_url)
61
+ if (attParts.length === 0 && att.fallback) attParts.push(att.fallback)
62
+ if (attParts.length > 0) parts.push(attParts.join('\n'))
63
+ }
64
+ }
65
+
66
+ if (parts.length > 0) return parts.join('\n')
67
+
68
+ if (msg.files) {
69
+ return msg.files.map((f: any) => `[file: ${f.name || f.title || f.id}]`).join(', ')
70
+ }
71
+
72
+ return msg.text || ''
73
+ }
package/lib/gate.ts ADDED
@@ -0,0 +1,47 @@
1
+ export interface Access {
2
+ allowFrom: string[]
3
+ ackReaction?: string
4
+ botOwner?: string
5
+ }
6
+
7
+ export interface GateResult {
8
+ action: 'deliver' | 'drop'
9
+ access?: Access
10
+ }
11
+
12
+ export interface GateOptions {
13
+ access: Access
14
+ botUserId: string
15
+ }
16
+
17
+ export function defaultAccess(): Access {
18
+ return { allowFrom: [] }
19
+ }
20
+
21
+ /**
22
+ * Check if the MCP client supports the channel capability.
23
+ */
24
+ export function clientSupportsChannels(capabilities: Record<string, unknown> | undefined): boolean {
25
+ if (!capabilities) return false
26
+ const experimental = capabilities['experimental']
27
+ if (!experimental || typeof experimental !== 'object') return false
28
+ return 'claude/channel' in experimental
29
+ }
30
+
31
+ export function gate(event: unknown, opts: GateOptions): GateResult {
32
+ const ev = event as Record<string, unknown>
33
+
34
+ if (ev['user'] && ev['user'] === opts.botUserId) return { action: 'drop' }
35
+ if (ev['subtype'] && ev['subtype'] !== 'file_share' && ev['subtype'] !== 'bot_message') {
36
+ return { action: 'drop' }
37
+ }
38
+ if (ev['subtype'] === 'bot_message') return { action: 'deliver', access: opts.access }
39
+ if (!ev['user']) return { action: 'drop' }
40
+ if (opts.access.botOwner && ev['user'] === opts.access.botOwner) {
41
+ return { action: 'deliver', access: opts.access }
42
+ }
43
+ if (opts.access.allowFrom.includes(ev['user'] as string)) {
44
+ return { action: 'deliver', access: opts.access }
45
+ }
46
+ return { action: 'drop' }
47
+ }
package/lib/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from './gate.ts'
2
+ export * from './security.ts'
3
+ export * from './formatting.ts'
4
+ export * from './audit.ts'
5
+ export * from './event.ts'
6
+ export * from './resilience.ts'
7
+ export * from './permalink.ts'
@@ -0,0 +1,8 @@
1
+ export function buildPermalink(workspace: string, channel: string, ts: string, threadTs?: string): string {
2
+ const tsNoDot = ts.replace('.', '')
3
+ const base = `https://${workspace}.slack.com/archives/${channel}/p${tsNoDot}`
4
+ if (threadTs) {
5
+ return `${base}?thread_ts=${threadTs}&cid=${channel}`
6
+ }
7
+ return base
8
+ }
@@ -0,0 +1,45 @@
1
+ import { DEFAULT_STALE_THRESHOLD_MS } from './event.ts'
2
+
3
+ export class EventDeduplicator {
4
+ private seen = new Map<string, number>()
5
+ private readonly ttlMs: number
6
+ private readonly cleanupInterval: number
7
+
8
+ private callCount = 0
9
+
10
+ constructor(ttlMs: number = DEFAULT_STALE_THRESHOLD_MS, cleanupInterval: number = 100) {
11
+ this.ttlMs = ttlMs
12
+ this.cleanupInterval = cleanupInterval
13
+ }
14
+
15
+ /** Returns true if this event was already seen (duplicate). */
16
+ isDuplicate(channel: string, ts: string): boolean {
17
+ const key = `${channel}:${ts}`
18
+ const now = Date.now()
19
+
20
+ // Periodic cleanup
21
+ if (++this.callCount % this.cleanupInterval === 0) {
22
+ this.cleanup(now)
23
+ }
24
+
25
+ const existing = this.seen.get(key)
26
+ if (existing !== undefined && now - existing < this.ttlMs) {
27
+ return true
28
+ }
29
+
30
+ this.seen.set(key, now)
31
+ return false
32
+ }
33
+
34
+ private cleanup(now: number): void {
35
+ for (const [key, timestamp] of this.seen) {
36
+ if (now - timestamp >= this.ttlMs) {
37
+ this.seen.delete(key)
38
+ }
39
+ }
40
+ }
41
+
42
+ get size(): number {
43
+ return this.seen.size
44
+ }
45
+ }
@@ -0,0 +1,9 @@
1
+ export function assertOutboundAllowed(
2
+ chatId: string,
3
+ deliveredChannels: ReadonlySet<string>,
4
+ ): void {
5
+ if (deliveredChannels.has(chatId)) return
6
+ throw new Error(
7
+ `Outbound gate: channel ${chatId} has not received any inbound messages.`,
8
+ )
9
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@rokrokss/claude-slack-channel",
3
+ "version": "0.1.0",
4
+ "description": "Two-way Slack channel for Claude Code — Slack DM과 채널 멘션으로 Claude Code 세션과 대화",
5
+ "type": "module",
6
+ "bin": "./server.ts",
7
+ "scripts": {
8
+ "start": "bun server.ts",
9
+ "test": "bun test",
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.0.0",
14
+ "@slack/socket-mode": "^2.0.0",
15
+ "@slack/web-api": "^7.0.0",
16
+ "zod": "^4.3.6"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "^1.0.0",
20
+ "typescript": "^5.4.0"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/rokrokss/claude-slack-channel.git"
26
+ }
27
+ }