"use strict";
var Promise = require("bluebird");
var MatrixUser = require("../models/users/matrix");
var MatrixEvent = require("matrix-js-sdk").MatrixEvent;
var RoomMember = require("matrix-js-sdk").RoomMember;
var STATE_EVENT_TYPES = [
"m.room.name", "m.room.topic", "m.room.power_levels", "m.room.member",
"m.room.join_rules", "m.room.history_visibility"
];
/**
* Create an entity which can fulfil the intent of a given user.
* @constructor
* @param {MatrixClient} client The matrix client instance whose intent is being
* fulfilled e.g. the entity joining the room when you call intent.join(roomId).
* @param {MatrixClient} botClient The client instance for the AS bot itself.
* This will be used to perform more priveleged actions such as creating new
* rooms, sending invites, etc.
* @param {Object} opts Options for this Intent instance.
* @param {boolean} opts.registered True to inform this instance that the client
* is already registered. No registration requests will be made from this Intent.
* Default: false.
* @param {boolean} opts.dontCheckPowerLevel True to not check for the right power
* level before sending events. Default: false.
*/
function Intent(client, botClient, opts) {
this.client = client;
this.botClient = botClient;
this._membershipStates = {
// room_id : "join|invite|leave|null" null=unknown
};
this._powerLevels = {
// room_id: event.content
};
this.opts = opts || {};
}
/**
* Return the client this Intent is acting on behalf of.
* @return {MatrixClient} The client
*/
Intent.prototype.getClient = function() {
return this.client;
};
/**
* <p>Send a plaintext message to a room.</p>
* This will automatically make the client join the room so they can send the
* message if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} text The text string to send.
* @return {Promise}
*/
Intent.prototype.sendText = function(roomId, text) {
return this.sendMessage(roomId, {
body: text,
msgtype: "m.text"
});
};
/**
* <p>Set the name of a room.</p>
* This will automatically make the client join the room so they can set the
* name if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} name The room name.
* @return {Promise}
*/
Intent.prototype.setRoomName = function(roomId, name) {
return this.sendStateEvent(roomId, "m.room.name", "", {
name: name
});
};
/**
* <p>Set the topic of a room.</p>
* This will automatically make the client join the room so they can set the
* topic if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} topic The room topic.
* @return {Promise}
*/
Intent.prototype.setRoomTopic = function(roomId, topic) {
return this.sendStateEvent(roomId, "m.room.topic", "", {
topic: topic
});
};
/**
* <p>Set the avatar of a room.</p>
* This will automatically make the client join the room so they can set the
* topic if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} avatar The url of the avatar.
* @param {string} info Extra information about the image. See m.room.avatar for details.
* @return {Promise}
*/
Intent.prototype.setRoomAvatar = function(roomId, avatar, info) {
var content = {
url: avatar
};
if (info) {
content.info = info;
}
return this.sendStateEvent(roomId, "m.room.avatar", "", content);
};
/**
* <p>Send a typing event to a room.</p>
* This will automatically make the client join the room so they can send the
* typing event if they are not already joined.
* @param {string} roomId The room to send to.
* @param {boolean} isTyping True if typing
* @return {Promise}
*/
Intent.prototype.sendTyping = function(roomId, isTyping) {
var self = this;
return self._ensureJoined(roomId).then(function() {
return self._ensureHasPowerLevelFor(roomId, "m.typing");
}).then(function() {
return self.client.sendTyping(roomId, isTyping);
});
};
/**
* <p>Send a read receipt to a room.</p>
* This will automatically make the client join the room so they can send the
* receipt event if they are not already joined.
* @param{string} roomId The room to send to.
* @param{string} eventId The event ID to set the receipt mark to.
* @return {Promise}
*/
Intent.prototype.sendReadReceipt = function(roomId, eventId) {
var self = this;
var event = new MatrixEvent({
room_id: roomId,
event_id: eventId,
});
return self._ensureJoined(roomId).then(function() {
return self.client.sendReadReceipt(event);
});
}
/**
* Set the power level of the given target.
* @param {string} roomId The room to set the power level in.
* @param {string} target The target user ID
* @param {number} level The desired level
* @return {Promise}
*/
Intent.prototype.setPowerLevel = function(roomId, target, level) {
var self = this;
return self._ensureJoined(roomId).then(function() {
return self._ensureHasPowerLevelFor(roomId, "m.room.power_levels");
}).then(function(event) {
return self.client.setPowerLevel(roomId, target, level, event);
});
};
/**
* <p>Send an <code>m.room.message</code> event to a room.</p>
* This will automatically make the client join the room so they can send the
* message if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {Object} content The event content
* @return {Promise}
*/
Intent.prototype.sendMessage = function(roomId, content) {
return this.sendEvent(roomId, "m.room.message", content);
};
/**
* <p>Send a message event to a room.</p>
* This will automatically make the client join the room so they can send the
* message if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} type The event type
* @param {Object} content The event content
* @return {Promise}
*/
Intent.prototype.sendEvent = function(roomId, type, content) {
var self = this;
return self._ensureJoined(roomId).then(function() {
return self._ensureHasPowerLevelFor(roomId, type);
}).then(function() {
return self.client.sendEvent(roomId, type, content);
});
};
/**
* <p>Send a state event to a room.</p>
* This will automatically make the client join the room so they can send the
* state if they are not already joined. It will also make sure that the client
* has sufficient power level to do this.
* @param {string} roomId The room to send to.
* @param {string} type The event type
* @param {string} skey The state key
* @param {Object} content The event content
* @return {Promise}
*/
Intent.prototype.sendStateEvent = function(roomId, type, skey, content) {
var self = this;
return self._ensureJoined(roomId).then(function() {
return self._ensureHasPowerLevelFor(roomId, type);
}).then(function() {
return self.client.sendStateEvent(roomId, type, content, skey);
});
};
/**
* <p>Get the current room state for a room.</p>
* This will automatically make the client join the room so they can get the
* state if they are not already joined.
* @param {string} roomId The room to get the state from.
* @return {Promise}
*/
Intent.prototype.roomState = function(roomId) {
var self = this;
return self._ensureJoined(roomId).then(function() {
return self.client.roomState(roomId);
});
};
/**
* Create a room with a set of options.
* @param {Object} opts Options.
* @param {boolean} opts.createAsClient True to create this room as a client and
* not the bot: the bot will not join. False to create this room as the bot and
* auto-join the client. Default: false.
* @param {Object} opts.options Options to pass to the client SDK /createRoom API.
* @return {Promise}
*/
Intent.prototype.createRoom = function(opts) {
var self = this;
var cli = opts.createAsClient ? this.client : this.botClient;
var options = opts.options || {};
if (!opts.createAsClient) {
// invite the client if they aren't already
options.invite = options.invite || [];
if (options.invite.indexOf(this.client.credentials.userId) === -1) {
options.invite.push(this.client.credentials.userId);
}
}
// make sure that the thing doing the room creation isn't inviting itself
// else Synapse hard fails the operation with M_FORBIDDEN
if (options.invite && options.invite.indexOf(cli.credentials.userId) !== -1) {
options.invite.splice(options.invite.indexOf(cli.credentials.userId), 1);
}
return this._ensureRegistered().then(function() {
return cli.createRoom(options);
}).then(function(res) {
// create a fake power level event to give the room creator ops if we
// don't yet have a power level event.
if (self._powerLevels[res.room_id]) {
return res;
}
self._powerLevels[res.room_id] = {
users_default: 0,
events_default: 0,
state_default: 50,
users: {},
events: {}
};
self._powerLevels[res.room_id].users[cli.credentials.userId] = 100;
return res;
});
};
/**
* <p>Invite a user to a room.</p>
* This will automatically make the client join the room so they can send the
* invite if they are not already joined.
* @param {string} roomId The room to invite the user to.
* @param {string} target The user ID to invite.
* @return {Promise} Resolved when invited, else rejected with an error.
*/
Intent.prototype.invite = function(roomId, target) {
var self = this;
return this._ensureJoined(roomId).then(function() {
return self.client.invite(roomId, target);
});
};
/**
* <p>Kick a user from a room.</p>
* This will automatically make the client join the room so they can send the
* kick if they are not already joined.
* @param {string} roomId The room to kick the user from.
* @param {string} target The target of the kick operation.
* @param {string} reason Optional. The reason for the kick.
* @return {Promise} Resolved when kickked, else rejected with an error.
*/
Intent.prototype.kick = function(roomId, target, reason) {
var self = this;
return this._ensureJoined(roomId).then(function() {
return self.client.kick(roomId, target, reason);
});
};
/**
* <p>Ban a user from a room.</p>
* This will automatically make the client join the room so they can send the
* ban if they are not already joined.
* @param {string} roomId The room to ban the user from.
* @param {string} target The target of the ban operation.
* @param {string} reason Optional. The reason for the ban.
* @return {Promise} Resolved when banned, else rejected with an error.
*/
Intent.prototype.ban = function(roomId, target, reason) {
var self = this;
return this._ensureJoined(roomId).then(function() {
return self.client.ban(roomId, target, reason);
});
};
/**
* <p>Unban a user from a room.</p>
* This will automatically make the client join the room so they can send the
* unban if they are not already joined.
* @param {string} roomId The room to unban the user from.
* @param {string} target The target of the unban operation.
* @return {Promise} Resolved when unbanned, else rejected with an error.
*/
Intent.prototype.unban = function(roomId, target) {
var self = this;
return this._ensureJoined(roomId).then(function() {
return self.client.unban(roomId, target);
});
};
/**
* <p>Join a room</p>
* This will automatically send an invite from the bot if it is an invite-only
* room, which may make the bot attempt to join the room if it isn't already.
* @param {string} roomId The room to join.
* @return {Promise}
*/
Intent.prototype.join = function(roomId) {
return this._ensureJoined(roomId);
};
/**
* <p>Leave a room</p>
* This will no-op if the user isn't in the room.
* @param {string} roomId The room to leave.
* @return {Promise}
*/
Intent.prototype.leave = function(roomId) {
return this.client.leave(roomId);
};
/**
* <p>Get a user's profile information</p>
* @param {string} userId The ID of the user whose profile to return
* @param {string} info The profile field name to retrieve (e.g. 'displayname'
* or 'avatar_url'), or null to fetch the entire profile information.
* @return {Promise} A Promise that resolves with the requested user's profile
* information
*/
Intent.prototype.getProfileInfo = function(userId, info) {
var self = this;
return self._ensureRegistered().then(function() {
return self.client.getProfileInfo(userId, info);
});
};
/**
* <p>Set the user's display name</p>
* @param {string} name The new display name
* @return {Promise}
*/
Intent.prototype.setDisplayName = function(name) {
var self = this;
return self._ensureRegistered().then(function() {
return self.client.setDisplayName(name);
});
};
/**
* <p>Set the user's avatar URL</p>
* @param {string} url The new avatar URL
* @return {Promise}
*/
Intent.prototype.setAvatarUrl = function(url) {
var self = this;
return self._ensureRegistered().then(function() {
return self.client.setAvatarUrl(url);
});
};
/**
* Create a new alias mapping.
* @param {string} alias The room alias to create
* @param {string} roomId The room ID the alias should point at.
* @return {Promise}
*/
Intent.prototype.createAlias = function(alias, roomId) {
var self = this;
return self._ensureRegistered().then(function() {
return self.client.createAlias(alias, roomId);
});
};
/**
* Inform this Intent class of an incoming event. Various optimisations will be
* done if this is provided. For example, a /join request won't be sent out if
* it knows you've already been joined to the room.
* @param {Object} event The incoming event JSON
*/
Intent.prototype.onEvent = function(event) {
if (event.type === "m.room.member" &&
event.state_key === this.client.credentials.userId) {
this._membershipStates[event.room_id] = event.content.membership;
}
else if (event.type === "m.room.power_levels") {
this._powerLevels[event.room_id] = event.content;
}
};
Intent.prototype._ensureJoined = function(roomId) {
if (this._membershipStates[roomId] === "join") {
return Promise.resolve();
}
/* Logic:
if client /join:
SUCCESS
else if bot /invite client:
if client /join:
SUCCESS
else:
FAIL (client couldn't join)
else if bot /join:
if bot /invite client and client /join:
SUCCESS
else:
FAIL (bot couldn't invite)
else:
FAIL (bot can't get into the room)
*/
var self = this;
var d = new Promise.defer();
var userId = self.client.credentials.userId;
function mark(r, state) {
self._membershipStates[r] = state;
if (state === "join") {
d.resolve();
}
}
self._ensureRegistered().done(function() {
self.client.joinRoom(roomId, { syncRoom: false }).then(function() {
mark(roomId, "join");
}, function(e) {
if (e.errcode !== "M_FORBIDDEN") {
d.reject(new Error("Failed to join room"));
return;
}
// Try bot inviting client
self.botClient.invite(roomId, userId).then(function() {
return self.client.joinRoom(roomId, { syncRoom: false });
}).done(function() {
mark(roomId, "join");
}, function(invErr) {
// Try bot joining
self.botClient.joinRoom(roomId, { syncRoom: false }).then(function() {
return self.botClient.invite(roomId, userId);
}).then(function() {
return self.client.joinRoom(roomId, { syncRoom: false });
}).done(function() {
mark(roomId, "join");
}, function(finalErr) {
d.reject(new Error("Failed to join room"));
return;
});
});
});
}, function(e) {
d.reject(e);
});
return d.promise;
};
Intent.prototype._ensureHasPowerLevelFor = function(roomId, eventType) {
if (this.opts.dontCheckPowerLevel && eventType !== "m.room.power_levels") {
return Promise.resolve();
}
var self = this;
var userId = this.client.credentials.userId;
var promise = Promise.resolve(this._powerLevels[roomId]);
if (!this._powerLevels[roomId]) {
promise = this.client.getStateEvent(roomId, "m.room.power_levels", "");
}
return promise.then(function(eventContent) {
self._powerLevels[roomId] = eventContent;
var event = {
content: eventContent,
room_id: roomId,
user_id: "",
event_id: "_",
state_key: "",
type: "m.room.power_levels"
}
var powerLevelEvent = new MatrixEvent(event);
// What level do we need for this event type?
var defaultLevel = event.content.events_default;
if (STATE_EVENT_TYPES.indexOf(eventType) !== -1) {
defaultLevel = event.content.state_default;
}
var requiredLevel = event.content.events[eventType] || defaultLevel;
// Parse out what level the client has by abusing the JS SDK
var roomMember = new RoomMember(roomId, userId);
roomMember.setPowerLevelEvent(powerLevelEvent);
if (requiredLevel > roomMember.powerLevel) {
// can the bot update our power level?
var bot = new RoomMember(roomId, self.botClient.credentials.userId);
bot.setPowerLevelEvent(powerLevelEvent);
var levelRequiredToModifyPowerLevels = event.content.events[
"m.room.power_levels"
] || event.content.state_default;
if (levelRequiredToModifyPowerLevels > bot.powerLevel) {
// even the bot has no power here.. give up.
throw new Error(
"Cannot ensure client has power level for event " + eventType +
" : client has " + roomMember.powerLevel + " and we require " +
requiredLevel + " and the bot doesn't have permission to " +
"edit the client's power level."
);
}
// update the client's power level first
return self.botClient.setPowerLevel(
roomId, userId, requiredLevel, powerLevelEvent
).then(function() {
// tweak the level for the client to reflect the new reality
var userLevels = powerLevelEvent.getContent().users || {};
userLevels[userId] = requiredLevel;
powerLevelEvent.getContent().users = userLevels;
return Promise.resolve(powerLevelEvent);
});
}
return Promise.resolve(powerLevelEvent);
});
};
Intent.prototype._ensureRegistered = function() {
if (this.opts.registered) {
return Promise.resolve("registered=true");
}
var self = this;
var userId = self.client.credentials.userId;
var localpart = new MatrixUser(userId).localpart;
return self.botClient.register(localpart).then(function(res) {
self.opts.registered = true;
return res;
}, function(err) {
if (err.errcode === "M_USER_IN_USE") {
self.opts.registered = true;
return err;
}
});
};
module.exports = Intent;