Compare commits

..

10 Commits

Author SHA1 Message Date
Timofey Parrrate
f12889ccdf Add 'LICENSE' 2021-12-22 10:32:26 +00:00
da1d9a23d6 readme
and some untested `exact` and `flag` code also
2021-12-22 13:30:05 +03:00
5f49cd7f46 typing 2020-11-08 17:42:40 +03:00
41b1579785 !return code 2020-08-23 00:11:20 +03:00
c9d8be1a24 push vibrate 2020-08-23 00:09:52 +03:00
f008ed2462 run encoding 2020-08-23 00:06:16 +03:00
ee4a6865d8 popen -> run 2020-08-23 00:03:46 +03:00
ab311c54d8 push x 2020-08-22 23:19:22 +03:00
57fc83567d staging 2020-08-19 00:16:01 +03:00
502afe9827 rc1 2020-08-16 17:32:16 +03:00
18 changed files with 197 additions and 98 deletions

3
.gitignore vendored
View File

@ -206,3 +206,6 @@ fabric.properties
# Others # Others
*.db *.db
/v25pushx/
/report.sql
/vapidkeys.json

View File

@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/v25pushx" />
<excludeFolder url="file://$MODULE_DIR$/venv" /> <excludeFolder url="file://$MODULE_DIR$/venv" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />

View File

@ -2,5 +2,6 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" /> <mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/v25pushx" vcs="Git" />
</component> </component>
</project> </project>

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2021 PARRRATE T&V
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -1,2 +1,3 @@
# v25 # v25
replace public keys in `dev-config.json` and `staging-config.json` with your own to allow messaging

View File

@ -1,7 +1,7 @@
from flask import Flask from flask import Flask
from werkzeug.middleware.dispatcher import DispatcherMiddleware from werkzeug.middleware.dispatcher import DispatcherMiddleware
import config from v25 import config
from v25.storage.dbstorage import DBStorage from v25.storage.dbstorage import DBStorage
from v25.web.server.api import API from v25.web.server.api import API
@ -11,7 +11,7 @@ def simple(_env, resp):
return [] return []
d = config.get_config() d = config.get_config('dev-config.json')
config.from_config(d) config.from_config(d)
app = Flask(__name__) app = Flask(__name__)

View File

