From 096152394a2c4bf71fad980a24cdf949792d840a Mon Sep 17 00:00:00 2001 From: Finn Date: Fri, 27 Aug 2021 02:54:09 -0700 Subject: [PATCH] Update protocol and add support for remote-config to signaldctl --- .../cmd/account/remoteconfig/show.go | 82 +++++++++++ cmd/signaldctl/cmd/account/root.go | 2 + protocol.json | 137 +++++++++++++++--- signald/client-protocol/v1/requests.go | 68 ++++++++- signald/client-protocol/v1/structs.go | 71 ++++++--- 5 files changed, 320 insertions(+), 40 deletions(-) create mode 100644 cmd/signaldctl/cmd/account/remoteconfig/show.go diff --git a/cmd/signaldctl/cmd/account/remoteconfig/show.go b/cmd/signaldctl/cmd/account/remoteconfig/show.go new file mode 100644 index 0000000..c569dac --- /dev/null +++ b/cmd/signaldctl/cmd/account/remoteconfig/show.go @@ -0,0 +1,82 @@ +// Copyright © 2021 Finn Herzfeld +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +package remoteconfig + +import ( + "encoding/json" + "log" + "os" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/spf13/cobra" + "gopkg.in/yaml.v2" + + "gitlab.com/signald/signald-go/cmd/signaldctl/common" + "gitlab.com/signald/signald-go/cmd/signaldctl/config" + v1 "gitlab.com/signald/signald-go/signald/client-protocol/v1" +) + +var ( + account string + RemoteConfigCmd = &cobra.Command{ + Use: "remote-config", + Short: "return a list of accounts", + PreRun: func(cmd *cobra.Command, args []string) { + if account == "" { + account = config.Config.DefaultAccount + } + }, + Run: func(_ *cobra.Command, _ []string) { + go common.Signald.Listen(nil) + req := &v1.RemoteConfigRequest{Account: account} + remoteconfig, err := req.Submit(common.Signald) + if err != nil { + log.Fatal(err) + } + + switch common.OutputFormat { + case common.OutputFormatJSON: + err := json.NewEncoder(os.Stdout).Encode(remoteconfig.Config) + if err != nil { + log.Fatal("error encoding response to stdout:", err) + } + case common.OutputFormatYAML: + err := yaml.NewEncoder(os.Stdout).Encode(remoteconfig.Config) + if err != nil { + log.Fatal("error encoding response to stdout:", err) + } + case common.OutputFormatCSV, common.OutputFormatTable, common.OutputFormatDefault: + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Key", "Value"}) + for _, config := range remoteconfig.Config { + t.AppendRow(table.Row{config.Name, config.Value}) + } + if common.OutputFormat == common.OutputFormatCSV { + t.RenderCSV() + } else { + common.StylizeTable(t) + t.Render() + } + default: + log.Fatal("Unsupported output format") + } + }, + } +) + +func init() { + RemoteConfigCmd.Flags().StringVarP(&account, "account", "a", "", "the signald account to use") +} diff --git a/cmd/signaldctl/cmd/account/root.go b/cmd/signaldctl/cmd/account/root.go index ac812c0..2888868 100644 --- a/cmd/signaldctl/cmd/account/root.go +++ b/cmd/signaldctl/cmd/account/root.go @@ -22,6 +22,7 @@ import ( "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/link" "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/list" "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/register" + "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/remoteconfig" "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/setprofile" "gitlab.com/signald/signald-go/cmd/signaldctl/cmd/account/verify" ) @@ -38,4 +39,5 @@ func init() { AccountCmd.AddCommand(register.RegisterAccountCmd) AccountCmd.AddCommand(verify.VerifyAccountCmd) AccountCmd.AddCommand(setprofile.SetProfileCmd) + AccountCmd.AddCommand(remoteconfig.RemoteConfigCmd) } diff --git a/protocol.json b/protocol.json index 5a84d62..f416485 100644 --- a/protocol.json +++ b/protocol.json @@ -2,9 +2,9 @@ "doc_version": "v1", "version": { "name": "signald", - "version": "0.13.1+git2021-07-09rabe585d6.42", + "version": "0.14.1+git2021-08-27r6dd96a17.28", "branch": "main", - "commit": "abe585d68fdb00b440c1b0c517e1ece2bc095ba3" + "commit": "6dd96a17c921766b0d284165e571476744ac2829" }, "info": "This document describes objects that may be used when communicating with signald.", "types": { @@ -256,7 +256,7 @@ }, "version": { "type": "String", - "example": "\"0.13.1+git2021-07-09rabe585d6.42\"" + "example": "\"0.14.1+git2021-08-27r6dd96a17.28\"" }, "branch": { "type": "String", @@ -264,7 +264,7 @@ }, "commit": { "type": "String", - "example": "\"abe585d68fdb00b440c1b0c517e1ece2bc095ba3\"" + "example": "\"6dd96a17c921766b0d284165e571476744ac2829\"" } } }, @@ -439,19 +439,26 @@ "type": "String", "example": "\"Parkdale Run Club\"" }, + "description": { + "type": "String", + "example": "\"A club for running in Parkdale\"" + }, "memberCount": { "type": "int", "example": "3" }, "addFromInviteLink": { - "type": "int" + "type": "int", + "doc": "The access level required in order to join the group from the invite link, as an AccessControl.AccessRequired enum from the upstream Signal groups.proto file. This is UNSATISFIABLE (4) when the group link is disabled; ADMINISTRATOR (3) when the group link is enabled, but an administrator must approve new members; and ANY (1) when the group link is enabled and no approval is required. See theGroupAccessControl structure and the upstream enum ordinals." }, "revision": { "type": "int", + "doc": "The Group V2 revision. This is incremented by clients whenever they update group information, and it is often used by clients to determine if the local group state is out-of-date with the server's revision.", "example": "5" }, "pendingAdminApproval": { - "type": "boolean" + "type": "boolean", + "doc": "Whether the account is waiting for admin approval in order to be added to the group." } } }, @@ -490,6 +497,11 @@ "type": "String", "example": "\"Parkdale Run Club\"" }, + "description": { + "type": "String", + "doc": "A new group description. Set to empty string to remove an existing description.", + "example": "\"A club for running in Parkdale\"" + }, "avatar": { "type": "String", "example": "\"/tmp/image.jpg\"" @@ -522,7 +534,7 @@ "doc": "regenerate the group link password, invalidating the old one" } }, - "doc": "modify a group. Note that only one modification action may be preformed at once" + "doc": "modify a group. Note that only one modification action may be performed at once" }, "GroupInfo": { "fields": { @@ -553,14 +565,20 @@ }, "avatarFile": { "type": "String", - "doc": "Path to new profile avatar file, if the avatar should be updated", + "doc": "Path to new profile avatar file. If unset or null, unset the profile avatar", "example": "\"/tmp/image.jpg\"" }, "about": { - "type": "String" + "type": "String", + "doc": "an optional about string. If unset, null or an empty string will unset profile about field" }, "emoji": { - "type": "String" + "type": "String", + "doc": "an optional single emoji character. If unset, null or an empty string will unset profile emoji" + }, + "mobilecoin_address": { + "type": "String", + "doc": "an optional *base64-encoded* MobileCoin address to set in the profile. Note that this is not the traditional MobileCoin address encoding, which is custom. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side. If unset, null or an empty string, will empty the profile payment address" } } }, @@ -680,6 +698,10 @@ }, "expiration_time": { "type": "int" + }, + "mobilecoin_address": { + "type": "String", + "doc": "*base64-encoded* mobilecoin address. Note that this is not the traditional MobileCoin address encoding. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side. If unset, null or an empty string, will empty the profile payment address" } }, "doc": "Information about a Signal user" @@ -1221,6 +1243,51 @@ } } }, + "SendPaymentRequest": { + "fields": { + "account": { + "type": "String", + "doc": "the account to use", + "example": "\"+12024561414\"", + "required": true + }, + "address": { + "type": "JsonAddress", + "version": "v1", + "doc": "the address to send the payment message to", + "required": true + }, + "payment": { + "type": "Payment", + "version": "v1", + "required": true + }, + "when": { + "type": "Long" + } + }, + "doc": "send a mobilecoin payment" + }, + "RemoteConfigRequest": { + "fields": { + "account": { + "type": "String", + "doc": "The account to use to retrieve the remote config", + "example": "\"+12024561414\"", + "required": true + } + }, + "doc": "Retrieves the remote config (feature flags) from the server." + }, + "RemoteConfigList": { + "fields": { + "config": { + "list": true, + "type": "RemoteConfig", + "version": "v1" + } + } + }, "JsonDataMessage": { "fields": { "timestamp": { @@ -1667,13 +1734,6 @@ }, "doc": "a Signal server" }, - "RemoteDelete": { - "fields": { - "target_sent_timestamp": { - "type": "long" - } - } - }, "Payment": { "fields": { "receipt": { @@ -1687,6 +1747,28 @@ }, "doc": "details about a MobileCoin payment" }, + "RemoteConfig": { + "fields": { + "name": { + "type": "String", + "doc": "The name of this remote config entry. These names may be prefixed with the platform type (\"android.\", \"ios.\", \"desktop.\", etc.) Typically, clients only handle the relevant configs for its platform, hardcoding the names it cares about handling and ignoring the rest.", + "example": "desktop.mediaQuality.levels" + }, + "value": { + "type": "String", + "doc": "The value for this remote config entry. Even though this is a string, it could be a boolean as a string, an integer/long value, a comma-delimited list, etc. Clients usually consume this by hardcoding the feature flagsit should track in the app and assuming that the server will send the type that the client expects. If an unexpected type occurs, it falls back to a default value.", + "example": "1:2,61:2,81:2,82:2,65:2,31:2,47:2,41:2,32:2,385:2,971:2,974:2,49:2,33:2,*:1" + } + }, + "doc": "A remote config (feature flag) entry." + }, + "RemoteDelete": { + "fields": { + "target_sent_timestamp": { + "type": "long" + } + } + }, "JsonSentTranscriptMessage": { "fields": { "destination": { @@ -1846,7 +1928,14 @@ } }, "ServerCDN": { - "fields": {} + "fields": { + "number": { + "type": "int" + }, + "url": { + "type": "String" + } + } } }, "v0": { @@ -2826,7 +2915,7 @@ "update_group": { "request": "UpdateGroupRequest", "response": "GroupInfo", - "doc": "modify a group. Note that only one modification action may be preformed at once" + "doc": "modify a group. Note that only one modification action may be performed at once" }, "set_profile": { "request": "SetProfile" @@ -2963,6 +3052,16 @@ }, "delete_server": { "request": "RemoveServerRequest" + }, + "send_payment": { + "request": "SendPaymentRequest", + "response": "SendResponse", + "doc": "send a mobilecoin payment" + }, + "get_remote_config": { + "request": "RemoteConfigRequest", + "response": "RemoteConfigList", + "doc": "Retrieves the remote config (feature flags) from the server." } } } diff --git a/signald/client-protocol/v1/requests.go b/signald/client-protocol/v1/requests.go index 38fb1c8..e8de4d3 100644 --- a/signald/client-protocol/v1/requests.go +++ b/signald/client-protocol/v1/requests.go @@ -449,6 +449,39 @@ func (r *GetProfileRequest) Submit(conn *signald.Signald) (response Profile, err } +// Submit: Retrieves the remote config (feature flags) from the server. +func (r *RemoteConfigRequest) Submit(conn *signald.Signald) (response RemoteConfigList, err error) { + r.Version = "v1" + r.Type = "get_remote_config" + if r.ID == "" { + r.ID = signald.GenerateID() + } + err = conn.RawRequest(r) + if err != nil { + log.Println("signald-go: error submitting request to signald") + return + } + + responseChannel := conn.GetResponseListener(r.ID) + defer conn.CloseResponseListener(r.ID) + + rawResponse := <-responseChannel + if rawResponse.Error != nil { + err = fmt.Errorf("signald error: %s", string(rawResponse.Error)) + return + } + + err = json.Unmarshal(rawResponse.Data, &response) + if err != nil { + rawResponseJson, _ := rawResponse.Data.MarshalJSON() + log.Println("signald-go: error unmarshalling response from signald of type", rawResponse.Type, string(rawResponseJson)) + return + } + + return response, nil + +} + func (r *GetServersRequest) Submit(conn *signald.Signald) (response ServerList, err error) { r.Version = "v1" r.Type = "get_servers" @@ -950,6 +983,39 @@ func (r *SendRequest) Submit(conn *signald.Signald) (response SendResponse, err } +// Submit: send a mobilecoin payment +func (r *SendPaymentRequest) Submit(conn *signald.Signald) (response SendResponse, err error) { + r.Version = "v1" + r.Type = "send_payment" + if r.ID == "" { + r.ID = signald.GenerateID() + } + err = conn.RawRequest(r) + if err != nil { + log.Println("signald-go: error submitting request to signald") + return + } + + responseChannel := conn.GetResponseListener(r.ID) + defer conn.CloseResponseListener(r.ID) + + rawResponse := <-responseChannel + if rawResponse.Error != nil { + err = fmt.Errorf("signald error: %s", string(rawResponse.Error)) + return + } + + err = json.Unmarshal(rawResponse.Data, &response) + if err != nil { + rawResponseJson, _ := rawResponse.Data.MarshalJSON() + log.Println("signald-go: error unmarshalling response from signald of type", rawResponse.Type, string(rawResponseJson)) + return + } + + return response, nil + +} + // Submit: set this device's name. This will show up on the mobile device on the same account under func (r *SetDeviceNameRequest) Submit(conn *signald.Signald) (err error) { r.Version = "v1" @@ -1171,7 +1237,7 @@ func (r *UpdateContactRequest) Submit(conn *signald.Signald) (response Profile, } -// Submit: modify a group. Note that only one modification action may be preformed at once +// Submit: modify a group. Note that only one modification action may be performed at once func (r *UpdateGroupRequest) Submit(conn *signald.Signald) (response GroupInfo, err error) { r.Version = "v1" r.Type = "update_group" diff --git a/signald/client-protocol/v1/structs.go b/signald/client-protocol/v1/structs.go index 7cd5f78..726126c 100644 --- a/signald/client-protocol/v1/structs.go +++ b/signald/client-protocol/v1/structs.go @@ -290,11 +290,12 @@ type JsonGroupInfo struct { } type JsonGroupJoinInfo struct { - AddFromInviteLink int32 `json:"addFromInviteLink,omitempty" yaml:"addFromInviteLink,omitempty"` + AddFromInviteLink int32 `json:"addFromInviteLink,omitempty" yaml:"addFromInviteLink,omitempty"` // The access level required in order to join the group from the invite link, as an AccessControl.AccessRequired enum from the upstream Signal groups.proto file. This is UNSATISFIABLE (4) when the group link is disabled; ADMINISTRATOR (3) when the group link is enabled, but an administrator must approve new members; and ANY (1) when the group link is enabled and no approval is required. See theGroupAccessControl structure and the upstream enum ordinals. + Description string `json:"description,omitempty" yaml:"description,omitempty"` GroupID string `json:"groupID,omitempty" yaml:"groupID,omitempty"` MemberCount int32 `json:"memberCount,omitempty" yaml:"memberCount,omitempty"` - PendingAdminApproval bool `json:"pendingAdminApproval,omitempty" yaml:"pendingAdminApproval,omitempty"` - Revision int32 `json:"revision,omitempty" yaml:"revision,omitempty"` + PendingAdminApproval bool `json:"pendingAdminApproval,omitempty" yaml:"pendingAdminApproval,omitempty"` // Whether the account is waiting for admin approval in order to be added to the group. + Revision int32 `json:"revision,omitempty" yaml:"revision,omitempty"` // The Group V2 revision. This is incremented by clients whenever they update group information, and it is often used by clients to determine if the local group state is out-of-date with the server's revision. Title string `json:"title,omitempty" yaml:"title,omitempty"` } @@ -480,16 +481,17 @@ type Payment struct { // Profile: Information about a Signal user type Profile struct { - About string `json:"about,omitempty" yaml:"about,omitempty"` - Address *JsonAddress `json:"address,omitempty" yaml:"address,omitempty"` - Avatar string `json:"avatar,omitempty" yaml:"avatar,omitempty"` // path to avatar on local disk - Capabilities *Capabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` - Color string `json:"color,omitempty" yaml:"color,omitempty"` // color of the chat with this user - Emoji string `json:"emoji,omitempty" yaml:"emoji,omitempty"` - ExpirationTime int32 `json:"expiration_time,omitempty" yaml:"expiration_time,omitempty"` - InboxPosition int32 `json:"inbox_position,omitempty" yaml:"inbox_position,omitempty"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` // The user's name from local contact names if available, or if not in contact list their Signal profile name - ProfileName string `json:"profile_name,omitempty" yaml:"profile_name,omitempty"` // The user's Signal profile name + About string `json:"about,omitempty" yaml:"about,omitempty"` + Address *JsonAddress `json:"address,omitempty" yaml:"address,omitempty"` + Avatar string `json:"avatar,omitempty" yaml:"avatar,omitempty"` // path to avatar on local disk + Capabilities *Capabilities `json:"capabilities,omitempty" yaml:"capabilities,omitempty"` + Color string `json:"color,omitempty" yaml:"color,omitempty"` // color of the chat with this user + Emoji string `json:"emoji,omitempty" yaml:"emoji,omitempty"` + ExpirationTime int32 `json:"expiration_time,omitempty" yaml:"expiration_time,omitempty"` + InboxPosition int32 `json:"inbox_position,omitempty" yaml:"inbox_position,omitempty"` + MobilecoinAddress string `json:"mobilecoin_address,omitempty" yaml:"mobilecoin_address,omitempty"` // *base64-encoded* mobilecoin address. Note that this is not the traditional MobileCoin address encoding. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side. If unset, null or an empty string, will empty the profile payment address + Name string `json:"name,omitempty" yaml:"name,omitempty"` // The user's name from local contact names if available, or if not in contact list their Signal profile name + ProfileName string `json:"profile_name,omitempty" yaml:"profile_name,omitempty"` // The user's Signal profile name } type ProfileList struct { @@ -521,6 +523,22 @@ type RegisterRequest struct { Voice bool `json:"voice,omitempty" yaml:"voice,omitempty"` // set to true to request a voice call instead of an SMS for verification } +// RemoteConfig: A remote config (feature flag) entry. +type RemoteConfig struct { + Name string `json:"name,omitempty" yaml:"name,omitempty"` // The name of this remote config entry. These names may be prefixed with the platform type ("android.", "ios.", "desktop.", etc.) Typically, clients only handle the relevant configs for its platform, hardcoding the names it cares about handling and ignoring the rest. + Value string `json:"value,omitempty" yaml:"value,omitempty"` // The value for this remote config entry. Even though this is a string, it could be a boolean as a string, an integer/long value, a comma-delimited list, etc. Clients usually consume this by hardcoding the feature flagsit should track in the app and assuming that the server will send the type that the client expects. If an unexpected type occurs, it falls back to a default value. +} + +type RemoteConfigList struct { + Config []*RemoteConfig `json:"config,omitempty" yaml:"config,omitempty"` +} + +// RemoteConfigRequest: Retrieves the remote config (feature flags) from the server. +type RemoteConfigRequest struct { + Request + Account string `json:"account,omitempty" yaml:"account,omitempty"` // The account to use to retrieve the remote config +} + type RemoteDelete struct { TargetSentTimestamp int64 `json:"target_sent_timestamp,omitempty" yaml:"target_sent_timestamp,omitempty"` } @@ -571,6 +589,15 @@ type ResolveAddressRequest struct { Partial *JsonAddress `json:"partial,omitempty" yaml:"partial,omitempty"` // The partial address, missing fields } +// SendPaymentRequest: send a mobilecoin payment +type SendPaymentRequest struct { + Request + Account string `json:"account,omitempty" yaml:"account,omitempty"` // the account to use + Address *JsonAddress `json:"address,omitempty" yaml:"address,omitempty"` // the address to send the payment message to + Payment *Payment `json:"payment,omitempty" yaml:"payment,omitempty"` + When int64 `json:"when,omitempty" yaml:"when,omitempty"` +} + type SendRequest struct { Request Attachments []*v0.JsonAttachment `json:"attachments,omitempty" yaml:"attachments,omitempty"` @@ -603,6 +630,8 @@ type Server struct { } type ServerCDN struct { + Number int32 `json:"number,omitempty" yaml:"number,omitempty"` + Url string `json:"url,omitempty" yaml:"url,omitempty"` } type ServerList struct { @@ -627,11 +656,12 @@ type SetExpirationRequest struct { type SetProfile struct { Request - About string `json:"about,omitempty" yaml:"about,omitempty"` - Account string `json:"account,omitempty" yaml:"account,omitempty"` // The phone number of the account to use - AvatarFile string `json:"avatarFile,omitempty" yaml:"avatarFile,omitempty"` // Path to new profile avatar file, if the avatar should be updated - Emoji string `json:"emoji,omitempty" yaml:"emoji,omitempty"` - Name string `json:"name,omitempty" yaml:"name,omitempty"` // New profile name. Set to empty string for no profile name + About string `json:"about,omitempty" yaml:"about,omitempty"` // an optional about string. If unset, null or an empty string will unset profile about field + Account string `json:"account,omitempty" yaml:"account,omitempty"` // The phone number of the account to use + AvatarFile string `json:"avatarFile,omitempty" yaml:"avatarFile,omitempty"` // Path to new profile avatar file. If unset or null, unset the profile avatar + Emoji string `json:"emoji,omitempty" yaml:"emoji,omitempty"` // an optional single emoji character. If unset, null or an empty string will unset profile emoji + MobilecoinAddress string `json:"mobilecoin_address,omitempty" yaml:"mobilecoin_address,omitempty"` // an optional *base64-encoded* MobileCoin address to set in the profile. Note that this is not the traditional MobileCoin address encoding, which is custom. Clients are responsible for converting between MobileCoin's custom base58 on the user-facing side and base64 encoding on the signald side. If unset, null or an empty string, will empty the profile payment address + Name string `json:"name,omitempty" yaml:"name,omitempty"` // New profile name. Set to empty string for no profile name } // SubscribeRequest: receive incoming messages. After making a subscribe request, incoming messages will be sent to the client encoded as ClientMessageWrapper. Send an unsubscribe request or disconnect from the socket to stop receiving messages. @@ -682,13 +712,14 @@ type UpdateContactRequest struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` } -// UpdateGroupRequest: modify a group. Note that only one modification action may be preformed at once +// UpdateGroupRequest: modify a group. Note that only one modification action may be performed at once type UpdateGroupRequest struct { Request Account string `json:"account,omitempty" yaml:"account,omitempty"` // The identifier of the account to interact with AddMembers []*JsonAddress `json:"addMembers,omitempty" yaml:"addMembers,omitempty"` Avatar string `json:"avatar,omitempty" yaml:"avatar,omitempty"` - GroupID string `json:"groupID,omitempty" yaml:"groupID,omitempty"` // the ID of the group to update + Description string `json:"description,omitempty" yaml:"description,omitempty"` // A new group description. Set to empty string to remove an existing description. + GroupID string `json:"groupID,omitempty" yaml:"groupID,omitempty"` // the ID of the group to update RemoveMembers []*JsonAddress `json:"removeMembers,omitempty" yaml:"removeMembers,omitempty"` ResetLink bool `json:"resetLink,omitempty" yaml:"resetLink,omitempty"` // regenerate the group link password, invalidating the old one Title string `json:"title,omitempty" yaml:"title,omitempty"`