@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,252 @@
|
|
|
1
|
+
import { App, LogLevel } from '@slack/bolt';
|
|
2
|
+
import { audit } from '@auxiora/audit';
|
|
3
|
+
import type {
|
|
4
|
+
ChannelAdapter,
|
|
5
|
+
InboundMessage,
|
|
6
|
+
OutboundMessage,
|
|
7
|
+
SendResult,
|
|
8
|
+
} from '../types.js';
|
|
9
|
+
|
|
10
|
+
export interface SlackAdapterConfig {
|
|
11
|
+
botToken: string;
|
|
12
|
+
appToken: string;
|
|
13
|
+
signingSecret?: string;
|
|
14
|
+
allowedChannels?: string[];
|
|
15
|
+
allowedUsers?: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const MAX_MESSAGE_LENGTH = 40000;
|
|
19
|
+
|
|
20
|
+
export class SlackAdapter implements ChannelAdapter {
|
|
21
|
+
readonly type = 'slack' as const;
|
|
22
|
+
readonly name = 'Slack';
|
|
23
|
+
|
|
24
|
+
private app: App;
|
|
25
|
+
private config: SlackAdapterConfig;
|
|
26
|
+
private messageHandler?: (message: InboundMessage) => Promise<void>;
|
|
27
|
+
private errorHandler?: (error: Error) => void;
|
|
28
|
+
private connected = false;
|
|
29
|
+
private botUserId?: string;
|
|
30
|
+
|
|
31
|
+
constructor(config: SlackAdapterConfig) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.app = new App({
|
|
34
|
+
token: config.botToken,
|
|
35
|
+
appToken: config.appToken,
|
|
36
|
+
socketMode: true,
|
|
37
|
+
logLevel: LogLevel.WARN,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.setupEventHandlers();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private setupEventHandlers(): void {
|
|
44
|
+
// Listen to all messages
|
|
45
|
+
this.app.message(async ({ message, say, client }) => {
|
|
46
|
+
// Type guard for regular messages
|
|
47
|
+
if (!('user' in message) || !('text' in message)) return;
|
|
48
|
+
if (message.subtype) return; // Ignore edited, deleted, etc.
|
|
49
|
+
|
|
50
|
+
// Get bot user ID if we don't have it
|
|
51
|
+
if (!this.botUserId) {
|
|
52
|
+
const auth = await client.auth.test();
|
|
53
|
+
this.botUserId = auth.user_id;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Ignore bot's own messages
|
|
57
|
+
if (message.user === this.botUserId) return;
|
|
58
|
+
|
|
59
|
+
// Check allowed channels
|
|
60
|
+
if (this.config.allowedChannels?.length && message.channel && !this.config.allowedChannels.includes(message.channel as string)) {
|
|
61
|
+
audit('message.filtered', { channelType: 'slack', senderId: message.user as string, channelId: message.channel as string, reason: 'channel_not_allowed' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check allowed users
|
|
66
|
+
if (this.config.allowedUsers?.length && !this.config.allowedUsers.includes(message.user as string)) {
|
|
67
|
+
audit('message.filtered', { channelType: 'slack', senderId: message.user as string, channelId: message.channel as string, reason: 'user_not_allowed' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const inbound = this.toInboundMessage(message);
|
|
72
|
+
|
|
73
|
+
audit('message.received', {
|
|
74
|
+
channelType: 'slack',
|
|
75
|
+
senderId: inbound.senderId,
|
|
76
|
+
channelId: inbound.channelId,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (this.messageHandler) {
|
|
80
|
+
try {
|
|
81
|
+
await this.messageHandler(inbound);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Handle mentions specifically
|
|
89
|
+
this.app.event('app_mention', async ({ event, client }) => {
|
|
90
|
+
// Get bot user ID if we don't have it
|
|
91
|
+
if (!this.botUserId) {
|
|
92
|
+
const auth = await client.auth.test();
|
|
93
|
+
this.botUserId = auth.user_id;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check allowed channels
|
|
97
|
+
if (this.config.allowedChannels?.length && event.channel && !this.config.allowedChannels.includes(event.channel)) {
|
|
98
|
+
audit('message.filtered', { channelType: 'slack', senderId: event.user || '', channelId: event.channel, reason: 'channel_not_allowed' });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Check allowed users
|
|
103
|
+
if (this.config.allowedUsers?.length && event.user && !this.config.allowedUsers.includes(event.user)) {
|
|
104
|
+
audit('message.filtered', { channelType: 'slack', senderId: event.user, channelId: event.channel || '', reason: 'user_not_allowed' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Strip bot mention from text
|
|
109
|
+
let content = event.text || '';
|
|
110
|
+
if (this.botUserId) {
|
|
111
|
+
content = content.replace(new RegExp(`<@${this.botUserId}>`, 'g'), '').trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const inbound: InboundMessage = {
|
|
115
|
+
id: event.ts,
|
|
116
|
+
channelType: 'slack',
|
|
117
|
+
channelId: event.channel || '',
|
|
118
|
+
senderId: event.user || '',
|
|
119
|
+
content,
|
|
120
|
+
timestamp: parseFloat(event.ts) * 1000,
|
|
121
|
+
replyToId: event.thread_ts,
|
|
122
|
+
raw: event,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
audit('message.received', {
|
|
126
|
+
channelType: 'slack',
|
|
127
|
+
senderId: inbound.senderId,
|
|
128
|
+
channelId: inbound.channelId,
|
|
129
|
+
isMention: true,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (this.messageHandler) {
|
|
133
|
+
try {
|
|
134
|
+
await this.messageHandler(inbound);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
this.app.error(async (error) => {
|
|
142
|
+
audit('channel.error', { channelType: 'slack', error: error.message });
|
|
143
|
+
this.errorHandler?.(error);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private toInboundMessage(message: {
|
|
148
|
+
ts: string;
|
|
149
|
+
channel?: string;
|
|
150
|
+
user?: string;
|
|
151
|
+
text?: string;
|
|
152
|
+
thread_ts?: string;
|
|
153
|
+
}): InboundMessage {
|
|
154
|
+
return {
|
|
155
|
+
id: message.ts,
|
|
156
|
+
channelType: 'slack',
|
|
157
|
+
channelId: message.channel || '',
|
|
158
|
+
senderId: message.user || '',
|
|
159
|
+
content: message.text || '',
|
|
160
|
+
timestamp: parseFloat(message.ts) * 1000,
|
|
161
|
+
replyToId: message.thread_ts,
|
|
162
|
+
raw: message,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async connect(): Promise<void> {
|
|
167
|
+
await this.app.start();
|
|
168
|
+
this.connected = true;
|
|
169
|
+
audit('channel.connected', { channelType: 'slack' });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async disconnect(): Promise<void> {
|
|
173
|
+
await this.app.stop();
|
|
174
|
+
this.connected = false;
|
|
175
|
+
audit('channel.disconnected', { channelType: 'slack' });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
isConnected(): boolean {
|
|
179
|
+
return this.connected;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
|
|
183
|
+
try {
|
|
184
|
+
// Chunk long messages
|
|
185
|
+
const chunks = this.chunkMessage(message.content);
|
|
186
|
+
let lastTs: string | undefined;
|
|
187
|
+
|
|
188
|
+
for (const chunk of chunks) {
|
|
189
|
+
const result = await this.app.client.chat.postMessage({
|
|
190
|
+
channel: channelId,
|
|
191
|
+
text: chunk,
|
|
192
|
+
thread_ts: message.replyToId,
|
|
193
|
+
mrkdwn: message.formatting?.markdown !== false,
|
|
194
|
+
});
|
|
195
|
+
lastTs = result.ts;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
audit('message.sent', {
|
|
199
|
+
channelType: 'slack',
|
|
200
|
+
channelId,
|
|
201
|
+
messageId: lastTs,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return { success: true, messageId: lastTs };
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
207
|
+
audit('channel.error', {
|
|
208
|
+
channelType: 'slack',
|
|
209
|
+
action: 'send',
|
|
210
|
+
error: errorMessage,
|
|
211
|
+
});
|
|
212
|
+
return { success: false, error: errorMessage };
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private chunkMessage(content: string): string[] {
|
|
217
|
+
if (content.length <= MAX_MESSAGE_LENGTH) {
|
|
218
|
+
return [content];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const chunks: string[] = [];
|
|
222
|
+
let remaining = content;
|
|
223
|
+
|
|
224
|
+
while (remaining.length > 0) {
|
|
225
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
226
|
+
chunks.push(remaining);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
|
|
231
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
232
|
+
breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
|
|
233
|
+
}
|
|
234
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
235
|
+
breakPoint = MAX_MESSAGE_LENGTH;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
239
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return chunks;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
onMessage(handler: (message: InboundMessage) => Promise<void>): void {
|
|
246
|
+
this.messageHandler = handler;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
onError(handler: (error: Error) => void): void {
|
|
250
|
+
this.errorHandler = handler;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
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 TeamsAdapterConfig {
|
|
10
|
+
microsoftAppId: string;
|
|
11
|
+
microsoftAppPassword: string;
|
|
12
|
+
allowedTenants?: string[];
|
|
13
|
+
allowedUsers?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface TeamsActivity {
|
|
17
|
+
type: string;
|
|
18
|
+
id: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
channelId: string;
|
|
21
|
+
from: {
|
|
22
|
+
id: string;
|
|
23
|
+
name?: string;
|
|
24
|
+
aadObjectId?: string;
|
|
25
|
+
};
|
|
26
|
+
conversation: {
|
|
27
|
+
id: string;
|
|
28
|
+
conversationType?: string;
|
|
29
|
+
tenantId?: string;
|
|
30
|
+
isGroup?: boolean;
|
|
31
|
+
};
|
|
32
|
+
recipient?: {
|
|
33
|
+
id: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
};
|
|
36
|
+
text?: string;
|
|
37
|
+
serviceUrl: string;
|
|
38
|
+
channelData?: Record<string, unknown>;
|
|
39
|
+
replyToId?: string;
|
|
40
|
+
attachments?: Array<{
|
|
41
|
+
contentType: string;
|
|
42
|
+
contentUrl?: string;
|
|
43
|
+
name?: string;
|
|
44
|
+
content?: unknown;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TeamsTokenResponse {
|
|
49
|
+
access_token: string;
|
|
50
|
+
expires_in: number;
|
|
51
|
+
token_type: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface TeamsSendResponse {
|
|
55
|
+
id: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MAX_MESSAGE_LENGTH = 28000;
|
|
59
|
+
const TOKEN_URL = 'https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token';
|
|
60
|
+
|
|
61
|
+
export class TeamsAdapter implements ChannelAdapter {
|
|
62
|
+
readonly type = 'teams' as const;
|
|
63
|
+
readonly name = 'Microsoft Teams';
|
|
64
|
+
|
|
65
|
+
private config: TeamsAdapterConfig;
|
|
66
|
+
private messageHandler?: (message: InboundMessage) => Promise<void>;
|
|
67
|
+
private errorHandler?: (error: Error) => void;
|
|
68
|
+
private connected = false;
|
|
69
|
+
private accessToken?: string;
|
|
70
|
+
private tokenExpiry = 0;
|
|
71
|
+
|
|
72
|
+
constructor(config: TeamsAdapterConfig) {
|
|
73
|
+
this.config = config;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async connect(): Promise<void> {
|
|
77
|
+
// Verify credentials by obtaining a token
|
|
78
|
+
await this.getAccessToken();
|
|
79
|
+
this.connected = true;
|
|
80
|
+
|
|
81
|
+
audit('channel.connected', {
|
|
82
|
+
channelType: 'teams',
|
|
83
|
+
appId: this.config.microsoftAppId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async disconnect(): Promise<void> {
|
|
88
|
+
this.connected = false;
|
|
89
|
+
this.accessToken = undefined;
|
|
90
|
+
this.tokenExpiry = 0;
|
|
91
|
+
audit('channel.disconnected', { channelType: 'teams' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
isConnected(): boolean {
|
|
95
|
+
return this.connected;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async getAccessToken(): Promise<string> {
|
|
99
|
+
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
100
|
+
return this.accessToken;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const params = new URLSearchParams({
|
|
104
|
+
grant_type: 'client_credentials',
|
|
105
|
+
client_id: this.config.microsoftAppId,
|
|
106
|
+
client_secret: this.config.microsoftAppPassword,
|
|
107
|
+
scope: 'https://api.botframework.com/.default',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const response = await fetch(TOKEN_URL, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
113
|
+
body: params.toString(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!response.ok) {
|
|
117
|
+
throw new Error(`Failed to obtain Teams token: ${response.status} ${response.statusText}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const data = await response.json() as TeamsTokenResponse;
|
|
121
|
+
this.accessToken = data.access_token;
|
|
122
|
+
// Expire 5 minutes early to avoid edge cases
|
|
123
|
+
this.tokenExpiry = Date.now() + (data.expires_in - 300) * 1000;
|
|
124
|
+
|
|
125
|
+
return this.accessToken;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Handle incoming webhook from Microsoft Bot Framework.
|
|
130
|
+
* Call this from your HTTP server when receiving POST to /api/messages.
|
|
131
|
+
*/
|
|
132
|
+
async handleWebhook(activity: TeamsActivity): Promise<void> {
|
|
133
|
+
if (activity.type !== 'message') return;
|
|
134
|
+
if (!activity.text) return;
|
|
135
|
+
|
|
136
|
+
// Check allowed tenants
|
|
137
|
+
const tenantId = activity.conversation.tenantId;
|
|
138
|
+
if (tenantId && this.config.allowedTenants?.length && !this.config.allowedTenants.includes(tenantId)) {
|
|
139
|
+
audit('message.filtered', { channelType: 'teams', senderId: activity.from.id, tenantId, reason: 'tenant_not_allowed' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check allowed users
|
|
144
|
+
if (this.config.allowedUsers?.length && !this.config.allowedUsers.includes(activity.from.id)) {
|
|
145
|
+
audit('message.filtered', { channelType: 'teams', senderId: activity.from.id, reason: 'user_not_allowed' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const inbound = this.toInboundMessage(activity);
|
|
150
|
+
|
|
151
|
+
audit('message.received', {
|
|
152
|
+
channelType: 'teams',
|
|
153
|
+
senderId: inbound.senderId,
|
|
154
|
+
channelId: inbound.channelId,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (this.messageHandler) {
|
|
158
|
+
try {
|
|
159
|
+
await this.messageHandler(inbound);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
this.errorHandler?.(error instanceof Error ? error : new Error(String(error)));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private toInboundMessage(activity: TeamsActivity): InboundMessage {
|
|
167
|
+
// Strip bot mention from content
|
|
168
|
+
let content = activity.text || '';
|
|
169
|
+
if (activity.recipient?.name) {
|
|
170
|
+
content = content.replace(
|
|
171
|
+
new RegExp(`<at>${activity.recipient.name}</at>`, 'gi'),
|
|
172
|
+
'',
|
|
173
|
+
).trim();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
id: activity.id,
|
|
178
|
+
channelType: 'teams',
|
|
179
|
+
channelId: activity.conversation.id,
|
|
180
|
+
senderId: activity.from.id,
|
|
181
|
+
senderName: activity.from.name,
|
|
182
|
+
content,
|
|
183
|
+
timestamp: new Date(activity.timestamp).getTime(),
|
|
184
|
+
replyToId: activity.replyToId,
|
|
185
|
+
attachments: activity.attachments?.map((a) => ({
|
|
186
|
+
type: a.contentType.startsWith('image/')
|
|
187
|
+
? 'image' as const
|
|
188
|
+
: 'file' as const,
|
|
189
|
+
url: a.contentUrl,
|
|
190
|
+
mimeType: a.contentType,
|
|
191
|
+
filename: a.name,
|
|
192
|
+
})),
|
|
193
|
+
raw: activity,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async send(channelId: string, message: OutboundMessage): Promise<SendResult> {
|
|
198
|
+
try {
|
|
199
|
+
const token = await this.getAccessToken();
|
|
200
|
+
const chunks = this.chunkMessage(message.content);
|
|
201
|
+
let lastMessageId: string | undefined;
|
|
202
|
+
|
|
203
|
+
// We need the serviceUrl from the incoming activity
|
|
204
|
+
// For now, use the default Bot Framework service URL
|
|
205
|
+
const serviceUrl = 'https://smba.trafficmanager.net/teams/';
|
|
206
|
+
|
|
207
|
+
for (const chunk of chunks) {
|
|
208
|
+
const activity = {
|
|
209
|
+
type: 'message',
|
|
210
|
+
text: chunk,
|
|
211
|
+
...(message.replyToId ? { replyToId: message.replyToId } : {}),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const url = `${serviceUrl}v3/conversations/${encodeURIComponent(channelId)}/activities`;
|
|
215
|
+
const response = await fetch(url, {
|
|
216
|
+
method: 'POST',
|
|
217
|
+
headers: {
|
|
218
|
+
'Authorization': `Bearer ${token}`,
|
|
219
|
+
'Content-Type': 'application/json',
|
|
220
|
+
},
|
|
221
|
+
body: JSON.stringify(activity),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const errorText = await response.text().catch(() => response.statusText);
|
|
226
|
+
throw new Error(`Teams API error ${response.status}: ${errorText}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const result = await response.json() as TeamsSendResponse;
|
|
230
|
+
lastMessageId = result.id;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
audit('message.sent', {
|
|
234
|
+
channelType: 'teams',
|
|
235
|
+
channelId,
|
|
236
|
+
messageId: lastMessageId,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
return { success: true, messageId: lastMessageId };
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
242
|
+
audit('channel.error', {
|
|
243
|
+
channelType: 'teams',
|
|
244
|
+
action: 'send',
|
|
245
|
+
error: errorMessage,
|
|
246
|
+
});
|
|
247
|
+
return { success: false, error: errorMessage };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private chunkMessage(content: string): string[] {
|
|
252
|
+
if (content.length <= MAX_MESSAGE_LENGTH) {
|
|
253
|
+
return [content];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const chunks: string[] = [];
|
|
257
|
+
let remaining = content;
|
|
258
|
+
|
|
259
|
+
while (remaining.length > 0) {
|
|
260
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
261
|
+
chunks.push(remaining);
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let breakPoint = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
|
|
266
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
267
|
+
breakPoint = remaining.lastIndexOf(' ', MAX_MESSAGE_LENGTH);
|
|
268
|
+
}
|
|
269
|
+
if (breakPoint === -1 || breakPoint < MAX_MESSAGE_LENGTH / 2) {
|
|
270
|
+
breakPoint = MAX_MESSAGE_LENGTH;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
chunks.push(remaining.slice(0, breakPoint));
|
|
274
|
+
remaining = remaining.slice(breakPoint).trimStart();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return chunks;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async startTyping(channelId: string): Promise<() => void> {
|
|
281
|
+
let token: string;
|
|
282
|
+
try {
|
|
283
|
+
token = await this.getAccessToken();
|
|
284
|
+
} catch {
|
|
285
|
+
return () => {};
|
|
286
|
+
}
|
|
287
|
+
const serviceUrl = 'https://smba.trafficmanager.net/teams/';
|
|
288
|
+
const url = `${serviceUrl}v3/conversations/${encodeURIComponent(channelId)}/activities`;
|
|
289
|
+
|
|
290
|
+
// Send typing activity immediately, repeat every 3s
|
|
291
|
+
let stopped = false;
|
|
292
|
+
const sendTyping = () =>
|
|
293
|
+
fetch(url, {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: {
|
|
296
|
+
'Authorization': `Bearer ${token}`,
|
|
297
|
+
'Content-Type': 'application/json',
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({ type: 'typing' }),
|
|
300
|
+
}).catch(() => {});
|
|
301
|
+
|
|
302
|
+
sendTyping();
|
|
303
|
+
const interval = setInterval(() => {
|
|
304
|
+
if (stopped) return;
|
|
305
|
+
sendTyping();
|
|
306
|
+
}, 3000);
|
|
307
|
+
return () => {
|
|
308
|
+
stopped = true;
|
|
309
|
+
clearInterval(interval);
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
onMessage(handler: (message: InboundMessage) => Promise<void>): void {
|
|
314
|
+
this.messageHandler = handler;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
onError(handler: (error: Error) => void): void {
|
|
318
|
+
this.errorHandler = handler;
|
|
319
|
+
}
|
|
320
|
+
}
|