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>
|