core: Implement inbound contact requests

This does not yet include persistence of inbound requests in the config,
and it's mostly untested, but it's more or less complete.
This commit is contained in:
John Brooks 2017-08-10 11:25:50 -06:00
parent 3d13263fac
commit dcda924d56
8 changed files with 456 additions and 60 deletions

View File

@ -133,6 +133,12 @@ func (c *Contact) Data() *ricochet.Contact {
return data
}
func (c *Contact) IsRequest() bool {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.data.Request.Pending
}
func (c *Contact) Conversation() *Conversation {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -312,6 +318,7 @@ func (c *Contact) connectOutbound(ctx context.Context, connChannel chan *protoco
// is fragile and dumb.
// XXX BUG: This means no backoff for authentication failure
handler := &ProtocolConnection{
Core: c.core,
Conn: oc,
Contact: c,
MyHostname: c.core.Identity.Address()[9:],

View File

@ -17,6 +17,7 @@ type ContactList struct {
events *utils.Publisher
contacts map[int]*Contact
inboundRequests map[string]*InboundContactRequest
}
func LoadContactList(core *Ricochet) (*ContactList, error) {
@ -80,30 +81,43 @@ func (this *ContactList) ContactByAddress(address string) *Contact {
return nil
}
func (this *ContactList) AddContactRequest(address, name, fromName, text string) (*Contact, error) {
if !IsAddressValid(address) {
return nil, errors.New("Invalid ricochet address")
func (cl *ContactList) InboundRequestByAddress(address string) *InboundContactRequest {
cl.mutex.RLock()
defer cl.mutex.RUnlock()
for _, request := range cl.inboundRequests {
if request.Address == address {
return request
}
if len(fromName) > 0 && !IsNicknameAcceptable(fromName) {
return nil, errors.New("Invalid nickname")
}
if len(text) > 0 && !IsMessageAcceptable(text) {
return nil, errors.New("Invalid message")
return nil
}
// AddNewContact adds a new contact to the persistent contact list, broadcasts a
// contact add RPC event, and returns a newly constructed Contact. AddNewContact
// does not create contact requests or trigger any other protocol behavior.
//
// Generally, you will use AddContactRequest (for outbound requests) and
// AddOrUpdateInboundContactRequest plus InboundContactRequest.Accept() instead of
// using this function directly.
func (this *ContactList) AddNewContact(configContact ConfigContact) (*Contact, error) {
this.mutex.Lock()
defer this.mutex.Unlock()
address, ok := AddressFromOnion(configContact.Hostname)
if !ok {
return nil, errors.New("Invalid ricochet address")
}
for _, contact := range this.contacts {
if contact.Address() == address {
return nil, errors.New("Contact already exists with this address")
}
if contact.Nickname() == name {
if contact.Nickname() == configContact.Nickname {
return nil, errors.New("Contact already exists with this nickname")
}
}
// XXX check inbound requests
// XXX check inbound requests (but this can be called for an inbound req too)
// Write new contact into config
config := this.core.Config.OpenWrite()
@ -118,18 +132,6 @@ func (this *ContactList) AddContactRequest(address, name, fromName, text string)
}
contactId := maxContactId + 1
onion, _ := OnionFromAddress(address)
configContact := ConfigContact{
Hostname: onion,
Nickname: name,
WhenCreated: time.Now().Format(time.RFC3339),
Request: ConfigContactRequest{
Pending: true,
MyNickname: fromName,
Message: text,
},
}
config.Contacts[strconv.Itoa(contactId)] = configContact
if err := config.Save(); err != nil {
return nil, err
@ -150,10 +152,54 @@ func (this *ContactList) AddContactRequest(address, name, fromName, text string)
}
this.events.Publish(event)
// XXX Should this be here? Is it ok for inbound where we might pass conn over momentarily?
contact.StartConnection()
return contact, nil
}
// AddContactRequest creates a new outbound contact request with the given parameters,
// adds it to the contact list, and returns the newly constructed Contact.
//
// If an inbound request already exists for this address, that request will be automatically
// accepted, and the returned contact will already be fully established.
func (cl *ContactList) AddContactRequest(address, name, fromName, text string) (*Contact, error) {
onion, valid := OnionFromAddress(address)
if !valid {
return nil, errors.New("Invalid ricochet address")
}
if !IsNicknameAcceptable(name) {
return nil, errors.New("Invalid nickname")
}
if len(fromName) > 0 && !IsNicknameAcceptable(fromName) {
return nil, errors.New("Invalid 'from' nickname")
}
if len(text) > 0 && !IsMessageAcceptable(text) {
return nil, errors.New("Invalid message")
}
configContact := ConfigContact{
Hostname: onion,
Nickname: name,
WhenCreated: time.Now().Format(time.RFC3339),
Request: ConfigContactRequest{
Pending: true,
MyNickname: fromName,
Message: text,
},
}
contact, err := cl.AddNewContact(configContact)
if err != nil {
return nil, err
}
if inboundRequest := cl.InboundRequestByAddress(address); inboundRequest != nil {
contact.UpdateContactRequest("Accepted")
inboundRequest.AcceptWithContact(contact)
}
return contact, nil
}
func (this *ContactList) RemoveContact(contact *Contact) error {
this.mutex.Lock()
defer this.mutex.Unlock()
@ -186,6 +232,99 @@ func (this *ContactList) RemoveContact(contact *Contact) error {
return nil
}
// AddOrUpdateInboundContactRequest creates or modifies an inbound contact request for
// the hostname, with an optional nickname suggestion and introduction message
//
// The nickname, message, and address must be validated before calling this function.
//
// This function may change the state of the request, and the caller is responsible for
// sending a reply message and closing the channel or connection as appropriate. If the
// request is still active when the state changes spontaneously later (e.g. it's accepted
// by the user), replies will be sent by InboundContactRequest.
//
// This function may return either an InboundContactRequest (which may be pending or already
// rejected), an existing contact (which should be treated as accepting the request), or
// neither, which is considered a rejection.
func (cl *ContactList) AddOrUpdateInboundContactRequest(address, nickname, message string) (*InboundContactRequest, *Contact) {
cl.mutex.Lock()
defer cl.mutex.Unlock()
// Look up existing request
if request := cl.inboundRequests[address]; request != nil {
// Errors in Update will change the state of the request, which the caller sends as a reply
request.Update(nickname, message)
return request, nil
}
// Check for existing contacts or outbound contact requests
for _, contact := range cl.contacts {
if contact.Address() == address {
if contact.IsRequest() {
contact.UpdateContactRequest("Accepted")
}
return nil, contact
}
}
// Create new request
request := CreateInboundContactRequest(cl.core, address, nickname, message)
request.StatusChanged = cl.inboundRequestChanged
cl.inboundRequests[address] = request
// XXX update config
requestData := request.Data()
event := ricochet.ContactEvent{
Type: ricochet.ContactEvent_ADD,
Subject: &ricochet.ContactEvent_Request{
Request: &requestData,
},
}
cl.events.Publish(event)
return request, nil
}
// RemoveInboundContactRequest removes the record of an inbound contact request,
// without taking any other actions. Generally you will want to act on a request with
// Accept() or Reject(), rather than call this function directly, but it is valid to
// call on any inbound request regardless.
func (cl *ContactList) RemoveInboundContactRequest(request *InboundContactRequest) error {
cl.mutex.Lock()
defer cl.mutex.Unlock()
requestData := request.Data()
if cl.inboundRequests[requestData.Address] != request {
return errors.New("Request is not in contact list")
}
request.CloseConnection()
// XXX Remove from config
delete(cl.inboundRequests, requestData.Address)
event := ricochet.ContactEvent{
Type: ricochet.ContactEvent_DELETE,
Subject: &ricochet.ContactEvent_Request{
Request: &requestData,
},
}
cl.events.Publish(event)
return nil
}
// inboundRequestChanged is called by the StatusChanged callback of InboundContactRequest
// after the request has been modified, such as through Update() or Reject(). Accepted
// requests do not pass through this function, because they're immediately removed.
func (cl *ContactList) inboundRequestChanged(request *InboundContactRequest) {
// XXX update config
requestData := request.Data()
event := ricochet.ContactEvent{
Type: ricochet.ContactEvent_UPDATE,
Subject: &ricochet.ContactEvent_Request{
Request: &requestData,
},
}
cl.events.Publish(event)
}
func (this *ContactList) StartConnections() {
for _, contact := range this.Contacts() {
contact.StartConnection()

View File

@ -114,6 +114,7 @@ func (is *identityService) OnNewConnection(oc *protocol.OpenConnection) {
// XXX Should have pre-auth handling, timeouts
identity := is.Identity
handler := &ProtocolConnection{
Core: identity.core,
Conn: oc,
GetContactByHostname: func(hostname string) *Contact {
address, ok := AddressFromPlainHost(hostname)

View File

@ -0,0 +1,167 @@
package core
import (
"errors"
"github.com/ricochet-im/ricochet-go/rpc"
protocol "github.com/s-rah/go-ricochet"
"log"
"sync"
"time"
)
type InboundContactRequest struct {
core *Ricochet
mutex sync.Mutex
data ricochet.ContactRequest
conn *protocol.OpenConnection
channelID int32
Address string
// Called when the request state is changed
StatusChanged func(request *InboundContactRequest)
}
// CreateInboundContactRequest constructs a new InboundContactRequest, usually from a newly
// received request on an open connection. Requests are managed through the ContactList, so
// generally you should use ContactList.AddOrUpdateInboundContactRequest instead of calling
// this function directly.
func CreateInboundContactRequest(core *Ricochet, address, nickname, message string) *InboundContactRequest {
cr := &InboundContactRequest{
core: core,
data: ricochet.ContactRequest{
Direction: ricochet.ContactRequest_INBOUND,
Address: address,
Text: message,
FromNickname: nickname,
WhenCreated: time.Now().Format(time.RFC3339),
},
Address: address,
}
return cr
}
// XXX There should be stricter management & a timeout for this connection
func (cr *InboundContactRequest) SetConnection(conn *protocol.OpenConnection, channelID int32) {
cr.mutex.Lock()
defer cr.mutex.Unlock()
if cr.conn != nil && cr.conn != conn {
log.Printf("Replacing connection on an inbound contact request")
cr.conn.Close()
}
cr.conn = conn
cr.channelID = channelID
}
func (cr *InboundContactRequest) CloseConnection() {
if cr.conn != nil {
cr.conn.Close()
cr.conn = nil
}
}
func (cr *InboundContactRequest) Update(nickname, message string) {
cr.mutex.Lock()
defer cr.mutex.Unlock()
if cr.data.Rejected {
return
}
// These should already be validated, but just in case..
if len(nickname) == 0 || IsNicknameAcceptable(nickname) {
cr.data.FromNickname = nickname
}
if len(message) == 0 || IsMessageAcceptable(message) {
cr.data.Text = message
}
if cr.StatusChanged != nil {
cr.StatusChanged(cr)
}
}
func (cr *InboundContactRequest) Accept() (*Contact, error) {
cr.mutex.Lock()
defer cr.mutex.Unlock()
if cr.data.Rejected {
log.Printf("Accept called on an inbound contact request that was already rejected; request is %v", cr)
return nil, errors.New("Contact request has already been rejected")
}
log.Printf("Accepting contact request from %s", cr.data.Address)
onion, _ := OnionFromAddress(cr.data.Address)
configContact := ConfigContact{
Hostname: onion,
Nickname: cr.data.FromNickname,
WhenCreated: cr.data.WhenCreated,
}
contact, err := cr.core.Identity.ContactList().AddNewContact(configContact)
if err != nil {
log.Printf("Error occurred in accepting contact request: %s", err)
return nil, err
}
if err := cr.AcceptWithContact(contact); err != nil {
return contact, err
}
return contact, nil
}
func (cr *InboundContactRequest) AcceptWithContact(contact *Contact) error {
if contact.Address() != cr.data.Address {
return errors.New("Contact address does not match request in accept")
}
cr.core.Identity.ContactList().RemoveInboundContactRequest(cr)
// Pass the open connection to the new contact
if cr.conn != nil && !cr.conn.Closed {
cr.conn.AckContactRequest(cr.channelID, "Accepted")
cr.conn.CloseChannel(cr.channelID)
contact.OnConnectionAuthenticated(cr.conn, true)
cr.conn = nil
}
return nil
}
func (cr *InboundContactRequest) Reject() {
cr.mutex.Lock()
defer cr.mutex.Unlock()
if cr.data.Rejected {
return
}
log.Printf("Rejecting contact request from %s", cr.data.Address)
cr.data.Rejected = true
// Signal update to the callback (probably from ContactList)
if cr.StatusChanged != nil {
cr.StatusChanged(cr)
}
if cr.conn != nil && !cr.conn.Closed {
cr.conn.AckContactRequest(cr.channelID, "Rejected")
cr.conn.CloseChannel(cr.channelID)
cr.conn.Close()
cr.conn = nil
// The request can be removed once a protocol response is sent
cr.core.Identity.ContactList().RemoveInboundContactRequest(cr)
}
}
func (cr *InboundContactRequest) Data() ricochet.ContactRequest {
return cr.data
}
func (cr *InboundContactRequest) IsRejected() bool {
cr.mutex.Lock()
defer cr.mutex.Unlock()
return cr.data.Rejected
}

View File

@ -9,6 +9,8 @@ import (
)
type ProtocolConnection struct {
Core *Ricochet
Conn *protocol.OpenConnection
Contact *Contact
@ -95,6 +97,46 @@ func (pc *ProtocolConnection) OnAuthenticationResult(channelID int32, result boo
// Contact Management
func (pc *ProtocolConnection) OnContactRequest(channelID int32, nick string, message string) {
if pc.Conn.Client || !pc.Conn.IsAuthed || pc.Contact != nil {
pc.Conn.CloseChannel(channelID)
return
}
address, ok := AddressFromPlainHost(pc.Conn.OtherHostname)
if !ok {
pc.Conn.CloseChannel(channelID)
return
}
if len(nick) > 0 && !IsNicknameAcceptable(nick) {
log.Printf("protocol: Stripping unacceptable nickname from inbound request; encoded: %x", []byte(nick))
nick = ""
}
if len(message) > 0 && !IsMessageAcceptable(message) {
log.Printf("protocol: Stripping unacceptable message from inbound request; len: %d, encoded: %x", len(message), []byte(message))
message = ""
}
contactList := pc.Core.Identity.ContactList()
request, contact := contactList.AddOrUpdateInboundContactRequest(address, nick, message)
if contact != nil {
// Accepted immediately
pc.Conn.AckContactRequestOnResponse(channelID, "Accepted")
pc.Conn.CloseChannel(channelID)
contact.OnConnectionAuthenticated(pc.Conn, true)
} else if request != nil && !request.IsRejected() {
// Pending
pc.Conn.AckContactRequestOnResponse(channelID, "Pending")
request.SetConnection(pc.Conn, channelID)
} else {
// Rejected
pc.Conn.AckContactRequestOnResponse(channelID, "Rejected")
pc.Conn.CloseChannel(channelID)
pc.Conn.Close()
if request != nil {
contactList.RemoveInboundContactRequest(request)
}
}
}
func (pc *ProtocolConnection) OnContactRequestAck(channelID int32, status string) {

View File

@ -151,11 +151,32 @@ func (s *RpcServer) DeleteContact(ctx context.Context, req *ricochet.DeleteConta
}
func (s *RpcServer) AcceptInboundRequest(ctx context.Context, req *ricochet.ContactRequest) (*ricochet.Contact, error) {
return nil, NotImplementedError
if req.Direction != ricochet.ContactRequest_INBOUND {
return nil, errors.New("Request must be inbound")
}
contactList := s.Core.Identity.ContactList()
request := contactList.InboundRequestByAddress(req.Address)
if request == nil {
return nil, errors.New("Request does not exist")
}
contact, err := request.Accept()
if err != nil {
return nil, err
}
return contact.Data(), nil
}
func (s *RpcServer) RejectInboundRequest(ctx context.Context, req *ricochet.ContactRequest) (*ricochet.RejectInboundRequestReply, error) {
return nil, NotImplementedError
if req.Direction != ricochet.ContactRequest_INBOUND {
return nil, errors.New("Request must be inbound")
}
contactList := s.Core.Identity.ContactList()
request := contactList.InboundRequestByAddress(req.Address)
if request == nil {
return nil, errors.New("Request does not exist")
}
request.Reject()
return &ricochet.RejectInboundRequestReply{}, nil
}
func (s *RpcServer) MonitorConversations(req *ricochet.MonitorConversationsRequest, stream ricochet.RicochetCore_MonitorConversationsServer) error {

View File

@ -207,6 +207,8 @@ type ContactRequest struct {
Nickname string `protobuf:"bytes,3,opt,name=nickname" json:"nickname,omitempty"`
Text string `protobuf:"bytes,4,opt,name=text" json:"text,omitempty"`
FromNickname string `protobuf:"bytes,5,opt,name=fromNickname" json:"fromNickname,omitempty"`
WhenCreated string `protobuf:"bytes,6,opt,name=whenCreated" json:"whenCreated,omitempty"`
Rejected bool `protobuf:"varint,7,opt,name=rejected" json:"rejected,omitempty"`
}
func (m *ContactRequest) Reset() { *m = ContactRequest{} }
@ -249,6 +251,20 @@ func (m *ContactRequest) GetFromNickname() string {
return ""
}
func (m *ContactRequest) GetWhenCreated() string {
if m != nil {
return m.WhenCreated
}
return ""
}
func (m *ContactRequest) GetRejected() bool {
if m != nil {
return m.Rejected
}
return false
}
type MonitorContactsRequest struct {
}
@ -451,38 +467,39 @@ func init() {
func init() { proto.RegisterFile("contact.proto", fileDescriptor0) }
var fileDescriptor0 = []byte{
// 520 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0x9c, 0x53, 0x51, 0x6f, 0xd3, 0x30,
0x10, 0x6e, 0xd2, 0xac, 0x69, 0xaf, 0x5d, 0xc9, 0xac, 0x09, 0x05, 0xf6, 0x52, 0x59, 0x08, 0xf5,
0x85, 0x80, 0x0a, 0xef, 0xac, 0x6b, 0x32, 0x51, 0x08, 0x49, 0xf1, 0x12, 0xf1, 0x9c, 0x26, 0x46,
0x0b, 0x74, 0x76, 0x49, 0x5c, 0xa0, 0xff, 0x90, 0x9f, 0xc2, 0x23, 0x3f, 0x01, 0xd9, 0x49, 0x3a,
0xba, 0x01, 0x42, 0x7b, 0xf3, 0x7d, 0xdf, 0x77, 0xf6, 0x7d, 0xbe, 0x3b, 0x38, 0x4c, 0x39, 0x13,
0x49, 0x2a, 0x9c, 0x75, 0xc1, 0x05, 0x47, 0xdd, 0x22, 0x4f, 0x79, 0x7a, 0x49, 0x05, 0xfe, 0xae,
0x83, 0x39, 0xab, 0x38, 0x34, 0x04, 0x3d, 0xcf, 0x6c, 0x6d, 0xa4, 0x8d, 0x0f, 0x88, 0x9e, 0x67,
0xc8, 0x06, 0x33, 0xc9, 0xb2, 0x82, 0x96, 0xa5, 0xad, 0x8f, 0xb4, 0x71, 0x8f, 0x34, 0x21, 0x7a,
0x08, 0x5d, 0x96, 0xa7, 0x9f, 0x58, 0x72, 0x45, 0xed, 0xb6, 0xa2, 0x76, 0x31, 0x1a, 0x41, 0xff,
0xeb, 0x25, 0x65, 0xb3, 0x82, 0x26, 0x82, 0x66, 0xb6, 0xa1, 0xe8, 0xdf, 0x21, 0xf4, 0x08, 0x0e,
0x57, 0x49, 0x29, 0x66, 0x9c, 0x31, 0x9a, 0x4a, 0xcd, 0x81, 0xd2, 0xec, 0x83, 0x68, 0x02, 0x66,
0x41, 0x3f, 0x6f, 0x68, 0x29, 0xec, 0xce, 0x48, 0x1b, 0xf7, 0x27, 0xb6, 0xd3, 0x54, 0xed, 0xd4,
0x15, 0x93, 0x8a, 0x27, 0x8d, 0x10, 0x3d, 0x83, 0x4e, 0x29, 0x12, 0xb1, 0x29, 0x6d, 0x18, 0x69,
0xe3, 0xe1, 0x1f, 0x52, 0x9c, 0x0b, 0xc5, 0x93, 0x5a, 0x87, 0xe7, 0xd0, 0xa9, 0x10, 0xd4, 0x07,
0x33, 0x0e, 0xde, 0x04, 0xe1, 0xfb, 0xc0, 0x6a, 0xc9, 0x20, 0x3c, 0x3f, 0xf7, 0xe7, 0x81, 0x67,
0x69, 0x08, 0xa0, 0x13, 0x06, 0xea, 0xac, 0x4b, 0x82, 0x78, 0xef, 0x62, 0xef, 0x22, 0xb2, 0xda,
0x68, 0x00, 0x5d, 0xe2, 0xbd, 0xf6, 0x66, 0x91, 0xe7, 0x5a, 0x06, 0xfe, 0xa1, 0xc1, 0x70, 0xbf,
0x30, 0x74, 0x0a, 0xbd, 0x2c, 0x2f, 0x68, 0x2a, 0x72, 0xce, 0xd4, 0xc7, 0x0e, 0x27, 0xf8, 0x6f,
0x2e, 0x1c, 0xb7, 0x51, 0x92, 0xeb, 0xa4, 0x3b, 0xf6, 0x00, 0x81, 0x21, 0xe8, 0x37, 0x51, 0x7f,
0xbe, 0x3a, 0x23, 0x0c, 0x83, 0x0f, 0x05, 0xbf, 0x0a, 0x9a, 0x9c, 0xea, 0xd3, 0xf7, 0x30, 0xfc,
0x18, 0x7a, 0xbb, 0x2a, 0xa4, 0xd5, 0x79, 0x70, 0x16, 0xc6, 0x81, 0x6b, 0xb5, 0xa4, 0xd5, 0x30,
0x8e, 0xaa, 0x48, 0xc3, 0x36, 0xdc, 0x7f, 0xcb, 0x59, 0x2e, 0x78, 0x51, 0x7b, 0x28, 0x6b, 0x13,
0xf8, 0xa7, 0x06, 0x83, 0x1a, 0xf3, 0xbe, 0x50, 0x26, 0xd0, 0x53, 0x30, 0xc4, 0x76, 0x4d, 0x6b,
0xf7, 0x27, 0xb7, 0xdc, 0x2b, 0x95, 0x13, 0x6d, 0xd7, 0x94, 0x28, 0x21, 0x7a, 0x02, 0x66, 0x3d,
0xac, 0xca, 0x71, 0x7f, 0x72, 0x74, 0x2b, 0xe7, 0x55, 0x8b, 0x34, 0x1a, 0xf4, 0xe2, 0x7a, 0x4c,
0xda, 0xff, 0x1e, 0x13, 0x99, 0x55, 0x4b, 0xf1, 0x4b, 0x30, 0xe4, 0x93, 0xa8, 0x0b, 0x46, 0x10,
0xfb, 0x7e, 0x65, 0x70, 0x11, 0x2e, 0x62, 0x7f, 0x1a, 0xc9, 0x96, 0x9b, 0xd0, 0x9e, 0xba, 0xae,
0xa5, 0xcb, 0xde, 0xc7, 0x0b, 0x57, 0x82, 0x6d, 0x79, 0x76, 0x3d, 0xdf, 0x8b, 0x3c, 0xcb, 0x38,
0xeb, 0x81, 0x59, 0x6e, 0x96, 0x1f, 0x69, 0x2a, 0xf0, 0x11, 0xdc, 0x9b, 0x66, 0xd9, 0xee, 0xad,
0xf5, 0x6a, 0x8b, 0x4f, 0xe1, 0xd8, 0xa5, 0x2b, 0x2a, 0xe8, 0x8d, 0x79, 0xf8, 0xef, 0x0d, 0xc3,
0xc7, 0x80, 0x6e, 0xdc, 0x20, 0xef, 0x3d, 0x81, 0x07, 0x84, 0xca, 0x47, 0xe7, 0x6c, 0xc9, 0x37,
0x2c, 0x6b, 0x16, 0x40, 0x92, 0xcb, 0x8e, 0xda, 0xed, 0xe7, 0xbf, 0x02, 0x00, 0x00, 0xff, 0xff,
0x94, 0xcf, 0x1c, 0xce, 0xec, 0x03, 0x00, 0x00,
// 539 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0x9c, 0x54, 0xd1, 0x72, 0x93, 0x40,
0x14, 0x2d, 0x84, 0x02, 0xb9, 0x69, 0x23, 0xdd, 0xe9, 0x38, 0xd8, 0xbe, 0x30, 0x3b, 0x8e, 0x93,
0x17, 0xd1, 0x89, 0xbe, 0xdb, 0x34, 0xd0, 0x31, 0x1a, 0x21, 0x6e, 0x61, 0x7c, 0x26, 0xb0, 0x4e,
0xd1, 0x74, 0x89, 0xb0, 0x51, 0xf3, 0x43, 0x7e, 0x8b, 0x9f, 0xe3, 0x27, 0x38, 0xbb, 0x40, 0x6a,
0x12, 0x75, 0x1c, 0xdf, 0xf6, 0x9e, 0x73, 0x2e, 0xbb, 0x7b, 0xee, 0x59, 0xe0, 0x38, 0x2d, 0x18,
0x4f, 0x52, 0xee, 0x2e, 0xcb, 0x82, 0x17, 0xc8, 0x2c, 0xf3, 0xb4, 0x48, 0x6f, 0x28, 0xc7, 0xdf,
0x55, 0x30, 0xc6, 0x35, 0x87, 0xfa, 0xa0, 0xe6, 0x99, 0xad, 0x38, 0xca, 0xe0, 0x90, 0xa8, 0x79,
0x86, 0x6c, 0x30, 0x92, 0x2c, 0x2b, 0x69, 0x55, 0xd9, 0xaa, 0xa3, 0x0c, 0xba, 0xa4, 0x2d, 0xd1,
0x19, 0x98, 0x2c, 0x4f, 0x3f, 0xb2, 0xe4, 0x96, 0xda, 0x1d, 0x49, 0x6d, 0x6a, 0xe4, 0x40, 0xef,
0xcb, 0x0d, 0x65, 0xe3, 0x92, 0x26, 0x9c, 0x66, 0xb6, 0x26, 0xe9, 0x5f, 0x21, 0xf4, 0x10, 0x8e,
0x17, 0x49, 0xc5, 0xc7, 0x05, 0x63, 0x34, 0x15, 0x9a, 0x43, 0xa9, 0xd9, 0x06, 0xd1, 0x10, 0x8c,
0x92, 0x7e, 0x5a, 0xd1, 0x8a, 0xdb, 0xba, 0xa3, 0x0c, 0x7a, 0x43, 0xdb, 0x6d, 0x4f, 0xed, 0x36,
0x27, 0x26, 0x35, 0x4f, 0x5a, 0x21, 0x7a, 0x0a, 0x7a, 0xc5, 0x13, 0xbe, 0xaa, 0x6c, 0x70, 0x94,
0x41, 0xff, 0x37, 0x2d, 0xee, 0xb5, 0xe4, 0x49, 0xa3, 0xc3, 0x13, 0xd0, 0x6b, 0x04, 0xf5, 0xc0,
0x88, 0x83, 0xd7, 0x41, 0xf8, 0x2e, 0xb0, 0x0e, 0x44, 0x11, 0x5e, 0x5d, 0x4d, 0x27, 0x81, 0x6f,
0x29, 0x08, 0x40, 0x0f, 0x03, 0xb9, 0x56, 0x05, 0x41, 0xfc, 0xb7, 0xb1, 0x7f, 0x1d, 0x59, 0x1d,
0x74, 0x04, 0x26, 0xf1, 0x5f, 0xf9, 0xe3, 0xc8, 0xf7, 0x2c, 0x0d, 0x7f, 0x53, 0xa1, 0xbf, 0x7d,
0x30, 0x74, 0x01, 0xdd, 0x2c, 0x2f, 0x69, 0xca, 0xf3, 0x82, 0x49, 0x63, 0xfb, 0x43, 0xfc, 0xa7,
0x5b, 0xb8, 0x5e, 0xab, 0x24, 0x77, 0x4d, 0xff, 0x39, 0x03, 0x04, 0x1a, 0xa7, 0x5f, 0x79, 0x63,
0xbe, 0x5c, 0x23, 0x0c, 0x47, 0xef, 0xcb, 0xe2, 0x36, 0x68, 0x7b, 0x6a, 0xd3, 0xb7, 0xb0, 0xdd,
0xd9, 0xe9, 0xfb, 0xb3, 0x3b, 0x03, 0xb3, 0xa4, 0x1f, 0xea, 0xb1, 0x19, 0x8e, 0x32, 0x30, 0xc9,
0xa6, 0xc6, 0x8f, 0xa0, 0xbb, 0xb9, 0x83, 0x30, 0x6a, 0x12, 0x5c, 0x86, 0x71, 0xe0, 0x59, 0x07,
0xc2, 0xa8, 0x30, 0x8e, 0xea, 0x4a, 0xc1, 0x36, 0xdc, 0x7f, 0x53, 0xb0, 0x9c, 0x17, 0x65, 0xe3,
0x40, 0xd5, 0x58, 0x80, 0x7f, 0x28, 0x70, 0xd4, 0x60, 0xfe, 0x67, 0xca, 0x38, 0x7a, 0x02, 0x1a,
0x5f, 0x2f, 0x69, 0xe3, 0xdd, 0xf9, 0x9e, 0x77, 0x52, 0xe5, 0x46, 0xeb, 0x25, 0x25, 0x52, 0x88,
0x1e, 0x83, 0xd1, 0x44, 0x5d, 0xfa, 0xd5, 0x1b, 0x9e, 0xec, 0xf5, 0xbc, 0x3c, 0x20, 0xad, 0x06,
0x3d, 0xbf, 0x0b, 0x59, 0xe7, 0xef, 0x21, 0x13, 0x5d, 0x8d, 0x14, 0xbf, 0x00, 0x4d, 0x6c, 0x89,
0x4c, 0xd0, 0x82, 0x78, 0x3a, 0xad, 0x2f, 0x38, 0x0b, 0x67, 0xf1, 0x74, 0x14, 0x89, 0xc0, 0x18,
0xd0, 0x19, 0x79, 0x9e, 0xa5, 0x8a, 0xe4, 0xc4, 0x33, 0x4f, 0x80, 0x1d, 0xb1, 0xf6, 0xfc, 0xa9,
0x1f, 0xf9, 0x96, 0x76, 0xd9, 0x05, 0xa3, 0x5a, 0xcd, 0x85, 0x6d, 0xf8, 0x04, 0xee, 0x8d, 0xb2,
0x6c, 0xb3, 0xd7, 0x72, 0xb1, 0xc6, 0x17, 0x70, 0xea, 0xd1, 0x05, 0xe5, 0x74, 0x27, 0x4d, 0xff,
0xfc, 0x3e, 0xf1, 0x29, 0xa0, 0x9d, 0x2f, 0x88, 0xef, 0x9e, 0xc3, 0x03, 0x22, 0x67, 0x35, 0x61,
0xf3, 0x62, 0xc5, 0xb2, 0xf6, 0xf9, 0x08, 0x72, 0xae, 0xcb, 0x3f, 0xc3, 0xb3, 0x9f, 0x01, 0x00,
0x00, 0xff, 0xff, 0xe5, 0x62, 0x19, 0x3f, 0x2a, 0x04, 0x00, 0x00,
}

View File

@ -32,6 +32,8 @@ message ContactRequest {
string nickname = 3;
string text = 4;
string fromNickname = 5;
string whenCreated = 6;
bool rejected = 7;
}
message MonitorContactsRequest {