SSH UI Quick Fixes (#408)

* fix: set golbal ssh config to correct path

This adds the missing "etc" directory to the path for the global config
file.

* chore: update auth mode tooltip

This just changes the text to be slightly more accurate to the current
behavior.

* feat: add box to disable waveshell install modal

This hooks in to the existing don't show this again code that pops up
when creating a modal.

* refactor: remove install modal in remote creation

There used to be a modal that popped up while installing a remote that
informed the user that waveshell gets installed on their remote. Since
we have a new modal that pops up at the time of install, the older modal
can be removed.

* fix: allow user to cancel ssh dial

The new ssh code broke dial for invalid urls since the context did not
cancel the dial or any associated user input. This change reconnects
the context along with the context for installing waveshell.

* style: widen the rconndetail modal

The rconndetail modal is currently narrower than the xtermjs element
which results in awkward scrolling if a line is long. This change makes
the width auto so it can size itself as needed.

* add a max-width for safety
This commit is contained in:
Sylvie Crowe 2024-03-07 22:37:00 -08:00 committed by GitHub
parent 0b9834171d
commit 2a5857bc3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 165 additions and 141 deletions

View File

@ -13,7 +13,6 @@ export { NumberField } from "./numberfield";
export { PasswordField } from "./passwordfield";
export { ResizableSidebar } from "./resizablesidebar";
export { SettingsError } from "./settingserror";
export { ShowWaveShellInstallPrompt } from "./showwaveshellinstallprompt";
export { Status } from "./status";
export { TextField } from "./textfield";
export { Toggle } from "./toggle";

View File

@ -1,28 +0,0 @@
// Copyright 2023, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
import { GlobalModel } from "@/models";
import * as appconst from "@/app/appconst";
function ShowWaveShellInstallPrompt(callbackFn: () => void) {
let message: string = `
In order to use Wave's advanced features like unified history and persistent sessions, Wave installs a small, open-source helper program called WaveShell on your remote machine. WaveShell does not open any external ports and only communicates with your *local* Wave terminal instance over ssh. For more information please see [the docs](https://docs.waveterm.dev/reference/waveshell).
`;
message = message.trim();
let prtn = GlobalModel.showAlert({
message: message,
confirm: true,
markdown: true,
confirmflag: appconst.ConfirmKey_HideShellPrompt,
});
prtn.then((confirm) => {
if (!confirm) {
return;
}
if (callbackFn) {
callbackFn();
}
});
}
export { ShowWaveShellInstallPrompt };

View File

@ -7,16 +7,7 @@ import * as mobx from "mobx";
import { boundMethod } from "autobind-decorator";
import { If } from "tsx-control-statements/components";
import { GlobalModel, GlobalCommandRunner, RemotesModel } from "@/models";
import {
Modal,
TextField,
NumberField,
InputDecoration,
Dropdown,
PasswordField,
Tooltip,
ShowWaveShellInstallPrompt,
} from "@/elements";
import { Modal, TextField, NumberField, InputDecoration, Dropdown, PasswordField, Tooltip } from "@/elements";
import * as util from "@/util/util";
import "./createremoteconn.less";
@ -73,12 +64,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
}
@boundMethod
handleOk(): void {
ShowWaveShellInstallPrompt(this.submitRemote);
}
@boundMethod
submitRemote(): void {
handleSubmitRemote(): void {
mobx.action(() => {
this.errorStr.set(null);
})();
@ -275,7 +261,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
{ value: "none", label: "none" },
{ value: "key", label: "key" },
{ value: "password", label: "password" },
{ value: "key+password", label: "key+password" },
{ value: "key+password", label: "key+passphrase" },
]}
value={this.tempAuthMode.get()}
onChange={(val: string) => {
@ -288,17 +274,18 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
message={
<ul>
<li>
<b>none</b> - no authentication, or authentication is already
configured in your ssh config.
<b>none</b> - no authentication details are stored.
</li>
<li>
<b>key</b> - use a private key.
<b>key</b> - provide a custom private key for authentication.
</li>
<li>
<b>password</b> - use a password.
<b>password</b> - provide a password (to save) for
authentication.
</li>
<li>
<b>key+password</b> - use a key with a passphrase.
<b>key+passphrase</b> - provide a custom private key with a
passphrase (to save) for authentication.
</li>
</ul>
}
@ -374,7 +361,7 @@ class CreateRemoteConnModal extends React.Component<{}, {}> {
<div className="settings-field settings-error">Error: {this.getErrorStr()}</div>
</If>
</div>
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleOk} okLabel="Connect" />
<Modal.Footer onCancel={this.model.closeModal} onOk={this.handleSubmitRemote} okLabel="Connect" />
</Modal>
);
}

