@elizaos/plugin-twitch 2.0.0-alpha.3
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/__tests__/integration.test.ts +889 -0
- package/build.ts +17 -0
- package/dist/index.js +1021 -0
- package/package.json +35 -0
- package/src/actions/joinChannel.ts +147 -0
- package/src/actions/leaveChannel.ts +172 -0
- package/src/actions/listChannels.ts +100 -0
- package/src/actions/sendMessage.ts +177 -0
- package/src/index.ts +117 -0
- package/src/providers/channelState.ts +98 -0
- package/src/providers/userContext.ts +117 -0
- package/src/service.ts +488 -0
- package/src/types.ts +340 -0
- package/tsconfig.json +21 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Twitch service implementation for ElizaOS.
|
|
3
|
+
*
|
|
4
|
+
* This service provides Twitch chat integration using the @twurple library.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type EventPayload,
|
|
9
|
+
type IAgentRuntime,
|
|
10
|
+
logger,
|
|
11
|
+
Service,
|
|
12
|
+
} from "@elizaos/core";
|
|
13
|
+
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
|
14
|
+
import { ChatClient, type ChatMessage } from "@twurple/chat";
|
|
15
|
+
import {
|
|
16
|
+
type ITwitchService,
|
|
17
|
+
normalizeChannel,
|
|
18
|
+
splitMessageForTwitch,
|
|
19
|
+
stripMarkdownForTwitch,
|
|
20
|
+
TWITCH_SERVICE_NAME,
|
|
21
|
+
TwitchConfigurationError,
|
|
22
|
+
TwitchEventTypes,
|
|
23
|
+
type TwitchMessage,
|
|
24
|
+
type TwitchMessageSendOptions,
|
|
25
|
+
TwitchNotConnectedError,
|
|
26
|
+
type TwitchRole,
|
|
27
|
+
type TwitchSendResult,
|
|
28
|
+
type TwitchSettings,
|
|
29
|
+
type TwitchUserInfo,
|
|
30
|
+
} from "./types.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Twitch chat service for ElizaOS agents.
|
|
34
|
+
*/
|
|
35
|
+
export class TwitchService extends Service implements ITwitchService {
|
|
36
|
+
static serviceType: string = TWITCH_SERVICE_NAME;
|
|
37
|
+
capabilityDescription =
|
|
38
|
+
"Provides Twitch chat integration for sending and receiving messages";
|
|
39
|
+
|
|
40
|
+
private settings!: TwitchSettings;
|
|
41
|
+
private client!: ChatClient;
|
|
42
|
+
private connected: boolean = false;
|
|
43
|
+
private joinedChannels: Set<string> = new Set();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start the Twitch service.
|
|
47
|
+
*/
|
|
48
|
+
static async start(runtime: IAgentRuntime): Promise<TwitchService> {
|
|
49
|
+
const service = new TwitchService();
|
|
50
|
+
await service.initialize(runtime);
|
|
51
|
+
return service;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stop the Twitch service.
|
|
56
|
+
*/
|
|
57
|
+
static async stopRuntime(runtime: IAgentRuntime): Promise<void> {
|
|
58
|
+
const service = runtime.getService<TwitchService>(TWITCH_SERVICE_NAME);
|
|
59
|
+
if (service) {
|
|
60
|
+
await service.stop();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Initialize the Twitch service.
|
|
66
|
+
*/
|
|
67
|
+
private async initialize(runtime: IAgentRuntime): Promise<void> {
|
|
68
|
+
this.runtime = runtime;
|
|
69
|
+
|
|
70
|
+
// Load configuration
|
|
71
|
+
this.settings = this.loadSettings();
|
|
72
|
+
|
|
73
|
+
// Validate configuration
|
|
74
|
+
this.validateSettings();
|
|
75
|
+
|
|
76
|
+
// Create auth provider
|
|
77
|
+
const authProvider = await this.createAuthProvider();
|
|
78
|
+
|
|
79
|
+
// Create chat client
|
|
80
|
+
const allChannels = [
|
|
81
|
+
this.settings.channel,
|
|
82
|
+
...this.settings.additionalChannels,
|
|
83
|
+
].map(normalizeChannel);
|
|
84
|
+
|
|
85
|
+
this.client = new ChatClient({
|
|
86
|
+
authProvider,
|
|
87
|
+
channels: allChannels,
|
|
88
|
+
rejoinChannelsOnReconnect: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Set up event handlers
|
|
92
|
+
this.setupEventHandlers();
|
|
93
|
+
|
|
94
|
+
// Connect
|
|
95
|
+
await this.connect();
|
|
96
|
+
|
|
97
|
+
logger.info(
|
|
98
|
+
`Twitch service initialized for ${this.settings.username}, joined channels: ${allChannels.join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Load settings from runtime.
|
|
104
|
+
*/
|
|
105
|
+
private loadSettings(): TwitchSettings {
|
|
106
|
+
const username = this.runtime.getSetting("TWITCH_USERNAME");
|
|
107
|
+
const clientId = this.runtime.getSetting("TWITCH_CLIENT_ID");
|
|
108
|
+
const accessToken = this.runtime.getSetting("TWITCH_ACCESS_TOKEN");
|
|
109
|
+
const clientSecret = this.runtime.getSetting("TWITCH_CLIENT_SECRET");
|
|
110
|
+
const refreshToken = this.runtime.getSetting("TWITCH_REFRESH_TOKEN");
|
|
111
|
+
const channel = this.runtime.getSetting("TWITCH_CHANNEL");
|
|
112
|
+
const additionalChannelsStr = this.runtime.getSetting("TWITCH_CHANNELS");
|
|
113
|
+
const requireMentionStr = this.runtime.getSetting("TWITCH_REQUIRE_MENTION");
|
|
114
|
+
const allowedRolesStr = this.runtime.getSetting("TWITCH_ALLOWED_ROLES");
|
|
115
|
+
|
|
116
|
+
const additionalChannels =
|
|
117
|
+
typeof additionalChannelsStr === "string" && additionalChannelsStr
|
|
118
|
+
? additionalChannelsStr
|
|
119
|
+
.split(",")
|
|
120
|
+
.map((c: string) => c.trim())
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
: [];
|
|
123
|
+
|
|
124
|
+
const allowedRoles: TwitchRole[] =
|
|
125
|
+
typeof allowedRolesStr === "string" && allowedRolesStr
|
|
126
|
+
? (allowedRolesStr
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((r: string) => r.trim().toLowerCase()) as TwitchRole[])
|
|
129
|
+
: ["all"];
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
username: typeof username === "string" ? username : "",
|
|
133
|
+
clientId: typeof clientId === "string" ? clientId : "",
|
|
134
|
+
accessToken: typeof accessToken === "string" ? accessToken : "",
|
|
135
|
+
clientSecret: typeof clientSecret === "string" ? clientSecret : undefined,
|
|
136
|
+
refreshToken: typeof refreshToken === "string" ? refreshToken : undefined,
|
|
137
|
+
channel: typeof channel === "string" ? channel : "",
|
|
138
|
+
additionalChannels,
|
|
139
|
+
requireMention: requireMentionStr === "true",
|
|
140
|
+
allowedRoles,
|
|
141
|
+
allowedUserIds: [],
|
|
142
|
+
enabled: true,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validate the settings.
|
|
148
|
+
*/
|
|
149
|
+
private validateSettings(): void {
|
|
150
|
+
if (!this.settings.username) {
|
|
151
|
+
throw new TwitchConfigurationError(
|
|
152
|
+
"TWITCH_USERNAME is required",
|
|
153
|
+
"TWITCH_USERNAME",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (!this.settings.clientId) {
|
|
158
|
+
throw new TwitchConfigurationError(
|
|
159
|
+
"TWITCH_CLIENT_ID is required",
|
|
160
|
+
"TWITCH_CLIENT_ID",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (!this.settings.accessToken) {
|
|
165
|
+
throw new TwitchConfigurationError(
|
|
166
|
+
"TWITCH_ACCESS_TOKEN is required",
|
|
167
|
+
"TWITCH_ACCESS_TOKEN",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!this.settings.channel) {
|
|
172
|
+
throw new TwitchConfigurationError(
|
|
173
|
+
"TWITCH_CHANNEL is required",
|
|
174
|
+
"TWITCH_CHANNEL",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create the authentication provider.
|
|
181
|
+
*/
|
|
182
|
+
private async createAuthProvider(): Promise<
|
|
183
|
+
StaticAuthProvider | RefreshingAuthProvider
|
|
184
|
+
> {
|
|
185
|
+
const token = this.normalizeToken(this.settings.accessToken);
|
|
186
|
+
|
|
187
|
+
if (this.settings.clientSecret) {
|
|
188
|
+
const authProvider = new RefreshingAuthProvider({
|
|
189
|
+
clientId: this.settings.clientId,
|
|
190
|
+
clientSecret: this.settings.clientSecret,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await authProvider.addUserForToken({
|
|
194
|
+
accessToken: token,
|
|
195
|
+
refreshToken: this.settings.refreshToken || null,
|
|
196
|
+
expiresIn: null,
|
|
197
|
+
obtainmentTimestamp: Date.now(),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
authProvider.onRefresh((userId, newToken) => {
|
|
201
|
+
logger.info(
|
|
202
|
+
`Twitch token refreshed for user ${userId}, expires in ${newToken.expiresIn}s`,
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
authProvider.onRefreshFailure((userId, error) => {
|
|
207
|
+
logger.error(
|
|
208
|
+
`Twitch token refresh failed for user ${userId}: ${error.message}`,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
logger.info(`Using RefreshingAuthProvider for ${this.settings.username}`);
|
|
213
|
+
return authProvider;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
logger.info(`Using StaticAuthProvider for ${this.settings.username}`);
|
|
217
|
+
return new StaticAuthProvider(this.settings.clientId, token);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Normalize an OAuth token (remove oauth: prefix if present).
|
|
222
|
+
*/
|
|
223
|
+
private normalizeToken(token: string): string {
|
|
224
|
+
return token.startsWith("oauth:") ? token.slice(6) : token;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Set up event handlers for the chat client.
|
|
229
|
+
*/
|
|
230
|
+
private setupEventHandlers(): void {
|
|
231
|
+
// Connection events
|
|
232
|
+
this.client.onConnect(() => {
|
|
233
|
+
this.connected = true;
|
|
234
|
+
logger.info("Twitch chat connected");
|
|
235
|
+
this.runtime.emitEvent(TwitchEventTypes.CONNECTION_READY, {
|
|
236
|
+
runtime: this.runtime,
|
|
237
|
+
} as EventPayload);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
this.client.onDisconnect((_manually, reason) => {
|
|
241
|
+
this.connected = false;
|
|
242
|
+
logger.warn(`Twitch chat disconnected: ${reason || "unknown reason"}`);
|
|
243
|
+
this.runtime.emitEvent(TwitchEventTypes.CONNECTION_LOST, {
|
|
244
|
+
runtime: this.runtime,
|
|
245
|
+
reason,
|
|
246
|
+
} as EventPayload);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Channel events
|
|
250
|
+
this.client.onJoin((channel, user) => {
|
|
251
|
+
const normalized = normalizeChannel(channel);
|
|
252
|
+
if (user.toLowerCase() === this.settings.username.toLowerCase()) {
|
|
253
|
+
this.joinedChannels.add(normalized);
|
|
254
|
+
logger.info(`Joined Twitch channel: ${normalized}`);
|
|
255
|
+
this.runtime.emitEvent(TwitchEventTypes.JOIN_CHANNEL, {
|
|
256
|
+
runtime: this.runtime,
|
|
257
|
+
channel: normalized,
|
|
258
|
+
} as EventPayload);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
this.client.onPart((channel, user) => {
|
|
263
|
+
const normalized = normalizeChannel(channel);
|
|
264
|
+
if (user.toLowerCase() === this.settings.username.toLowerCase()) {
|
|
265
|
+
this.joinedChannels.delete(normalized);
|
|
266
|
+
logger.info(`Left Twitch channel: ${normalized}`);
|
|
267
|
+
this.runtime.emitEvent(TwitchEventTypes.LEAVE_CHANNEL, {
|
|
268
|
+
runtime: this.runtime,
|
|
269
|
+
channel: normalized,
|
|
270
|
+
} as EventPayload);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Message events
|
|
275
|
+
this.client.onMessage(
|
|
276
|
+
(channel: string, user: string, text: string, msg: ChatMessage) => {
|
|
277
|
+
this.handleMessage(channel, user, text, msg);
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Handle an incoming chat message.
|
|
284
|
+
*/
|
|
285
|
+
private handleMessage(
|
|
286
|
+
channel: string,
|
|
287
|
+
_user: string,
|
|
288
|
+
text: string,
|
|
289
|
+
msg: ChatMessage,
|
|
290
|
+
): void {
|
|
291
|
+
const normalizedChannel = normalizeChannel(channel);
|
|
292
|
+
|
|
293
|
+
// Ignore own messages
|
|
294
|
+
if (
|
|
295
|
+
msg.userInfo.userName.toLowerCase() ===
|
|
296
|
+
this.settings.username.toLowerCase()
|
|
297
|
+
) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const userInfo: TwitchUserInfo = {
|
|
302
|
+
userId: msg.userInfo.userId,
|
|
303
|
+
username: msg.userInfo.userName,
|
|
304
|
+
displayName: msg.userInfo.displayName,
|
|
305
|
+
isModerator: msg.userInfo.isMod,
|
|
306
|
+
isBroadcaster: msg.userInfo.isBroadcaster,
|
|
307
|
+
isVip: msg.userInfo.isVip,
|
|
308
|
+
isSubscriber: msg.userInfo.isSubscriber,
|
|
309
|
+
color: msg.userInfo.color,
|
|
310
|
+
badges: msg.userInfo.badges,
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// Check access control
|
|
314
|
+
if (!this.isUserAllowed(userInfo)) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Check mention requirement
|
|
319
|
+
if (this.settings.requireMention) {
|
|
320
|
+
const mentionPattern = new RegExp(`@${this.settings.username}\\b`, "i");
|
|
321
|
+
if (!mentionPattern.test(text)) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const message: TwitchMessage = {
|
|
327
|
+
id: msg.id,
|
|
328
|
+
channel: normalizedChannel,
|
|
329
|
+
text,
|
|
330
|
+
user: userInfo,
|
|
331
|
+
timestamp: new Date(),
|
|
332
|
+
isAction: msg.isCheer,
|
|
333
|
+
isHighlighted: msg.isHighlight,
|
|
334
|
+
replyTo: msg.parentMessageId
|
|
335
|
+
? {
|
|
336
|
+
messageId: msg.parentMessageId,
|
|
337
|
+
userId: msg.parentMessageUserId || "",
|
|
338
|
+
username: msg.parentMessageUserName || "",
|
|
339
|
+
text: msg.parentMessageText || "",
|
|
340
|
+
}
|
|
341
|
+
: undefined,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
logger.debug(
|
|
345
|
+
`Twitch message from ${userInfo.displayName} in #${normalizedChannel}: ${text.slice(0, 50)}...`,
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
this.runtime.emitEvent(TwitchEventTypes.MESSAGE_RECEIVED, {
|
|
349
|
+
runtime: this.runtime,
|
|
350
|
+
message,
|
|
351
|
+
} as EventPayload);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Connect to Twitch.
|
|
356
|
+
*/
|
|
357
|
+
private async connect(): Promise<void> {
|
|
358
|
+
await this.client.connect();
|
|
359
|
+
this.connected = true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Stop the service.
|
|
364
|
+
*/
|
|
365
|
+
async stop(): Promise<void> {
|
|
366
|
+
if (this.client) {
|
|
367
|
+
this.client.quit();
|
|
368
|
+
}
|
|
369
|
+
this.connected = false;
|
|
370
|
+
this.joinedChannels.clear();
|
|
371
|
+
logger.info("Twitch service stopped");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ============================================================================
|
|
375
|
+
// Public Interface
|
|
376
|
+
// ============================================================================
|
|
377
|
+
|
|
378
|
+
isConnected(): boolean {
|
|
379
|
+
return this.connected;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
getBotUsername(): string {
|
|
383
|
+
return this.settings.username;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
getPrimaryChannel(): string {
|
|
387
|
+
return this.settings.channel;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
getJoinedChannels(): string[] {
|
|
391
|
+
return Array.from(this.joinedChannels);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
isUserAllowed(user: TwitchUserInfo): boolean {
|
|
395
|
+
// Check allowlist first
|
|
396
|
+
if (
|
|
397
|
+
this.settings.allowedUserIds.length > 0 &&
|
|
398
|
+
!this.settings.allowedUserIds.includes(user.userId)
|
|
399
|
+
) {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Check roles
|
|
404
|
+
if (this.settings.allowedRoles.includes("all")) {
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (this.settings.allowedRoles.includes("owner") && user.isBroadcaster) {
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (this.settings.allowedRoles.includes("moderator") && user.isModerator) {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (this.settings.allowedRoles.includes("vip") && user.isVip) {
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (
|
|
421
|
+
this.settings.allowedRoles.includes("subscriber") &&
|
|
422
|
+
user.isSubscriber
|
|
423
|
+
) {
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return false;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async sendMessage(
|
|
431
|
+
text: string,
|
|
432
|
+
options?: TwitchMessageSendOptions,
|
|
433
|
+
): Promise<TwitchSendResult> {
|
|
434
|
+
if (!this.connected) {
|
|
435
|
+
throw new TwitchNotConnectedError();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const channel = normalizeChannel(options?.channel || this.settings.channel);
|
|
439
|
+
|
|
440
|
+
// Strip markdown for Twitch
|
|
441
|
+
const cleanedText = stripMarkdownForTwitch(text);
|
|
442
|
+
if (!cleanedText) {
|
|
443
|
+
return { success: true, messageId: "skipped-empty" };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Split long messages
|
|
447
|
+
const chunks = splitMessageForTwitch(cleanedText);
|
|
448
|
+
|
|
449
|
+
let lastMessageId: string | undefined;
|
|
450
|
+
|
|
451
|
+
for (const chunk of chunks) {
|
|
452
|
+
if (options?.replyTo) {
|
|
453
|
+
await this.client.say(channel, chunk, { replyTo: options.replyTo });
|
|
454
|
+
} else {
|
|
455
|
+
await this.client.say(channel, chunk);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Generate a message ID since Twurple doesn't return one
|
|
459
|
+
lastMessageId = crypto.randomUUID();
|
|
460
|
+
|
|
461
|
+
// Small delay between chunks
|
|
462
|
+
if (chunks.length > 1) {
|
|
463
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
this.runtime.emitEvent(TwitchEventTypes.MESSAGE_SENT, {
|
|
468
|
+
runtime: this.runtime,
|
|
469
|
+
channel,
|
|
470
|
+
text: cleanedText,
|
|
471
|
+
messageId: lastMessageId,
|
|
472
|
+
} as EventPayload);
|
|
473
|
+
|
|
474
|
+
return { success: true, messageId: lastMessageId };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async joinChannel(channel: string): Promise<void> {
|
|
478
|
+
const normalized = normalizeChannel(channel);
|
|
479
|
+
await this.client.join(normalized);
|
|
480
|
+
this.joinedChannels.add(normalized);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async leaveChannel(channel: string): Promise<void> {
|
|
484
|
+
const normalized = normalizeChannel(channel);
|
|
485
|
+
await this.client.part(normalized);
|
|
486
|
+
this.joinedChannels.delete(normalized);
|
|
487
|
+
}
|
|
488
|
+
}
|