@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/adapter.test.ts +919 -0
- package/src/__tests__/formatter.test.ts +219 -0
- package/src/adapter.ts +254 -0
- package/src/formatter.ts +58 -0
- package/src/index.ts +2 -0
|
@@ -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
|
+
}
|
package/src/formatter.ts
ADDED
|
@@ -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