View File

@ -3,13 +3,17 @@
.wave-modal-content {
.wave-modal-body {
padding: 20px 20px;
padding: 0px 20px 0px 20px;
.wave-modal-dialog {
padding: 20px 0px 20px 0px;
.userinput-query {
margin-bottom: 10px;
}
}
}
}
}
.markdown {

View File

@ -1,7 +1,7 @@
import * as React from "react";
import { GlobalModel } from "@/models";
import { Choose, When, If } from "tsx-control-statements/components";
import { Modal, PasswordField, Markdown } from "@/elements";
import { Modal, PasswordField, Markdown, Checkbox } from "@/elements";
import { checkKeyPressed, adaptFromReactOrNativeKeyEvent } from "@/util/keyutil";
import "./userinput.less";
@ -9,6 +9,7 @@ import "./userinput.less";
export const UserInputModal = (userInputRequest: UserInputRequest) => {
const [responseText, setResponseText] = React.useState("");
const [countdown, setCountdown] = React.useState(Math.floor(userInputRequest.timeoutms / 1000));
const checkboxStatus = React.useRef(false);
const handleSendCancel = React.useCallback(() => {
GlobalModel.sendUserInput({
@ -24,16 +25,19 @@ export const UserInputModal = (userInputRequest: UserInputRequest) => {
type: "userinputresp",
requestid: userInputRequest.requestid,
text: responseText,
checkboxstat: checkboxStatus.current,
});
GlobalModel.remotesModel.closeModal();
}, [responseText, userInputRequest]);
const handleSendConfirm = React.useCallback(
(response: boolean) => {
console.log(`checkbox ${checkboxStatus}\n\n`);
GlobalModel.sendUserInput({
type: "userinputresp",
requestid: userInputRequest.requestid,
confirm: response,
checkboxstat: checkboxStatus.current,
});
GlobalModel.remotesModel.closeModal();
},
@ -71,6 +75,7 @@ export const UserInputModal = (userInputRequest: UserInputRequest) => {
<Modal className="userinput-modal">
<Modal.Header onClose={handleSendCancel} title={userInputRequest.title + ` (${countdown}s)`} />
<div className="wave-modal-body">
<div className="wave-modal-dialog">
<div className="userinput-query">
<If condition={userInputRequest.markdown}>
<Markdown text={userInputRequest.querytext} extraClassName="bottom-margin" />
@ -89,6 +94,14 @@ export const UserInputModal = (userInputRequest: UserInputRequest) => {
</When>
</Choose>
</div>
<If condition={userInputRequest.checkboxmsg != ""}>
<Checkbox
onChange={() => (checkboxStatus.current = !checkboxStatus.current)}
label={userInputRequest.checkboxmsg}
className="checkbox-text"
/>
</If>
</div>
<Choose>
<When condition={userInputRequest.responsetype == "text"}>
<Modal.Footer onCancel={handleSendCancel} onOk={handleSendText} okLabel="Continue" />

View File

@ -1,5 +1,6 @@
.rconndetail-modal {
width: 631px;
width: auto;
max-width: 80vw;
min-height: 565px;
.wave-modal-content {

View File

@ -8,7 +8,7 @@ import { boundMethod } from "autobind-decorator";
import { If, For } from "tsx-control-statements/components";
import cn from "classnames";
import { GlobalModel, RemotesModel, GlobalCommandRunner } from "@/models";
import { Button, Status, ShowWaveShellInstallPrompt } from "@/common/elements";
import { Button, Status } from "@/common/elements";
import * as util from "@/util/util";
import "./connections.less";
@ -71,14 +71,9 @@ class ConnectionsView extends React.Component<{ model: RemotesModel }, { hovered
GlobalModel.remotesModel.openAddModal({ remoteedit: true });
}
@boundMethod
importSshConfig(): void {
GlobalCommandRunner.importSshConfig();
}
@boundMethod
handleImportSshConfig(): void {
ShowWaveShellInstallPrompt(this.importSshConfig);
GlobalCommandRunner.importSshConfig();
}
@boundMethod

View File

@ -666,6 +666,7 @@ declare global {
title: string;
markdown: boolean;
timeoutms: number;
checkboxmsg: string;
};
type UserInputResponsePacket = {
@ -674,6 +675,7 @@ declare global {
text?: string;
confirm?: boolean;
errormsg?: string;
checkboxstat?: boolean;
};
type RenderModeType = "normal" | "collapsed" | "expanded";

View File

@ -2202,7 +2202,7 @@ func NewHostInfo(hostName string) (*HostInfoType, error) {
func RemoteConfigParseCommand(ctx context.Context, pk *scpacket.FeCommandPacketType) (scbus.UpdatePacket, error) {
home := base.GetHomeDir()
localConfig := filepath.Join(home, ".ssh", "config")
systemConfig := filepath.Join("/", "ssh", "config")
systemConfig := filepath.Join("/etc", "ssh", "config")
sshConfigFiles := []string{localConfig, systemConfig}
ssh_config.ReloadConfigs()
hostPatterns, hostPatternsErr := resolveSshConfigPatterns(sshConfigFiles)

View File

@ -1202,14 +1202,54 @@ func (msh *MShellProc) RunInstall(autoInstall bool) {
msh.WriteToPtyBuffer("*error: cannot install on archived remote\n")
return
}
if autoInstall {
var makeClientCtx context.Context
var makeClientCancelFn context.CancelFunc
msh.WithLock(func() {
makeClientCtx, makeClientCancelFn = context.WithCancel(context.Background())
msh.MakeClientCancelFn = makeClientCancelFn
msh.MakeClientDeadline = nil
go msh.NotifyRemoteUpdate()
})
defer makeClientCancelFn()
clientData, err := sstore.EnsureClientData(makeClientCtx)
if err != nil {
msh.WriteToPtyBuffer("*error: cannot obtain client data: %v", err)
return
}
hideShellPrompt := clientData.ClientOpts.ConfirmFlags["hideshellprompt"]
baseStatus := msh.GetStatus()
if baseStatus == StatusConnected {
ctx, cancelFn := context.WithTimeout(makeClientCtx, 60*time.Second)
defer cancelFn()
request := &userinput.UserInputRequestType{
ResponseType: "confirm",
QueryText: "Waveshell is running on your connection and must be restarted to re-install. Would you like to continue?",
Title: "Restart Waveshell",
}
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
if err != nil {
if err == context.Canceled {
msh.WriteToPtyBuffer("installation canceled by user\n")
} else {
msh.WriteToPtyBuffer("timed out waiting for user input\n")
}
return
}
if !response.Confirm {
msh.WriteToPtyBuffer("installation canceled by user\n")
return
}
} else if !hideShellPrompt {
ctx, cancelFn := context.WithTimeout(makeClientCtx, 60*time.Second)
defer cancelFn()
request := &userinput.UserInputRequestType{
ResponseType: "confirm",
QueryText: "Waveshell must be reinstalled on the connection to continue. Would you like to install it?",
Title: "Install Waveshell",
CheckBoxMsg: "Don't show me this again",
}
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFn()
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
if err != nil {
var errMsg error
@ -1234,29 +1274,25 @@ func (msh *MShellProc) RunInstall(autoInstall bool) {
})
return
}
}
baseStatus := msh.GetStatus()
if baseStatus == StatusConnected {
request := &userinput.UserInputRequestType{
ResponseType: "confirm",
QueryText: "Waveshell is running on your connection and must be restarted to re-install. Would you like to continue?",
Title: "Restart Waveshell",
}
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
defer cancelFn()
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
if response.CheckboxStat {
clientData.ClientOpts.ConfirmFlags["hideshellprompt"] = true
err = sstore.SetClientOpts(makeClientCtx, clientData.ClientOpts)
if err != nil {
if err == context.Canceled {
msh.WriteToPtyBuffer("installation canceled by user\n")
} else {
msh.WriteToPtyBuffer("timed out waiting for user input\n")
}
msh.WriteToPtyBuffer("*error, %s\n", err)
msh.setErrorStatus(err)
return
}
if !response.Confirm {
msh.WriteToPtyBuffer("installation canceled by user\n")
//reload updated clientdata before sending
clientData, err = sstore.EnsureClientData(makeClientCtx)
if err != nil {
msh.WriteToPtyBuffer("*error, %s\n", err)
msh.setErrorStatus(err)
return
}
update := scbus.MakeUpdatePacket()
update.AddUpdate(*clientData)
}
}
curStatus := msh.GetInstallStatus()
if curStatus == StatusConnecting {
@ -1267,14 +1303,14 @@ func (msh *MShellProc) RunInstall(autoInstall bool) {
msh.WriteToPtyBuffer("*error: cannot install on a local remote\n")
return
}
_, err := shellapi.MakeShellApi(packet.ShellType_bash)
_, err = shellapi.MakeShellApi(packet.ShellType_bash)
if err != nil {
msh.WriteToPtyBuffer("*error: %v\n", err)
return
}
if msh.Client == nil {
remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName)
client, err := ConnectToClient(remoteCopy.SSHOpts, remoteDisplayName)
client, err := ConnectToClient(makeClientCtx, remoteCopy.SSHOpts, remoteDisplayName)
if err != nil {
statusErr := fmt.Errorf("ssh cannot connect to client: %w", err)
msh.setInstallErrorStatus(statusErr)
@ -1481,7 +1517,7 @@ func (msh *MShellProc) getActiveShellTypes(ctx context.Context) ([]string, error
return utilfn.CombineStrArrays(rtn, activeShells), nil
}
func (msh *MShellProc) createWaveshellSession(remoteCopy sstore.RemoteType) (shexec.ConnInterface, error) {
func (msh *MShellProc) createWaveshellSession(clientCtx context.Context, remoteCopy sstore.RemoteType) (shexec.ConnInterface, error) {
msh.WithLock(func() {
msh.Err = nil
msh.ErrNoInitPk = false
@ -1510,7 +1546,7 @@ func (msh *MShellProc) createWaveshellSession(remoteCopy sstore.RemoteType) (she
wsSession = shexec.CmdWrap{Cmd: ecmd}
} else if msh.Client == nil {
remoteDisplayName := fmt.Sprintf("%s [%s]", remoteCopy.RemoteAlias, remoteCopy.RemoteCanonicalName)
client, err := ConnectToClient(remoteCopy.SSHOpts, remoteDisplayName)
client, err := ConnectToClient(clientCtx, remoteCopy.SSHOpts, remoteDisplayName)
if err != nil {
return nil, fmt.Errorf("ssh cannot connect to client: %w", err)
}
@ -1559,16 +1595,6 @@ func (NewLauncher) Launch(msh *MShellProc, interactive bool) {
msh.WriteToPtyBuffer("remote is trying to install, cancel install before trying to connect again\n")
return
}
msh.WriteToPtyBuffer("connecting to %s...\n", remoteCopy.RemoteCanonicalName)
wsSession, err := msh.createWaveshellSession(remoteCopy)
if err != nil {
msh.WriteToPtyBuffer("*error, %s\n", err.Error())
msh.setErrorStatus(err)
msh.WithLock(func() {
msh.Client = nil
})
return
}
var makeClientCtx context.Context
var makeClientCancelFn context.CancelFunc
msh.WithLock(func() {
@ -1578,6 +1604,16 @@ func (NewLauncher) Launch(msh *MShellProc, interactive bool) {
go msh.NotifyRemoteUpdate()
})
defer makeClientCancelFn()
msh.WriteToPtyBuffer("connecting to %s...\n", remoteCopy.RemoteCanonicalName)
wsSession, err := msh.createWaveshellSession(makeClientCtx, remoteCopy)
if err != nil {
msh.WriteToPtyBuffer("*error, %s\n", err.Error())
msh.setErrorStatus(err)
msh.WithLock(func() {
msh.Client = nil
})
return
}
cproc, err := shexec.MakeClientProc(makeClientCtx, wsSession)
msh.WithLock(func() {
msh.MakeClientCancelFn = nil

View File

@ -65,7 +65,7 @@ func createDummySigner() ([]ssh.Signer, error) {
// they were successes. An error in this function prevents any other
// keys from being attempted. But if there's an error because of a dummy
// file, the library can still try again with a new key.
func createPublicKeyCallback(sshKeywords *SshKeywords, passphrase string) func() ([]ssh.Signer, error) {
func createPublicKeyCallback(connCtx context.Context, sshKeywords *SshKeywords, passphrase string) func() ([]ssh.Signer, error) {
var identityFiles []string
existingKeys := make(map[string][]byte)
@ -124,7 +124,7 @@ func createPublicKeyCallback(sshKeywords *SshKeywords, passphrase string) func()
QueryText: fmt.Sprintf("Enter passphrase for the SSH key: %s", identityFile),
Title: "Publickey Auth + Passphrase",
}
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
defer cancelFn()
response, err := userinput.GetUserInput(ctx, scbus.MainRpcBus, request)
if err != nil {
@ -150,11 +150,11 @@ func createDefaultPasswordCallbackPrompt(password string) func() (secret string,
}
}
func createInteractivePasswordCallbackPrompt(remoteDisplayName string) func() (secret string, err error) {
func createInteractivePasswordCallbackPrompt(connCtx context.Context, remoteDisplayName string) func() (secret string, err error) {
return func() (secret string, err error) {
// limited to 15 seconds for some reason. this should be investigated more
// in the future
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
defer cancelFn()
queryText := fmt.Sprintf(
"Password Authentication requested from connection \n"+
@ -174,13 +174,13 @@ func createInteractivePasswordCallbackPrompt(remoteDisplayName string) func() (s
}
}
func createCombinedPasswordCallbackPrompt(password string, remoteDisplayName string) func() (secret string, err error) {
func createCombinedPasswordCallbackPrompt(connCtx context.Context, password string, remoteDisplayName string) func() (secret string, err error) {
var once sync.Once
return func() (secret string, err error) {
var prompt func() (secret string, err error)
once.Do(func() { prompt = createDefaultPasswordCallbackPrompt(password) })
if prompt == nil {
prompt = createInteractivePasswordCallbackPrompt(remoteDisplayName)
prompt = createInteractivePasswordCallbackPrompt(connCtx, remoteDisplayName)
}
return prompt()
}
@ -199,14 +199,14 @@ func createNaiveKbdInteractiveChallenge(password string) func(name, instruction
}
}
func createInteractiveKbdInteractiveChallenge(remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
func createInteractiveKbdInteractiveChallenge(connCtx context.Context, remoteName string) func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
if len(questions) != len(echos) {
return nil, fmt.Errorf("bad response from server: questions has len %d, echos has len %d", len(questions), len(echos))
}
for i, question := range questions {
echo := echos[i]
answer, err := promptChallengeQuestion(question, echo, remoteName)
answer, err := promptChallengeQuestion(connCtx, question, echo, remoteName)
if err != nil {
return nil, err
}
@ -216,10 +216,10 @@ func createInteractiveKbdInteractiveChallenge(remoteName string) func(name, inst
}
}
func promptChallengeQuestion(question string, echo bool, remoteName string) (answer string, err error) {
func promptChallengeQuestion(connCtx context.Context, question string, echo bool, remoteName string) (answer string, err error) {
// limited to 15 seconds for some reason. this should be investigated more
// in the future
ctx, cancelFn := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancelFn := context.WithTimeout(connCtx, 60*time.Second)
defer cancelFn()
queryText := fmt.Sprintf(
"Keyboard Interactive Authentication requested from connection \n"+
@ -238,13 +238,13 @@ func promptChallengeQuestion(question string, echo bool, remoteName string) (ans
return response.Text, nil
}
func createCombinedKbdInteractiveChallenge(password string, remoteName string) ssh.KeyboardInteractiveChallenge {
func createCombinedKbdInteractiveChallenge(connCtx context.Context, password string, remoteName string) ssh.KeyboardInteractiveChallenge {
var once sync.Once
return func(name, instruction string, questions []string, echos []bool) (answers []string, err error) {
var challenge ssh.KeyboardInteractiveChallenge
once.Do(func() { challenge = createNaiveKbdInteractiveChallenge(password) })
if challenge == nil {
challenge = createInteractiveKbdInteractiveChallenge(remoteName)
challenge = createInteractiveKbdInteractiveChallenge(connCtx, remoteName)
}
return challenge(name, instruction, questions, echos)
}
@ -505,7 +505,20 @@ func createHostKeyCallback(opts *sstore.SSHOpts) (ssh.HostKeyCallback, error) {
return waveHostKeyCallback, nil
}
func ConnectToClient(opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Client, error) {
func DialContext(ctx context.Context, network string, addr string, config *ssh.ClientConfig) (*ssh.Client, error) {
d := net.Dialer{Timeout: config.Timeout}
conn, err := d.DialContext(ctx, network, addr)
if err != nil {
return nil, err
}
c, chans, reqs, err := ssh.NewClientConn(conn, addr, config)
if err != nil {
return nil, err
}
return ssh.NewClient(c, chans, reqs), nil
}
func ConnectToClient(connCtx context.Context, opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Client, error) {
sshConfigKeywords, err := findSshConfigKeywords(opts.SSHHost)
if err != nil {
return nil, err
@ -516,9 +529,9 @@ func ConnectToClient(opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Clien
return nil, err
}
publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(sshKeywords, opts.SSHPassword))
keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(opts.SSHPassword, remoteDisplayName))
passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(opts.SSHPassword, remoteDisplayName))
publicKeyCallback := ssh.PublicKeysCallback(createPublicKeyCallback(connCtx, sshKeywords, opts.SSHPassword))
keyboardInteractive := ssh.KeyboardInteractive(createCombinedKbdInteractiveChallenge(connCtx, opts.SSHPassword, remoteDisplayName))
passwordCallback := ssh.PasswordCallback(createCombinedPasswordCallbackPrompt(connCtx, opts.SSHPassword, remoteDisplayName))
// batch mode turns off interactive input. this means the number of
// attemtps must drop to 1 with this setup
@ -566,7 +579,7 @@ func ConnectToClient(opts *sstore.SSHOpts, remoteDisplayName string) (*ssh.Clien
HostKeyCallback: hostKeyCallback,
}
networkAddr := sshKeywords.HostName + ":" + sshKeywords.Port
return ssh.Dial("tcp", networkAddr, clientConfig)
return DialContext(connCtx, "tcp", networkAddr, clientConfig)
}
type SshKeywords struct {

View File

@ -21,6 +21,7 @@ type UserInputRequestType struct {
Title string `json:"title"`
Markdown bool `json:"markdown"`
TimeoutMs int `json:"timeoutms"`
CheckBoxMsg string `json:"checkboxmsg"`
}
func (*UserInputRequestType) GetType() string {
@ -44,6 +45,7 @@ type UserInputResponsePacketType struct {
Text string `json:"text,omitempty"`
Confirm bool `json:"confirm,omitempty"`
ErrorMsg string `json:"errormsg,omitempty"`
CheckboxStat bool `json:"checkboxstat,omitempty"`
}
func (*UserInputResponsePacketType) GetType() string {