@auxiora/channels 1.0.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 +191 -0
- package/dist/adapters/bluebubbles.d.ts +63 -0
- package/dist/adapters/bluebubbles.d.ts.map +1 -0
- package/dist/adapters/bluebubbles.js +197 -0
- package/dist/adapters/bluebubbles.js.map +1 -0
- package/dist/adapters/discord.d.ts +27 -0
- package/dist/adapters/discord.d.ts.map +1 -0
- package/dist/adapters/discord.js +202 -0
- package/dist/adapters/discord.js.map +1 -0
- package/dist/adapters/email.d.ts +39 -0
- package/dist/adapters/email.d.ts.map +1 -0
- package/dist/adapters/email.js +359 -0
- package/dist/adapters/email.js.map +1 -0
- package/dist/adapters/googlechat.d.ts +77 -0
- package/dist/adapters/googlechat.d.ts.map +1 -0
- package/dist/adapters/googlechat.js +232 -0
- package/dist/adapters/googlechat.js.map +1 -0
- package/dist/adapters/matrix.d.ts +37 -0
- package/dist/adapters/matrix.d.ts.map +1 -0
- package/dist/adapters/matrix.js +262 -0
- package/dist/adapters/matrix.js.map +1 -0
- package/dist/adapters/signal.d.ts +32 -0
- package/dist/adapters/signal.d.ts.map +1 -0
- package/dist/adapters/signal.js +216 -0
- package/dist/adapters/signal.js.map +1 -0
- package/dist/adapters/slack.d.ts +29 -0
- package/dist/adapters/slack.d.ts.map +1 -0
- package/dist/adapters/slack.js +202 -0
- package/dist/adapters/slack.js.map +1 -0
- package/dist/adapters/teams.d.ts +66 -0
- package/dist/adapters/teams.d.ts.map +1 -0
- package/dist/adapters/teams.js +227 -0
- package/dist/adapters/teams.js.map +1 -0
- package/dist/adapters/telegram.d.ts +28 -0
- package/dist/adapters/telegram.d.ts.map +1 -0
- package/dist/adapters/telegram.js +170 -0
- package/dist/adapters/telegram.js.map +1 -0
- package/dist/adapters/twilio.d.ts +63 -0
- package/dist/adapters/twilio.d.ts.map +1 -0
- package/dist/adapters/twilio.js +193 -0
- package/dist/adapters/twilio.js.map +1 -0
- package/dist/adapters/whatsapp.d.ts +99 -0
- package/dist/adapters/whatsapp.d.ts.map +1 -0
- package/dist/adapters/whatsapp.js +218 -0
- package/dist/adapters/whatsapp.js.map +1 -0
- package/dist/adapters/zalo.d.ts +64 -0
- package/dist/adapters/zalo.d.ts.map +1 -0
- package/dist/adapters/zalo.js +216 -0
- package/dist/adapters/zalo.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/manager.d.ts +35 -0
- package/dist/manager.d.ts.map +1 -0
- package/dist/manager.js +127 -0
- package/dist/manager.js.map +1 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +32 -0
- package/src/adapters/bluebubbles.ts +294 -0
- package/src/adapters/discord.ts +253 -0
- package/src/adapters/email.ts +457 -0
- package/src/adapters/googlechat.ts +364 -0
- package/src/adapters/matrix.ts +376 -0
- package/src/adapters/signal.ts +313 -0
- package/src/adapters/slack.ts +252 -0
- package/src/adapters/teams.ts +320 -0
- package/src/adapters/telegram.ts +208 -0
- package/src/adapters/twilio.ts +256 -0
- package/src/adapters/whatsapp.ts +342 -0
- package/src/adapters/zalo.ts +319 -0
- package/src/index.ts +78 -0
- package/src/manager.ts +180 -0
- package/src/types.ts +84 -0
- package/tests/bluebubbles.test.ts +438 -0
- package/tests/email.test.ts +136 -0
- package/tests/googlechat.test.ts +439 -0
- package/tests/matrix.test.ts +564 -0
- package/tests/signal.test.ts +404 -0
- package/tests/slack.test.ts +343 -0
- package/tests/teams.test.ts +429 -0
- package/tests/twilio.test.ts +269 -0
- package/tests/whatsapp.test.ts +530 -0
- package/tests/zalo.test.ts +499 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { audit } from '@auxiora/audit';
|
|
2
|
+
import type {
|
|
3
|
+
ChannelAdapter,
|
|
4
|
+
InboundMessage,
|
|
5
|
+
OutboundMessage,
|
|
6
|
+
SendResult,
|
|
7
|
+
} from '../types.js';
|
|
8
|
+
|
|
9
|
+
export interface BlueBubblesAdapterConfig {
|
|
10
|
+
serverUrl: string;
|
|
11
|
+
password: string;
|
|
12
|
+
allowedAddresses?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BlueBubblesMessage {
|
|
16
|
+
guid: string;
|
|
17
|
+
text: string;
|
|
18
|
+
handle?: {
|
|
19
|
+
address: string;
|
|
20
|
+
service: string;
|
|
21
|
+
uncanonicalizedId?: string;
|
|
22
|
+
};
|
|
23
|
+
chats?: Array<{
|
|
24
|
+
guid: string;
|
|
25
|
+
chatIdentifier: string;
|
|
26
|
+
displayName?: string;
|
|
27
|
+
participants?: Array<{
|
|
28
|
+
address: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
dateCreated: number;
|
|
32
|
+
isFromMe: boolean;
|
|
33
|
+
threadOriginatorGuid?: string;
|
|
34
|
+
attachments?: Array<{
|
|
35
|
+
guid: string;
|
|
36
|
+
mimeType: string;
|
|
37
|
+
transferName: string;
|
|
38
|
+
totalBytes: number;
|
|
39
|
+
}>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface BlueBubblesWebhookEvent {
|
|
43
|
+
type: string;
|
|
44
|
+
data: BlueBubblesMessage;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BlueBubblesSendResponse {
|
|
48
|
+
status: number;
|
|
49
|
+
message: string;
|
|
50
|
+
data?: BlueBubblesMessage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_MESSAGE_LENGTH = 20000;
|
|
54
|
+
|
|
55
|
+
export class BlueBubblesAdapter implements ChannelAdapter {
|
|
56
|
+
readonly type = 'bluebubbles' as const;
|
|
57
|
+
readonly name = 'BlueBubbles (iMessage)';
|
|
58
|
+
|
|
59
|
+
private config: BlueBubblesAdapterConfig;
|
|
60
|
+
private messageHandler?: (message: InboundMessage) => Promise<void>;
|
|
61
|
+
private errorHandler?: (error: Error) => void;
|
|
62
|
+
private connected = false;
|
|
63
|
+
|
|
64
|
+
constructor(config: BlueBubblesAdapterConfig) {
|
|
65
|
+
this.config = config;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private get baseUrl(): string {
|
|
69
|
+
return this.config.serverUrl.replace(/\/+$/, '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async apiRequest<T>(
|
|
73
|
+
method: string,
|
|
74
|
+
path: string,
|
|
75
|
+
body?: Record<string, unknown>,
|
|
76
|
+
): Promise<T> {
|
|
77
|
+
const url = new URL(path, this.baseUrl);
|
|
78
|
+
url.searchParams.set('password', this.config.password);
|
|
79
|
+
|
|
80
|
+
const response = await fetch(url.toString(), {
|
|
81
|
+
method,
|
|
82
|
+
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
83
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.ok) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`BlueBubbles API error ${response.status}: ${response.statusText}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (await response.json()) as T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async connect(): Promise<void> {
|
|
96
|
+
// Verify connection by fetching server info
|
|
97
|
+
await this.apiRequest<{ status: number; message: string }>('GET', '/api/v1/server/info');
|
|
98
|
+
this.connected = true;
|
|
99
|
+
|
|
100
|
+
audit('channel.connected', {
|
|
101
|
+
channelType: 'bluebubbles',
|
|
102
|
+
serverUrl: this.baseUrl,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async disconnect(): Promise<void> {
|
|
107
|
+
this.connected = false;
|
|
108
|
+
audit('channel.disconnected', { channelType: 'bluebubbles' });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
isConnected(): boolean {
|
|
112
|
+
return this.connected;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Handle incoming webhook from BlueBubbles server.
|
|
117
|
+
* Configure your BlueBubbles server to send webhooks to your endpoint.
|
|
118
|
+
*/
|
|
119
|
+
async handleWebhook(event: BlueBubblesWebhookEvent): Promise<void> {
|
|
120
|
+
if (event.type !== 'new-message') return;
|
|
121
|
+
|
|
122
|
+
const msg = event.data;
|
|
123
|
+
|
|
124
|
+
// Ignore own messages
|
|
125
|
+
if (msg.isFromMe) return;
|
|
126
|
+
|
|
127
|
+
// Must have text content
|
|
128
|
+
if (!msg.text) return;
|
|
129
|
+
|
|
130
|
+
// Check allowed addresses
|
|
131
|
+
const senderAddress = msg.handle?.address;
|
|
132
|
+
if (
|
|
133
|
+
senderAddress &&
|
|
134
|
+
this.config.allowedAddresses?.length &&
|
|
135
|
+
!this.config.allowedAddresses.includes(senderAddress)
|
|
136
|
+
) {
|
|
137
|
+
audit('message.filtered', {
|
|
138
|
+
channelType: 'bluebubbles',
|
|
139
|
+
senderId: senderAddress,
|
|
140
|
+
reason: 'address_not_allowed',
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const inbound = this.toInboundMessage(msg);
|
|
146
|
+
|
|
147
|
+
audit('message.received', {
|
|
148
|
+
channelType: 'bluebubbles',
|
|
149
|
+
senderId: inbound.senderId,
|
|
150
|
+
channelId: inbound.channelId,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (this.messageHandler) {
|
|
154
|
+
try {
|
|
155
|
+
await this.messageHandler(inbound);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private toInboundMessage(msg: BlueBubblesMessage): InboundMessage {
|
|
163
|
+
const chatGuid = msg.chats?.[0]?.guid || msg.handle?.address || 'unknown';
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
id: msg.guid,
|
|
167
|
+
channelType: 'bluebubbles',
|
|
168
|
+
channelId: chatGuid,
|
|
169
|
+
senderId: msg.handle?.address || 'unknown',
|
|
170
|
+
senderName: msg.handle?.uncanonicalizedId || msg.handle?.address,
|
|
171
|
+
content: msg.text,
|
|
172
|
+
timestamp: msg.dateCreated,
|
|
173
|
+
replyToId: msg.threadOriginatorGuid,
|
|
174
|
+
attachments: msg.attachments?.map((a) => ({
|
|
175
|
+
type: a.mimeType.startsWith('image/')
|
|
176
|
+
? ('image' as const)
|
|
177
|
+
: a.mimeType.startsWith('audio/')
|
|
178
|
+
? ('audio' as const)
|
|
179
|
+
: a.mimeType.startsWith('video/')
|
|
180
|
+
? ('video' as const)
|
|
181
|
+
: ('file' as const),
|
|
182
|
+
mimeType: a.mimeType,
|
|
183
|
+
filename: a.transferName,
|
|
184
|
+
size: a.totalBytes,
|
|
185
|
+
})),
|
|
186
|
+
raw: msg,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
|
|
191
|
+
try {
|
|
192
|
+
const chunks = this.chunkMessage(message.content);
|
|
193
|
+
let lastGuid: string | undefined;
|
|
194
|
+
|
|
195
|
+
for (const chunk of chunks) {
|
|
196
|
+
const body: Record<string, unknown> = {
|
|
197
|
+
chatGuid: channelId,
|
|
198
|
+
message: chunk,
|
|
199
|
+
method: 'private-api',
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (message.replyToId) {
|
|
203
|
+
body.selectedMessageGuid = message.replyToId;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const result = await this.apiRequest<BlueBubblesSendResponse>(
|
|
207
|
+
'POST',
|
|
208
|
+
'/api/v1/message/text',
|
|
209
|
+
body,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
if (result.status !== 200) {
|
|
213
|
+
throw new Error(`BlueBubbles send error: ${result.message}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
lastGuid = result.data?.guid;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
audit('message.sent', {
|
|
220
|
+
channelType: 'bluebubbles',
|
|
221
|
+
channelId,
|
|
222
|
+
messageId: lastGuid,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return { success: true, messageId: lastGuid };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
228
|
+
audit('channel.error', {
|
|
229
|
+
channelType: 'bluebubbles',
|
|
230
|
+
action: 'send',
|
|
231
|
+
error: errorMessage,
|
|
232
|
+
});
|
|
233
|
+
return { success: false, error: errorMessage };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private chunkMessage(content: string): string[] {
|
|
238
|
+
if (content.length <= MAX_MESSAGE_LENGTH) {
|
|
239
|
+
return [content];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const chunks: string[] = [];
|
|
243
|
+
let remaining = content;
|
|
244
|
+
|
|
245
|
+
while (remaining.length > 0) {
|
|
246
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
247
|
+
chunks.push(remaining);
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
|
|
252
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
253
|
+
breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
|
|
254
|
+
}
|
|
255
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
256
|
+
breakPoint = MAX_MESSAGE_LENGTH;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
260
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return chunks;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async startTyping(channelId: string): Promise<() => void> {
|
|
267
|
+
// BlueBubbles supports typing indicators via the private API
|
|
268
|
+
this.apiRequest('POST', '/api/v1/chat/typing', {
|
|
269
|
+
chatGuid: channelId,
|
|
270
|
+
status: 'typing',
|
|
271
|
+
}).catch((e: Error) => {
|
|
272
|
+
audit('channel.error', {
|
|
273
|
+
channelType: 'bluebubbles',
|
|
274
|
+
action: 'typing',
|
|
275
|
+
error: e.message,
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return () => {
|
|
280
|
+
this.apiRequest('POST', '/api/v1/chat/typing', {
|
|
281
|
+
chatGuid: channelId,
|
|
282
|
+
status: 'idle',
|
|
283
|
+
}).catch(() => {});
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
onMessage(handler: (message: InboundMessage) => Promise<void>): void {
|
|
288
|
+
this.messageHandler = handler;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
onError(handler: (error: Error) => void): void {
|
|
292
|
+
this.errorHandler = handler;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Client,
|
|
3
|
+
GatewayIntentBits,
|
|
4
|
+
Partials,
|
|
5
|
+
Message as DiscordMessage,
|
|
6
|
+
ChannelType as DiscordChannelType,
|
|
7
|
+
} from 'discord.js';
|
|
8
|
+
import { audit } from '@auxiora/audit';
|
|
9
|
+
import type {
|
|
10
|
+
ChannelAdapter,
|
|
11
|
+
InboundMessage,
|
|
12
|
+
OutboundMessage,
|
|
13
|
+
SendResult,
|
|
14
|
+
} from '../types.js';
|
|
15
|
+
|
|
16
|
+
export interface DiscordAdapterConfig {
|
|
17
|
+
token: string;
|
|
18
|
+
mentionOnly?: boolean;
|
|
19
|
+
allowedGuilds?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MAX_MESSAGE_LENGTH = 2000;
|
|
23
|
+
|
|
24
|
+
export class DiscordAdapter implements ChannelAdapter {
|
|
25
|
+
readonly type = 'discord' as const;
|
|
26
|
+
readonly name = 'Discord';
|
|
27
|
+
|
|
28
|
+
private client: Client;
|
|
29
|
+
private config: DiscordAdapterConfig;
|
|
30
|
+
private messageHandler?: (message: InboundMessage) => Promise<void>;
|
|
31
|
+
private errorHandler?: (error: Error) => void;
|
|
32
|
+
private connected = false;
|
|
33
|
+
|
|
34
|
+
constructor(config: DiscordAdapterConfig) {
|
|
35
|
+
this.config = config;
|
|
36
|
+
this.client = new Client({
|
|
37
|
+
intents: [
|
|
38
|
+
GatewayIntentBits.Guilds,
|
|
39
|
+
GatewayIntentBits.GuildMessages,
|
|
40
|
+
GatewayIntentBits.DirectMessages,
|
|
41
|
+
GatewayIntentBits.MessageContent,
|
|
42
|
+
],
|
|
43
|
+
partials: [Partials.Channel, Partials.Message],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
this.setupEventHandlers();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private setupEventHandlers(): void {
|
|
50
|
+
this.client.on('clientReady', () => {
|
|
51
|
+
audit('channel.connected', {
|
|
52
|
+
channelType: 'discord',
|
|
53
|
+
username: this.client.user?.tag,
|
|
54
|
+
});
|
|
55
|
+
this.connected = true;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
this.client.on('messageCreate', async (message: DiscordMessage) => {
|
|
59
|
+
// Ignore bot messages
|
|
60
|
+
if (message.author.bot) return;
|
|
61
|
+
|
|
62
|
+
// Check guild allowlist
|
|
63
|
+
if (
|
|
64
|
+
message.guild &&
|
|
65
|
+
this.config.allowedGuilds?.length &&
|
|
66
|
+
!this.config.allowedGuilds.includes(message.guild.id)
|
|
67
|
+
) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check mention requirement for guild messages
|
|
72
|
+
if (
|
|
73
|
+
this.config.mentionOnly &&
|
|
74
|
+
message.guild &&
|
|
75
|
+
!message.mentions.has(this.client.user!.id)
|
|
76
|
+
) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Convert to inbound message
|
|
81
|
+
const inbound = this.toInboundMessage(message);
|
|
82
|
+
|
|
83
|
+
audit('message.received', {
|
|
84
|
+
channelType: 'discord',
|
|
85
|
+
senderId: inbound.senderId,
|
|
86
|
+
channelId: inbound.channelId,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (this.messageHandler) {
|
|
90
|
+
try {
|
|
91
|
+
await this.messageHandler(inbound);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
this.client.on('error', (error) => {
|
|
99
|
+
audit('channel.error', { channelType: 'discord', error: error.message });
|
|
100
|
+
this.errorHandler?.(error);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
this.client.on('disconnect', () => {
|
|
104
|
+
this.connected = false;
|
|
105
|
+
audit('channel.disconnected', { channelType: 'discord' });
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private toInboundMessage(message: DiscordMessage): InboundMessage {
|
|
110
|
+
// Strip bot mention from content
|
|
111
|
+
let content = message.content;
|
|
112
|
+
if (this.client.user) {
|
|
113
|
+
content = content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
id: message.id,
|
|
118
|
+
channelType: 'discord',
|
|
119
|
+
channelId: message.channel.id,
|
|
120
|
+
senderId: message.author.id,
|
|
121
|
+
senderName: message.author.displayName || message.author.username,
|
|
122
|
+
content,
|
|
123
|
+
timestamp: message.createdTimestamp,
|
|
124
|
+
replyToId: message.reference?.messageId,
|
|
125
|
+
attachments: message.attachments.map((a) => ({
|
|
126
|
+
type: a.contentType?.startsWith('image/')
|
|
127
|
+
? 'image'
|
|
128
|
+
: a.contentType?.startsWith('audio/')
|
|
129
|
+
? 'audio'
|
|
130
|
+
: a.contentType?.startsWith('video/')
|
|
131
|
+
? 'video'
|
|
132
|
+
: 'file',
|
|
133
|
+
url: a.url,
|
|
134
|
+
mimeType: a.contentType || undefined,
|
|
135
|
+
filename: a.name,
|
|
136
|
+
size: a.size,
|
|
137
|
+
})),
|
|
138
|
+
raw: message,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async connect(): Promise<void> {
|
|
143
|
+
await this.client.login(this.config.token);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async disconnect(): Promise<void> {
|
|
147
|
+
this.client.destroy();
|
|
148
|
+
this.connected = false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
isConnected(): boolean {
|
|
152
|
+
return this.connected;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
|
|
156
|
+
try {
|
|
157
|
+
const channel = await this.client.channels.fetch(channelId);
|
|
158
|
+
|
|
159
|
+
if (!channel || !channel.isTextBased() || !('send' in channel)) {
|
|
160
|
+
return { success: false, error: 'Channel not found or not text-based' };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Chunk long messages
|
|
164
|
+
const chunks = this.chunkMessage(message.content);
|
|
165
|
+
let lastMessageId: string | undefined;
|
|
166
|
+
|
|
167
|
+
for (const chunk of chunks) {
|
|
168
|
+
const sent = await channel.send({
|
|
169
|
+
content: chunk,
|
|
170
|
+
reply: message.replyToId
|
|
171
|
+
? { messageReference: message.replyToId }
|
|
172
|
+
: undefined,
|
|
173
|
+
});
|
|
174
|
+
lastMessageId = sent.id;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
audit('message.sent', {
|
|
178
|
+
channelType: 'discord',
|
|
179
|
+
channelId,
|
|
180
|
+
messageId: lastMessageId,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return { success: true, messageId: lastMessageId };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
186
|
+
audit('channel.error', {
|
|
187
|
+
channelType: 'discord',
|
|
188
|
+
action: 'send',
|
|
189
|
+
error: errorMessage,
|
|
190
|
+
});
|
|
191
|
+
return { success: false, error: errorMessage };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private chunkMessage(content: string): string[] {
|
|
196
|
+
if (content.length <= MAX_MESSAGE_LENGTH) {
|
|
197
|
+
return [content];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const chunks: string[] = [];
|
|
201
|
+
let remaining = content;
|
|
202
|
+
|
|
203
|
+
while (remaining.length > 0) {
|
|
204
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
205
|
+
chunks.push(remaining);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Find a good break point
|
|
210
|
+
let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
|
|
211
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
212
|
+
breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
|
|
213
|
+
}
|
|
214
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
215
|
+
breakPoint = MAX_MESSAGE_LENGTH;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
219
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return chunks;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async startTyping(channelId: string): Promise<() => void> {
|
|
226
|
+
// Use cache (populated by messageCreate) to avoid extra API call
|
|
227
|
+
const channel = this.client.channels.cache.get(channelId);
|
|
228
|
+
if (!channel || !channel.isTextBased() || !('sendTyping' in channel)) {
|
|
229
|
+
return () => {};
|
|
230
|
+
}
|
|
231
|
+
// Send immediately, then repeat every 8s (Discord typing expires after ~10s)
|
|
232
|
+
let stopped = false;
|
|
233
|
+
channel.sendTyping().catch((e: Error) => {
|
|
234
|
+
audit('channel.error', { channelType: 'discord', action: 'typing', error: e.message });
|
|
235
|
+
});
|
|
236
|
+
const interval = setInterval(() => {
|
|
237
|
+
if (stopped) return;
|
|
238
|
+
channel.sendTyping().catch(() => {});
|
|
239
|
+
}, 8000);
|
|
240
|
+
return () => {
|
|
241
|
+
stopped = true;
|
|
242
|
+
clearInterval(interval);
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
onMessage(handler: (message: InboundMessage) => Promise<void>): void {
|
|
247
|
+
this.messageHandler = handler;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
onError(handler: (error: Error) => void): void {
|
|
251
|
+
this.errorHandler = handler;
|
|
252
|
+
}
|
|
253
|
+
}
|