diff options
43 files changed, 1800 insertions, 794 deletions
diff --git a/docs/client-server/swagger_matrix/rooms b/docs/client-server/swagger_matrix/rooms index bb49ec5a6a..1c32d135aa 100644 --- a/docs/client-server/swagger_matrix/rooms +++ b/docs/client-server/swagger_matrix/rooms @@ -14,12 +14,12 @@ }, "apis": [ { - "path": "/rooms/{roomId}/send/{eventType}/{txnId}", + "path": "/rooms/{roomId}/send/{eventType}", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Send a generic non-state event to this room.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/{eventType}", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "EventId", "nickname": "send_non_state_event", "consumes": [ @@ -46,13 +46,6 @@ "required": true, "type": "string", "paramType": "path" - }, - { - "name": "txnId", - "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", - "required": true, - "type": "string", - "paramType": "path" } ] } @@ -104,12 +97,12 @@ ] }, { - "path": "/rooms/{roomId}/send/m.room.message/{txnId}", + "path": "/rooms/{roomId}/send/m.room.message", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Send a message in this room.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "EventId", "nickname": "send_message", "consumes": [ @@ -129,13 +122,6 @@ "required": true, "type": "string", "paramType": "path" - }, - { - "name": "txnId", - "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", - "required": true, - "type": "string", - "paramType": "path" } ] } @@ -195,12 +181,12 @@ ] }, { - "path": "/rooms/{roomId}/send/m.room.message.feedback/{txnId}", + "path": "/rooms/{roomId}/send/m.room.message.feedback", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Send feedback to a message.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/send/m.room.message.feedback", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "EventId", "nickname": "send_feedback", "consumes": [ @@ -220,13 +206,6 @@ "required": true, "type": "string", "paramType": "path" - }, - { - "name": "txnId", - "description": "A client transaction ID to ensure idempotency. This can only be omitted if the HTTP method becomes a POST.", - "required": true, - "type": "string", - "paramType": "path" } ], "responseMessages": [ @@ -239,12 +218,12 @@ ] }, { - "path": "/rooms/{roomId}/invite/{txnId}", + "path": "/rooms/{roomId}/invite", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Invite a user to this room.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/invite", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "void", "nickname": "invite", "consumes": [ @@ -259,13 +238,6 @@ "paramType": "path" }, { - "name": "txnId", - "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", - "required": false, - "type": "string", - "paramType": "path" - }, - { "name": "body", "description": "The user to invite.", "required": true, @@ -277,12 +249,12 @@ ] }, { - "path": "/rooms/{roomId}/join/{txnId}", + "path": "/rooms/{roomId}/join", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Join this room.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/join", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "void", "nickname": "join_room", "consumes": [ @@ -295,25 +267,18 @@ "required": true, "type": "string", "paramType": "path" - }, - { - "name": "txnId", - "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", - "required": false, - "type": "string", - "paramType": "path" } ] } ] }, { - "path": "/rooms/{roomId}/leave/{txnId}", + "path": "/rooms/{roomId}/leave", "operations": [ { - "method": "PUT", + "method": "POST", "summary": "Leave this room.", - "notes": "This operation can also be done as a POST to /rooms/{roomId}/leave", + "notes": "This operation can also be done as a PUT by suffixing /{txnId}.", "type": "void", "nickname": "leave", "consumes": [ @@ -326,13 +291,6 @@ "required": true, "type": "string", "paramType": "path" - }, - { - "name": "txnId", - "description": "A client transaction ID for this PUT to ensure idempotency. This can only be omitted if the HTTP method becomes a POST. ", - "required": false, - "type": "string", - "paramType": "path" } ] } @@ -476,7 +434,7 @@ "parameters": [ { "name": "body", - "description": "The desired configuration for the room.", + "description": "The desired configuration for the room. This operation can also be done as a PUT by suffixing /{txnId}.", "required": true, "type": "RoomConfig", "paramType": "body" diff --git a/docs/specification.rst b/docs/specification.rst index fa085bac27..d4a01a3fc2 100644 --- a/docs/specification.rst +++ b/docs/specification.rst @@ -5,16 +5,20 @@ TODO(Introduction) : Matthew - Similar to intro paragraph from README. - Explaining the overall mission, what this spec describes... - "What is Matrix?" + - Draw parallels with email? Architecture ============ -- Basic structure: What are clients/home servers and what are their - responsibilities? What are events. +Clients transmit data to other clients through home servers (HSes). Clients do not communicate with each +other directly. :: - { Matrix clients } { Matrix clients } + How data flows between clients + ============================== + + { Matrix client A } { Matrix client B } ^ | ^ | | events | | events | | V | V @@ -22,22 +26,205 @@ Architecture | |---------( HTTP )---------->| | | Home Server | | Home Server | | |<--------( HTTP )-----------| | - +------------------+ +------------------+ + +------------------+ Federation +------------------+ + +A "Client" is an end-user, typically a human using a web application or mobile app. Clients use the +"Client-to-Server" (C-S) API to communicate with their home server. A single Client is usually +responsible for a single user account. A user account is represented by their "User ID". This ID is +namespaced to the home server which allocated the account and looks like:: + + @localpart:domain + +The ``localpart`` of a user ID may be a user name, or an opaque ID identifying this user. + + +A "Home Server" is a server which provides C-S APIs and has the ability to federate with other HSes. +It is typically responsible for multiple clients. "Federation" is the term used to describe the +sharing of data between two or more home servers. + +Data in Matrix is encapsulated in an "Event". An event is an action within the system. Typically each +action (e.g. sending a message) correlates with exactly one event. Each event has a ``type`` which is +used to differentiate different kinds of data. ``type`` values SHOULD be namespaced according to standard +Java package naming conventions, e.g. ``com.example.myapp.event``. Events are usually sent in the context +of a "Room". + +Room structure +-------------- + +A room is a conceptual place where users can send and receive events. Rooms +can be created, joined and left. Events are sent to a room, and all +participants in that room will receive the event. Rooms are uniquely +identified via a "Room ID", which look like:: + + !opaque_id:domain + +There is exactly one room ID for each room. Whilst the room ID does contain a +domain, it is simply for namespacing room IDs. The room does NOT reside on the +domain specified. Room IDs are not meant to be human readable. + +The following diagram shows an ``m.room.message`` event being sent in the room +``!qporfwt:matrix.org``:: + + { @alice:matrix.org } { @bob:domain.com } + | ^ + | | + Room ID: !qporfwt:matrix.org Room ID: !qporfwt:matrix.org + Event type: m.room.message Event type: m.room.message + Content: { JSON object } Content: { JSON object } + | | + V | + +------------------+ +------------------+ + | Home Server | | Home Server | + | matrix.org |<-------Federation------->| domain.com | + +------------------+ +------------------+ + | ................................. | + |______| Partially Shared State |_______| + | Room ID: !qporfwt:matrix.org | + | Servers: matrix.org, domain.com | + | Members: | + | - @alice:matrix.org | + | - @bob:domain.com | + |.................................| + +Federation maintains shared state between multiple home servers, such that when an event is +sent to a room, the home server knows where to forward the event on to, and how to process +the event. Home servers do not need to have completely shared state in order to participate +in a room. State is scoped to a single room, and federation ensures that all home servers +have the information they need, even if that means the home server has to request more +information from another home server before processing the event. + +Room Aliases +------------ + +Each room can also have multiple "Room Aliases", which looks like:: + + #room_alias:domain + +A room alias "points" to a room ID. The room ID the alias is pointing to can be obtained +by visiting the domain specified. Room aliases are designed to be human readable strings +which can be used to publicise rooms. Note that the mapping from a room alias to a +room ID is not fixed, and may change over time to point to a different room ID. For this +reason, Clients SHOULD resolve the room alias to a room ID once and then use that ID on +subsequent requests. + +:: + + GET + #matrix:domain.com !aaabaa:matrix.org + | ^ + | | + _______V____________________|____ + | domain.com | + | Mappings: | + | #matrix >> !aaabaa:matrix.org | + | #golf >> !wfeiofh:sport.com | + | #bike >> !4rguxf:matrix.org | + |________________________________| + -- How do identity servers fit in? 3PIDs? Users? Aliases -- Pattern of the APIs (HTTP/JSON, REST + txns) -- Standard error response format. -- C-S Event stream +Identity +-------- +- Identity in relation to 3PIDs. Discovery of users based on 3PIDs. +- Identity servers; trusted clique of servers which replicate content. +- They govern the mapping of 3PIDs to user IDs and the creation of said mappings. +- Not strictly required in order to communicate. -Rooms -===== -A room is a conceptual place where users can send and receive messages. Rooms -can be created, joined and left. Messages are sent to a room, and all -participants in that room will receive the message. Rooms are uniquely -identified via a room ID. There is exactly one room ID for each room. +API Standards +------------- +All communication in Matrix is performed over HTTP[S] using a Content-Type of ``application/json``. +Any errors which occur on the Matrix API level MUST return a "standard error response". This is a +JSON object which looks like:: + + { + "errcode": "<error code>", + "error": "<error message>" + } + +The ``error`` string will be a human-readable error message, usually a sentence +explaining what went wrong. The ``errcode`` string will be a unique string which can be +used to handle an error message e.g. ``M_FORBIDDEN``. These error codes should have their +namespace first in ALL CAPS, followed by a single _. For example, if there was a custom +namespace ``com.mydomain.here``, and a ``FORBIDDEN`` code, the error code should look +like ``COM.MYDOMAIN.HERE_FORBIDDEN``. There may be additional keys depending on +the error, but the keys ``error`` and ``errcode`` MUST always be present. + +Some standard error codes are below: + +:``M_FORBIDDEN``: + Forbidden access, e.g. joining a room without permission, failed login. + +:``M_UNKNOWN_TOKEN``: + The access token specified was not recognised. + +:``M_BAD_JSON``: + Request contained valid JSON, but it was malformed in some way, e.g. missing + required keys, invalid values for keys. + +:``M_NOT_JSON``: + Request did not contain valid JSON. + +:``M_NOT_FOUND``: + No resource was found for this request. + +Some requests have unique error codes: + +:``M_USER_IN_USE``: + Encountered when trying to register a user ID which has been taken. + +:``M_ROOM_IN_USE``: + Encountered when trying to create a room which has been taken. + +:``M_BAD_PAGINATION``: + Encountered when specifying bad pagination query parameters. + +:``M_LOGIN_EMAIL_URL_NOT_YET``: + Encountered when polling for an email link which has not been clicked yet. + +The C-S API typically uses ``HTTP POST`` to submit requests. This means these requests +are not idempotent. The C-S API also allows ``HTTP PUT`` to make requests idempotent. +In order to use a ``PUT``, paths should be suffixed with ``/{txnId}``. ``{txnId}`` is a +client-generated transaction ID which identifies the request. Crucially, it **only** +serves to identify new requests from retransmits. After the request has finished, the +``{txnId}`` value should be changed (how is not specified, it could be a monotonically +increasing integer, etc). It is preferable to use ``HTTP PUT`` to make sure requests to +send messages do not get sent more than once should clients need to retransmit requests. + +Valid requests look like:: + + POST /some/path/here + { + "key": "This is a post." + } + + PUT /some/path/here/11 + { + "key": "This is a put with a txnId of 11." + } + +In contrast, these are invalid requests:: + + POST /some/path/here/11 + { + "key": "This is a post, but it has a txnId." + } + + PUT /some/path/here + { + "key": "This is a put but it is missing a txnId." + } + +Receiving live updates on a client +---------------------------------- +- C-S longpoll event stream +- Concept of start/end tokens. +- Mention /initialSync to get token. + -- Aliases +Rooms +===== +- How are they created? PDU anchor point: "root of the tree". +- Adding / removing aliases. - Invite/join dance - State and non-state data (+extensibility) @@ -46,10 +233,8 @@ TODO : Room permissions / config / power levels. Messages ======== -This specification outlines several standard message types, all of which are -prefixed with "m.". - -- Namespacing? +This specification outlines several standard event types, all of which are +prefixed with ``m.`` State messages -------------- @@ -102,15 +287,15 @@ below: - ``body`` : "string" - The alt text of the image, or some kind of content description for accessibility e.g. "image attachment". -ImageInfo: - Information about an image:: + ImageInfo: + Information about an image:: - { - "size" : integer (size of image in bytes), - "w" : integer (width of image in pixels), - "h" : integer (height of image in pixels), - "mimetype" : "string (e.g. image/jpeg)", - } + { + "size" : integer (size of image in bytes), + "w" : integer (width of image in pixels), + "h" : integer (height of image in pixels), + "mimetype" : "string (e.g. image/jpeg)", + } ``m.audio`` Required keys: @@ -121,15 +306,14 @@ ImageInfo: - ``body`` : "string" - A description of the audio e.g. "Bee Gees - Stayin' Alive", or some kind of content description for accessibility e.g. "audio attachment". + AudioInfo: + Information about a piece of audio:: -AudioInfo: - Information about a piece of audio:: - - { - "mimetype" : "string (e.g. audio/aac)", - "size" : integer (size of audio in bytes), - "duration" : integer (duration of audio in milliseconds), - } + { + "mimetype" : "string (e.g. audio/aac)", + "size" : integer (size of audio in bytes), + "duration" : integer (duration of audio in milliseconds), + } ``m.video`` Required keys: @@ -140,18 +324,18 @@ AudioInfo: - ``body`` : "string" - A description of the video e.g. "Gangnam style", or some kind of content description for accessibility e.g. "video attachment". -VideoInfo: - Information about a video:: + VideoInfo: + Information about a video:: - { - "mimetype" : "string (e.g. video/mp4)", - "size" : integer (size of video in bytes), - "duration" : integer (duration of video in milliseconds), - "w" : integer (width of video in pixels), - "h" : integer (height of video in pixels), - "thumbnail_url" : "string (URL to image)", - "thumbanil_info" : JSON object (ImageInfo) - } + { + "mimetype" : "string (e.g. video/mp4)", + "size" : integer (size of video in bytes), + "duration" : integer (duration of video in milliseconds), + "w" : integer (width of video in pixels), + "h" : integer (height of video in pixels), + "thumbnail_url" : "string (URL to image)", + "thumbanil_info" : JSON object (ImageInfo) + } ``m.location`` Required keys: @@ -174,88 +358,59 @@ The following keys can be attached to any ``m.room.message``: Presence ======== -Each user has the concept of Presence information. This encodes a sense of the -"availability" of that user, suitable for display on other user's clients. - -The basic piece of presence information is an enumeration of a small set of -state; such as "free to chat", "online", "busy", or "offline". The default state -unless the user changes it is "online". Lower states suggest some amount of -decreased availability from normal, which might have some client-side effect -like muting notification sounds and suggests to other users not to bother them -unless it is urgent. Equally, the "free to chat" state exists to let the user -announce their general willingness to receive messages moreso than default. - -Home servers should also allow a user to set their state as "hidden" - a state -which behaves as offline, but allows the user to see the client state anyway and -generally interact with client features such as reading message history or -accessing contacts in the address book. - -This basic state field applies to the user as a whole, regardless of how many +Each user has the concept of presence information. This encodes the +"availability" of that user, suitable for display on other user's clients. This +is transmitted as an ``m.presence`` event and is one of the few events which +are sent *outside the context of a room*. The basic piece of presence information +is represented by the ``state`` key, which is an enum of one of the following: + + - ``online`` : The default state when the user is connected to an event stream. + - ``unavailable`` : The user is not reachable at this time. + - ``offline`` : The user is not connected to an event stream. + - ``free_for_chat`` : The user is generally willing to receive messages + moreso than default. + - ``hidden`` : TODO. Behaves as offline, but allows the user to see the client + state anyway and generally interact with client features. + +This basic ``state`` field applies to the user as a whole, regardless of how many client devices they have connected. The home server should synchronise this status choice among multiple devices to ensure the user gets a consistent experience. Idle Time --------- -As well as the basic state field, the presence information can also show a sense +As well as the basic ``state`` field, the presence information can also show a sense of an "idle timer". This should be maintained individually by the user's -clients, and the homeserver can take the highest reported time as that to -report. Likely this should be presented in fairly coarse granularity; possibly -being limited to letting the home server automatically switch from a "free to -chat" or "online" mode into "idle". - -When a user is offline, the Home Server can still report when the user was last -seen online, again perhaps in a somewhat coarse manner. +clients, and the home server can take the highest reported time as that to +report. When a user is offline, the home server can still report when the user was last +seen online. -Device Type ------------ -Client devices that may limit the user experience somewhat (such as "mobile" -devices with limited ability to type on a real keyboard or read large amounts of -text) should report this to the home server, as this is also useful information -to report as "presence" if the user cannot be expected to provide a good typed -response to messages. - -- m.presence and enums (when should they be used) +Transmission +------------ +- Transmitted as an EDU. +- Presence lists determine who to send to. Presence List ------------- Each user's home server stores a "presence list" for that user. This stores a -list of other user IDs the user has chosen to add to it (remembering any ACL -Pointer if appropriate). - -To be added to a contact list, the user being added must grant permission. Once -granted, both user's HS(es) store this information, as it allows the user who -has added the contact some more abilities; see below. Since such subscriptions +list of other user IDs the user has chosen to add to it. To be added to this +list, the user being added must receive permission from the list owner. Once +granted, both user's HS(es) store this information. Since such subscriptions are likely to be bidirectional, HSes may wish to automatically accept requests when a reverse subscription already exists. -As a convenience, presence lists should support the ability to collect users -into groups, which could allow things like inviting the entire group to a new -("ad-hoc") chat room, or easy interaction with the profile information ACL -implementation of the HS. - Presence and Permissions ------------------------ For a viewing user to be allowed to see the presence information of a target -user, either +user, either: - * The target user has allowed the viewing user to add them to their presence + - The target user has allowed the viewing user to add them to their presence list, or - - * The two users share at least one room in common + - The two users share at least one room in common In the latter case, this allows for clients to display some minimal sense of presence information in a user list for a room. -Home servers can also use the user's choice of presence state as a signal for -how to handle new private one-to-one chat message requests. For example, it -might decide: - - - "free to chat": accept anything - - "online": accept from anyone in my address book list - - "busy": accept from anyone in this "important people" group in my address - book list - Typing notifications ==================== @@ -274,18 +429,14 @@ human-friendly string. Profiles grant users the ability to see human-readable names for other users that are in some way meaningful to them. Additionally, profiles can publish additional information, such as the user's age or location. -It is also conceivable that since we are attempting to provide a -worldwide-applicable messaging system, that users may wish to present different -subsets of information in their profile to different other people, from a -privacy and permissions perspective. - A Profile consists of a display name, an avatar picture, and a set of other metadata fields that the user may wish to publish (email address, phone numbers, website URLs, etc...). This specification puts no requirements on the -display name other than it being a valid Unicode string. +display name other than it being a valid unicode string. - Metadata extensibility - Bundled with which events? e.g. m.room.member +- Generate own events? What type? Registration and login ====================== @@ -312,8 +463,8 @@ The login process breaks down into the following: step 2. As each home server may have different ways of logging in, the client needs to know how -they should login. All distinct login stages MUST have a corresponding ``'type'``. -A ``'type'`` is a namespaced string which details the mechanism for logging in. +they should login. All distinct login stages MUST have a corresponding ``type``. +A ``type`` is a namespaced string which details the mechanism for logging in. A client may be able to login via multiple valid login flows, and should choose a single flow when logging in. A flow is a series of login stages. The home server MUST respond @@ -359,17 +510,17 @@ subsequent requests until the login is completed:: } This specification defines the following login types: - - m.login.password - - m.login.oauth2 - - m.login.email.code - - m.login.email.url + - ``m.login.password`` + - ``m.login.oauth2`` + - ``m.login.email.code`` + - ``m.login.email.url`` Password-based -------------- -Type: - "m.login.password" -Description: +:Type: + m.login.password +:Description: Login is supported via a username and password. To respond to this type, reply with:: @@ -385,9 +536,9 @@ process, or a standard error response. OAuth2-based ------------ -Type: - "m.login.oauth2" -Description: +:Type: + m.login.oauth2 +:Description: Login is supported via OAuth2 URLs. This login consists of multiple requests. To respond to this type, reply with:: @@ -438,9 +589,9 @@ visits the REDIRECT_URI with the auth code= query parameter which returns:: Email-based (code) ------------------ -Type: - "m.login.email.code" -Description: +:Type: + m.login.email.code +:Description: Login is supported by typing in a code which is sent in an email. This login consists of multiple requests. @@ -473,9 +624,9 @@ the login process, or a standard error response. Email-based (url) ----------------- -Type: - "m.login.email.url" -Description: +:Type: + m.login.email.url +:Description: Login is supported by clicking on a URL in an email. This login consists of multiple requests. @@ -515,7 +666,7 @@ N-Factor Authentication ----------------------- Multiple login stages can be combined to create N-factor authentication during login. -This can be achieved by responding with the ``'next'`` login type on completion of a +This can be achieved by responding with the ``next`` login type on completion of a previous login stage:: { @@ -523,7 +674,7 @@ previous login stage:: } If a home server implements N-factor authentication, it MUST respond with all -``'stages'`` when initially queried for their login requirements:: +``stages`` when initially queried for their login requirements:: { "type": "<1st login type>", @@ -592,59 +743,62 @@ can also be performed. There are three main kinds of communication that occur between home servers: - * Queries +:Queries: These are single request/response interactions between a given pair of - servers, initiated by one side sending an HTTP request to obtain some + servers, initiated by one side sending an HTTP GET request to obtain some information, and responded by the other. They are not persisted and contain no long-term significant history. They simply request a snapshot state at the instant the query is made. - * EDUs - Ephemeral Data Units +:Ephemeral Data Units (EDUs): These are notifications of events that are pushed from one home server to another. They are not persisted and contain no long-term significant history, nor does the receiving home server have to reply to them. - * PDUs - Persisted Data Units +:Persisted Data Units (PDUs): These are notifications of events that are broadcast from one home server to any others that are interested in the same "context" (namely, a Room ID). They are persisted to long-term storage and form the record of history for that context. -Where Queries are presented directly across the HTTP connection as GET requests -to specific URLs, EDUs and PDUs are further wrapped in an envelope called a -Transaction, which is transferred from the origin to the destination home server -using a PUT request. +EDUs and PDUs are further wrapped in an envelope called a Transaction, which is +transferred from the origin to the destination home server using an HTTP PUT request. -Transactions and EDUs/PDUs --------------------------- +Transactions +------------ The transfer of EDUs and PDUs between home servers is performed by an exchange -of Transaction messages, which are encoded as JSON objects with a dict as the -top-level element, passed over an HTTP PUT request. A Transaction is meaningful -only to the pair of home servers that exchanged it; they are not globally- -meaningful. +of Transaction messages, which are encoded as JSON objects, passed over an +HTTP PUT request. A Transaction is meaningful only to the pair of home servers that +exchanged it; they are not globally-meaningful. + +Each transaction has: + - An opaque transaction ID. + - A timestamp (UNIX epoch time in milliseconds) generated by its origin server. + - An origin and destination server name. + - A list of "previous IDs". + - A list of PDUs and EDUs - the actual message payload that the Transaction carries. -Each transaction has an opaque ID and timestamp (UNIX epoch time in -milliseconds) generated by its origin server, an origin and destination server -name, a list of "previous IDs", and a list of PDUs - the actual message payload -that the Transaction carries. +:: - {"transaction_id":"916d630ea616342b42e98a3be0b74113", + { + "transaction_id":"916d630ea616342b42e98a3be0b74113", "ts":1404835423000, "origin":"red", "destination":"blue", "prev_ids":["e1da392e61898be4d2009b9fecce5325"], "pdus":[...], - "edus":[...]} + "edus":[...] + } -The "previous IDs" field will contain a list of previous transaction IDs that -the origin server has sent to this destination. Its purpose is to act as a +The ``prev_ids`` field contains a list of previous transaction IDs that +the ``origin`` server has sent to this ``destination``. Its purpose is to act as a sequence checking mechanism - the destination server can check whether it has successfully received that Transaction, or ask for a retransmission if not. -The "pdus" field of a transaction is a list, containing zero or more PDUs.[*] -Each PDU is itself a dict containing a number of keys, the exact details of -which will vary depending on the type of PDU. Similarly, the "edus" field is +The ``pdus`` field of a transaction is a list, containing zero or more PDUs.[*] +Each PDU is itself a JSON object containing a number of keys, the exact details of +which will vary depending on the type of PDU. Similarly, the ``edus`` field is another list containing the EDUs. This key may be entirely absent if there are no EDUs to transfer. @@ -653,25 +807,35 @@ receiving an "empty" transaction, as this is useful for informing peers of other transaction IDs they should be aware of. This effectively acts as a push mechanism to encourage peers to continue to replicate content.) -All PDUs have an ID, a context, a declaration of their type, a list of other PDU -IDs that have been seen recently on that context (regardless of which origin -sent them), and a nested content field containing the actual event content. +PDUs and EDUs +------------- + +All PDUs have: + - An ID + - A context + - A declaration of their type + - A list of other PDU IDs that have been seen recently on that context (regardless of which origin + sent them) [[TODO(paul): Update this structure so that 'pdu_id' is a two-element [origin,ref] pair like the prev_pdus are]] - {"pdu_id":"a4ecee13e2accdadf56c1025af232176", +:: + + { + "pdu_id":"a4ecee13e2accdadf56c1025af232176", "context":"#example.green", "origin":"green", "ts":1404838188000, "pdu_type":"m.text", "prev_pdus":[["blue","99d16afbc857975916f1d73e49e52b65"]], "content":... - "is_state":false} + "is_state":false + } -In contrast to the transaction layer, it is important to note that the prev_pdus +In contrast to Transactions, it is important to note that the ``prev_pdus`` field of a PDU refers to PDUs that any origin server has sent, rather than -previous IDs that this origin has sent. This list may refer to other PDUs sent +previous IDs that this ``origin`` has sent. This list may refer to other PDUs sent by the same origin as the current one, or other origins. Because of the distributed nature of participants in a Matrix conversation, it @@ -686,6 +850,8 @@ PDUs fall into two main categories: those that deliver Events, and those that synchronise State. For PDUs that relate to State synchronisation, additional keys exist to support this: +:: + {..., "is_state":true, "state_key":TODO @@ -704,6 +870,8 @@ EDUs, by comparison to PDUs, do not have an ID, a context, or a list of "previous" IDs. The only mandatory fields for these are the type, origin and destination home server names, and the actual nested content. +:: + {"edu_type":"m.presence", "origin":"blue", "destination":"orange", diff --git a/jsfiddles/create_room_send_msg/demo.js b/jsfiddles/create_room_send_msg/demo.js index c17eb26b98..db2ae2d606 100644 --- a/jsfiddles/create_room_send_msg/demo.js +++ b/jsfiddles/create_room_send_msg/demo.js @@ -25,11 +25,12 @@ $('.login').live('click', function() { }); var getCurrentRoomList = function() { - var url = "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=1"; + var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1"; $.getJSON(url, function(data) { - for (var i=0; i<data.length; ++i) { - data[i].latest_message = data[i].messages.chunk[0].content.body; - addRoom(data[i]); + var rooms = data.rooms; + for (var i=0; i<rooms.length; ++i) { + rooms[i].latest_message = rooms[i].messages.chunk[0].content.body; + addRoom(rooms[i]); } }).fail(function(err) { alert(JSON.stringify($.parseJSON(err.responseText))); @@ -43,7 +44,7 @@ $('.createRoom').live('click', function() { data.room_alias_name = roomAlias; } $.ajax({ - url: "http://localhost:8080/matrix/client/api/v1/rooms?access_token="+accountInfo.access_token, + url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token, type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), @@ -78,11 +79,9 @@ $('.sendMessage').live('click', function() { return; } - var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/messages/$user/$msgid?access_token=$token"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token"; url = url.replace("$token", accountInfo.access_token); url = url.replace("$roomid", encodeURIComponent(roomId)); - url = url.replace("$user", encodeURIComponent(accountInfo.user_id)); - url = url.replace("$msgid", msgId); var data = { msgtype: "m.text", @@ -91,7 +90,7 @@ $('.sendMessage').live('click', function() { $.ajax({ url: url, - type: "PUT", + type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), dataType: "json", diff --git a/jsfiddles/event_stream/demo.js b/jsfiddles/event_stream/demo.js index b657b809e2..3f132cd98e 100644 --- a/jsfiddles/event_stream/demo.js +++ b/jsfiddles/event_stream/demo.js @@ -65,14 +65,15 @@ $('.login').live('click', function() { var getCurrentRoomList = function() { $("#roomId").val(""); - var url = "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=1"; + var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1"; $.getJSON(url, function(data) { - for (var i=0; i<data.length; ++i) { - if ("messages" in data[i]) { - data[i].latest_message = data[i].messages.chunk[0].content.body; + var rooms = data.rooms; + for (var i=0; i<rooms.length; ++i) { + if ("messages" in rooms[i]) { + rooms[i].latest_message = rooms[i].messages.chunk[0].content.body; } } - roomInfo = data; + roomInfo = rooms; setRooms(roomInfo); }).fail(function(err) { alert(JSON.stringify($.parseJSON(err.responseText))); @@ -92,17 +93,14 @@ $('.sendMessage').live('click', function() { var sendMessage = function(roomId) { var body = "jsfiddle message @" + $.now(); - var msgId = $.now(); if (roomId.length === 0) { return; } - var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/messages/$user/$msgid?access_token=$token"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token"; url = url.replace("$token", accountInfo.access_token); url = url.replace("$roomid", encodeURIComponent(roomId)); - url = url.replace("$user", encodeURIComponent(accountInfo.user_id)); - url = url.replace("$msgid", msgId); var data = { msgtype: "m.text", @@ -111,7 +109,7 @@ var sendMessage = function(roomId) { $.ajax({ url: url, - type: "PUT", + type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), dataType: "json", diff --git a/jsfiddles/example_app/demo.js b/jsfiddles/example_app/demo.js index 295597f0f7..f5e08b326a 100644 --- a/jsfiddles/example_app/demo.js +++ b/jsfiddles/example_app/demo.js @@ -38,8 +38,9 @@ var longpollEventStream = function() { else if (data.chunk[i].type === "m.room.member") { if (viewingRoomId === data.chunk[i].room_id) { console.log("Got new member: " + JSON.stringify(data.chunk[i])); + addMessage(data.chunk[i]); for (j=0; j<memberInfo.length; ++j) { - if (memberInfo[j].target_user_id === data.chunk[i].target_user_id) { + if (memberInfo[j].state_key === data.chunk[i].state_key) { memberInfo[j] = data.chunk[i]; updatedMemberList = true; break; @@ -50,7 +51,7 @@ var longpollEventStream = function() { updatedMemberList = true; } } - if (data.chunk[i].target_user_id === accountInfo.user_id) { + if (data.chunk[i].state_key === accountInfo.user_id) { getCurrentRoomList(); // update our join/invite list } } @@ -133,7 +134,7 @@ $('.createRoom').live('click', function() { data.room_alias_name = roomAlias; } $.ajax({ - url: "http://localhost:8080/matrix/client/api/v1/rooms?access_token="+accountInfo.access_token, + url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token, type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), @@ -154,14 +155,15 @@ $('.createRoom').live('click', function() { // ************** Getting current state ************** var getCurrentRoomList = function() { - var url = "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=1"; + var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1"; $.getJSON(url, function(data) { - for (var i=0; i<data.length; ++i) { - if ("messages" in data[i]) { - data[i].latest_message = data[i].messages.chunk[0].content.body; + var rooms = data.rooms; + for (var i=0; i<rooms.length; ++i) { + if ("messages" in rooms[i]) { + rooms[i].latest_message = rooms[i].messages.chunk[0].content.body; } } - roomInfo = data; + roomInfo = rooms; setRooms(roomInfo); }).fail(function(err) { alert(JSON.stringify($.parseJSON(err.responseText))); @@ -179,7 +181,8 @@ var loadRoomContent = function(roomId) { var getMessages = function(roomId) { $("#messages").empty(); - var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + roomId + "/messages/list?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=10"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + + encodeURIComponent(roomId) + "/messages?access_token=" + accountInfo.access_token + "&from=END&dir=b&limit=10"; $.getJSON(url, function(data) { for (var i=data.chunk.length-1; i>=0; --i) { addMessage(data.chunk[i]); @@ -190,7 +193,8 @@ var getMessages = function(roomId) { var getMemberList = function(roomId) { $("#members").empty(); memberInfo = []; - var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + roomId + "/members/list?access_token=" + accountInfo.access_token; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/" + + encodeURIComponent(roomId) + "/members?access_token=" + accountInfo.access_token; $.getJSON(url, function(data) { for (var i=0; i<data.chunk.length; ++i) { memberInfo.push(data.chunk[i]); @@ -212,11 +216,9 @@ $('.sendMessage').live('click', function() { var sendMessage = function(roomId, body) { var msgId = $.now(); - var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/messages/$user/$msgid?access_token=$token"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/send/m.room.message?access_token=$token"; url = url.replace("$token", accountInfo.access_token); url = url.replace("$roomid", encodeURIComponent(roomId)); - url = url.replace("$user", encodeURIComponent(accountInfo.user_id)); - url = url.replace("$msgid", msgId); var data = { msgtype: "m.text", @@ -225,7 +227,7 @@ var sendMessage = function(roomId, body) { $.ajax({ url: url, - type: "PUT", + type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), dataType: "json", @@ -260,13 +262,12 @@ var setRooms = function(roomList) { var membership = $(this).find('td:eq(1)').text(); if (membership !== "join") { console.log("Joining room " + roomId); - var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/members/$user/state?access_token=$token"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/join?access_token=$token"; url = url.replace("$token", accountInfo.access_token); url = url.replace("$roomid", encodeURIComponent(roomId)); - url = url.replace("$user", encodeURIComponent(accountInfo.user_id)); $.ajax({ url: url, - type: "PUT", + type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify({membership: "join"}), dataType: "json", @@ -286,16 +287,33 @@ var setRooms = function(roomList) { }; var addMessage = function(data) { + + var msg = data.content.body; + if (data.type === "m.room.member") { + if (data.content.membership === "invite") { + msg = "<em>invited " + data.state_key + " to the room</em>"; + } + else if (data.content.membership === "join") { + msg = "<em>joined the room</em>"; + } + else if (data.content.membership === "leave") { + msg = "<em>left the room</em>"; + } + else { + msg = "<em>" + data.content.membership + "</em>"; + } + } + var row = "<tr>" + "<td>"+data.user_id+"</td>" + - "<td>"+data.content.body+"</td>" + + "<td>"+msg+"</td>" + "</tr>"; $("#messages").append(row); }; var addMember = function(data) { var row = "<tr>" + - "<td>"+data.target_user_id+"</td>" + + "<td>"+data.state_key+"</td>" + "<td>"+data.content.membership+"</td>" + "</tr>"; $("#members").append(row); diff --git a/jsfiddles/register_login/demo.js b/jsfiddles/register_login/demo.js index 1644f76ac7..270a96786c 100644 --- a/jsfiddles/register_login/demo.js +++ b/jsfiddles/register_login/demo.js @@ -45,7 +45,7 @@ $('.login').live('click', function() { var user = $("#userLogin").val(); var password = $("#passwordLogin").val(); $.getJSON("http://localhost:8080/matrix/client/api/v1/login", function(data) { - if (data.type !== "m.login.password") { + if (data.flows[0].type !== "m.login.password") { alert("I don't know how to login with this type: " + data.type); return; } @@ -60,7 +60,7 @@ $('.logout').live('click', function() { }); $('.testToken').live('click', function() { - var url = "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=1"; + var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1"; $.getJSON(url, function(data) { $("#imSyncText").text(JSON.stringify(data, undefined, 2)); }).fail(function(err) { diff --git a/jsfiddles/room_memberships/demo.html b/jsfiddles/room_memberships/demo.html index 96232e827a..4c1bf6b4bb 100644 --- a/jsfiddles/room_memberships/demo.html +++ b/jsfiddles/room_memberships/demo.html @@ -14,9 +14,9 @@ <input type="text" id="roomId" placeholder="Room ID"></input> <input type="text" id="targetUser" placeholder="Target User ID"></input> <select id="membership"> - <option value="invite">Invite</option> - <option value="join">Join</option> - <option value="leave">Leave</option> + <option value="invite">invite</option> + <option value="join">join</option> + <option value="leave">leave</option> </select> <input type="button" class="changeMembership" value="Change Membership"></input> </form> diff --git a/jsfiddles/room_memberships/demo.js b/jsfiddles/room_memberships/demo.js index b3ce0f0e33..91cc96ab6f 100644 --- a/jsfiddles/room_memberships/demo.js +++ b/jsfiddles/room_memberships/demo.js @@ -4,6 +4,14 @@ var showLoggedIn = function(data) { accountInfo = data; getCurrentRoomList(); $(".loggedin").css({visibility: "visible"}); + $("#membership").change(function() { + if ($("#membership").val() === "invite") { + $("#targetUser").css({visibility: "visible"}); + } + else { + $("#targetUser").css({visibility: "hidden"}); + } +}); }; $('.login').live('click', function() { @@ -31,10 +39,11 @@ var getCurrentRoomList = function() { // solution but that is out of scope of this fiddle. $("#rooms").find("tr:gt(0)").remove(); - var url = "http://localhost:8080/matrix/client/api/v1/im/sync?access_token=" + accountInfo.access_token + "&from=END&to=START&limit=1"; + var url = "http://localhost:8080/matrix/client/api/v1/initialSync?access_token=" + accountInfo.access_token + "&limit=1"; $.getJSON(url, function(data) { - for (var i=0; i<data.length; ++i) { - addRoom(data[i]); + var rooms = data.rooms; + for (var i=0; i<rooms.length; ++i) { + addRoom(rooms[i]); } }).fail(function(err) { alert(JSON.stringify($.parseJSON(err.responseText))); @@ -44,7 +53,7 @@ var getCurrentRoomList = function() { $('.createRoom').live('click', function() { var data = {}; $.ajax({ - url: "http://localhost:8080/matrix/client/api/v1/rooms?access_token="+accountInfo.access_token, + url: "http://localhost:8080/matrix/client/api/v1/createRoom?access_token="+accountInfo.access_token, type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify(data), @@ -78,44 +87,32 @@ $('.changeMembership').live('click', function() { return; } - var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/members/$user/state?access_token=$token"; + var url = "http://localhost:8080/matrix/client/api/v1/rooms/$roomid/$membership?access_token=$token"; url = url.replace("$token", accountInfo.access_token); url = url.replace("$roomid", encodeURIComponent(roomId)); - url = url.replace("$user", encodeURIComponent(member)); + url = url.replace("$membership", membership); - if (membership === "leave") { - $.ajax({ - url: url, - type: "DELETE", - contentType: "application/json; charset=utf-8", - dataType: "json", - success: function(data) { - getCurrentRoomList(); - }, - error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); - } - }); - } - else { - var data = { - membership: membership + var data = {}; + + if (membership === "invite") { + data = { + user_id: member }; - - $.ajax({ - url: url, - type: "PUT", - contentType: "application/json; charset=utf-8", - data: JSON.stringify(data), - dataType: "json", - success: function(data) { - getCurrentRoomList(); - }, - error: function(err) { - alert(JSON.stringify($.parseJSON(err.responseText))); - } - }); } + + $.ajax({ + url: url, + type: "POST", + contentType: "application/json; charset=utf-8", + data: JSON.stringify(data), + dataType: "json", + success: function(data) { + getCurrentRoomList(); + }, + error: function(err) { + alert(JSON.stringify($.parseJSON(err.responseText))); + } + }); }); $('.joinAlias').live('click', function() { @@ -125,7 +122,7 @@ $('.joinAlias').live('click', function() { url = url.replace("$roomalias", encodeURIComponent(roomAlias)); $.ajax({ url: url, - type: "PUT", + type: "POST", contentType: "application/json; charset=utf-8", data: JSON.stringify({}), dataType: "json", diff --git a/synapse/app/homeserver.py b/synapse/app/homeserver.py index 6d292ccf9a..f56dde846d 100755 --- a/synapse/app/homeserver.py +++ b/synapse/app/homeserver.py @@ -29,6 +29,7 @@ from synapse.http.client import TwistedHttpClient from synapse.api.urls import ( CLIENT_PREFIX, FEDERATION_PREFIX, WEB_CLIENT_PREFIX, CONTENT_REPO_PREFIX ) +from synapse.config.homeserver import HomeServerConfig from daemonize import Daemonize import twisted.manhole.telnet @@ -211,32 +212,7 @@ class SynapseHomeServer(HomeServer): logger.info("Synapse now listening on port %d", port) -def setup_logging(verbosity=0, filename=None, config_path=None): - """ Sets up logging with verbosity levels. - Args: - verbosity: The verbosity level. - filename: Log to the given file rather than to the console. - config_path: Path to a python logging config file. - """ - - if config_path is None: - log_format = ( - '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s' - ) - - level = logging.INFO - if verbosity: - level = logging.DEBUG - - # FIXME: we need a logging.WARN for a -q quiet option - - logging.basicConfig(level=level, filename=filename, format=log_format) - else: - logging.config.fileConfig(config_path) - - observer = PythonLoggingObserver() - observer.start() def run(): @@ -244,78 +220,49 @@ def run(): def setup(): - parser = argparse.ArgumentParser() - parser.add_argument("-p", "--port", dest="port", type=int, default=8080, - help="The port to listen on.") - parser.add_argument("-d", "--database", dest="db", default="homeserver.db", - help="The database name.") - parser.add_argument("-H", "--host", dest="host", default="localhost", - help="The hostname of the server.") - parser.add_argument('-v', '--verbose', dest="verbose", action='count', - help="The verbosity level.") - parser.add_argument('-f', '--log-file', dest="log_file", default=None, - help="File to log to.") - parser.add_argument('--log-config', dest="log_config", default=None, - help="Python logging config") - parser.add_argument('-D', '--daemonize', action='store_true', - default=False, help="Daemonize the home server") - parser.add_argument('--pid-file', dest="pid", help="When running as a " - "daemon, the file to store the pid in", - default="hs.pid") - parser.add_argument("-W", "--webclient", dest="webclient", default=True, - action="store_false", help="Don't host a web client.") - parser.add_argument("--manhole", dest="manhole", type=int, default=None, - help="Turn on the twisted telnet manhole service.") - args = parser.parse_args() - - verbosity = int(args.verbose) if args.verbose else None - - # Because if/when we daemonize we change to root dir. - db_name = os.path.abspath(args.db) - log_file = args.log_file - if log_file: - log_file = os.path.abspath(log_file) - - setup_logging( + config = HomeServerConfig.load_config("Synapse Homeserver", sys.argv[1:]) + + config.setup_logging( verbosity=verbosity, filename=log_file, config_path=args.log_config, ) - logger.info("Server hostname: %s", args.host) + logger.info("Server hostname: %s", config.server_name) - if re.search(":[0-9]+$", args.host): - domain_with_port = args.host + if re.search(":[0-9]+$", config.server_name): + domain_with_port = config.server_name else: - domain_with_port = "%s:%s" % (args.host, args.port) + domain_with_port = "%s:%s" % (args.server_name, config.bind_port) hs = SynapseHomeServer( - args.host, + config.server_name, domain_with_port=domain_with_port, upload_dir=os.path.abspath("uploads"), - db_name=db_name, + db_name=config.database_path, ) hs.register_servlets() hs.create_resource_tree( - web_client=args.webclient, - redirect_root_to_web_client=True) - hs.start_listening(args.port) + web_client=config.webclient, + redirect_root_to_web_client=True, + ) + hs.start_listening(config.bind_port) hs.get_db_pool() - if args.manhole: + if config.manhole: f = twisted.manhole.telnet.ShellFactory() f.username = "matrix" f.password = "rabbithole" f.namespace['hs'] = hs - reactor.listenTCP(args.manhole, f, interface='127.0.0.1') + reactor.listenTCP(config.manhole, f, interface='127.0.0.1') - if args.daemonize: + if config.daemonize: daemon = Daemonize( app="synapse-homeserver", - pid=args.pid, + pid=config.pid_file, action=run, auto_close_fds=False, verbose=True, diff --git a/synapse/config/__init__.py b/synapse/config/__init__.py new file mode 100644 index 0000000000..fe8a073cd3 --- /dev/null +++ b/synapse/config/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/synapse/config/_base.py b/synapse/config/_base.py new file mode 100644 index 0000000000..b4cf0262f4 --- /dev/null +++ b/synapse/config/_base.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import ConfigParser as configparser +import argparse +import sys +import os + + +class Config(object): + def __init__(self, args): + pass + + @staticmethod + def read_file(file_path): + with open(file_path) as file_stream: + return file_stream.read() + + @staticmethod + def read_config_file(file_path): + config = configparser.SafeConfigParser() + config.read([file_path]) + config_dict = {} + for section in config.sections(): + config_dict.update(config.items(section)) + return config_dict + + @classmethod + def add_arguments(cls, parser): + pass + + @classmethod + def generate_config(cls, args, config_dir_path): + pass + + @classmethod + def load_config(cls, description, argv, generate_section=None): + config_parser = argparse.ArgumentParser(add_help=False) + config_parser.add_argument( + "-c", "--config-path", + metavar="CONFIG_FILE", + help="Specify config file" + ) + config_args, remaining_args = config_parser.parse_known_args(argv) + + if generate_section: + if not config_args.config_path: + config_parser.error( + "Must specify where to generate the config file" + ) + config_dir_path = os.path.dirname(config_args.config_path) + if os.path.exists(config_args.config_path): + defaults = cls.read_config_file(config_args.config_path) + else: + if config_args.config_path: + defaults = cls.read_config_file(config_args.config_path) + else: + defaults = {} + + parser = argparse.ArgumentParser( + parents=[config_parser], + description=description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.set_defaults(**defaults) + + + cls.add_arguments(parser) + args = parser.parse_args(remaining_args) + + if generate_section: + config_dir_path = os.path.dirname(config_args.config_path) + config_dir_path = os.path.abspath(config_dir_path) + cls.generate_config(args, config_dir_path) + config = configparser.SafeConfigParser() + config.add_section(generate_section) + for key, value in vars(args).items(): + if key != "config_path" and value is not None: + config.set(generate_section, key, str(value)) + with open(config_args.config_path, "w") as config_file: + config.write(config_file) + + return cls(args) + + + diff --git a/synapse/config/database.py b/synapse/config/database.py new file mode 100644 index 0000000000..43f54be437 --- /dev/null +++ b/synapse/config/database.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._base import Config +import os + +class DatabaseConfig(Config): + def __init__(self, args): + self.db_path = os.path.abspath(args.database_path) + + @classmethod + def add_arguments(cls, parser): + super(DatabaseConfig, cls).add_arguments(parser) + db_group = parser.add_argument_group("database") + db_group.add_argument( + "-d", "--database", dest="database_path", default="homeserver.db", + help="The database name." + ) + + @classmethod + def generate_config(cls, args, config_dir_path): + super(DatabaseConfig, cls).generate_config(args, config_dir_path) + args.database_path = os.path.abspath(args.database_path) + diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py new file mode 100644 index 0000000000..18072e3196 --- /dev/null +++ b/synapse/config/homeserver.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .tls import TlsConfig +from .server import ServerConfig +from .logger import LoggingConfig +from .database import DatabaseConfig + +class HomeServerConfig(TlsConfig, ServerConfig, DatabaseConfig, LoggingConfig): + pass + +if __name__=='__main__': + import sys + HomeServerConfig.load_config("Generate config", sys.argv[1:], "HomeServer") diff --git a/synapse/config/logger.py b/synapse/config/logger.py new file mode 100644 index 0000000000..d34532c41a --- /dev/null +++ b/synapse/config/logger.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._base import Config + +from twisted.python.log import PythonLoggingObserver +import logging +import logging.config +import os + +class LoggingConfig(Config): + def __init__(self, args): + self.verbosity = int(args.verbose) if args.verbose else None + self.log_config = os.path.abspath(args.log_config) + self.log_file = os.path.abspath(args.log_file) + + @classmethod + def add_arguments(cls, parser): + super(LoggingConfig, cls).add_arguments(parser) + logging_group = parser.add_argument_group("logging") + logging_group.add_argument( + '-v', '--verbose', dest="verbose", action='count', + help="The verbosity level." + ) + logging_group.add_argument( + '-f', '--log-file', dest="log_file", default=None, + help="File to log to." + ) + logging_group.add_argument( + '--log-config', dest="log_config", default=None, + help="Python logging config file" + ) + + def setup_logging(self): + log_format = ( + '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s' + ) + if self.config_path is None: + + level = logging.INFO + if verbosity: + level = logging.DEBUG + + # FIXME: we need a logging.WARN for a -q quiet option + + logging.basicConfig( + level=level, + filename=filename, + format=log_format + ) + else: + logging.config.fileConfig(config_path) + + observer = PythonLoggingObserver() + observer.start() diff --git a/synapse/config/server.py b/synapse/config/server.py new file mode 100644 index 0000000000..4a656b06ab --- /dev/null +++ b/synapse/config/server.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import nacl.signing +import socket +import os +from ._base import Config +from syutil.base64util import encode_base64, decode_base64 + + +class ServerConfig(Config): + def __init__(self, args): + super(ServerConfig, self).__init__(args) + self.server_name = args.server_name + self.signing_key = self.read_signing_key(args.signing_key_path) + self.bind_port = args.bind_port + self.bind_host = args.bind_host + self.daemonize = args.daemonize + self.pid_file = os.path.abspath(args.pid_file) + + @classmethod + def add_arguments(cls, parser): + super(ServerConfig, cls).add_arguments(parser) + server_group = parser.add_argument_group("server") + server_group.add_argument("-H", "--server-name", default="localhost", + help="The name of the server") + server_group.add_argument("--signing-key-path", + help="The signing key to sign messages with") + server_group.add_argument("-p", "--bind-port", type=int, + help="TCP port to listen on") + server_group.add_argument("--bind-host", default="", + help="Local interface to listen on") + server_group.add_argument("-D", "--daemonize", action='store_true', + help="Daemonize the home server") + server_group.add_argument('--pid-file', default = "hs.pid", + help="When running as a daemon, the file to" + " store the pid in") + server_group.add_argument("-W", "--no-webclient", dest="webclient", + default=True, action="store_false", + help="Don't host a web client.") + server_group.add_argument("--manhole", dest="manhole", type=int, + help="Turn on the twisted telnet manhole" + " service on the given port.") + + def read_signing_key(self, signing_key_path): + signing_key_base64 = self.read_file(signing_key_path) + signing_key_bytes = decode_base64(signing_key_base64) + return nacl.signing.SigningKey(signing_key_bytes) + + @classmethod + def generate_config(cls, args, config_dir_path): + super(ServerConfig, cls).generate_config(args, config_dir_path) + base_key_name = os.path.join(config_dir_path, args.server_name) + + args.pid_file = os.path.abspath(args.pid_file) + + if not args.signing_key_path: + args.signing_key_path = base_key_name + ".signing.key" + + if not os.path.exists(args.signing_key_path): + with open(args.signing_key_path, "w") as signing_key_file: + key = nacl.signing.SigningKey.generate() + signing_key_file.write(encode_base64(key.encode())) diff --git a/synapse/config/tls.py b/synapse/config/tls.py new file mode 100644 index 0000000000..c65487ceb9 --- /dev/null +++ b/synapse/config/tls.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Copyright 2014 matrix.org +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ._base import Config + +from OpenSSL import crypto +import subprocess +import os + +class TlsConfig(Config): + def __init__(self, args): + super(TlsConfig, self).__init__(args) + self.tls_certificate = self.read_tls_certificate( + args.tls_certificate_path + ) + self.tls_private_key = self.read_tls_private_key( + args.tls_private_key_path + ) + self.tls_dh_params_path = args.tls_dh_params_path + + @classmethod + def add_arguments(cls, parser): + super(TlsConfig, cls).add_arguments(parser) + tls_group = parser.add_argument_group("tls") + tls_group.add_argument("--tls-certificate-path", + help="PEM encoded X509 certificate for TLS") + tls_group.add_argument("--tls-private-key-path", + help="PEM encoded private key for TLS") + tls_group.add_argument("--tls-dh-params-path", + help="PEM dh parameters for ephemeral keys") + + def read_tls_certificate(self, cert_path): + cert_pem = self.read_file(cert_path) + return crypto.load_certificate(crypto.FILETYPE_PEM, cert_pem) + + def read_tls_private_key(self, private_key_path): + private_key_pem = self.read_file(private_key_path) + return crypto.load_privatekey(crypto.FILETYPE_PEM, private_key_pem) + + @classmethod + def generate_config(cls, args, config_dir_path): + super(TlsConfig, cls).generate_config(args, config_dir_path) + base_key_name = os.path.join(config_dir_path, args.server_name) + + if args.tls_certificate_path is None: + args.tls_certificate_path = base_key_name + ".tls.crt" + + if args.tls_private_key_path is None: + args.tls_private_key_path = base_key_name + ".tls.key" + + if args.tls_dh_params_path is None: + args.tls_dh_params_path = base_key_name + ".tls.dh" + + if not os.path.exists(args.tls_private_key_path): + with open(args.tls_private_key_path, "w") as private_key_file: + tls_private_key = crypto.PKey() + tls_private_key.generate_key(crypto.TYPE_RSA, 2048) + private_key_pem = crypto.dump_privatekey( + crypto.FILETYPE_PEM, tls_private_key + ) + private_key_file.write(private_key_pem) + else: + with open(args.tls_private_key_path) as private_key_file: + private_key_pem = private_key_file.read() + tls_private_key = crypto.load_privatekey( + crypto.FILETYPE_PEM, private_key_pem + ) + + if not os.path.exists(args.tls_certificate_path): + with open(args.tls_certificate_path, "w") as certifcate_file: + cert = crypto.X509() + subject = cert.get_subject() + subject.CN = args.server_name + + cert.set_serial_number(1000) + cert.gmtime_adj_notBefore(0) + cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) + cert.set_issuer(cert.get_subject()) + cert.set_pubkey(tls_private_key) + + cert.sign(tls_private_key, 'sha256') + + cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) + + certifcate_file.write(cert_pem) + + if not os.path.exists(args.tls_dh_params_path): + subprocess.check_call([ + "openssl", "dhparam", + "-outform", "PEM", + "-out", args.tls_dh_params_path, + "2048" + ]) + diff --git a/synapse/crypto/config.py b/synapse/crypto/config.py deleted file mode 100644 index 2330133e71..0000000000 --- a/synapse/crypto/config.py +++ /dev/null @@ -1,160 +0,0 @@ -# -*- coding: utf-8 -*- -# Copyright 2014 matrix.org -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -import ConfigParser as configparser -import argparse -import socket -import sys -import os -from OpenSSL import crypto -import nacl.signing -from syutil.base64util import encode_base64 -import subprocess - - -def load_config(description, argv): - config_parser = argparse.ArgumentParser(add_help=False) - config_parser.add_argument("-c", "--config-path", metavar="CONFIG_FILE", - help="Specify config file") - config_args, remaining_args = config_parser.parse_known_args(argv) - if config_args.config_path: - config = configparser.SafeConfigParser() - config.read([config_args.config_path]) - defaults = dict(config.items("KeyServer")) - else: - defaults = {} - parser = argparse.ArgumentParser( - parents=[config_parser], - description=description, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.set_defaults(**defaults) - parser.add_argument("--server-name", default=socket.getfqdn(), - help="The name of the server") - parser.add_argument("--signing-key-path", - help="The signing key to sign responses with") - parser.add_argument("--tls-certificate-path", - help="PEM encoded X509 certificate for TLS") - parser.add_argument("--tls-private-key-path", - help="PEM encoded private key for TLS") - parser.add_argument("--tls-dh-params-path", - help="PEM encoded dh parameters for ephemeral keys") - parser.add_argument("--bind-port", type=int, - help="TCP port to listen on") - parser.add_argument("--bind-host", default="", - help="Local interface to listen on") - - args = parser.parse_args(remaining_args) - - server_config = vars(args) - del server_config["config_path"] - return server_config - - -def generate_config(argv): - parser = argparse.ArgumentParser() - parser.add_argument("-c", "--config-path", help="Specify config file", - metavar="CONFIG_FILE", required=True) - parser.add_argument("--server-name", default=socket.getfqdn(), - help="The name of the server") - parser.add_argument("--signing-key-path", - help="The signing key to sign responses with") - parser.add_argument("--tls-certificate-path", - help="PEM encoded X509 certificate for TLS") - parser.add_argument("--tls-private-key-path", - help="PEM encoded private key for TLS") - parser.add_argument("--tls-dh-params-path", - help="PEM encoded dh parameters for ephemeral keys") - parser.add_argument("--bind-port", type=int, required=True, - help="TCP port to listen on") - parser.add_argument("--bind-host", default="", - help="Local interface to listen on") - - args = parser.parse_args(argv) - - dir_name = os.path.dirname(args.config_path) - base_key_name = os.path.join(dir_name, args.server_name) - - if args.signing_key_path is None: - args.signing_key_path = base_key_name + ".signing.key" - - if args.tls_certificate_path is None: - args.tls_certificate_path = base_key_name + ".tls.crt" - - if args.tls_private_key_path is None: - args.tls_private_key_path = base_key_name + ".tls.key" - - if args.tls_dh_params_path is None: - args.tls_dh_params_path = base_key_name + ".tls.dh" - - if not os.path.exists(args.signing_key_path): - with open(args.signing_key_path, "w") as signing_key_file: - key = nacl.signing.SigningKey.generate() - signing_key_file.write(encode_base64(key.encode())) - - if not os.path.exists(args.tls_private_key_path): - with open(args.tls_private_key_path, "w") as private_key_file: - tls_private_key = crypto.PKey() - tls_private_key.generate_key(crypto.TYPE_RSA, 2048) - private_key_pem = crypto.dump_privatekey( - crypto.FILETYPE_PEM, tls_private_key - ) - private_key_file.write(private_key_pem) - else: - with open(args.tls_private_key_path) as private_key_file: - private_key_pem = private_key_file.read() - tls_private_key = crypto.load_privatekey( - crypto.FILETYPE_PEM, private_key_pem - ) - - if not os.path.exists(args.tls_certificate_path): - with open(args.tls_certificate_path, "w") as certifcate_file: - cert = crypto.X509() - subject = cert.get_subject() - subject.CN = args.server_name - - cert.set_serial_number(1000) - cert.gmtime_adj_notBefore(0) - cert.gmtime_adj_notAfter(10 * 365 * 24 * 60 * 60) - cert.set_issuer(cert.get_subject()) - cert.set_pubkey(tls_private_key) - - cert.sign(tls_private_key, 'sha256') - - cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert) - - certifcate_file.write(cert_pem) - - if not os.path.exists(args.tls_dh_params_path): - subprocess.check_call([ - "openssl", "dhparam", - "-outform", "PEM", - "-out", args.tls_dh_params_path, - "2048" - ]) - - config = configparser.SafeConfigParser() - config.add_section("KeyServer") - for key, value in vars(args).items(): - if key != "config_path": - config.set("KeyServer", key, str(value)) - - with open(args.config_path, "w") as config_file: - config.write(config_file) - - -if __name__ == "__main__": - generate_config(sys.argv[1:]) diff --git a/synapse/federation/replication.py b/synapse/federation/replication.py index 38ae360bcd..cadf574b3b 100644 --- a/synapse/federation/replication.py +++ b/synapse/federation/replication.py @@ -541,7 +541,10 @@ class _TransactionQueue(object): ) def eb(failure): - deferred.errback(failure) + if not deferred.called: + deferred.errback(failure) + else: + logger.exception("Failed to send edu", failure) self._attempt_new_transaction(destination).addErrback(eb) return deferred diff --git a/synapse/handlers/events.py b/synapse/handlers/events.py index e08231406d..980a169b25 100644 --- a/synapse/handlers/events.py +++ b/synapse/handlers/events.py @@ -16,6 +16,7 @@ from twisted.internet import defer from synapse.api.events import SynapseEvent +from synapse.util.logutils import log_function from ._base import BaseHandler @@ -44,6 +45,7 @@ class EventStreamHandler(BaseHandler): self.notifier = hs.get_notifier() @defer.inlineCallbacks + @log_function def get_stream(self, auth_user_id, pagin_config, timeout=0): auth_user = self.hs.parse_userid(auth_user_id) @@ -90,13 +92,15 @@ class EventStreamHandler(BaseHandler): # 10 seconds of grace to allow the client to reconnect again # before we think they're gone def _later(): + logger.debug("_later stopped_user_eventstream %s", auth_user) self.distributor.fire( "stopped_user_eventstream", auth_user ) del self._stop_timer_per_user[auth_user] + logger.debug("Scheduling _later: for %s", auth_user) self._stop_timer_per_user[auth_user] = ( - self.clock.call_later(5, _later) + self.clock.call_later(30, _later) ) diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py index 1cc820fb5b..eac110419c 100644 --- a/synapse/handlers/federation.py +++ b/synapse/handlers/federation.py @@ -146,7 +146,7 @@ class FederationHandler(BaseHandler): # Huh, let's try and get the current state try: yield self.replication_layer.get_state_for_context( - origin, event.room_id + event.origin, event.room_id ) hosts = yield self.store.get_joined_hosts_for_room( diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py index 32548e66fb..3d7f97bcff 100644 --- a/synapse/handlers/message.py +++ b/synapse/handlers/message.py @@ -277,10 +277,13 @@ class MessageHandler(BaseRoomHandler): end_token=now_token.events_key, ) + start_token = now_token.copy_and_replace("events_key", token[0]) + end_token = now_token.copy_and_replace("events_key", token[1]) + d["messages"] = { "chunk": [m.get_dict() for m in messages], - "start": token[0], - "end": token[1], + "start": start_token.to_string(), + "end": end_token.to_string(), } current_state = yield self.store.get_current_state( diff --git a/synapse/handlers/presence.py b/synapse/handlers/presence.py index c479908f61..7731de85c0 100644 --- a/synapse/handlers/presence.py +++ b/synapse/handlers/presence.py @@ -18,6 +18,8 @@ from twisted.internet import defer from synapse.api.errors import SynapseError, AuthError from synapse.api.constants import PresenceState +from synapse.util.logutils import log_function + from ._base import BaseHandler import logging @@ -142,7 +144,7 @@ class PresenceHandler(BaseHandler): @defer.inlineCallbacks def is_presence_visible(self, observer_user, observed_user): defer.returnValue(True) - return + # return # FIXME (erikj): This code path absolutely kills the database. assert(observed_user.is_mine) @@ -188,8 +190,9 @@ class PresenceHandler(BaseHandler): defer.returnValue(state) @defer.inlineCallbacks + @log_function def set_state(self, target_user, auth_user, state): - return + # return # TODO (erikj): Turn this back on. Why did we end up sending EDUs # everywhere? @@ -245,33 +248,42 @@ class PresenceHandler(BaseHandler): self.push_presence(user, statuscache=statuscache) + @log_function def started_user_eventstream(self, user): # TODO(paul): Use "last online" state self.set_state(user, user, {"state": PresenceState.ONLINE}) + @log_function def stopped_user_eventstream(self, user): # TODO(paul): Save current state as "last online" state self.set_state(user, user, {"state": PresenceState.OFFLINE}) @defer.inlineCallbacks def user_joined_room(self, user, room_id): - localusers = set() - remotedomains = set() - - rm_handler = self.homeserver.get_handlers().room_member_handler - yield rm_handler.fetch_room_distributions_into(room_id, - localusers=localusers, remotedomains=remotedomains, - ignore_user=user) if user.is_mine: - yield self._send_presence_to_distribution(srcuser=user, - localusers=localusers, remotedomains=remotedomains, + self.push_update_to_local_and_remote( + observed_user=user, + room_ids=[room_id], + statuscache=self._get_or_offline_usercache(user), + ) + + else: + self.push_update_to_clients( + observed_user=user, + room_ids=[room_id], statuscache=self._get_or_offline_usercache(user), ) - for srcuser in localusers: - yield self._send_presence(srcuser=srcuser, destuser=user, - statuscache=self._get_or_offline_usercache(srcuser), + # We also want to tell them about current presence of people. + rm_handler = self.homeserver.get_handlers().room_member_handler + curr_users = yield rm_handler.get_room_members(room_id) + + for local_user in [c for c in curr_users if c.is_mine]: + self.push_update_to_local_and_remote( + observed_user=local_user, + users_to_push=[user], + statuscache=self._get_or_offline_usercache(local_user), ) @defer.inlineCallbacks @@ -382,11 +394,13 @@ class PresenceHandler(BaseHandler): defer.returnValue(presence) @defer.inlineCallbacks + @log_function def start_polling_presence(self, user, target_user=None, state=None): logger.debug("Start polling for presence from %s", user) if target_user: target_users = set([target_user]) + room_ids = [] else: presence = yield self.store.get_presence_list( user.localpart, accepted=True @@ -400,23 +414,37 @@ class PresenceHandler(BaseHandler): rm_handler = self.homeserver.get_handlers().room_member_handler room_ids = yield rm_handler.get_rooms_for_user(user) - for room_id in room_ids: - for member in (yield rm_handler.get_room_members(room_id)): - target_users.add(member) - if state is None: state = yield self.store.get_presence_state(user.localpart) - - localusers, remoteusers = partitionbool( - target_users, - lambda u: u.is_mine + else: +# statuscache = self._get_or_make_usercache(user) +# self._user_cachemap_latest_serial += 1 +# statuscache.update(state, self._user_cachemap_latest_serial) + pass + + yield self.push_update_to_local_and_remote( + observed_user=user, + users_to_push=target_users, + room_ids=room_ids, + statuscache=self._get_or_make_usercache(user), ) - for target_user in localusers: - self._start_polling_local(user, target_user) + for target_user in target_users: + if target_user.is_mine: + self._start_polling_local(user, target_user) + + # We want to tell the person that just came online + # presence state of people they are interested in? + self.push_update_to_clients( + observed_user=target_user, + users_to_push=[user], + statuscache=self._get_or_offline_usercache(target_user), + ) deferreds = [] - remoteusers_by_domain = partition(remoteusers, lambda u: u.domain) + remote_users = [u for u in target_users if not u.is_mine] + remoteusers_by_domain = partition(remote_users, lambda u: u.domain) + # Only poll for people in our get_presence_list for domain in remoteusers_by_domain: remoteusers = remoteusers_by_domain[domain] @@ -438,25 +466,26 @@ class PresenceHandler(BaseHandler): self._local_pushmap[target_localpart].add(user) - self.push_update_to_clients( - observer_user=user, - observed_user=target_user, - statuscache=self._get_or_offline_usercache(target_user), - ) - def _start_polling_remote(self, user, domain, remoteusers): + to_poll = set() + for u in remoteusers: if u not in self._remote_recvmap: self._remote_recvmap[u] = set() + to_poll.add(u) self._remote_recvmap[u].add(user) + if not to_poll: + return defer.succeed(None) + return self.federation.send_edu( destination=domain, edu_type="m.presence", - content={"poll": [u.to_string() for u in remoteusers]} + content={"poll": [u.to_string() for u in to_poll]} ) + @log_function def stop_polling_presence(self, user, target_user=None): logger.debug("Stop polling for presence from %s", user) @@ -496,20 +525,28 @@ class PresenceHandler(BaseHandler): if not self._local_pushmap[localpart]: del self._local_pushmap[localpart] + @log_function def _stop_polling_remote(self, user, domain, remoteusers): + to_unpoll = set() + for u in remoteusers: self._remote_recvmap[u].remove(user) if not self._remote_recvmap[u]: del self._remote_recvmap[u] + to_unpoll.add(u) + + if not to_unpoll: + return defer.succeed(None) return self.federation.send_edu( destination=domain, edu_type="m.presence", - content={"unpoll": [u.to_string() for u in remoteusers]} + content={"unpoll": [u.to_string() for u in to_unpoll]} ) @defer.inlineCallbacks + @log_function def push_presence(self, user, statuscache): assert(user.is_mine) @@ -525,53 +562,17 @@ class PresenceHandler(BaseHandler): rm_handler = self.homeserver.get_handlers().room_member_handler room_ids = yield rm_handler.get_rooms_for_user(user) - for room_id in room_ids: - yield rm_handler.fetch_room_distributions_into( - room_id, localusers=localusers, remotedomains=remotedomains, - ignore_user=user, - ) - - if not localusers and not remotedomains: + if not localusers and not room_ids: defer.returnValue(None) - yield self._send_presence_to_distribution(user, - localusers=localusers, remotedomains=remotedomains, - statuscache=statuscache + yield self.push_update_to_local_and_remote( + observed_user=user, + users_to_push=localusers, + remote_domains=remotedomains, + room_ids=room_ids, + statuscache=statuscache, ) - def _send_presence(self, srcuser, destuser, statuscache): - if destuser.is_mine: - self.push_update_to_clients( - observer_user=destuser, - observed_user=srcuser, - statuscache=statuscache) - return defer.succeed(None) - else: - return self._push_presence_remote(srcuser, destuser.domain, - state=statuscache.get_state() - ) - - @defer.inlineCallbacks - def _send_presence_to_distribution(self, srcuser, localusers=set(), - remotedomains=set(), statuscache=None): - - for u in localusers: - logger.debug(" | push to local user %s", u) - self.push_update_to_clients( - observer_user=u, - observed_user=srcuser, - statuscache=statuscache, - ) - - deferreds = [] - for domain in remotedomains: - logger.debug(" | push to remote domain %s", domain) - deferreds.append(self._push_presence_remote(srcuser, domain, - state=statuscache.get_state()) - ) - - yield defer.DeferredList(deferreds) - @defer.inlineCallbacks def _push_presence_remote(self, user, destination, state=None): if state is None: @@ -587,12 +588,17 @@ class PresenceHandler(BaseHandler): self.clock.time_msec() - state.pop("mtime") ) + user_state = { + "user_id": user.to_string(), + } + user_state.update(**state) + yield self.federation.send_edu( destination=destination, edu_type="m.presence", content={ "push": [ - dict(user_id=user.to_string(), **state), + user_state, ], } ) @@ -611,12 +617,7 @@ class PresenceHandler(BaseHandler): rm_handler = self.homeserver.get_handlers().room_member_handler room_ids = yield rm_handler.get_rooms_for_user(user) - for room_id in room_ids: - yield rm_handler.fetch_room_distributions_into( - room_id, localusers=observers, ignore_user=user - ) - - if not observers: + if not observers and not room_ids: break state = dict(push) @@ -632,12 +633,12 @@ class PresenceHandler(BaseHandler): self._user_cachemap_latest_serial += 1 statuscache.update(state, serial=self._user_cachemap_latest_serial) - for observer_user in observers: - self.push_update_to_clients( - observer_user=observer_user, - observed_user=user, - statuscache=statuscache, - ) + self.push_update_to_clients( + observed_user=user, + users_to_push=observers, + room_ids=room_ids, + statuscache=statuscache, + ) if state["state"] == PresenceState.OFFLINE: del self._user_cachemap[user] @@ -671,12 +672,53 @@ class PresenceHandler(BaseHandler): yield defer.DeferredList(deferreds) - def push_update_to_clients(self, observer_user, observed_user, - statuscache): - statuscache.make_event(user=observed_user, clock=self.clock) + @defer.inlineCallbacks + def push_update_to_local_and_remote(self, observed_user, + users_to_push=[], room_ids=[], + remote_domains=[], + statuscache=None): + + localusers, remoteusers = partitionbool( + users_to_push, + lambda u: u.is_mine + ) + + localusers = set(localusers) + + self.push_update_to_clients( + observed_user=observed_user, + users_to_push=localusers, + room_ids=room_ids, + statuscache=statuscache, + ) + + remote_domains = set(remote_domains) + remote_domains |= set([r.domain for r in remoteusers]) + for room_id in room_ids: + remote_domains.update( + (yield self.store.get_joined_hosts_for_room(room_id)) + ) + + remote_domains.discard(self.hs.hostname) + + deferreds = [] + for domain in remote_domains: + logger.debug(" | push to remote domain %s", domain) + deferreds.append( + self._push_presence_remote( + observed_user, domain, state=statuscache.get_state() + ) + ) + + yield defer.DeferredList(deferreds) + + defer.returnValue((localusers, remote_domains)) + def push_update_to_clients(self, observed_user, users_to_push=[], + room_ids=[], statuscache=None): self.notifier.on_new_user_event( - [observer_user], + users_to_push, + room_ids, ) diff --git a/synapse/notifier.py b/synapse/notifier.py index 3d3fcdabdb..b6d5ec4820 100644 --- a/synapse/notifier.py +++ b/synapse/notifier.py @@ -119,6 +119,7 @@ class Notifier(object): ) @defer.inlineCallbacks + @log_function def on_new_user_event(self, users=[], rooms=[]): """ Used to inform listeners that something has happend presence/user event wise. diff --git a/synapse/storage/stream.py b/synapse/storage/stream.py index 4f42afc015..0b78222827 100644 --- a/synapse/storage/stream.py +++ b/synapse/storage/stream.py @@ -205,8 +205,11 @@ class StreamStore(SQLBaseStore): with_feedback=False): # TODO (erikj): Handle compressed feedback - from_comp = '<' if direction =='b' else '>' - to_comp = '>' if direction =='b' else '<' + # Tokens really represent positions between elements, but we use + # the convention of pointing to the event before the gap. Hence + # we have a bit of asymmetry when it comes to equalities. + from_comp = '<=' if direction =='b' else '>' + to_comp = '>' if direction =='b' else '<=' order = "DESC" if direction == 'b' else "ASC" args = [room_id] @@ -294,7 +297,7 @@ class StreamStore(SQLBaseStore): logger.debug("get_room_events_max_id: %s", res) if not res or not res[0] or not res[0]["m"]: - return "s1" + return "s0" key = res[0]["m"] return "s%d" % (key,) diff --git a/synapse/streams/config.py b/synapse/streams/config.py index 2434844d80..01bab568ff 100644 --- a/synapse/streams/config.py +++ b/synapse/streams/config.py @@ -81,4 +81,4 @@ class PaginationConfig(object): return ( "<PaginationConfig from_tok=%s, to_tok=%s, " "direction=%s, limit=%s>" - ) % (self.from_tok, self.to_tok, self.direction, self.limit) + ) % (self.from_token, self.to_token, self.direction, self.limit) diff --git a/synapse/util/logutils.py b/synapse/util/logutils.py index 021649071b..b94a749786 100644 --- a/synapse/util/logutils.py +++ b/synapse/util/logutils.py @@ -18,6 +18,8 @@ from inspect import getcallargs from functools import wraps import logging +import inspect +import traceback def log_function(f): @@ -65,4 +67,55 @@ def log_function(f): return f(*args, **kwargs) + wrapped.__name__ = func_name + return wrapped + + +def trace_function(f): + func_name = f.__name__ + linenum = f.func_code.co_firstlineno + pathname = f.func_code.co_filename + + def wrapped(*args, **kwargs): + name = f.__module__ + logger = logging.getLogger(name) + level = logging.DEBUG + + s = inspect.currentframe().f_back + + to_print = [ + "\t%s:%s %s. Args: args=%s, kwargs=%s" % ( + pathname, linenum, func_name, args, kwargs + ) + ] + while s: + if True or s.f_globals["__name__"].startswith("synapse"): + filename, lineno, function, _, _ = inspect.getframeinfo(s) + args_string = inspect.formatargvalues(*inspect.getargvalues(s)) + + to_print.append( + "\t%s:%d %s. Args: %s" % ( + filename, lineno, function, args_string + ) + ) + + s = s.f_back + + msg = "\nTraceback for %s:\n" % (func_name,) + "\n".join(to_print) + + record = logging.LogRecord( + name=name, + level=level, + pathname=pathname, + lineno=lineno, + msg=msg, + args=None, + exc_info=None + ) + + logger.handle(record) + + return f(*args, **kwargs) + + wrapped.__name__ = func_name return wrapped diff --git a/tests/handlers/test_presence.py b/tests/handlers/test_presence.py index 824ed07169..fcd7a784cd 100644 --- a/tests/handlers/test_presence.py +++ b/tests/handlers/test_presence.py @@ -15,7 +15,7 @@ from twisted.trial import unittest -from twisted.internet import defer +from twisted.internet import defer, reactor from mock import Mock, call, ANY import logging @@ -192,7 +192,8 @@ class PresenceStateTestCase(unittest.TestCase): ), SynapseError ) - test_get_disallowed_state.skip = "Presence polling is disabled" + + test_get_disallowed_state.skip = "Presence permissions are disabled" @defer.inlineCallbacks def test_set_my_state(self): @@ -217,7 +218,6 @@ class PresenceStateTestCase(unittest.TestCase): state={"state": OFFLINE}) self.mock_stop.assert_called_with(self.u_apple) - test_set_my_state.skip = "Presence polling is disabled" class PresenceInvitesTestCase(unittest.TestCase): @@ -499,6 +499,7 @@ class PresencePushTestCase(unittest.TestCase): db_pool=None, datastore=Mock(spec=[ "set_presence_state", + "get_joined_hosts_for_room", # Bits that Federation needs "prep_send_transaction", @@ -513,8 +514,12 @@ class PresencePushTestCase(unittest.TestCase): ) hs.handlers = JustPresenceHandlers(hs) + def update(*args,**kwargs): + # print "mock_update_client: Args=%s, kwargs=%s" %(args, kwargs,) + return defer.succeed(None) + self.mock_update_client = Mock() - self.mock_update_client.return_value = defer.succeed(None) + self.mock_update_client.side_effect = update self.datastore = hs.get_datastore() @@ -548,6 +553,14 @@ class PresencePushTestCase(unittest.TestCase): return defer.succeed([]) self.room_member_handler.get_room_members = get_room_members + def get_room_hosts(room_id): + if room_id == "a-room": + hosts = set([u.domain for u in self.room_members]) + return defer.succeed(hosts) + else: + return defer.succeed([]) + self.datastore.get_joined_hosts_for_room = get_room_hosts + @defer.inlineCallbacks def fetch_room_distributions_into(room_id, localusers=None, remotedomains=None, ignore_user=None): @@ -613,18 +626,10 @@ class PresencePushTestCase(unittest.TestCase): {"state": ONLINE}) self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, + call(users_to_push=set([self.u_apple, self.u_banana, self.u_clementine]), + room_ids=["a-room"], observed_user=self.u_apple, statuscache=ANY), # self-reflection - call(observer_user=self.u_banana, - observed_user=self.u_apple, - statuscache=ANY), - call(observer_user=self.u_clementine, - observed_user=self.u_apple, - statuscache=ANY), - call(observer_user=self.u_elderberry, - observed_user=self.u_apple, - statuscache=ANY), ], any_order=True) self.mock_update_client.reset_mock() @@ -653,30 +658,30 @@ class PresencePushTestCase(unittest.TestCase): ], presence) self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_banana, + call(users_to_push=set([self.u_banana]), + room_ids=[], observed_user=self.u_banana, statuscache=ANY), # self-reflection ]) # and no others... - test_push_local.skip = "Presence polling is disabled" @defer.inlineCallbacks def test_push_remote(self): put_json = self.mock_http_client.put_json - put_json.expect_call_and_return( - call("remote", - path=ANY, # Can't guarantee which txn ID will be which - data=_expect_edu("remote", "m.presence", - content={ - "push": [ - {"user_id": "@apple:test", - "state": "online", - "mtime_age": 0}, - ], - } - ) - ), - defer.succeed((200, "OK")) - ) +# put_json.expect_call_and_return( +# call("remote", +# path=ANY, # Can't guarantee which txn ID will be which +# data=_expect_edu("remote", "m.presence", +# content={ +# "push": [ +# {"user_id": "@apple:test", +# "state": "online", +# "mtime_age": 0}, +# ], +# } +# ) +# ), +# defer.succeed((200, "OK")) +# ) put_json.expect_call_and_return( call("farm", path=ANY, # Can't guarantee which txn ID will be which @@ -684,7 +689,7 @@ class PresencePushTestCase(unittest.TestCase): content={ "push": [ {"user_id": "@apple:test", - "state": "online", + "state": u"online", "mtime_age": 0}, ], } @@ -709,7 +714,6 @@ class PresencePushTestCase(unittest.TestCase): ) yield put_json.await_calls() - test_push_remote.skip = "Presence polling is disabled" @defer.inlineCallbacks def test_recv_remote(self): @@ -734,10 +738,8 @@ class PresencePushTestCase(unittest.TestCase): ) self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, - observed_user=self.u_potato, - statuscache=ANY), - call(observer_user=self.u_banana, + call(users_to_push=set([self.u_apple]), + room_ids=["a-room"], observed_user=self.u_potato, statuscache=ANY), ], any_order=True) @@ -757,19 +759,17 @@ class PresencePushTestCase(unittest.TestCase): ) self.mock_update_client.assert_has_calls([ - # Apple and Elderberry see each other - call(observer_user=self.u_apple, + call(room_ids=["a-room"], observed_user=self.u_elderberry, + users_to_push=set(), statuscache=ANY), - call(observer_user=self.u_elderberry, + call(users_to_push=set([self.u_elderberry]), observed_user=self.u_apple, + room_ids=[], statuscache=ANY), - # Banana and Elderberry see each other - call(observer_user=self.u_banana, - observed_user=self.u_elderberry, - statuscache=ANY), - call(observer_user=self.u_elderberry, + call(users_to_push=set([self.u_elderberry]), observed_user=self.u_banana, + room_ids=[], statuscache=ANY), ], any_order=True) @@ -857,6 +857,7 @@ class PresencePollingTestCase(unittest.TestCase): 'apple': [ "@banana:test", "@clementine:test" ], 'banana': [ "@apple:test" ], 'clementine': [ "@apple:test", "@potato:remote" ], + 'fig': [ "@potato:remote" ], } @@ -890,7 +891,12 @@ class PresencePollingTestCase(unittest.TestCase): self.datastore.get_received_txn_response = get_received_txn_response self.mock_update_client = Mock() - self.mock_update_client.return_value = defer.succeed(None) + + def update(*args,**kwargs): + # print "mock_update_client: Args=%s, kwargs=%s" %(args, kwargs,) + return defer.succeed(None) + + self.mock_update_client.side_effect = update self.handler = hs.get_handlers().presence_handler self.handler.push_update_to_clients = self.mock_update_client @@ -906,9 +912,10 @@ class PresencePollingTestCase(unittest.TestCase): # Mocked database state # Local users always start offline self.current_user_state = { - "apple": OFFLINE, - "banana": OFFLINE, - "clementine": OFFLINE, + "apple": OFFLINE, + "banana": OFFLINE, + "clementine": OFFLINE, + "fig": OFFLINE, } def get_presence_state(user_localpart): @@ -938,6 +945,7 @@ class PresencePollingTestCase(unittest.TestCase): self.u_apple = hs.parse_userid("@apple:test") self.u_banana = hs.parse_userid("@banana:test") self.u_clementine = hs.parse_userid("@clementine:test") + self.u_fig = hs.parse_userid("@fig:test") # Remote users self.u_potato = hs.parse_userid("@potato:remote") @@ -952,10 +960,10 @@ class PresencePollingTestCase(unittest.TestCase): # apple should see both banana and clementine currently offline self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, + call(users_to_push=[self.u_apple], observed_user=self.u_banana, statuscache=ANY), - call(observer_user=self.u_apple, + call(users_to_push=[self.u_apple], observed_user=self.u_clementine, statuscache=ANY), ], any_order=True) @@ -975,10 +983,11 @@ class PresencePollingTestCase(unittest.TestCase): # apple and banana should now both see each other online self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, + call(users_to_push=set([self.u_apple]), observed_user=self.u_banana, + room_ids=[], statuscache=ANY), - call(observer_user=self.u_banana, + call(users_to_push=[self.u_banana], observed_user=self.u_apple, statuscache=ANY), ], any_order=True) @@ -995,14 +1004,14 @@ class PresencePollingTestCase(unittest.TestCase): # banana should now be told apple is offline self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_banana, + call(users_to_push=set([self.u_banana, self.u_apple]), observed_user=self.u_apple, + room_ids=[], statuscache=ANY), ], any_order=True) self.assertFalse("banana" in self.handler._local_pushmap) self.assertFalse("clementine" in self.handler._local_pushmap) - test_push_local.skip = "Presence polling is disabled" @defer.inlineCallbacks @@ -1010,7 +1019,7 @@ class PresencePollingTestCase(unittest.TestCase): put_json = self.mock_http_client.put_json put_json.expect_call_and_return( call("remote", - path="/matrix/federation/v1/send/1000000/", + path=ANY, data=_expect_edu("remote", "m.presence", content={ "poll": [ "@potato:remote" ], @@ -1020,6 +1029,18 @@ class PresencePollingTestCase(unittest.TestCase): defer.succeed((200, "OK")) ) + put_json.expect_call_and_return( + call("remote", + path=ANY, + data=_expect_edu("remote", "m.presence", + content={ + "push": [ {"user_id": "@clementine:test" }], + }, + ), + ), + defer.succeed((200, "OK")) + ) + # clementine goes online yield self.handler.set_state( target_user=self.u_clementine, auth_user=self.u_clementine, @@ -1028,13 +1049,48 @@ class PresencePollingTestCase(unittest.TestCase): yield put_json.await_calls() # Gut-wrenching tests - self.assertTrue(self.u_potato in self.handler._remote_recvmap) + self.assertTrue(self.u_potato in self.handler._remote_recvmap, + msg="expected potato to be in _remote_recvmap" + ) self.assertTrue(self.u_clementine in self.handler._remote_recvmap[self.u_potato]) + + put_json.expect_call_and_return( + call("remote", + path=ANY, + data=_expect_edu("remote", "m.presence", + content={ + "push": [ {"user_id": "@fig:test" }], + }, + ), + ), + defer.succeed((200, "OK")) + ) + + # fig goes online; shouldn't send a second poll + yield self.handler.set_state( + target_user=self.u_fig, auth_user=self.u_fig, + state={"state": ONLINE} + ) + + # reactor.iterate(delay=0) + + yield put_json.await_calls() + + # fig goes offline + yield self.handler.set_state( + target_user=self.u_fig, auth_user=self.u_fig, + state={"state": OFFLINE} + ) + + reactor.iterate(delay=0) + + put_json.assert_had_no_calls() + put_json.expect_call_and_return( call("remote", - path="/matrix/federation/v1/send/1000001/", + path=ANY, data=_expect_edu("remote", "m.presence", content={ "unpoll": [ "@potato:remote" ], @@ -1049,10 +1105,11 @@ class PresencePollingTestCase(unittest.TestCase): target_user=self.u_clementine, auth_user=self.u_clementine, state={"state": OFFLINE}) - put_json.await_calls() + yield put_json.await_calls() - self.assertFalse(self.u_potato in self.handler._remote_recvmap) - test_remote_poll_send.skip = "Presence polling is disabled" + self.assertFalse(self.u_potato in self.handler._remote_recvmap, + msg="expected potato not to be in _remote_recvmap" + ) @defer.inlineCallbacks def test_remote_poll_receive(self): diff --git a/tests/handlers/test_presencelike.py b/tests/handlers/test_presencelike.py index 1b106fc2b3..e81d7ce101 100644 --- a/tests/handlers/test_presencelike.py +++ b/tests/handlers/test_presencelike.py @@ -81,7 +81,11 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): self.replication = hs.get_replication_layer() self.replication.send_edu = Mock() - self.replication.send_edu.return_value = defer.succeed((200, "OK")) + + def send_edu(*args, **kwargs): + # print "send_edu: %s, %s" % (args, kwargs) + return defer.succeed((200, "OK")) + self.replication.send_edu.side_effect = send_edu def get_profile_displayname(user_localpart): return defer.succeed("Frank") @@ -95,11 +99,12 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): return defer.succeed("http://foo") self.datastore.get_profile_avatar_url = get_profile_avatar_url + self.presence_list = [ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ] def get_presence_list(user_localpart, accepted=None): - return defer.succeed([ - {"observed_user_id": "@banana:test"}, - {"observed_user_id": "@clementine:test"}, - ]) + return defer.succeed(self.presence_list) self.datastore.get_presence_list = get_presence_list def do_users_share_a_room(userlist): @@ -109,7 +114,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): self.handlers = hs.get_handlers() self.mock_update_client = Mock() - self.mock_update_client.return_value = defer.succeed(None) + def update(*args, **kwargs): + # print "mock_update_client: %s, %s" %(args, kwargs) + return defer.succeed(None) + self.mock_update_client.side_effect = update self.handlers.presence_handler.push_update_to_clients = ( self.mock_update_client) @@ -130,6 +138,11 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): @defer.inlineCallbacks def test_set_my_state(self): + self.presence_list = [ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ] + mocked_set = self.datastore.set_presence_state mocked_set.return_value = defer.succeed({"state": OFFLINE}) @@ -139,10 +152,14 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): mocked_set.assert_called_with("apple", {"state": UNAVAILABLE, "status_msg": "Away"}) - test_set_my_state.skip = "Presence polling is disabled" @defer.inlineCallbacks def test_push_local(self): + self.presence_list = [ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ] + self.datastore.set_presence_state.return_value = defer.succeed( {"state": ONLINE}) @@ -174,12 +191,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): presence) self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, + call(users_to_push=set([self.u_apple, self.u_banana, self.u_clementine]), + room_ids=[], observed_user=self.u_apple, statuscache=ANY), # self-reflection - call(observer_user=self.u_banana, - observed_user=self.u_apple, - statuscache=ANY), ], any_order=True) statuscache = self.mock_update_client.call_args[1]["statuscache"] @@ -199,12 +214,10 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): self.u_apple, "I am an Apple") self.mock_update_client.assert_has_calls([ - call(observer_user=self.u_apple, + call(users_to_push=set([self.u_apple, self.u_banana, self.u_clementine]), + room_ids=[], observed_user=self.u_apple, statuscache=ANY), # self-reflection - call(observer_user=self.u_banana, - observed_user=self.u_apple, - statuscache=ANY), ], any_order=True) statuscache = self.mock_update_client.call_args[1]["statuscache"] @@ -214,11 +227,14 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): "displayname": "I am an Apple", "avatar_url": "http://foo", }, statuscache.state) - test_push_local.skip = "Presence polling is disabled" @defer.inlineCallbacks def test_push_remote(self): + self.presence_list = [ + {"observed_user_id": "@potato:remote"}, + ] + self.datastore.set_presence_state.return_value = defer.succeed( {"state": ONLINE}) @@ -246,10 +262,14 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): ], }, ) - test_push_remote.skip = "Presence polling is disabled" @defer.inlineCallbacks def test_recv_remote(self): + self.presence_list = [ + {"observed_user_id": "@banana:test"}, + {"observed_user_id": "@clementine:test"}, + ] + # TODO(paul): Gut-wrenching potato_set = self.handlers.presence_handler._remote_recvmap.setdefault( self.u_potato, set()) @@ -267,7 +287,8 @@ class PresenceProfilelikeDataTestCase(unittest.TestCase): ) self.mock_update_client.assert_called_with( - observer_user=self.u_apple, + users_to_push=set([self.u_apple]), + room_ids=[], observed_user=self.u_potato, statuscache=ANY) diff --git a/tests/rest/test_presence.py b/tests/rest/test_presence.py index e15ee38741..7f7347dcf9 100644 --- a/tests/rest/test_presence.py +++ b/tests/rest/test_presence.py @@ -114,7 +114,6 @@ class PresenceStateTestCase(unittest.TestCase): self.assertEquals(200, code) mocked_set.assert_called_with("apple", {"state": UNAVAILABLE, "status_msg": "Away"}) - test_set_my_status.skip = "Presence polling is disabled" class PresenceListTestCase(unittest.TestCase): @@ -318,4 +317,3 @@ class PresenceEventStreamTestCase(unittest.TestCase): "mtime_age": 0, }}, ]}, response) - test_shortpoll.skip = "Presence polling is disabled" diff --git a/tests/utils.py b/tests/utils.py index b32d5ef356..37b759febc 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -21,7 +21,7 @@ from synapse.api.events.room import ( RoomMemberEvent, MessageEvent ) -from twisted.internet import defer +from twisted.internet import defer, reactor from collections import namedtuple from mock import patch, Mock @@ -248,8 +248,11 @@ class DeferredMockCallable(object): def __init__(self): self.expectations = [] + self.calls = [] def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + if not self.expectations: raise ValueError("%r has no pending calls to handle call(%s)" % ( self, _format_call(args, kwargs)) @@ -260,15 +263,52 @@ class DeferredMockCallable(object): d.callback(None) return result - raise AssertionError("Was not expecting call(%s)" % + failure = AssertionError("Was not expecting call(%s)" % _format_call(args, kwargs) ) + for _, _, d in self.expectations: + try: + d.errback(failure) + except: + pass + + raise failure + def expect_call_and_return(self, call, result): self.expectations.append((call, result, defer.Deferred())) @defer.inlineCallbacks - def await_calls(self): - while self.expectations: - (_, _, d) = self.expectations.pop(0) - yield d + def await_calls(self, timeout=1000): + deferred = defer.DeferredList( + [d for _, _, d in self.expectations], + fireOnOneErrback=True + ) + + timer = reactor.callLater( + timeout/1000, + deferred.errback, + AssertionError( + "%d pending calls left: %s"% ( + len([e for e in self.expectations if not e[2].called]), + [e for e in self.expectations if not e[2].called] + ) + ) + ) + + yield deferred + + timer.cancel() + + self.calls = [] + + def assert_had_no_calls(self): + if self.calls: + calls = self.calls + self.calls = [] + + raise AssertionError("Expected not to received any calls, got:\n" + + "\n".join([ + "call(%s)" % _format_call(c[0], c[1]) for c in calls + ]) + ) diff --git a/webclient/app.js b/webclient/app.js index 2d6624ceec..02695c3ae6 100644 --- a/webclient/app.js +++ b/webclient/app.js @@ -24,6 +24,8 @@ var matrixWebClient = angular.module('matrixWebClient', [ 'SettingsController', 'UserController', 'matrixService', + 'matrixPhoneService', + 'MatrixCall', 'eventStreamService', 'eventHandlerService', 'infinite-scroll' diff --git a/webclient/components/matrix/event-handler-service.js b/webclient/components/matrix/event-handler-service.js index df61429db5..b6e5c2eaac 100644 --- a/webclient/components/matrix/event-handler-service.js +++ b/webclient/components/matrix/event-handler-service.js @@ -31,6 +31,7 @@ angular.module('eventHandlerService', []) var MSG_EVENT = "MSG_EVENT"; var MEMBER_EVENT = "MEMBER_EVENT"; var PRESENCE_EVENT = "PRESENCE_EVENT"; + var CALL_EVENT = "CALL_EVENT"; var InitialSyncDeferred = $q.defer(); @@ -94,12 +95,16 @@ angular.module('eventHandlerService', []) $rootScope.presence[event.content.user_id] = event; $rootScope.$broadcast(PRESENCE_EVENT, event, isLiveEvent); }; - + + var handleCallEvent = function(event, isLiveEvent) { + $rootScope.$broadcast(CALL_EVENT, event, isLiveEvent); + }; return { MSG_EVENT: MSG_EVENT, MEMBER_EVENT: MEMBER_EVENT, PRESENCE_EVENT: PRESENCE_EVENT, + CALL_EVENT: CALL_EVENT, handleEvent: function(event, isLiveEvent) { @@ -117,6 +122,9 @@ angular.module('eventHandlerService', []) console.log("Unable to handle event type " + event.type); break; } + if (event.type.indexOf('m.call.') == 0) { + handleCallEvent(event, isLiveEvent); + } }, // isLiveEvents determines whether notifications should be shown, whether diff --git a/webclient/components/matrix/event-stream-service.js b/webclient/components/matrix/event-stream-service.js index 4cc2bf4c4e..441148670e 100644 --- a/webclient/components/matrix/event-stream-service.js +++ b/webclient/components/matrix/event-stream-service.js @@ -25,7 +25,8 @@ the eventHandlerService. angular.module('eventStreamService', []) .factory('eventStreamService', ['$q', '$timeout', 'matrixService', 'eventHandlerService', function($q, $timeout, matrixService, eventHandlerService) { var END = "END"; - var TIMEOUT_MS = 30000; + var SERVER_TIMEOUT_MS = 30000; + var CLIENT_TIMEOUT_MS = 40000; var ERR_TIMEOUT_MS = 5000; var settings = { @@ -55,7 +56,7 @@ angular.module('eventStreamService', []) deferred = deferred || $q.defer(); // run the stream from the latest token - matrixService.getEventStream(settings.from, TIMEOUT_MS).then( + matrixService.getEventStream(settings.from, SERVER_TIMEOUT_MS, CLIENT_TIMEOUT_MS).then( function(response) { if (!settings.isActive) { console.log("[EventStream] Got response but now inactive. Dropping data."); @@ -80,7 +81,7 @@ angular.module('eventStreamService', []) } }, function(error) { - if (error.status == 403) { + if (error.status === 403) { settings.shouldPoll = false; } diff --git a/webclient/components/matrix/matrix-call.js b/webclient/components/matrix/matrix-call.js new file mode 100644 index 0000000000..c0a7735a7c --- /dev/null +++ b/webclient/components/matrix/matrix-call.js @@ -0,0 +1,268 @@ +/* +Copyright 2014 matrix.org + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +var forAllVideoTracksOnStream = function(s, f) { + var tracks = s.getVideoTracks(); + for (var i = 0; i < tracks.length; i++) { + f(tracks[i]); + } +} + +var forAllAudioTracksOnStream = function(s, f) { + var tracks = s.getAudioTracks(); + for (var i = 0; i < tracks.length; i++) { + f(tracks[i]); + } +} + +var forAllTracksOnStream = function(s, f) { + forAllVideoTracksOnStream(s, f); + forAllAudioTracksOnStream(s, f); +} + +angular.module('MatrixCall', []) +.factory('MatrixCall', ['matrixService', 'matrixPhoneService', function MatrixCallFactory(matrixService, matrixPhoneService) { + var MatrixCall = function(room_id) { + this.room_id = room_id; + this.call_id = "c" + new Date().getTime(); + this.state = 'fledgling'; + } + + navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia; + + window.RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection; + + MatrixCall.prototype.placeCall = function() { + self = this; + matrixPhoneService.callPlaced(this); + navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForInvite(s); }, function(e) { self.getUserMediaFailed(e); }); + self.state = 'wait_local_media'; + }; + + MatrixCall.prototype.initWithInvite = function(msg) { + this.msg = msg; + this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}) + self= this; + this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); }; + this.peerConn.onicecandidate = function(c) { self.gotLocalIceCandidate(c); }; + this.peerConn.onsignalingstatechange = function() { self.onSignallingStateChanged(); }; + this.peerConn.onaddstream = function(s) { self.onAddStream(s); }; + this.peerConn.setRemoteDescription(new RTCSessionDescription(this.msg.offer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError); + this.state = 'ringing'; + }; + + MatrixCall.prototype.answer = function() { + console.trace("Answering call "+this.call_id); + self = this; + navigator.getUserMedia({audio: true, video: false}, function(s) { self.gotUserMediaForAnswer(s); }, function(e) { self.getUserMediaFailed(e); }); + this.state = 'wait_local_media'; + }; + + MatrixCall.prototype.stopAllMedia = function() { + if (this.localAVStream) { + forAllTracksOnStream(this.localAVStream, function(t) { + t.stop(); + }); + } + if (this.remoteAVStream) { + forAllTracksOnStream(this.remoteAVStream, function(t) { + t.stop(); + }); + } + }; + + MatrixCall.prototype.hangup = function() { + console.trace("Ending call "+this.call_id); + + this.stopAllMedia(); + + var content = { + version: 0, + call_id: this.call_id, + }; + matrixService.sendEvent(this.room_id, 'm.call.hangup', undefined, content).then(this.messageSent, this.messageSendFailed); + this.state = 'ended'; + }; + + MatrixCall.prototype.gotUserMediaForInvite = function(stream) { + this.localAVStream = stream; + var audioTracks = stream.getAudioTracks(); + for (var i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = true; + } + this.peerConn = new window.RTCPeerConnection({"iceServers":[{"urls":"stun:stun.l.google.com:19302"}]}) + self = this; + this.peerConn.oniceconnectionstatechange = function() { self.onIceConnectionStateChanged(); }; + this.peerConn.onsignalingstatechange = function() { self.onSignallingStateChanged(); }; + this.peerConn.onicecandidate = function(c) { self.gotLocalIceCandidate(c); }; + this.peerConn.onaddstream = function(s) { self.onAddStream(s); }; + this.peerConn.addStream(stream); + this.peerConn.createOffer(function(d) { + self.gotLocalOffer(d); + }, function(e) { + self.getLocalOfferFailed(e); + }); + this.state = 'create_offer'; + }; + + MatrixCall.prototype.gotUserMediaForAnswer = function(stream) { + this.localAVStream = stream; + var audioTracks = stream.getAudioTracks(); + for (var i = 0; i < audioTracks.length; i++) { + audioTracks[i].enabled = true; + } + this.peerConn.addStream(stream); + self = this; + var constraints = { + 'mandatory': { + 'OfferToReceiveAudio': true, + 'OfferToReceiveVideo': false + }, + }; + this.peerConn.createAnswer(function(d) { self.createdAnswer(d); }, function(e) {}, constraints); + this.state = 'create_answer'; + }; + + MatrixCall.prototype.gotLocalIceCandidate = function(event) { + console.trace(event); + if (event.candidate) { + var content = { + version: 0, + call_id: this.call_id, + candidate: event.candidate + }; + matrixService.sendEvent(this.room_id, 'm.call.candidate', undefined, content).then(this.messageSent, this.messageSendFailed); + } + } + + MatrixCall.prototype.gotRemoteIceCandidate = function(cand) { + console.trace("Got ICE candidate from remote: "+cand); + var candidateObject = new RTCIceCandidate({ + sdpMLineIndex: cand.label, + candidate: cand.candidate + }); + this.peerConn.addIceCandidate(candidateObject, function() {}, function(e) {}); + }; + + MatrixCall.prototype.receivedAnswer = function(msg) { + this.peerConn.setRemoteDescription(new RTCSessionDescription(msg.answer), self.onSetRemoteDescriptionSuccess, self.onSetRemoteDescriptionError); + this.state = 'connecting'; + }; + + MatrixCall.prototype.gotLocalOffer = function(description) { + console.trace("Created offer: "+description); + this.peerConn.setLocalDescription(description); + + var content = { + version: 0, + call_id: this.call_id, + offer: description + }; + matrixService.sendEvent(this.room_id, 'm.call.invite', undefined, content).then(this.messageSent, this.messageSendFailed); + this.state = 'invite_sent'; + }; + + MatrixCall.prototype.createdAnswer = function(description) { + console.trace("Created answer: "+description); + this.peerConn.setLocalDescription(description); + var content = { + version: 0, + call_id: this.call_id, + answer: description + }; + matrixService.sendEvent(this.room_id, 'm.call.answer', undefined, content).then(this.messageSent, this.messageSendFailed); + this.state = 'connecting'; + }; + + MatrixCall.prototype.messageSent = function() { + }; + + MatrixCall.prototype.messageSendFailed = function(error) { + }; + + MatrixCall.prototype.getLocalOfferFailed = function(error) { + this.onError("Failed to start audio for call!"); + }; + + MatrixCall.prototype.getUserMediaFailed = function() { + this.onError("Couldn't start capturing audio! Is your microphone set up?"); + }; + + MatrixCall.prototype.onIceConnectionStateChanged = function() { + console.trace("Ice connection state changed to: "+this.peerConn.iceConnectionState); + // ideally we'd consider the call to be connected when we get media but chrome doesn't implement nay of the 'onstarted' events yet + if (this.peerConn.iceConnectionState == 'completed' || this.peerConn.iceConnectionState == 'connected') { + this.state = 'connected'; + } + }; + + MatrixCall.prototype.onSignallingStateChanged = function() { + console.trace("Signalling state changed to: "+this.peerConn.signalingState); + }; + + MatrixCall.prototype.onSetRemoteDescriptionSuccess = function() { + console.trace("Set remote description"); + }; + + MatrixCall.prototype.onSetRemoteDescriptionError = function(e) { + console.trace("Failed to set remote description"+e); + }; + + MatrixCall.prototype.onAddStream = function(event) { + console.trace("Stream added"+event); + + var s = event.stream; + + this.remoteAVStream = s; + + var self = this; + forAllTracksOnStream(s, function(t) { + // not currently implemented in chrome + t.onstarted = self.onRemoteStreamTrackStarted; + }); + + event.stream.onended = function(e) { self.onRemoteStreamEnded(e); }; + // not currently implemented in chrome + event.stream.onstarted = function(e) { self.onRemoteStreamStarted(e); }; + var player = new Audio(); + player.src = URL.createObjectURL(s); + player.play(); + }; + + MatrixCall.prototype.onRemoteStreamStarted = function(event) { + this.state = 'connected'; + }; + + MatrixCall.prototype.onRemoteStreamEnded = function(event) { + this.state = 'ended'; + this.stopAllMedia(); + this.onHangup(); + }; + + MatrixCall.prototype.onRemoteStreamTrackStarted = function(event) { + this.state = 'connected'; + }; + + MatrixCall.prototype.onHangupReceived = function() { + this.state = 'ended'; + this.stopAllMedia(); + this.onHangup(); + }; + + return MatrixCall; +}]); diff --git a/webclient/components/matrix/matrix-phone-service.js b/webclient/components/matrix/matrix-phone-service.js new file mode 100644 index 0000000000..d9e2e8baa3 --- /dev/null +++ b/webclient/components/matrix/matrix-phone-service.js @@ -0,0 +1,68 @@ +/* +Copyright 2014 matrix.org + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +'use strict'; + +angular.module('matrixPhoneService', []) +.factory('matrixPhoneService', ['$rootScope', '$injector', 'matrixService', 'eventHandlerService', function MatrixPhoneService($rootScope, $injector, matrixService, eventHandlerService) { + var matrixPhoneService = function() { + }; + + matrixPhoneService.INCOMING_CALL_EVENT = "INCOMING_CALL_EVENT"; + matrixPhoneService.allCalls = {}; + + matrixPhoneService.callPlaced = function(call) { + matrixPhoneService.allCalls[call.call_id] = call; + }; + + $rootScope.$on(eventHandlerService.CALL_EVENT, function(ngEvent, event, isLive) { + if (!isLive) return; // until matrix supports expiring messages + if (event.user_id == matrixService.config().user_id) return; + var msg = event.content; + if (event.type == 'm.call.invite') { + var MatrixCall = $injector.get('MatrixCall'); + var call = new MatrixCall(event.room_id); + call.call_id = msg.call_id; + call.initWithInvite(msg); + matrixPhoneService.allCalls[call.call_id] = call; + $rootScope.$broadcast(matrixPhoneService.INCOMING_CALL_EVENT, call); + } else if (event.type == 'm.call.answer') { + var call = matrixPhoneService.allCalls[msg.call_id]; + if (!call) { + console.trace("Got answer for unknown call ID "+msg.call_id); + return; + } + call.receivedAnswer(msg); + } else if (event.type == 'm.call.candidate') { + var call = matrixPhoneService.allCalls[msg.call_id]; + if (!call) { + console.trace("Got candidate for unknown call ID "+msg.call_id); + return; + } + call.gotRemoteIceCandidate(msg.candidate); + } else if (event.type == 'm.call.hangup') { + var call = matrixPhoneService.allCalls[msg.call_id]; + if (!call) { + console.trace("Got hangup for unknown call ID "+msg.call_id); + return; + } + call.onHangupReceived(); + matrixPhoneService.allCalls[msg.call_id] = undefined; + } + }); + + return matrixPhoneService; +}]); diff --git a/webclient/components/matrix/matrix-service.js b/webclient/components/matrix/matrix-service.js index 2feddac5d8..8543491dca 100644 --- a/webclient/components/matrix/matrix-service.js +++ b/webclient/components/matrix/matrix-service.js @@ -41,7 +41,7 @@ angular.module('matrixService', []) var prefixPath = "/matrix/client/api/v1"; var MAPPING_PREFIX = "alias_for_"; - var doRequest = function(method, path, params, data) { + var doRequest = function(method, path, params, data, $httpParams) { if (!config) { console.warn("No config exists. Cannot perform request to "+path); return; @@ -58,7 +58,7 @@ angular.module('matrixService', []) path = prefixPath + path; } - return doBaseRequest(config.homeserver, method, path, params, data, undefined); + return doBaseRequest(config.homeserver, method, path, params, data, undefined, $httpParams); }; var doBaseRequest = function(baseUrl, method, path, params, data, headers, $httpParams) { @@ -172,9 +172,9 @@ angular.module('matrixService', []) return doRequest("GET", path, undefined, {}); }, - sendMessage: function(room_id, txn_id, content) { + sendEvent: function(room_id, eventType, txn_id, content) { // The REST path spec - var path = "/rooms/$room_id/send/m.room.message/$txn_id"; + var path = "/rooms/$room_id/send/"+eventType+"/$txn_id"; if (!txn_id) { txn_id = "m" + new Date().getTime(); @@ -190,6 +190,10 @@ angular.module('matrixService', []) return doRequest("PUT", path, undefined, content); }, + sendMessage: function(room_id, txn_id, content) { + return this.sendEvent(room_id, 'm.room.message', txn_id, content); + }, + // Send a text message sendTextMessage: function(room_id, body, msg_id) { var content = { @@ -343,15 +347,31 @@ angular.module('matrixService', []) return doBaseRequest(config.homeserver, "POST", path, params, file, headers, $httpParams); }, - - // start listening on /events - getEventStream: function(from, timeout) { + + /** + * Start listening on /events + * @param {String} from the token from which to listen events to + * @param {Integer} serverTimeout the time in ms the server will hold open the connection + * @param {Integer} clientTimeout the timeout in ms used at the client HTTP request level + * @returns a promise + */ + getEventStream: function(from, serverTimeout, clientTimeout) { var path = "/events"; var params = { from: from, - timeout: timeout + timeout: serverTimeout }; - return doRequest("GET", path, params); + + var $httpParams; + if (clientTimeout) { + // If the Internet connection is lost, this timeout is used to be able to + // cancel the current request and notify the client so that it can retry with a new request. + $httpParams = { + timeout: clientTimeout + }; + } + + return doRequest("GET", path, params, undefined, $httpParams); }, // Indicates if user authentications details are stored in cache @@ -420,34 +440,38 @@ angular.module('matrixService', []) /****** Room aliases management ******/ /** - * Enhance data returned by rooms() and publicRooms() by adding room_alias - * & room_display_name which are computed from data already retrieved from the server. - * @param {Array} data the response of rooms() and publicRooms() - * @returns {Array} the same array with enriched objects + * Get the room_alias & room_display_name which are computed from data + * already retrieved from the server. + * @param {Room object} room one element of the array returned by the response + * of rooms() and publicRooms() + * @returns {Object} {room_alias: "...", room_display_name: "..."} */ - assignRoomAliases: function(data) { - for (var i=0; i<data.length; i++) { - var alias = this.getRoomIdToAliasMapping(data[i].room_id); - if (alias) { - // use the existing alias from storage - data[i].room_alias = alias; - data[i].room_display_name = alias; - } - else if (data[i].aliases && data[i].aliases[0]) { - // save the mapping - // TODO: select the smarter alias from the array - this.createRoomIdToAliasMapping(data[i].room_id, data[i].aliases[0]); - data[i].room_display_name = data[i].aliases[0]; - } - else if (data[i].membership == "invite" && "inviter" in data[i]) { - data[i].room_display_name = data[i].inviter + "'s room" - } - else { - // last resort use the room id - data[i].room_display_name = data[i].room_id; - } + getRoomAliasAndDisplayName: function(room) { + var result = { + room_alias: undefined, + room_display_name: undefined + }; + + var alias = this.getRoomIdToAliasMapping(room.room_id); + if (alias) { + // use the existing alias from storage + result.room_alias = alias; + result.room_display_name = alias; + } + else if (room.aliases && room.aliases[0]) { + // save the mapping + // TODO: select the smarter alias from the array + this.createRoomIdToAliasMapping(room.room_id, room.aliases[0]); + result.room_display_name = room.aliases[0]; + } + else if (room.membership === "invite" && "inviter" in room) { + result.room_display_name = room.inviter + "'s room"; + } + else { + // last resort use the room id + result.room_display_name = room.room_id; } - return data; + return result; }, createRoomIdToAliasMapping: function(roomId, alias) { diff --git a/webclient/components/matrix/presence-service.js b/webclient/components/matrix/presence-service.js index 6a1edcaf43..555118133b 100644 --- a/webclient/components/matrix/presence-service.js +++ b/webclient/components/matrix/presence-service.js @@ -23,9 +23,9 @@ angular.module('mPresence', []) .service('mPresence', ['$timeout', 'matrixService', function ($timeout, matrixService) { - // Time in ms after that a user is considered as offline/away - var OFFLINE_TIME = 5 * 60000; // 5 mins - + // Time in ms after that a user is considered as unavailable/away + var UNAVAILABLE_TIME = 5 * 60000; // 5 mins + // The current presence state var state = undefined; @@ -88,11 +88,11 @@ angular.module('mPresence', []) }; /** - * Callback called when the user made no action on the page for OFFLINE_TIME ms. + * Callback called when the user made no action on the page for UNAVAILABLE_TIME ms. * @private */ - function onOfflineTimerFire() { - self.setState(matrixService.presence.offline); + function onUnvailableTimerFire() { + self.setState(matrixService.presence.unavailable); } /** @@ -105,7 +105,7 @@ angular.module('mPresence', []) // Re-arm the timer $timeout.cancel(timer); - timer = $timeout(onOfflineTimerFire, OFFLINE_TIME); + timer = $timeout(onUnvailableTimerFire, UNAVAILABLE_TIME); } }]); diff --git a/webclient/home/home-controller.js b/webclient/home/home-controller.js index 008dff7422..547a5c5603 100644 --- a/webclient/home/home-controller.js +++ b/webclient/home/home-controller.js @@ -42,7 +42,13 @@ angular.module('HomeController', ['matrixService', 'eventHandlerService', 'Recen matrixService.publicRooms().then( function(response) { - $scope.public_rooms = matrixService.assignRoomAliases(response.data.chunk); + $scope.public_rooms = response.data.chunk; + for (var i = 0; i < $scope.public_rooms.length; i++) { + var room = $scope.public_rooms[i]; + + // Add room_alias & room_display_name members + angular.extend(room, matrixService.getRoomAliasAndDisplayName(room)); + } } ); }; diff --git a/webclient/index.html b/webclient/index.html index 16f0e8ac5f..5faf165626 100644 --- a/webclient/index.html +++ b/webclient/index.html @@ -26,6 +26,8 @@ <script src="settings/settings-controller.js"></script> <script src="user/user-controller.js"></script> <script src="components/matrix/matrix-service.js"></script> + <script src="components/matrix/matrix-call.js"></script> + <script src="components/matrix/matrix-phone-service.js"></script> <script src="components/matrix/event-stream-service.js"></script> <script src="components/matrix/event-handler-service.js"></script> <script src="components/matrix/presence-service.js"></script> diff --git a/webclient/recents/recents-controller.js b/webclient/recents/recents-controller.js index 803ab420f9..d33d41a922 100644 --- a/webclient/recents/recents-controller.js +++ b/webclient/recents/recents-controller.js @@ -29,7 +29,7 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService']) // Refresh the list on matrix invitation and message event $scope.$on(eventHandlerService.MEMBER_EVENT, function(ngEvent, event, isLive) { var config = matrixService.config(); - if (event.state_key === config.user_id && event.content.membership === "invite") { + if (isLive && event.state_key === config.user_id && event.content.membership === "invite") { console.log("Invited to room " + event.room_id); // FIXME push membership to top level key to match /im/sync event.membership = event.content.membership; @@ -39,7 +39,9 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService']) } }); $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { - $scope.rooms[event.room_id].lastMsg = event; + if (isLive) { + $scope.rooms[event.room_id].lastMsg = event; + } }); }; @@ -53,13 +55,16 @@ angular.module('RecentsController', ['matrixService', 'eventHandlerService']) // Reset data $scope.rooms = {}; - var data = matrixService.assignRoomAliases(response.data.rooms); - for (var i=0; i<data.length; i++) { - $scope.rooms[data[i].room_id] = data[i]; + var rooms = response.data.rooms; + for (var i=0; i<rooms.length; i++) { + var room = rooms[i]; + + // Add room_alias & room_display_name members + $scope.rooms[room.room_id] = angular.extend(room, matrixService.getRoomAliasAndDisplayName(room)); // Create a shortcut for the last message of this room - if (data[i].messages && data[i].messages.chunk && data[i].messages.chunk[0]) { - $scope.rooms[data[i].room_id].lastMsg = data[i].messages.chunk[0]; + if (room.messages && room.messages.chunk && room.messages.chunk[0]) { + $scope.rooms[room.room_id].lastMsg = room.messages.chunk[0]; } } diff --git a/webclient/recents/recents.html b/webclient/recents/recents.html index 6fda6c5c6b..3f025a98d8 100644 --- a/webclient/recents/recents.html +++ b/webclient/recents/recents.html @@ -39,6 +39,11 @@ {{ room.lastMsg.user_id }} sent an image </div> + <div ng-switch-when="m.emote"> + <span ng-bind-html="'* ' + (room.lastMsg.user_id) + ' ' + room.lastMsg.content.body | linky:'_blank'"> + </span> + </div> + <div ng-switch-default> {{ room.lastMsg.content }} </div> diff --git a/webclient/room/room-controller.js b/webclient/room/room-controller.js index 910168754c..09dac85d26 100644 --- a/webclient/room/room-controller.js +++ b/webclient/room/room-controller.js @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) -.controller('RoomController', ['$scope', '$http', '$timeout', '$routeParams', '$location', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mUtilities', '$rootScope', - function($scope, $http, $timeout, $routeParams, $location, matrixService, eventHandlerService, mFileUpload, mUtilities, $rootScope) { +angular.module('RoomController', ['ngSanitize', 'mFileInput']) +.controller('RoomController', ['$scope', '$timeout', '$routeParams', '$location', '$rootScope', 'matrixService', 'eventHandlerService', 'mFileUpload', 'mPresence', 'matrixPhoneService', 'MatrixCall', + function($scope, $timeout, $routeParams, $location, $rootScope, matrixService, eventHandlerService, mFileUpload, mPresence, matrixPhoneService, MatrixCall) { 'use strict'; var MESSAGES_PER_PAGINATION = 30; var THUMBNAIL_SIZE = 320; @@ -51,21 +51,20 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) objDiv.scrollTop = objDiv.scrollHeight; }, 0); }; - + $scope.$on(eventHandlerService.MSG_EVENT, function(ngEvent, event, isLive) { if (isLive && event.room_id === $scope.room_id) { scrollToBottom(); if (window.Notification) { - // FIXME: we should also notify based on a timer or other heuristics - // rather than the window being minimised - if (document.hidden) { + // Show notification when the user is idle + if (matrixService.presence.offline === mPresence.getState()) { var notification = new window.Notification( ($scope.members[event.user_id].displayname || event.user_id) + " (" + ($scope.room_alias || $scope.room_id) + ")", // FIXME: don't leak room_ids here { "body": event.content.body, - "icon": $scope.members[event.user_id].avatar_url, + "icon": $scope.members[event.user_id].avatar_url }); $timeout(function() { notification.close(); @@ -82,6 +81,17 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) $scope.$on(eventHandlerService.PRESENCE_EVENT, function(ngEvent, event, isLive) { updatePresence(event); }); + + $rootScope.$on(matrixPhoneService.INCOMING_CALL_EVENT, function(ngEvent, call) { + console.trace("incoming call"); + call.onError = $scope.onCallError; + call.onHangup = $scope.onCallHangup; + $scope.currentCall = call; + }); + + $scope.memberCount = function() { + return Object.keys($scope.members).length; + }; $scope.paginateMore = function() { if ($scope.state.can_paginate) { @@ -89,6 +99,15 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) paginate(MESSAGES_PER_PAGINATION); } }; + + $scope.answerCall = function() { + $scope.currentCall.answer(); + }; + + $scope.hangupCall = function() { + $scope.currentCall.hangup(); + $scope.currentCall = undefined; + }; var paginate = function(numItems) { // console.log("paginate " + numItems); @@ -214,7 +233,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) var member = $scope.members[target_user_id]; member.content.membership = chunk.content.membership; } - } + }; var updatePresence = function(chunk) { if (!(chunk.content.user_id in $scope.members)) { @@ -241,10 +260,10 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) if ("avatar_url" in chunk.content) { member.avatar_url = chunk.content.avatar_url; } - } + }; $scope.send = function() { - if ($scope.textInput == "") { + if ($scope.textInput === "") { return; } @@ -253,7 +272,7 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) // Send the text message var promise; // FIXME: handle other commands too - if ($scope.textInput.indexOf("/me") == 0) { + if ($scope.textInput.indexOf("/me") === 0) { promise = matrixService.sendEmoteMessage($scope.room_id, $scope.textInput.substr(4)); } else { @@ -454,4 +473,21 @@ angular.module('RoomController', ['ngSanitize', 'mFileInput', 'mUtilities']) $scope.loadMoreHistory = function() { paginate(MESSAGES_PER_PAGINATION); }; + + $scope.startVoiceCall = function() { + var call = new MatrixCall($scope.room_id); + call.onError = $scope.onCallError; + call.onHangup = $scope.onCallHangup; + call.placeCall(); + $scope.currentCall = call; + } + + $scope.onCallError = function(errStr) { + $scope.feedback = errStr; + } + + $scope.onCallHangup = function() { + $scope.feedback = "Call ended"; + $scope.currentCall = undefined; + } }]); diff --git a/webclient/room/room.html b/webclient/room/room.html index 236ca0a89b..a3514c3a91 100644 --- a/webclient/room/room.html +++ b/webclient/room/room.html @@ -45,13 +45,13 @@ </td> <td ng-class="!msg.content.membership ? (msg.content.msgtype === 'm.emote' ? 'emote text' : 'text') : 'membership text'"> <div class="bubble"> - <span ng-hide='msg.type !== "m.room.member"'> + <span ng-show='msg.type === "m.room.member"'> {{ members[msg.user_id].displayname || msg.user_id }} {{ {"join": "joined", "leave": "left", "invite": "invited"}[msg.content.membership] }} {{ msg.content.membership === "invite" ? (msg.state_key || '') : '' }} </span> - <span ng-hide='msg.content.msgtype !== "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/> - <span ng-hide='msg.content.msgtype !== "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/> + <span ng-show='msg.content.msgtype === "m.emote"' ng-bind-html="'* ' + (members[msg.user_id].displayname || msg.user_id) + ' ' + msg.content.body | linky:'_blank'"/> + <span ng-show='msg.content.msgtype === "m.text"' ng-bind-html="((msg.content.msgtype === 'm.text') ? msg.content.body : '') | linky:'_blank'"/> <div ng-show='msg.content.msgtype === "m.image"'> <div ng-hide='msg.content.thumbnail_url' ng-style="msg.content.body.h && { 'height' : (msg.content.body.h < 320) ? msg.content.body.h : 320}"> <img class="image" ng-src="{{ msg.content.url }}"/> @@ -98,10 +98,18 @@ <button ng-click="inviteUser(userIDToInvite)">Invite</button> </span> <button ng-click="leaveRoom()">Leave</button> + <button ng-click="startVoiceCall()" ng-show="currentCall == undefined && memberCount() == 2">Voice Call</button> + <div ng-show="currentCall.state == 'ringing'"> + Incoming call from {{ currentCall.user_id }} + <button ng-click="answerCall()">Answer</button> + <button ng-click="hangupCall()">Reject</button> + </div> + <button ng-click="hangupCall()" ng-show="currentCall && currentCall.state != 'ringing'">Hang up</button> + <span style="display: none; ">{{ currentCall.state }}</span> </div> {{ feedback }} - <div ng-hide="!state.stream_failure"> + <div ng-show="state.stream_failure"> {{ state.stream_failure.data.error || "Connection failure" }} </div> </div> |