@ -2,7 +2,7 @@ from setuptools import setup
setup( setup(
name='v25', name='v25',
version='0.0.1-a1', version='0.0.1-rc1',
packages=['v25', 'v25.web', 'v25.web.client', 'v25.web.server', 'v25.storage', 'v25.messaging'], packages=['v25', 'v25.web', 'v25.web.client', 'v25.web.server', 'v25.storage', 'v25.messaging'],
url='', url='',
license='', license='',

17
staging-config.json Normal file
View File

@ -0,0 +1,17 @@
{
"db": "sqlite:///staging.db",
"subjects": {
"ro6ncuJxA_cGQ51hPKw11Q84of08j7GtOjL0Xr5GaFs=": {
"allowed": null,
"contacts": null
},
"oNNNAvX5nsJEQGf33xulhh27cpECgQtJT3jzu2VyNKY=": {
"allowed": null,
"contacts": null
},
"uLep1UFgMlYDaIM8MEgMYTDY6HWcUq6Y4VvkyglbGJ8=": {
"allowed": null,
"contacts": null
}
}
}

22
staging-main.py Normal file
View File

@ -0,0 +1,22 @@
from flask import Flask
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from v25 import config
from v25.storage.dbstorage import DBStorage
from v25.web.server.api import API
def simple(_env, resp):
resp('404 OK', [])
return []
d = config.get_config('staging-config.json')
config.from_config(d)
app = Flask(__name__)
app.wsgi_app = DispatcherMiddleware(simple, {
'/v25': API(__name__, DBStorage(d['db']).pushing())
})
app.config['ENV'] = 'staging'
app.run(port=5013)

View File

@ -4,11 +4,11 @@ from typing import Dict, Any, Union
from v25.storage.dbstorage import DBStorage from v25.storage.dbstorage import DBStorage
_d_type = Dict[Any, Union[str, Dict[str, Any]]] _d_type = Dict[str, Union[str, Dict[str, Any]]]
def get_config() -> _d_type: def get_config(file: str) -> _d_type:
with open('config.json') as f: with open(file) as f:
return json.load(f) return json.load(f)
@ -17,11 +17,3 @@ def from_config(d: _d_type):
subjects = d["subjects"] subjects = d["subjects"]
for subject in subjects: for subject in subjects:
storage.ssssj(subject, json.dumps(subjects[subject])) storage.ssssj(subject, json.dumps(subjects[subject]))
def main():
from_config(get_config())
if __name__ == '__main__':
main()

View File

@ -1,6 +1,6 @@
__all__ = ('Flags',) import nacl.hash
Q_FLAG = '<?>' __all__ = ('Flags',)
class Flags: class Flags:
@ -9,14 +9,17 @@ class Flags:
def __init__(self, flags: str): def __init__(self, flags: str):
self.flags: str = flags self.flags: str = flags
def quable(self) -> bool: def hash(self):
return Q_FLAG in self.flags return nacl.hash.sha256(self.flags.encode()).decode()
def deq(self) -> str: def joint(self, flags: str):
return Flags(self.flags.replace(Q_FLAG, '')).deq() if self.quable() else self.flags return flags.startswith(self.hash())
def enq(self) -> str: def join(self, flags: str):
return self.flags if self.quable() else self.flags + Q_FLAG h = self.hash()
if flags.startswith(h):
return flags
return h + flags
Flags.default = Flags('<unedited>').enq() Flags.default = '<unedited>'

View File

@ -95,7 +95,7 @@ class Message:
def edit(self, pcontent: bytes) -> 'Message': def edit(self, pcontent: bytes) -> 'Message':
return Message(self.sfrom, self.sto, self.idnonce, None, return Message(self.sfrom, self.sto, self.idnonce, None,
Encoding.nonce(), pcontent, None, Encoding.nonce(), pcontent, None,
Flags(self.flags.replace('<unedited>', '<edited>')).enq()).sealed() self.flags.replace('<unedited>', '<edited>')).sealed()
def edit_(self): def edit_(self):
return self.flags_(self.flags) return self.flags_(self.flags)

View File

@ -1,10 +1,11 @@
import json import json
from contextlib import closing
from subprocess import run
from sys import stderr from sys import stderr
from threading import Thread from threading import Thread
from time import time, sleep from time import time, sleep
from typing import Tuple, Optional, Iterable, List from typing import Tuple, Optional, Iterable, List
import requests
from nacl.bindings import crypto_sign_PUBLICKEYBYTES from nacl.bindings import crypto_sign_PUBLICKEYBYTES
from sqlalchemy import create_engine, LargeBinary, Column, REAL, BLOB, String, or_, and_, ForeignKeyConstraint, \ from sqlalchemy import create_engine, LargeBinary, Column, REAL, BLOB, String, or_, and_, ForeignKeyConstraint, \
Integer, UniqueConstraint, Index, Boolean Integer, UniqueConstraint, Index, Boolean
@ -145,6 +146,43 @@ class PushState(Base):
return True return True
class TypingState(Base):
__tablename__ = 'typingstate'
sf = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
st = Column(LargeBinary(crypto_sign_PUBLICKEYBYTES), primary_key=True)
last = Column(REAL)
@classmethod
def _get(cls, sfrom: Subject, sto: Subject, session) -> Tuple['TypingState', 'TypingState']:
sf = sfrom.vkey.encode()
st = sto.vkey.encode()
try:
statef = session.query(TypingState).filter_by(sf=sf, st=st).one()
except NoResultFound:
statef = TypingState(sf=sf, st=st, last=0)
session.add(statef)
try:
statet = session.query(TypingState).filter_by(sf=st, st=sf).one()
except NoResultFound:
statet = TypingState(sf=st, st=sf, last=0)
session.add(statet)
return statef, statet
@classmethod
def typing(cls, sfrom: Subject, sto: Subject, last: float, session) -> float:
statef, statet = cls._get(sfrom, sto, session)
statef.last = max(statef.last, last)
session.commit()
return statet.last
@classmethod
def reset(cls, m: Message, session):
statef, _ = cls._get(m.sfrom, m.sto, session)
statef.last = 0
session.commit()
class DBStorage(PushStorage): class DBStorage(PushStorage):
def __init__(self, *args): def __init__(self, *args):
self.args = args self.args = args
@ -153,8 +191,7 @@ class DBStorage(PushStorage):
self.Session = sessionmaker(bind=self.engine) self.Session = sessionmaker(bind=self.engine)
def check(self, subject: Subject) -> dict: def check(self, subject: Subject) -> dict:
session = self.Session() with closing(self.Session()) as session:
try:
sssj = session.query(SSSJ).filter_by(subject=subject.vkey.encode()).one_or_none() sssj = session.query(SSSJ).filter_by(subject=subject.vkey.encode()).one_or_none()
status = json.loads(sssj.status) if sssj else {} status = json.loads(sssj.status) if sssj else {}
if 'contacts' in status: if 'contacts' in status:
@ -169,29 +206,23 @@ class DBStorage(PushStorage):
contacts.add(st) contacts.add(st)
status['contacts'] = list(map(Encoding.encode, contacts)) status['contacts'] = list(map(Encoding.encode, contacts))
return status return status
finally:
session.close()
def push(self, m: Message) -> None: def push(self, m: Message) -> None:
session = self.Session() with closing(self.Session()) as session:
try:
msg = Msg.from_message(m) msg = Msg.from_message(m)
session.add(msg) session.add(msg)
session.add(msg.sgn()) session.add(msg.sgn())
self.event(session, m, EVENT_PUSH) self.event(session, m, EVENT_PUSH)
session.commit() session.commit()
if m.sfrom == m.sto: if m.sfrom != m.sto:
return state = PushState.of(m.sto, session)
state = PushState.of(m.sto, session) state.pushmsg = True
state.pushmsg = True session.commit()
session.commit() TypingState.reset(m, session)
finally:
session.close()
def edit(self, old: Message, new: Message): def edit(self, old: Message, new: Message):
assert old.edited(new), 'edit misuse' assert old.edited(new), 'edit misuse'
session = self.Session() with closing(self.Session()) as session:
try:
msg = self.one_alike(session, old) msg = self.one_alike(session, old)
assert msg.en == old.editnonce, 'edit misuse' assert msg.en == old.editnonce, 'edit misuse'
msgn = Msg.from_message(new) msgn = Msg.from_message(new)
@ -200,27 +231,22 @@ class DBStorage(PushStorage):
msg.flags = msgn.flags msg.flags = msgn.flags
self.event(session, new, EVENT_EDIT) self.event(session, new, EVENT_EDIT)
session.commit() session.commit()
finally: TypingState.reset(new, session)
session.close()
@staticmethod @staticmethod
def one_alike(session, m: Message) -> Msg: def one_alike(session, m: Message) -> Msg:
return session.query(Msg).filter_by(sf=m.sfrom.vkey.encode(), st=m.sto.vkey.encode(), idn=m.idnonce).one() return session.query(Msg).filter_by(sf=m.sfrom.vkey.encode(), st=m.sto.vkey.encode(), idn=m.idnonce).one()
def delete(self, m: Message): def delete(self, m: Message):
session = self.Session() with closing(self.Session()) as session:
try:
session.delete(self.one_alike(session, m)) session.delete(self.one_alike(session, m))
self.event(session, m, EVENT_DELETE) self.event(session, m, EVENT_DELETE)
session.commit() session.commit()
finally:
session.close()
def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]: def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
if params is None: if params is None:
params = {} params = {}
session = self.Session() with closing(self.Session()) as session:
try:
cquery: Query = session.query(Msg).filter(or_( cquery: Query = session.query(Msg).filter(or_(
and_( and_(
Msg.sf == pair[0].vkey.encode(), Msg.sf == pair[0].vkey.encode(),
@ -232,56 +258,51 @@ class DBStorage(PushStorage):
), ),
)) ))
query: Query = cquery query: Query = cquery
if 'ts' in params:
if '>' in params:
query = query.filter(Msg.ts > params['ts']['>'])
if '<' in params:
query = query.filter(Msg.ts < params['ts']['<'])
if params.get('before'): if params.get('before'):
query = query.filter(Msg.oid < cquery.filter(Msg.idn == Encoding.decode(params['before'])).one().oid) query = query.filter(Msg.oid < cquery.filter(Msg.idn == Encoding.decode(params['before'])).one().oid)
if params.get('after'): if params.get('after'):
query = query.filter(Msg.oid > cquery.filter(Msg.idn == Encoding.decode(params['after'])).one().oid) query = query.filter(Msg.oid > cquery.filter(Msg.idn == Encoding.decode(params['after'])).one().oid)
if params.get('exact'): if params.get('exact'):
query = query.filter_by(idn=Encoding.decode(params['exact'])) query = query.filter_by(idn=Encoding.decode(params['exact']), sf=pair[0].vkey.encode())
for flag in params.get('flags', ()): for flag in params.get('flags', ()):
query = query.filter(Msg.flags.contains(flag)) query = query.filter(Msg.flags.contains(flag))
query = query.order_by(Msg.oid.desc()) query = query.order_by(Msg.oid.desc())
if 'limit' in params: if 'limit' in params:
query = query.limit(params['limit']) query = query.limit(params['limit'])
return map(Msg.to_message, list(query.from_self().order_by(Msg.oid))) res = map(Msg.to_message, list(query.from_self().order_by(Msg.oid)))
finally: if 'edit_' in params:
session.close() res = (m.edit_() for m in res)
return res
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
with closing(self.Session()) as session:
query: Query = session.query(Msg)
query = query.filter_by(sf=sfrom, st=sto, idn=idnonce)
if editnonce:
query = query.filter_by(en=editnonce)
msg: Optional[Msg] = query.one_or_none()
return msg or msg.to_message()
def flags(self, m: Message, flags: str): def flags(self, m: Message, flags: str):
assert not Flags(flags).quable(), 'flags misuse' with closing(self.Session()) as session:
session = self.Session()
try:
msg: Msg = self.one_alike(session, m) msg: Msg = self.one_alike(session, m)
assert msg.en == m.editnonce, 'flags misuse' assert msg.en == m.editnonce, 'flags misuse'
assert Flags(msg.flags).quable(), 'flags misuse' assert Flags(msg.flags).joint(flags)
msg.flags = flags msg.flags = flags
session.commit() session.commit()
finally:
session.close()
def clearsssj(self): def clearsssj(self):
session = self.Session() with closing(self.Session()) as session:
try:
session.query(SSSJ).delete() session.query(SSSJ).delete()
session.commit() session.commit()
finally:
session.close()
def ssssj(self, subject: str, status: str): def ssssj(self, subject: str, status: str):
"""set SSSJ""" """set SSSJ"""
session = self.Session() with closing(self.Session()) as session:
try:
subject = Subject(Encoding.decode(subject)).vkey.encode() subject = Subject(Encoding.decode(subject)).vkey.encode()
session.query(SSSJ).filter_by(subject=subject).delete() session.query(SSSJ).filter_by(subject=subject).delete()
session.add(SSSJ(subject=subject, status=status)) session.add(SSSJ(subject=subject, status=status))
session.commit() session.commit()
finally:
session.close()
@staticmethod @staticmethod
def event(session, m: Message, code: int): def event(session, m: Message, code: int):
@ -294,8 +315,7 @@ class DBStorage(PushStorage):
return query.filter_by(en=en).one() return query.filter_by(en=en).one()
def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]: def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]:
session = self.Session() with closing(self.Session()) as session:
try:
PushState.of(sto, session).online() PushState.of(sto, session).online()
session.commit() session.commit()
query: Query = session.query(MsgEvent) query: Query = session.query(MsgEvent)
@ -308,67 +328,51 @@ class DBStorage(PushStorage):
query = query.order_by(MsgEvent.oid) query = query.order_by(MsgEvent.oid)
ev: MsgEvent ev: MsgEvent
return [(ev.en, ev.idn) for ev in query.all()] return [(ev.en, ev.idn) for ev in query.all()]
finally:
session.close()
def cleanevents(self): def cleanevents(self):
session = self.Session() with closing(self.Session()) as session:
try:
session.query(MsgEvent).delete(MsgEvent.ts < time() - 604800) session.query(MsgEvent).delete(MsgEvent.ts < time() - 604800)
session.commit() session.commit()
finally:
session.close()
def subscribe(self, subject: Subject, subscription: dict): def subscribe(self, subject: Subject, subscription: dict):
session = self.Session() with closing(self.Session()) as session:
try:
subject = subject.vkey.encode() subject = subject.vkey.encode()
session.query(PushSub).filter_by(subject=subject).delete() session.query(PushSub).filter_by(subject=subject).delete()
session.add(PushSub(subject=subject, subscription=json.dumps(subscription))) session.add(PushSub(subject=subject, subscription=json.dumps(subscription)))
session.commit() session.commit()
finally:
session.close()
def subscription(self, subject: Subject) -> Optional[dict]: def subscription(self, subject: Subject) -> Optional[dict]:
session = self.Session() with closing(self.Session()) as session:
try:
subscription: Optional[PushSub] = session.query(PushSub).filter_by( subscription: Optional[PushSub] = session.query(PushSub).filter_by(
subject=subject.vkey.encode()).one_or_none() subject=subject.vkey.encode()).one_or_none()
if subscription is None: if subscription is None:
return None return None
return json.loads(subscription.subscription) return json.loads(subscription.subscription)
finally:
session.close()
@staticmethod @staticmethod
def pushsubscription(subscription: dict): def pushsubscription(subscription: dict):
assert requests.post('http://localhost:5025/api/push', json={ assert not run(["node", "v25pushx/push.js"], input=json.dumps({
"subscription": subscription, "subscription": subscription,
"notification": { "notification": {
"notification": { "notification": {
"title": "New Message (V25PUSH)" "title": "New Message (V25PUSH)",
"vibrate": [100, 50, 100],
} }
} }
}).status_code == 200, "push failed" }), encoding='utf-8').returncode, "push subprocess failed"
def pushpush(self, subject: Subject): def pushpush(self, subject: Subject):
session = self.Session() with closing(self.Session()) as session:
try:
notify = PushState.of(subject, session).notify() notify = PushState.of(subject, session).notify()
session.commit() session.commit()
finally:
session.close()
if notify: if notify:
subscription = self.subscription(subject) subscription = self.subscription(subject)
if subscription: if subscription:
self.pushsubscription(subscription) self.pushsubscription(subscription)
def push_once(self): def push_once(self):
session = self.Session() with closing(self.Session()) as session:
try:
subs: List[PushSub] = session.query(PushSub).all() subs: List[PushSub] = session.query(PushSub).all()
finally:
session.close()
for sub in subs: for sub in subs:
try: try:
self.pushpush(Subject(sub.subject)) self.pushpush(Subject(sub.subject))
@ -390,3 +394,7 @@ class DBStorage(PushStorage):
def pushing(self): def pushing(self):
self.push_forever() self.push_forever()
return self return self
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
with closing(self.Session()) as session:
return TypingState.typing(sfrom, sto, last, session)

