@onmars/lunar-discord 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,219 @@
1
+ /**
2
+ * # Discord Formatter — Functional Specification
3
+ *
4
+ * ## splitMessage(text, maxLength=2000)
5
+ * Splits long text into Discord-safe chunks (default 2000 chars).
6
+ *
7
+ * ### Split strategy (ordered by preference):
8
+ * 1. **Paragraph break** (`\n\n`) — cleanest split, preserves structure
9
+ * 2. **Line break** (`\n`) — next best, preserves lines
10
+ * 3. **Space** (` `) — word-boundary split, no words cut in half
11
+ * 4. **Hard split** (exact char count) — last resort for single long tokens
12
+ *
13
+ * ### Invariants:
14
+ * - Every chunk ≤ maxLength
15
+ * - Concatenating all chunks exactly recreates the original text
16
+ * - Empty input → single-element array with empty string
17
+ * - Text at or under limit → single-element array (no unnecessary split)
18
+ */
19
+ import { describe, expect, it } from 'bun:test'
20
+ import { formatForDiscord, splitMessage } from '../formatter'
21
+
22
+ // ═══════════════════════════════════════════════════════════════════
23
+ // splitMessage — Discord message chunking
24
+ // ═══════════════════════════════════════════════════════════════════
25
+
26
+ describe('splitMessage', () => {
27
+ // --- No split needed ---
28
+
29
+ it('short text: returns single-element array', () => {
30
+ expect(splitMessage('Hello world')).toEqual(['Hello world'])
31
+ })
32
+
33
+ it('text exactly at limit: no split', () => {
34
+ const text = 'a'.repeat(2000)
35
+ const result = splitMessage(text)
36
+ expect(result).toHaveLength(1)
37
+ expect(result[0]).toBe(text)
38
+ })
39
+
40
+ it('empty string: returns [""]', () => {
41
+ expect(splitMessage('')).toEqual([''])
42
+ })
43
+
44
+ // --- Split strategies (in preference order) ---
45
+
46
+ it('strategy 1: splits at paragraph boundary (\\n\\n)', () => {
47
+ const para1 = 'a'.repeat(1500)
48
+ const para2 = 'b'.repeat(800)
49
+ const text = `${para1}\n\n${para2}`
50
+
51
+ const result = splitMessage(text)
52
+ expect(result.length).toBeGreaterThanOrEqual(2)
53
+ expect(result[0].trim()).toBe(para1)
54
+ })
55
+
56
+ it('strategy 2: splits at line boundary when no paragraph break', () => {
57
+ const line1 = 'a'.repeat(1500)
58
+ const line2 = 'b'.repeat(800)
59
+ const text = `${line1}\n${line2}`
60
+
61
+ const result = splitMessage(text)
62
+ expect(result.length).toBeGreaterThanOrEqual(2)
63
+ })
64
+
65
+ it('strategy 4: hard-splits continuous text with no break points', () => {
66
+ const text = 'a'.repeat(5000) // no spaces, no newlines
67
+
68
+ const result = splitMessage(text)
69
+ expect(result.length).toBeGreaterThanOrEqual(3)
70
+ for (const chunk of result) {
71
+ expect(chunk.length).toBeLessThanOrEqual(2000)
72
+ }
73
+ })
74
+
75
+ // --- Invariants ---
76
+
77
+ it('invariant: every chunk ≤ maxLength', () => {
78
+ const text = 'Hello world, this is a test message'
79
+ const result = splitMessage(text, 15) // very small limit
80
+
81
+ for (const chunk of result) {
82
+ expect(chunk.length).toBeLessThanOrEqual(15)
83
+ }
84
+ })
85
+
86
+ it('invariant: concatenated chunks == original text', () => {
87
+ const text = Array(100).fill('The quick brown fox jumps over the lazy dog.').join('\n')
88
+ const chunks = splitMessage(text)
89
+
90
+ expect(chunks.join('')).toBe(text)
91
+ })
92
+ })
93
+
94
+ // ═══════════════════════════════════════════════════════════════════
95
+ // formatForDiscord — markdown formatting (currently passthrough)
96
+ // ═══════════════════════════════════════════════════════════════════
97
+
98
+ describe('formatForDiscord', () => {
99
+ it('passes markdown text through unchanged', () => {
100
+ const text = '**bold** and *italic*'
101
+ expect(formatForDiscord(text)).toBe(text)
102
+ })
103
+
104
+ it('preserves code blocks', () => {
105
+ const text = '```js\nconsole.log("hello")\n```'
106
+ expect(formatForDiscord(text)).toBe(text)
107
+ })
108
+
109
+ it('preserves inline code', () => {
110
+ const text = 'Use `npm install` to install'
111
+ expect(formatForDiscord(text)).toBe(text)
112
+ })
113
+
114
+ it('handles empty string', () => {
115
+ expect(formatForDiscord('')).toBe('')
116
+ })
117
+
118
+ it('preserves multiline text', () => {
119
+ const text = 'Line 1\nLine 2\n\nParagraph 2'
120
+ expect(formatForDiscord(text)).toBe(text)
121
+ })
122
+
123
+ it('preserves Discord-specific formatting (mentions, channels)', () => {
124
+ const text = 'Hey <@123456> check <#789012>'
125
+ expect(formatForDiscord(text)).toBe(text)
126
+ })
127
+ })
128
+
129
+ // ═══════════════════════════════════════════════════════════════════
130
+ // splitMessage — additional edge cases and strategies
131
+ // ═══════════════════════════════════════════════════════════════════
132
+
133
+ describe('splitMessage — additional cases', () => {
134
+ it('strategy 3: splits at space boundary when no newlines', () => {
135
+ // Build a string with words separated by spaces, exceeding the limit
136
+ const words = Array(300).fill('hello').join(' ') // ~1800 chars of words
137
+ const extra = Array(50).fill('world').join(' ') // ~300 more
138
+ const text = words + ' ' + extra
139
+
140
+ const result = splitMessage(text)
141
+ expect(result.length).toBeGreaterThanOrEqual(2)
142
+ for (const chunk of result) {
143
+ expect(chunk.length).toBeLessThanOrEqual(2000)
144
+ }
145
+ // Verify no words are split mid-word
146
+ const allText = result.join('')
147
+ expect(allText).toBe(text)
148
+ })
149
+
150
+ it('custom maxLength parameter is respected', () => {
151
+ const text = 'abcdefghij' // 10 chars
152
+ const result = splitMessage(text, 5)
153
+
154
+ for (const chunk of result) {
155
+ expect(chunk.length).toBeLessThanOrEqual(5)
156
+ }
157
+ expect(result.join('')).toBe(text)
158
+ })
159
+
160
+ it('single character per chunk at maxLength=1', () => {
161
+ const text = 'abc'
162
+ const result = splitMessage(text, 1)
163
+
164
+ expect(result).toEqual(['a', 'b', 'c'])
165
+ })
166
+
167
+ it('preserves content exactly for text at maxLength boundary', () => {
168
+ const text = 'x'.repeat(2000)
169
+ const result = splitMessage(text)
170
+ expect(result).toHaveLength(1)
171
+ expect(result[0]).toBe(text)
172
+ })
173
+
174
+ it('text exactly 1 char over limit splits into two', () => {
175
+ const text = 'x'.repeat(2001)
176
+ const result = splitMessage(text)
177
+ expect(result.length).toBeGreaterThanOrEqual(2)
178
+ expect(result.join('')).toBe(text)
179
+ })
180
+
181
+ it('prefers paragraph split over line split', () => {
182
+ // Build: ~1200 chars, then \n\n, then ~1200 chars, then \n, then ~200 chars
183
+ const part1 = 'a'.repeat(1200)
184
+ const part2 = 'b'.repeat(700)
185
+ const part3 = 'c'.repeat(200)
186
+ const text = `${part1}\n\n${part2}\n${part3}`
187
+
188
+ const result = splitMessage(text)
189
+ // First chunk should end at the paragraph break
190
+ expect(result[0]).toBe(part1 + '\n\n')
191
+ expect(result.join('')).toBe(text)
192
+ })
193
+
194
+ it('handles many consecutive newlines', () => {
195
+ const text = 'a'.repeat(1500) + '\n\n\n\n' + 'b'.repeat(1500)
196
+ const result = splitMessage(text)
197
+ expect(result.length).toBeGreaterThanOrEqual(2)
198
+ for (const chunk of result) {
199
+ expect(chunk.length).toBeLessThanOrEqual(2000)
200
+ }
201
+ expect(result.join('')).toBe(text)
202
+ })
203
+
204
+ it('handles text with only whitespace content', () => {
205
+ const text = ' '
206
+ const result = splitMessage(text)
207
+ expect(result).toEqual([' '])
208
+ })
209
+
210
+ it('three chunks for very long text', () => {
211
+ const text = 'x'.repeat(5500) // should need at least 3 chunks
212
+ const result = splitMessage(text)
213
+ expect(result.length).toBeGreaterThanOrEqual(3)
214
+ for (const chunk of result) {
215
+ expect(chunk.length).toBeLessThanOrEqual(2000)
216
+ }
217
+ expect(result.join('')).toBe(text)
218
+ })
219
+ })
package/src/adapter.ts ADDED
@@ -0,0 +1,254 @@
1
+ import type { Attachment, Channel, IncomingMessage, OutgoingMessage } from '@onmars/lunar-core'
2
+ import { log } from '@onmars/lunar-core'
3
+ import { Client, type Message as DMessage, Events, GatewayIntentBits } from 'discord.js'
4
+ import { formatForDiscord, splitMessage } from './formatter'
5
+
6
+ export interface DiscordChannelOptions {
7
+ /** Adapter ID — unique per bot account (default: 'discord') */
8
+ id?: string
9
+ /** Discord bot token */
10
+ token: string
11
+ /** Guild ID to operate in */
12
+ guildId: string
13
+ /** Allowed user IDs (empty = allow all) */
14
+ allowedUsers?: string[]
15
+ /** Allowed channel IDs (empty = allow all in guild) */
16
+ allowedChannels?: string[]
17
+ }
18
+
19
+ export class DiscordChannel implements Channel {
20
+ readonly id: string
21
+ readonly name: string
22
+
23
+ private client: Client
24
+ private messageHandler?: (msg: IncomingMessage) => Promise<void>
25
+ private connected = false
26
+
27
+ constructor(private options: DiscordChannelOptions) {
28
+ this.id = options.id ?? 'discord'
29
+ this.name = options.id ?? 'discord'
30
+ this.client = new Client({
31
+ intents: [
32
+ GatewayIntentBits.Guilds,
33
+ GatewayIntentBits.GuildMessages,
34
+ GatewayIntentBits.MessageContent,
35
+ GatewayIntentBits.DirectMessages,
36
+ ],
37
+ })
38
+ }
39
+
40
+ async connect(): Promise<void> {
41
+ return new Promise((resolve, reject) => {
42
+ this.client.once(Events.ClientReady, (c) => {
43
+ log.info({ user: c.user.tag, guilds: c.guilds.cache.size }, 'Discord connected')
44
+ this.connected = true
45
+ this.setupMessageListener()
46
+ this.setupLifecycleListeners()
47
+ resolve()
48
+ })
49
+
50
+ this.client.once(Events.Error, reject)
51
+ this.client.login(this.options.token).catch(reject)
52
+ })
53
+ }
54
+
55
+ async disconnect(): Promise<void> {
56
+ this.connected = false
57
+ this.client.destroy()
58
+ log.info('Discord disconnected')
59
+ }
60
+
61
+ isConnected(): boolean {
62
+ return this.connected
63
+ }
64
+
65
+ onMessage(handler: (msg: IncomingMessage) => Promise<void>): void {
66
+ this.messageHandler = handler
67
+ }
68
+
69
+ async send(target: string, content: OutgoingMessage): Promise<string | undefined> {
70
+ if (!this.connected) {
71
+ log.warn({ target }, 'Cannot send — Discord is disconnected')
72
+ return undefined
73
+ }
74
+
75
+ const channel = await this.client.channels.fetch(target)
76
+ if (!channel?.isTextBased() || !('send' in channel)) {
77
+ log.error({ target }, 'Cannot send to channel')
78
+ return undefined
79
+ }
80
+
81
+ const formatted = formatForDiscord(content.text)
82
+ const chunks = splitMessage(formatted)
83
+
84
+ // Prepare file attachments (audio, images, etc.)
85
+ const files =
86
+ content.attachments?.map((att) => ({
87
+ attachment:
88
+ att.data instanceof Buffer ? att.data : Buffer.from(att.data as string, 'base64'),
89
+ name: att.filename,
90
+ contentType: att.mimeType,
91
+ })) ?? []
92
+
93
+ let lastMessageId: string | undefined
94
+
95
+ for (let i = 0; i < chunks.length; i++) {
96
+ const isLastChunk = i === chunks.length - 1
97
+ const sent = await channel.send({
98
+ content: chunks[i],
99
+ reply:
100
+ content.replyTo && i === 0
101
+ ? { messageReference: content.replyTo, failIfNotExists: false }
102
+ : undefined,
103
+ // Attach files to the last text chunk so audio follows the complete message
104
+ files: isLastChunk && files.length > 0 ? files : undefined,
105
+ })
106
+ lastMessageId = sent.id
107
+ }
108
+
109
+ return lastMessageId
110
+ }
111
+
112
+ async sendTyping(target: string): Promise<void> {
113
+ if (!this.connected) return
114
+ try {
115
+ const channel = await this.client.channels.fetch(target)
116
+ if (channel?.isTextBased() && 'sendTyping' in channel) {
117
+ await channel.sendTyping()
118
+ }
119
+ } catch {
120
+ // Best effort
121
+ }
122
+ }
123
+
124
+ async edit(messageId: string, target: string, content: OutgoingMessage): Promise<void> {
125
+ if (!this.connected) return
126
+ const channel = await this.client.channels.fetch(target)
127
+ if (!channel?.isTextBased() || !('messages' in channel)) return
128
+
129
+ const msg = await channel.messages.fetch(messageId)
130
+ await msg.edit({ content: formatForDiscord(content.text) })
131
+ }
132
+
133
+ async delete(messageId: string, target: string): Promise<void> {
134
+ if (!this.connected) return
135
+ const channel = await this.client.channels.fetch(target)
136
+ if (!channel?.isTextBased() || !('messages' in channel)) return
137
+
138
+ const msg = await channel.messages.fetch(messageId)
139
+ await msg.delete()
140
+ }
141
+
142
+ async react(messageId: string, target: string, emoji: string): Promise<void> {
143
+ if (!this.connected) return
144
+ const channel = await this.client.channels.fetch(target)
145
+ if (!channel?.isTextBased() || !('messages' in channel)) return
146
+
147
+ const msg = await channel.messages.fetch(messageId)
148
+ await msg.react(emoji)
149
+ }
150
+
151
+ // --- Private ---
152
+
153
+ private setupLifecycleListeners(): void {
154
+ // Persistent error handler (replaces the once() from connect)
155
+ this.client.on(Events.Error, (err) => {
156
+ log.error({ err: err.message }, 'Discord client error')
157
+ })
158
+
159
+ this.client.on(Events.Warn, (msg) => {
160
+ log.warn({ msg }, 'Discord warning')
161
+ })
162
+
163
+ // Shard lifecycle — discord.js auto-reconnects, we track state
164
+ this.client.on(Events.ShardDisconnect, (event, shardId) => {
165
+ this.connected = false
166
+ log.warn(
167
+ { shardId, code: event.code, reason: event.reason },
168
+ 'Discord disconnected — auto-reconnect pending',
169
+ )
170
+ })
171
+
172
+ this.client.on(Events.ShardReconnecting, (shardId) => {
173
+ log.info({ shardId }, 'Discord reconnecting...')
174
+ })
175
+
176
+ this.client.on(Events.ShardResume, (shardId, replayed) => {
177
+ this.connected = true
178
+ log.info({ shardId, replayed }, 'Discord reconnected (resumed)')
179
+ })
180
+
181
+ this.client.on(Events.ShardReady, (shardId) => {
182
+ this.connected = true
183
+ log.info({ shardId }, 'Discord shard ready')
184
+ })
185
+ }
186
+
187
+ private setupMessageListener(): void {
188
+ this.client.on(Events.MessageCreate, async (msg: DMessage) => {
189
+ // Ignore bots
190
+ if (msg.author.bot) return
191
+
192
+ // Guild check
193
+ if (msg.guildId !== this.options.guildId) return
194
+
195
+ // Channel allowlist
196
+ if (
197
+ this.options.allowedChannels?.length &&
198
+ !this.options.allowedChannels.includes(msg.channelId)
199
+ ) {
200
+ return
201
+ }
202
+
203
+ // User allowlist
204
+ if (this.options.allowedUsers?.length && !this.options.allowedUsers.includes(msg.author.id)) {
205
+ return
206
+ }
207
+
208
+ // Skip empty messages (unless attachments)
209
+ if (!msg.content.trim() && msg.attachments.size === 0) return
210
+
211
+ // Build IncomingMessage
212
+ const incoming: IncomingMessage = {
213
+ id: msg.id,
214
+ channelId: msg.channelId,
215
+ sender: {
216
+ id: msg.author.id,
217
+ name: msg.member?.displayName ?? msg.author.displayName,
218
+ username: msg.author.username,
219
+ },
220
+ text: msg.content,
221
+ attachments: this.parseAttachments(msg),
222
+ replyTo: msg.reference?.messageId,
223
+ raw: msg,
224
+ timestamp: msg.createdAt,
225
+ }
226
+
227
+ if (this.messageHandler) {
228
+ try {
229
+ await this.messageHandler(incoming)
230
+ } catch (err) {
231
+ log.error({ err, messageId: msg.id }, 'Error handling message')
232
+ }
233
+ }
234
+ })
235
+ }
236
+
237
+ private parseAttachments(msg: DMessage): Attachment[] {
238
+ return msg.attachments.map((att) => ({
239
+ type: this.inferAttachmentType(att.contentType),
240
+ url: att.url,
241
+ filename: att.name ?? undefined,
242
+ mimeType: att.contentType ?? undefined,
243
+ size: att.size,
244
+ }))
245
+ }
246
+
247
+ private inferAttachmentType(contentType?: string | null): Attachment['type'] {
248
+ if (!contentType) return 'file'
249
+ if (contentType.startsWith('image/')) return 'image'
250
+ if (contentType.startsWith('audio/')) return 'audio'
251
+ if (contentType.startsWith('video/')) return 'video'
252
+ return 'file'
253
+ }
254
+ }
@@ -0,0 +1,58 @@
1
+ /** Max message length for Discord */
2
+ const MAX_LENGTH = 2000
3
+
4
+ /**
5
+ * Format text for Discord's markdown dialect.
6
+ * Handles code blocks, bold, italic, etc.
7
+ */
8
+ export function formatForDiscord(text: string): string {
9
+ // Discord uses a subset of markdown — most standard markdown works.
10
+ // Main differences: no heading rendering, limited HTML.
11
+ // For now, pass through. Extend as needed.
12
+ return text
13
+ }
14
+
15
+ /**
16
+ * Split a long message into chunks that fit Discord's 2000 char limit.
17
+ * Tries to split at paragraph/line boundaries to avoid breaking formatting.
18
+ */
19
+ export function splitMessage(text: string, maxLength = MAX_LENGTH): string[] {
20
+ if (text.length <= maxLength) return [text]
21
+
22
+ const chunks: string[] = []
23
+ let remaining = text
24
+
25
+ while (remaining.length > 0) {
26
+ if (remaining.length <= maxLength) {
27
+ chunks.push(remaining)
28
+ break
29
+ }
30
+
31
+ // Find best split point
32
+ let splitAt = maxLength
33
+
34
+ // Try to split at double newline (paragraph)
35
+ const paraBreak = remaining.lastIndexOf('\n\n', maxLength)
36
+ if (paraBreak > maxLength * 0.5) {
37
+ splitAt = paraBreak + 2
38
+ } else {
39
+ // Try single newline
40
+ const lineBreak = remaining.lastIndexOf('\n', maxLength)
41
+ if (lineBreak > maxLength * 0.5) {
42
+ splitAt = lineBreak + 1
43
+ } else {
44
+ // Try space
45
+ const spaceBreak = remaining.lastIndexOf(' ', maxLength)
46
+ if (spaceBreak > maxLength * 0.5) {
47
+ splitAt = spaceBreak + 1
48
+ }
49
+ // Else hard split at maxLength
50
+ }
51
+ }
52
+
53
+ chunks.push(remaining.slice(0, splitAt))
54
+ remaining = remaining.slice(splitAt)
55
+ }
56
+
57
+ return chunks
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { DiscordChannel } from './adapter'
2
+ export { formatForDiscord, splitMessage } from './formatter'