@tf2pickup-org/mumble-client
Version:
A simple bot for managing mumble servers
188 lines • 7.59 kB
JavaScript
import { tlsConnect } from './tls-connect.js';
import { MumbleSocket } from './mumble-socket.js';
import { concatMap, debounceTime, exhaustMap, filter, groupBy, interval, lastValueFrom, map, race, Subject, switchMap, take, takeUntil, tap, throwError, timeout, zip, } from 'rxjs';
import { Authenticate, PermissionDenied, PermissionQuery, Ping, Reject, ServerConfig, ServerSync, UserList, UserRemove, Version, } from '@tf2pickup-org/mumble-protocol';
import { ChannelManager } from './channel-manager.js';
import { UserManager } from './user-manager.js';
import { encodeMumbleVersion } from './encode-mumble-version.js';
import { ClientDisconnectedError, CommandTimedOutError, ConnectionRejectedError, PermissionDeniedError, } from './errors/index.js';
import { filterPacket } from './rxjs-operators/filter-packet.js';
import { platform, release } from 'os';
import { Permissions } from './permissions.js';
import { encodeMumbleVersionLegacy } from './encode-mumble-version-legacy.js';
import { TypedEventEmitter } from './typed-event-emitter.js';
import { CommandTimeout } from './config.js';
const defaultOptions = {
port: 64738,
clientName: 'simple mumble bot',
pingInterval: 10000,
};
export class Client extends TypedEventEmitter {
channels = new ChannelManager(this);
users = new UserManager(this);
serverVersion;
serverConfig;
socket;
session;
welcomeText;
options;
permissions = new Map();
constructor(options) {
super();
this.options = { ...defaultOptions, ...options };
}
get user() {
return this.self;
}
get self() {
if (!this.session) {
return undefined;
}
return this.users.bySession(this.session);
}
isConnected() {
return !!this.socket;
}
assertConnected() {
if (!this.socket) {
throw new ClientDisconnectedError();
}
}
async connect() {
this.socket = new MumbleSocket(await tlsConnect(this.options.host, this.options.port, this.options));
this.socket.audioPacket
.pipe(groupBy(packet => packet.source))
.subscribe(group => {
const user = this.users.bySession(group.key);
if (!user) {
console.warn(`unknown session: ${group.key}`);
return;
}
let speaking = false;
group.pipe(filter(() => !speaking)).subscribe(() => {
speaking = true;
this.emit('speakingStateChange', { user, speaking });
});
group.pipe(debounceTime(40)).subscribe(() => {
speaking = false;
this.emit('speakingStateChange', { user, speaking });
});
});
this.emit('socketConnect', this.socket);
this.socket.packet
.pipe(filterPacket(PermissionQuery), filter(permissionQuery => permissionQuery.channelId !== undefined), map(permissionQuery => ({
channelId: permissionQuery.channelId,
permissions: new Permissions(permissionQuery.permissions ?? 0),
})))
.subscribe(({ channelId, permissions }) => {
this.permissions.set(channelId, permissions);
});
this.socket.packet
.pipe(filterPacket(UserRemove), filter(userRemove => userRemove.session === this.session))
.subscribe(userRemove => {
this.emit('disconnect', {
reason: userRemove.reason ?? 'unknown reason',
});
delete this.socket;
});
const initialize = lastValueFrom(race(zip(this.socket.packet.pipe(filterPacket(ServerSync), take(1)), this.socket.packet.pipe(filterPacket(ServerConfig), take(1)), this.socket.packet.pipe(filterPacket(Version), take(1))).pipe(tap(([serverSync, serverConfig, version]) => {
if (serverSync.session) {
this.session = serverSync.session;
}
this.welcomeText = serverSync.welcomeText ?? '';
this.serverVersion = version;
this.serverConfig = serverConfig;
this.emit('connect');
this.startPinger();
}), map(() => this)), this.socket.packet.pipe(filterPacket(Reject), switchMap(reject => throwError(() => new ConnectionRejectedError(reject))))));
await this.authenticate();
await this.sendVersion();
await this.ping();
return await initialize;
}
disconnect() {
this.emit('disconnect');
this.socket?.end();
delete this.socket;
return this;
}
async command(name, props) {
this.assertConnected();
const ret = lastValueFrom(race(this.socket.packet.pipe(filterPacket(props.expectPacket[0]), filter(props.expectPacket[1]), take(1), timeout({
first: CommandTimeout,
with: () => throwError(() => new CommandTimedOutError(name)),
})), this.socket.packet.pipe(filterPacket(PermissionDenied), filter(pd => pd.session === this.session), take(1), concatMap(pd => throwError(() => new PermissionDeniedError(pd))))));
if ('sendPackets' in props) {
for (const [type, payload] of props.sendPackets) {
await this.socket.send(type, payload);
}
}
else {
await this.socket.send(props.sendPacket[0], props.sendPacket[1]);
}
return ret;
}
async fetchRegisteredUsers() {
const ret = await this.command('fetchRegisteredUsers', {
sendPacket: [UserList, UserList.create()],
expectPacket: [UserList, () => true],
});
return ret.users;
}
async deregisterUser(userId) {
await this.command('deregisterUser', {
sendPackets: [
[UserList, UserList.create({ users: [{ userId }] })],
[UserList, UserList.create()],
],
expectPacket: [UserList, () => true],
});
}
async renameRegisteredUser(userId, name) {
await this.command('renameRegisteredUser', {
sendPackets: [
[UserList, UserList.create({ users: [{ userId, name }] })],
[UserList, UserList.create()],
],
expectPacket: [UserList, () => true],
});
}
async sendVersion() {
const version = {
major: 1,
minor: 4,
patch: 287,
};
return await this.socket?.send(Version, Version.create({
release: this.options.clientName ?? '',
versionV1: encodeMumbleVersionLegacy(version),
versionV2: encodeMumbleVersion(version),
os: platform(),
osVersion: release(),
}));
}
async authenticate() {
return await this.socket?.send(Authenticate, Authenticate.create({
username: this.options.username,
...(this.options.password ? { password: this.options.password } : {}),
tokens: this.options.tokens ?? [],
opus: true,
}));
}
async ping() {
return await this.socket?.send(Ping, Ping.create());
}
startPinger() {
const disconnect$ = new Subject();
this.on('disconnect', () => {
disconnect$.next();
disconnect$.complete();
});
interval(this.options.pingInterval)
.pipe(takeUntil(disconnect$), exhaustMap(() => this.ping().catch(error => {
this.emit('error', error);
})))
.subscribe();
}
}
//# sourceMappingURL=client.js.map