View File

@ -33,6 +33,10 @@ class SecureStorage(PushStorage):
assert self.subject in pair, self.asrt() assert self.subject in pair, self.asrt()
return self.storage.pull(pair, params) return self.storage.pull(pair, params)
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
assert self.subject in (sfrom, sto)
return self.storage.exact(sfrom, sto, idnonce, editnonce)
def flags(self, m: Message, flags: str): def flags(self, m: Message, flags: str):
assert self.subject in m.pair, self.asrt() assert self.subject in m.pair, self.asrt()
return self.storage.flags(m, flags) return self.storage.flags(m, flags)
@ -44,3 +48,7 @@ class SecureStorage(PushStorage):
def subscribe(self, subject: Subject, subscription: dict): def subscribe(self, subject: Subject, subscription: dict):
assert self.subject == subject, self.asrt() assert self.subject == subject, self.asrt()
return self.storage.subscribe(subject, subscription) return self.storage.subscribe(subject, subscription)
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
assert self.subject == sfrom, self.asrt()
return self.storage.typing(sfrom, sto, last)

View File

@ -21,6 +21,9 @@ class AbstractStorage(ABC):
def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]: def pull(self, pair: Tuple[Subject, Subject], params: Optional[dict] = None) -> Iterable[Message]:
raise NotImplementedError raise NotImplementedError
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
raise NotImplementedError
def flags(self, m: Message, flags: str): def flags(self, m: Message, flags: str):
raise NotImplementedError raise NotImplementedError
@ -29,6 +32,9 @@ class EventStorage(AbstractStorage, ABC):
def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]: def events(self, sfrom: Subject, sto: Subject, after: Optional[bytes]) -> Iterable[Tuple[bytes, bytes]]:
raise NotImplementedError raise NotImplementedError
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
raise NotImplementedError
EVENT_PUSH = 0 EVENT_PUSH = 0
EVENT_EDIT = 1 EVENT_EDIT = 1

