"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
Object.defineProperty(exports, "__esModule", { value: true });
const express = require("express");
const Intent_1 = require("./Intent");
const __1 = require("..");
const events_1 = require("events");
const morgan = require("morgan");
const MatrixBridge_1 = require("./MatrixBridge");
const LRU = require("lru-cache");
/**
 * Represents an application service. This provides helper utilities such as tracking
 * of user intents (clients that are aware of their membership in rooms).
 * @category Application services
 */
class Appservice extends events_1.EventEmitter {
    /**
     * Creates a new application service.
     * @param {IAppserviceOptions} options The options for the application service.
     */
    constructor(options) {
        super();
        this.options = options;
        /**
         * The metrics instance for this appservice. This will raise all metrics
         * from this appservice instance as well as any intents/MatrixClients created
         * by the appservice.
         */
        this.metrics = new __1.Metrics();
        this.bridgeInstance = new MatrixBridge_1.MatrixBridge(this);
        this.app = express();
        this.eventProcessors = {};
        this.pendingTransactions = {};
        options.joinStrategy = new __1.AppserviceJoinRoomStrategy(options.joinStrategy, this);
        if (!options.intentOptions)
            options.intentOptions = {};
        if (!options.intentOptions.maxAgeMs)
            options.intentOptions.maxAgeMs = 60 * 60 * 1000;
        if (!options.intentOptions.maxCached)
            options.intentOptions.maxCached = 10000;
        this.intentsCache = new LRU({
            max: options.intentOptions.maxCached,
            maxAge: options.intentOptions.maxAgeMs,
        });
        this.registration = options.registration;
        // If protocol is not defined, define an empty array.
        if (!this.registration.protocols) {
            this.registration.protocols = [];
        }
        this.storage = options.storage || new __1.MemoryStorageProvider();
        options.storage = this.storage;
        this.app.use(express.json());
        this.app.use(morgan("combined"));
        // ETag headers break the tests sometimes, and we don't actually need them anyways for
        // appservices - none of this should be cached.
        this.app.set('etag', false);
        this.app.get("/users/:userId", this.onUser.bind(this));
        this.app.get("/rooms/:roomAlias", this.onRoomAlias.bind(this));
        this.app.put("/transactions/:txnId", this.onTransaction.bind(this));
        this.app.get("/_matrix/app/v1/users/:userId", this.onUser.bind(this));
        this.app.get("/_matrix/app/v1/rooms/:roomAlias", this.onRoomAlias.bind(this));
        this.app.put("/_matrix/app/v1/transactions/:txnId", this.onTransaction.bind(this));
        this.app.get("/_matrix/app/v1/thirdparty/protocol/:protocol", this.onThirdpartyProtocol.bind(this));
        this.app.get("/_matrix/app/v1/thirdparty/user/:protocol", this.onThirdpartyUser.bind(this));
        this.app.get("/_matrix/app/v1/thirdparty/user", this.onThirdpartyUser.bind(this));
        this.app.get("/_matrix/app/v1/thirdparty/location/:protocol", this.onThirdpartyLocation.bind(this));
        this.app.get("/_matrix/app/v1/thirdparty/location", this.onThirdpartyLocation.bind(this));
        // Everything else can 404
        // TODO: Should we permit other user namespaces and instead error when trying to use doSomethingBySuffix()?
        if (!this.registration.namespaces || !this.registration.namespaces.users || this.registration.namespaces.users.length === 0) {
            throw new Error("No user namespaces in registration");
        }
        if (this.registration.namespaces.users.length !== 1) {
            throw new Error("Too many user namespaces registered: expecting exactly one");
        }
        this.userPrefix = (this.registration.namespaces.users[0].regex || "").split(":")[0];
        if (!this.userPrefix.endsWith(".*")) {
            throw new Error("Expected user namespace to be a prefix");
        }
        this.userPrefix = this.userPrefix.substring(0, this.userPrefix.length - 2); // trim off the .* part
        if (!this.registration.namespaces || !this.registration.namespaces.aliases || this.registration.namespaces.aliases.length === 0 || this.registration.namespaces.aliases.length !== 1) {
            this.aliasPrefix = null;
        }
        else {
            this.aliasPrefix = (this.registration.namespaces.aliases[0].regex || "").split(":")[0];
            if (!this.aliasPrefix.endsWith(".*")) {
                this.aliasPrefix = null;
            }
            else {
                this.aliasPrefix = this.aliasPrefix.substring(0, this.aliasPrefix.length - 2); // trim off the .* part
            }
        }
    }
    /**
     * Gets the express app instance which is serving requests. Not recommended for
     * general usage, but may be used to append routes to the web server.
     */
    get expressAppInstance() {
        return this.app;
    }
    /**
     * Gets the bridge-specific APIs for this application service.
     */
    get bridge() {
        return this.bridgeInstance;
    }
    /**
     * Get the application service's "bot" user ID (the sender_localpart).
     */
    get botUserId() {
        return this.getUserId(this.registration.sender_localpart);
    }
    /**
     * Get the application service's "bot" Intent (the sender_localpart).
     * @returns {Intent} The intent for the application service itself.
     */
    get botIntent() {
        return this.getIntentForUserId(this.botUserId);
    }
    /**
     * Get the application service's "bot" MatrixClient (the sender_localpart).
     * Normally the botIntent should be used to ensure that the bot user is safely
     * handled.
     * @returns {MatrixClient} The client for the application service itself.
     */
    get botClient() {
        return this.botIntent.underlyingClient;
    }
    /**
     * Starts the application service, opening the bind address to begin processing requests.
     * @returns {Promise<any>} resolves when started
     */
    begin() {
        return new Promise((resolve, reject) => {
            this.appServer = this.app.listen(this.options.port, this.options.bindAddress, () => resolve());
        }).then(() => this.botIntent.ensureRegistered());
    }
    /**
     * Stops the application service, freeing the web server.
     */
    stop() {
        if (!this.appServer)
            return;
        this.appServer.close();
    }
    /**
     * Gets an intent for a given localpart. The user ID will be formed with the domain name given
     * in the constructor.
     * @param localpart The localpart to get an Intent for.
     * @returns {Intent} An Intent for the user.
     */
    getIntent(localpart) {
        return this.getIntentForUserId(this.getUserId(localpart));
    }
    /**
     * Gets a full user ID for a given localpart. The user ID will be formed with the domain name given
     * in the constructor.
     * @param localpart The localpart to get a user ID for.
     * @returns {string} The user's ID.
     */
    getUserId(localpart) {
        return `@${localpart}:${this.options.homeserverName}`;
    }
    /**
     * Gets an Intent for a given user suffix. The prefix is automatically detected from the registration
     * options.
     * @param suffix The user's suffix
     * @returns {Intent} An Intent for the user.
     */
    getIntentForSuffix(suffix) {
        return this.getIntentForUserId(this.getUserIdForSuffix(suffix));
    }
    /**
     * Gets a full user ID for a given suffix. The prefix is automatically detected from the registration
     * options.
     * @param suffix The user's suffix
     * @returns {string} The user's ID.
     */
    getUserIdForSuffix(suffix) {
        return `${this.userPrefix}${suffix}:${this.options.homeserverName}`;
    }
    /**
     * Gets an Intent for a given user ID.
     * @param {string} userId The user ID to get an Intent for.
     * @returns {Intent} An Intent for the user.
     */
    getIntentForUserId(userId) {
        let intent = this.intentsCache.get(userId);
        if (!intent) {
            intent = new Intent_1.Intent(this.options, userId, this);
            this.intentsCache.set(userId, intent);
        }
        return intent;
    }
    /**
     * Gets the suffix for the provided user ID. If the user ID is not a namespaced
     * user, this will return a falsey value.
     * @param {string} userId The user ID to parse
     * @returns {string} The suffix from the user ID.
     */
    getSuffixForUserId(userId) {
        if (!userId || !userId.startsWith(this.userPrefix) || !userId.endsWith(`:${this.options.homeserverName}`)) {
            // Invalid ID
            return null;
        }
        return userId
            .split('')
            .slice(this.userPrefix.length)
            .reverse()
            .slice(this.options.homeserverName.length + 1)
            .reverse()
            .join('');
    }
    /**
     * Determines if a given user ID is namespaced by this application service.
     * @param {string} userId The user ID to check
     * @returns {boolean} true if the user is namespaced, false otherwise
     */
    isNamespacedUser(userId) {
        return userId === this.botUserId || (userId.startsWith(this.userPrefix) && userId.endsWith(":" + this.options.homeserverName));
    }
    /**
     * Gets a full alias for a given localpart. The alias will be formed with the domain name given
     * in the constructor.
     * @param localpart The localpart to get an alias for.
     * @returns {string} The alias.
     */
    getAlias(localpart) {
        return `#${localpart}:${this.options.homeserverName}`;
    }
    /**
     * Gets a full alias for a given suffix. The prefix is automatically detected from the registration
     * options.
     * @param suffix The alias's suffix
     * @returns {string} The alias.
     */
    getAliasForSuffix(suffix) {
        if (!this.aliasPrefix) {
            throw new Error("Invalid configured alias prefix");
        }
        return `${this.aliasPrefix}${suffix}:${this.options.homeserverName}`;
    }
    /**
     * Gets the localpart of an alias for a given suffix. The prefix is automatically detected from the registration
     * options. Useful for the createRoom endpoint.
     * @param suffix The alias's suffix
     * @returns {string} The alias localpart.
     */
    getAliasLocalpartForSuffix(suffix) {
        if (!this.aliasPrefix) {
            throw new Error("Invalid configured alias prefix");
        }
        return `${this.aliasPrefix.substr(1)}${suffix}`;
    }
    /**
     * Gets the suffix for the provided alias. If the alias is not a namespaced
     * alias, this will return a falsey value.
     * @param {string} alias The alias to parse
     * @returns {string} The suffix from the alias.
     */
    getSuffixForAlias(alias) {
        if (!this.aliasPrefix) {
            throw new Error("Invalid configured alias prefix");
        }
        if (!alias || !this.isNamespacedAlias(alias)) {
            // Invalid ID
            return null;
        }
        return alias
            .split('')
            .slice(this.aliasPrefix.length)
            .reverse()
            .slice(this.options.homeserverName.length + 1)
            .reverse()
            .join('');
    }
    /**
     * Determines if a given alias is namespaced by this application service.
     * @param {string} alias The alias to check
     * @returns {boolean} true if the alias is namespaced, false otherwise
     */
    isNamespacedAlias(alias) {
        if (!this.aliasPrefix) {
            throw new Error("Invalid configured alias prefix");
        }
        return alias.startsWith(this.aliasPrefix) && alias.endsWith(":" + this.options.homeserverName);
    }
    /**
     * Adds a preprocessor to the event pipeline. When this appservice encounters an event, it
     * will try to run it through the preprocessors it can in the order they were added.
     * @param {IPreprocessor} preprocessor the preprocessor to add
     */
    addPreprocessor(preprocessor) {
        if (!preprocessor)
            throw new Error("Preprocessor cannot be null");
        const eventTypes = preprocessor.getSupportedEventTypes();
        if (!eventTypes)
            return; // Nothing to do
        for (const eventType of eventTypes) {
            if (!this.eventProcessors[eventType])
                this.eventProcessors[eventType] = [];
            this.eventProcessors[eventType].push(preprocessor);
        }
    }
    /**
     * Sets the visibility of a room in the appservice's room directory.
     * @param {string} networkId The network ID to group the room under.
     * @param {string} roomId The room ID to manipulate the visibility of.
     * @param {"public" | "private"} visibility The visibility to set for the room.
     * @return {Promise<any>} resolves when the visibility has been updated.
     */
    setRoomDirectoryVisibility(networkId, roomId, visibility) {
        roomId = encodeURIComponent(roomId);
        networkId = encodeURIComponent(networkId);
        return this.botClient.doRequest("PUT", `/_matrix/client/r0/directory/list/appservice/${networkId}/${roomId}`, null, {
            visibility,
        });
    }
    processEvent(event) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!event)
                return event;
            if (!this.eventProcessors[event["type"]])
                return event;
            for (const processor of this.eventProcessors[event["type"]]) {
                yield processor.processEvent(event, this.botIntent.underlyingClient);
            }
            return event;
        });
    }
    processMembershipEvent(event) {
        if (!event["content"])
            return;
        const targetMembership = event["content"]["membership"];
        if (targetMembership === "join") {
            this.emit("room.join", event["room_id"], event);
        }
        else if (targetMembership === "ban" || targetMembership === "leave") {
            this.emit("room.leave", event["room_id"], event);
        }
        else if (targetMembership === "invite") {
            this.emit("room.invite", event["room_id"], event);
        }
    }
    isAuthed(req) {
        let providedToken = req.query ? req.query["access_token"] : null;
        if (req.headers && req.headers["Authorization"]) {
            const authHeader = req.headers["Authorization"];
            if (!authHeader.startsWith("Bearer "))
                providedToken = null;
            else
                providedToken = authHeader.substring("Bearer ".length);
        }
        return providedToken === this.registration.hs_token;
    }
    onTransaction(req, res) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!this.isAuthed(req)) {
                res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" });
                return;
            }
            if (typeof (req.body) !== "object") {
                res.status(400).json({ errcode: "BAD_REQUEST", error: "Expected JSON" });
                return;
            }
            if (!req.body["events"] || !Array.isArray(req.body["events"])) {
                res.status(400).json({ errcode: "BAD_REQUEST", error: "Invalid JSON: expected events" });
                return;
            }
            const txnId = req.params["txnId"];
            if (this.storage.isTransactionCompleted(txnId)) {
                res.status(200).json({});
                return;
            }
            if (this.pendingTransactions[txnId]) {
                try {
                    yield this.pendingTransactions[txnId];
                    res.status(200).json({});
                }
                catch (e) {
                    __1.LogService.error("Appservice", e);
                    res.status(500).json({});
                }
                return;
            }
            __1.LogService.info("Appservice", "Processing transaction " + txnId);
            this.pendingTransactions[txnId] = new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () {
                for (let event of req.body["events"]) {
                    __1.LogService.info("Appservice", `Processing event of type ${event["type"]}`);
                    event = yield this.processEvent(event);
                    this.emit("room.event", event["room_id"], event);
                    if (event['type'] === 'm.room.message') {
                        this.emit("room.message", event["room_id"], event);
                    }
                    if (event['type'] === 'm.room.member' && this.isNamespacedUser(event['state_key'])) {
                        this.processMembershipEvent(event);
                    }
                    if (event['type'] === 'm.room.tombstone' && event['state_key'] === '') {
                        this.emit("room.archived", event['room_id'], event);
                    }
                    if (event['type'] === 'm.room.create' && event['state_key'] === '' && event['content'] && event['content']['predecessor']) {
                        this.emit("room.upgraded", event['room_id'], event);
                    }
                }
                resolve();
            }));
            try {
                yield this.pendingTransactions[txnId];
                this.storage.setTransactionCompleted(txnId);
                res.status(200).json({});
            }
            catch (e) {
                __1.LogService.error("Appservice", e);
                res.status(500).json({});
            }
        });
    }
    onUser(req, res) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!this.isAuthed(req)) {
                res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" });
                return;
            }
            const userId = req.params["userId"];
            this.emit("query.user", userId, (result) => __awaiter(this, void 0, void 0, function* () {
                if (result.then)
                    result = yield result;
                if (result === false) {
                    res.status(404).json({ errcode: "USER_DOES_NOT_EXIST", error: "User not created" });
                }
                else {
                    const intent = this.getIntentForUserId(userId);
                    yield intent.ensureRegistered();
                    if (result.display_name)
                        yield intent.underlyingClient.setDisplayName(result.display_name);
                    if (result.avatar_mxc)
                        yield intent.underlyingClient.setAvatarUrl(result.avatar_mxc);
                    res.status(200).json(result); // return result for debugging + testing
                }
            }));
        });
    }
    onRoomAlias(req, res) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!this.isAuthed(req)) {
                res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" });
                return;
            }
            const roomAlias = req.params["roomAlias"];
            this.emit("query.room", roomAlias, (result) => __awaiter(this, void 0, void 0, function* () {
                if (result.then)
                    result = yield result;
                if (result === false) {
                    res.status(404).json({ errcode: "ROOM_DOES_NOT_EXIST", error: "Room not created" });
                }
                else {
                    const intent = this.botIntent;
                    yield intent.ensureRegistered();
                    result["room_alias_name"] = roomAlias.substring(1).split(':')[0];
                    result["__roomId"] = yield intent.underlyingClient.createRoom(result);
                    res.status(200).json(result); // return result for debugging + testing
                }
            }));
        });
    }
    onThirdpartyProtocol(req, res) {
        if (!this.isAuthed(req)) {
            res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" });
            return;
        }
        const protocol = req.params["protocol"];
        if (!this.registration.protocols.includes(protocol)) {
            res.status(404).json({
                errcode: "PROTOCOL_NOT_HANDLED",
                error: "Protocol is not handled by this appservice"
            });
            return;
        }
        this.emit("thirdparty.protocol", protocol, (protocolResponse) => {
            res.status(200).json(protocolResponse);
        });
    }
    handleThirdpartyObject(req, res, objType, matrixId) {
        if (!this.isAuthed(req)) {
            res.status(401).json({ errcode: "AUTH_FAILED", error: "Authentication failed" });
            return;
        }
        const protocol = req.params["protocol"];
        const responseFunc = (items) => {
            if (items && items.length > 0) {
                res.status(200).json(items);
                return;
            }
            res.status(404).json({
                errcode: "NO_MAPPING_FOUND",
                error: "No mappings found"
            });
        };
        // Lookup remote objects(s)
        if (protocol) { // If protocol is given, we are looking up a objects based on fields
            if (!this.registration.protocols.includes(protocol)) {
                res.status(404).json({
                    errcode: "PROTOCOL_NOT_HANDLED",
                    error: "Protocol is not handled by this appservice"
                });
                return;
            }
            // Remove the access_token
            delete req.query.access_token;
            this.emit(`thirdparty.${objType}.remote`, protocol, req.query, responseFunc);
            return;
        }
        else if (matrixId) { // If a user ID is given, we are looking up a remote objects based on a id
            this.emit(`thirdparty.${objType}.matrix`, matrixId, responseFunc);
            return;
        }
        res.status(400).json({
            errcode: "INVALID_PARAMETERS",
            error: "Invalid parameters given"
        });
    }
    onThirdpartyUser(req, res) {
        return this.handleThirdpartyObject(req, res, "user", req.query["userid"]);
    }
    onThirdpartyLocation(req, res) {
        return this.handleThirdpartyObject(req, res, "location", req.query["alias"]);
    }
}
exports.Appservice = Appservice;