View File

@ -42,6 +42,9 @@ class RemoteStorage(PushStorage):
return map(Message.loads, return map(Message.loads,
self.req('pull', {'params': params, 'pair': [subject.dumps() for subject in pair]})) self.req('pull', {'params': params, 'pair': [subject.dumps() for subject in pair]}))
def exact(self, sfrom: Subject, sto: Subject, idnonce: bytes, editnonce: Optional[bytes]) -> Optional[Message]:
raise NotImplementedError
def flags(self, m: Message, flags: str): def flags(self, m: Message, flags: str):
self.req('flags', {'m': m.dumps(), 'flags': flags}) self.req('flags', {'m': m.dumps(), 'flags': flags})
@ -50,6 +53,13 @@ class RemoteStorage(PushStorage):
self.req('events', {'sfrom': sfrom.dumps(), 'sto': sto.dumps(), self.req('events', {'sfrom': sfrom.dumps(), 'sto': sto.dumps(),
'after': Encoding.encode(after)})] 'after': Encoding.encode(after)})]
def typing(self, sfrom: Subject, sto: Subject, last: float) -> float:
return self.req('typing', {
'sfrom': sfrom.dumps(),
'sto': sto.dumps(),
'last': last
})
def subscribe(self, subject: Subject, subscription: dict): def subscribe(self, subject: Subject, subscription: dict):
self.req('subscribe', { self.req('subscribe', {
'subject': subject.dumps(), 'subject': subject.dumps(),

View File

@ -80,3 +80,11 @@ class API(Flask):
def subscribe(): def subscribe():
return self.nomessassertcall(lambda d, storage: storage.subscribe(Subject.loads(d['subject']), return self.nomessassertcall(lambda d, storage: storage.subscribe(Subject.loads(d['subject']),
d['subscription'])) d['subscription']))
@app.route('/typing', methods=['POST'])
def typing():
return self.nomessassertcall(lambda d, storage: storage.typing(
Subject.loads(d['sfrom']),
Subject.loads(d['sto']),
d['last']
))