diff --git a/go.mod b/go.mod index 02830db2..91aa38dc 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rs/zerolog v1.35.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e - go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69 + go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b google.golang.org/protobuf v1.36.11 ) @@ -24,7 +24,7 @@ require ( github.com/rabbitmq/amqp091-go v1.10.0 github.com/vincent-petithory/dataurl v1.0.0 golang.org/x/image v0.32.0 - golang.org/x/sync v0.20.0 + golang.org/x/sync v0.21.0 modernc.org/sqlite v1.37.1 ) @@ -40,7 +40,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect github.com/aws/smithy-go v1.22.3 // indirect github.com/beeper/argo-go v1.1.2 // indirect - github.com/coder/websocket v1.8.14 // indirect + github.com/coder/websocket v1.8.15 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect @@ -48,9 +48,9 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.6.0 // indirect github.com/vektah/gqlparser/v2 v2.5.33 // indirect - golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect - golang.org/x/term v0.43.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect modernc.org/libc v1.65.8 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect @@ -63,10 +63,10 @@ require ( github.com/joho/godotenv v1.5.1 github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - go.mau.fi/libsignal v0.2.1 // indirect - go.mau.fi/util v0.9.9 // indirect - golang.org/x/crypto v0.51.0 // indirect - golang.org/x/net v0.54.0 - golang.org/x/sys v0.44.0 // indirect + go.mau.fi/libsignal v0.2.2 // indirect + go.mau.fi/util v0.9.10 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 + golang.org/x/sys v0.46.0 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 20121606..dfa7689d 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -71,8 +71,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -104,12 +104,12 @@ github.com/vektah/gqlparser/v2 v2.5.33/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6O github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI= github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= -go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= -go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE= -go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY= -go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69 h1:rRcG2I1LVQ11kH2+KSYxNeFNZXp1oKA5Yxmagsf5bks= -go.mau.fi/whatsmeow v0.0.0-20260516102357-8d3700152a69/go.mod h1:SY+3c678dbtckGZF16M+sfEW8ZxTb9xkaKwhXueF5yE= +go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU= +go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0= +go.mau.fi/util v0.9.10 h1:wzvz5iDHyqDXB8vgisD4d3SzucLXNM3iNY+1O1RoHtg= +go.mau.fi/util v0.9.10/go.mod h1:YQOxySn+ZE3qSYqNxvyX7Yi3suA8YK17PS6QqBREW7A= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b h1:ZUk1ErarDNpnbosXR/MeOz2gkqA4S1bh8zjaSRj7N+Y= +go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b/go.mod h1:9dmNTYZ/1pHjPw/bz+azBsGjAkcrZbqzMrKcvG5bJ8U= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -118,10 +118,10 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw= -golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ= golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -129,8 +129,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -140,8 +140,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -149,8 +149,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -162,8 +162,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -173,8 +173,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -184,8 +184,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -194,8 +194,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/handlers.go b/handlers.go index ef64235b..9038464e 100644 --- a/handlers.go +++ b/handlers.go @@ -632,7 +632,17 @@ func (s *server) GetQR() http.HandlerFunc { } log.Info().Str("instance", txtid).Str("qrcode", code).Msg("Get QR successful") - response := map[string]interface{}{"QRCode": fmt.Sprintf("%s", code)} + response := map[string]interface{}{ + "QRCode": fmt.Sprintf("%s", code), + "passkeyPending": false, + "publicKey": nil, + } + + if pk := peekPendingPasskey(txtid); pk != nil && pk.Request != nil { + response["passkeyPending"] = true + response["publicKey"] = pk.Request.PublicKey + } + responseJson, err := json.Marshal(response) if err != nil { s.Respond(w, r, http.StatusInternalServerError, err) @@ -744,6 +754,112 @@ func (s *server) PairPhone() http.HandlerFunc { } } +// PasskeyResponse receives a WebAuthn response from the frontend and sends it to WhatsApp +func (s *server) PasskeyResponse() http.HandlerFunc { + type passkeyResponseStruct struct { + Response *types.WebAuthnResponse `json:"response"` + } + + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + state := getAndConsumePendingPasskey(txtid) + if state == nil || state.Request == nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("no pending passkey request")) + return + } + + decoder := json.NewDecoder(r.Body) + var t passkeyResponseStruct + err := decoder.Decode(&t) + if err != nil { + storePendingPasskey(txtid, state) // put it back so user can retry + s.Respond(w, r, http.StatusBadRequest, errors.New("could not decode Payload")) + return + } + + if t.Response == nil { + storePendingPasskey(txtid, state) // put it back + s.Respond(w, r, http.StatusBadRequest, errors.New("missing response in Payload")) + return + } + + if state.Client == nil { + storePendingPasskey(txtid, state) // put it back + s.Respond(w, r, http.StatusInternalServerError, errors.New("passkey client unavailable")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err = state.Client.SendPasskeyResponse(ctx, t.Response) + if err != nil { + log.Error().Err(err).Str("userID", txtid).Msg("Failed to send passkey response") + s.Respond(w, r, http.StatusInternalServerError, err) + return + } + + log.Info().Str("userID", txtid).Msg("Passkey response sent successfully") + s.Respond(w, r, http.StatusOK, map[string]interface{}{"status": "passkey_response_sent"}) + } +} + +// PasskeyConfirm confirms the passkey pairing code was shown to the user. +// Unlike PasskeyResponse, this uses peek (non-consuming read) because the +// confirmation step does not require exclusive ownership of the state — the +// state is only removed after a successful SendPasskeyConfirmation call. +func (s *server) PasskeyConfirm() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + state := peekPendingPasskey(txtid) + if state == nil || state.Client == nil { + s.Respond(w, r, http.StatusBadRequest, errors.New("no pending passkey confirmation")) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := state.Client.SendPasskeyConfirmation(ctx) + if err != nil { + log.Error().Err(err).Str("userID", txtid).Msg("Failed to send passkey confirmation") + s.Respond(w, r, http.StatusInternalServerError, err) + return + } + + // Now consume the state — the confirmation was sent successfully. + deletePendingPasskey(txtid) + + log.Info().Str("userID", txtid).Msg("Passkey confirmation sent successfully") + s.Respond(w, r, http.StatusOK, map[string]interface{}{"status": "passkey_confirmed"}) + } +} + +// GetPasskeyStatus returns whether there is a pending passkey request for this session +func (s *server) GetPasskeyStatus() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + txtid := r.Context().Value("userinfo").(Values).Get("Id") + + pk := peekPendingPasskey(txtid) + response := map[string]interface{}{ + "passkeyPending": pk != nil && pk.Request != nil, + "publicKey": nil, + } + if pk != nil && pk.Request != nil { + response["publicKey"] = pk.Request.PublicKey + } + + responseJson, err := json.Marshal(response) + if err != nil { + s.Respond(w, r, http.StatusInternalServerError, err) + } else { + s.Respond(w, r, http.StatusOK, string(responseJson)) + } + } +} + // Gets Connected and LoggedIn Status func (s *server) GetStatus() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -821,6 +937,13 @@ func (s *server) GetStatus() http.HandlerFunc { } proxyConfig := proxyConfigResponse(proxyURL, webhookUseProxy) + passkeyPending := false + var publicKey interface{} = nil + if pk := peekPendingPasskey(txtid); pk != nil && pk.Request != nil { + passkeyPending = true + publicKey = pk.Request.PublicKey + } + response := map[string]interface{}{ "id": txtid, "name": userInfo.Get("Name"), @@ -832,6 +955,8 @@ func (s *server) GetStatus() http.HandlerFunc { "events": userInfo.Get("Events"), "proxy_url": userInfo.Get("Proxy"), "qrcode": userInfo.Get("Qrcode"), + "passkeyPending": passkeyPending, + "publicKey": publicKey, "history": userInfo.Get("History"), "proxy_config": proxyConfig, "s3_config": s3Config, diff --git a/main.go b/main.go index 0b7711cc..84a5780f 100644 --- a/main.go +++ b/main.go @@ -492,6 +492,9 @@ func main() { s.connectOnStartup() + // Start background cleanup of stale passkey pairing states (needed for both modes) + startPasskeyCleanup() + if serverMode == Stdio { startStdioMode(s) } else { @@ -557,6 +560,7 @@ func startHTTPMode(s *server) { } }() log.Info().Str("address", *address).Str("port", *port).Msg("Server started. Waiting for connections...") + select {} } diff --git a/passkey_test.go b/passkey_test.go new file mode 100644 index 00000000..4ab4bd75 --- /dev/null +++ b/passkey_test.go @@ -0,0 +1,489 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/mux" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" +) + +// ===================== Test helpers ===================== + +// resetPasskeyMap clears the global pending passkey map and registers a +// cleanup so it is always cleared after each test finishes. +func resetPasskeyMap(t *testing.T) { + t.Helper() + pendingPasskeyMu.Lock() + pendingPasskeyRequests = make(map[string]*PendingPasskeyState) + pendingPasskeyMu.Unlock() + t.Cleanup(func() { + pendingPasskeyMu.Lock() + pendingPasskeyRequests = make(map[string]*PendingPasskeyState) + pendingPasskeyMu.Unlock() + }) +} + +// addTestUserForPasskey inserts a minimal user row and returns its ID. +func addTestUserForPasskey(t *testing.T, s *server, token string) string { + t.Helper() + userID := "test-user-" + token + _, err := s.db.Exec( + "INSERT INTO users (id, name, token) VALUES (?, ?, ?)", + userID, "Test User", token, + ) + if err != nil { + t.Fatalf("insert test user: %v", err) + } + return userID +} + +// injectUserInfo simulates the authalice middleware by placing a Values +// context value on the request. +func injectUserInfo(r *http.Request, userID string) *http.Request { + v := Values{map[string]string{ + "Id": userID, + "Token": "test-token", + }} + return r.WithContext(context.WithValue(r.Context(), "userinfo", v)) +} + +// parseResponse unmarshals the recorder body and returns the map. +func parseResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON response: %v\nbody: %s", err, w.Body.String()) + } + return resp +} + +// assertErrorBody checks that the response is a JSON-RPC-style error +// envelope (as returned by s.Respond with an error) and that the error +// message contains the expected substring. +func assertErrorBody(t *testing.T, w *httptest.ResponseRecorder, expectedStatus int, substr string) { + t.Helper() + if w.Code != expectedStatus { + t.Fatalf("expected status %d, got %d", expectedStatus, w.Code) + } + resp := parseResponse(t, w) + if resp["success"] != false { + t.Fatalf("expected success=false, got %v", resp["success"]) + } + errMsg, _ := resp["error"].(string) + if !strings.Contains(errMsg, substr) { + t.Fatalf("expected error to contain %q, got %q", substr, errMsg) + } +} + +// ===================== Memory / lifecycle tests ===================== + +func TestPendingPasskeyState_Lifecycle(t *testing.T) { + resetPasskeyMap(t) + + t.Run("empty_map_returns_nil", func(t *testing.T) { + if pk := peekPendingPasskey("u1"); pk != nil { + t.Fatalf("peek expected nil, got %+v", pk) + } + if pk := getAndConsumePendingPasskey("u1"); pk != nil { + t.Fatalf("consume expected nil, got %+v", pk) + } + }) + + t.Run("peek_is_non_destructive", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u2", state) + + if pk := peekPendingPasskey("u2"); pk == nil { + t.Fatal("first peek: expected state") + } + if pk := peekPendingPasskey("u2"); pk == nil { + t.Fatal("second peek: expected state again") + } + }) + + t.Run("consume_removes_state", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u3", state) + + if pk := getAndConsumePendingPasskey("u3"); pk == nil { + t.Fatal("consume: expected state") + } + if pk := peekPendingPasskey("u3"); pk != nil { + t.Fatal("after consume: expected nil") + } + }) + + t.Run("double_consume_returns_nil", func(t *testing.T) { + state := &PendingPasskeyState{Request: &events.PairPasskeyRequest{}} + storePendingPasskey("u4", state) + + _ = getAndConsumePendingPasskey("u4") + if pk := getAndConsumePendingPasskey("u4"); pk != nil { + t.Fatal("second consume should return nil") + } + }) +} + +func TestPendingPasskeyState_DeleteExplicitly(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("d1", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + if peekPendingPasskey("d1") == nil { + t.Fatal("expected state after store") + } + + deletePendingPasskey("d1") + if peekPendingPasskey("d1") != nil { + t.Fatal("expected nil after explicit delete") + } +} + +func TestPendingPasskeyState_IsolationBetweenUsers(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("iso-a", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + storePendingPasskey("iso-b", &PendingPasskeyState{Request: &events.PairPasskeyRequest{}}) + + getAndConsumePendingPasskey("iso-a") + + if peekPendingPasskey("iso-a") != nil { + t.Fatal("user-a should be consumed") + } + if peekPendingPasskey("iso-b") == nil { + t.Fatal("user-b should still be present") + } +} + +func TestPendingPasskeyState_Overwrite(t *testing.T) { + resetPasskeyMap(t) + + state1 := &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now().Add(-10 * time.Minute), + } + state2 := &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now(), + } + + storePendingPasskey("ow", state1) + storePendingPasskey("ow", state2) // overwrite + + pk := peekPendingPasskey("ow") + if pk == nil { + t.Fatal("expected state after overwrite") + } + if pk.CreatedAt.Before(state2.CreatedAt.Add(-time.Second)) { + t.Fatal("second store should have overwritten the first") + } +} + +func TestPendingPasskeyState_TTLExpiry(t *testing.T) { + resetPasskeyMap(t) + + // Inject an old state directly (bypass store to set CreatedAt in the past) + pendingPasskeyMu.Lock() + pendingPasskeyRequests["old-user"] = &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now().Add(-20 * time.Minute), + } + pendingPasskeyMu.Unlock() + + // Simulate cleanup (mirrors the goroutine logic) + now := time.Now() + pendingPasskeyMu.Lock() + for uid, st := range pendingPasskeyRequests { + if now.Sub(st.CreatedAt) > passkeyStateTTL { + delete(pendingPasskeyRequests, uid) + } + } + pendingPasskeyMu.Unlock() + + if peekPendingPasskey("old-user") != nil { + t.Fatal("expired state should have been cleaned up") + } +} + +func TestPendingPasskeyState_FreshStateSurvivesCleanup(t *testing.T) { + resetPasskeyMap(t) + + storePendingPasskey("fresh", &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + CreatedAt: time.Now(), + }) + + now := time.Now() + pendingPasskeyMu.Lock() + for uid, st := range pendingPasskeyRequests { + if now.Sub(st.CreatedAt) > passkeyStateTTL { + delete(pendingPasskeyRequests, uid) + } + } + pendingPasskeyMu.Unlock() + + if peekPendingPasskey("fresh") == nil { + t.Fatal("fresh state should survive cleanup") + } +} + +// getData extracts the "data" field from the standard s.Respond envelope. +func getData(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} { + t.Helper() + resp := parseResponse(t, w) + data, _ := resp["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("expected data field in response, got: %v", resp) + } + return data +} + +// ===================== HTTP handler: GetPasskeyStatus ===================== + +func TestGetPasskeyStatus_NoPending(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-np") + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + data := getData(t, w) + if data["passkeyPending"] != false { + t.Fatalf("expected passkeyPending=false, got %v", data["passkeyPending"]) + } + if data["publicKey"] != nil { + t.Fatalf("expected publicKey=nil, got %v", data["publicKey"]) + } +} + +func TestGetPasskeyStatus_WithPending(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-wp") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com", Timeout: 600000} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + data := getData(t, w) + if data["passkeyPending"] != true { + t.Fatalf("expected passkeyPending=true, got %v", data["passkeyPending"]) + } + if data["publicKey"] == nil { + t.Fatal("expected publicKey to be non-nil") + } +} + +func TestGetPasskeyStatus_StateWithNilRequest(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "ps-nr") + + // Store a state where Request is nil + pendingPasskeyMu.Lock() + pendingPasskeyRequests[userID] = &PendingPasskeyState{ + Request: nil, + CreatedAt: time.Now(), + } + pendingPasskeyMu.Unlock() + + req := httptest.NewRequest("GET", "/session/passkey-status", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetPasskeyStatus()(w, req) + + data := getData(t, w) + // Request is nil → passkeyPending should be false + if data["passkeyPending"] != false { + t.Fatalf("expected passkeyPending=false when Request=nil, got %v", data["passkeyPending"]) + } +} + +// ===================== HTTP handler: GetQR ===================== + +// TestGetQR_NoSession_ReturnsNoSession verifies that GetQR returns a 500 +// with "no session" when the clientManager has no client for this user. +// This is the realistic path since we cannot spin up a real whatsmeow +// client in a unit test. +func TestGetQR_NoSession_ReturnsNoSession(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "qr-ns") + + req := httptest.NewRequest("GET", "/session/qr", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetQR()(w, req) + + // GetQR checks clientManager first → no client = "no session" error + assertErrorBody(t, w, http.StatusInternalServerError, "no session") +} + +// TestGetQR_NoSession_DoesNotLeakPasskey verifies that even when a +// passkey is pending, the "no session" check happens first. The passkey +// state should remain unconsumed. +func TestGetQR_NoSession_DoesNotLeakPasskey(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "qr-leak") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("GET", "/session/qr", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.GetQR()(w, req) + + // Should fail with "no session" + if w.Code != http.StatusInternalServerError { + t.Fatalf("expected 500, got %d", w.Code) + } + + // Passkey state should NOT have been consumed + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("passkey state should still exist after GetQR 'no session' error") + } +} + +// ===================== HTTP handler: PasskeyResponse ===================== + +func TestPasskeyResponse_NoPendingReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-400") + + body := `{"response":{"id":"x","rawId":"x","type":"public-key","response":{"clientDataJSON":"x","authenticatorData":"x","signature":"x","userHandle":null}}}` + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader(body)) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") +} + +func TestPasskeyResponse_InvalidJSON_Returns400AndRestoresState(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-badjson") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader("NOT_JSON")) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "could not decode") + + // State should be restored so the user can retry + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should be restored after decode error") + } +} + +func TestPasskeyResponse_MissingResponseField_Returns400AndRestoresState(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pr-noresp") + + fakePK := &types.WebAuthnPublicKey{RelyingPartID: "whatsapp.com"} + storePendingPasskey(userID, &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{PublicKey: fakePK}, + CreatedAt: time.Now(), + }) + + // Valid JSON but missing the "response" field + req := httptest.NewRequest("POST", "/session/passkey-response", strings.NewReader("{}")) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyResponse()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "missing response") + + // State should be restored + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should be restored after missing response field") + } +} + +// ===================== HTTP handler: PasskeyConfirm ===================== + +func TestPasskeyConfirm_NoPendingReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pc-400") + + req := httptest.NewRequest("POST", "/session/passkey-confirm", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyConfirm()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") +} + +func TestPasskeyConfirm_StateWithNilClientReturns400(t *testing.T) { + resetPasskeyMap(t) + s := makeTestServer(t) + userID := addTestUserForPasskey(t, s, "pc-nil") + + // State exists but Client is nil + pendingPasskeyMu.Lock() + pendingPasskeyRequests[userID] = &PendingPasskeyState{ + Request: &events.PairPasskeyRequest{}, + Client: nil, + CreatedAt: time.Now(), + } + pendingPasskeyMu.Unlock() + + req := httptest.NewRequest("POST", "/session/passkey-confirm", nil) + req = mux.SetURLVars(req, map[string]string{"id": userID}) + req = injectUserInfo(req, userID) + w := httptest.NewRecorder() + s.PasskeyConfirm()(w, req) + + assertErrorBody(t, w, http.StatusBadRequest, "no pending passkey") + + // State should still exist (peek was used, not consume) + if pk := peekPendingPasskey(userID); pk == nil { + t.Fatal("state should still exist after nil-client check") + } +} diff --git a/routes.go b/routes.go index ddb53655..d818e7d6 100644 --- a/routes.go +++ b/routes.go @@ -84,6 +84,9 @@ func (s *server) routes() { s.router.Handle("/session/status", c.Then(s.GetStatus())).Methods("GET") s.router.Handle("/session/qr", c.Then(s.GetQR())).Methods("GET") s.router.Handle("/session/pairphone", c.Then(s.PairPhone())).Methods("POST") + s.router.Handle("/session/passkey-response", c.Then(s.PasskeyResponse())).Methods("POST") + s.router.Handle("/session/passkey-confirm", c.Then(s.PasskeyConfirm())).Methods("POST") + s.router.Handle("/session/passkey-status", c.Then(s.GetPasskeyStatus())).Methods("GET") s.router.Handle("/session/history", c.Then(s.RequestHistorySync())).Methods("GET") s.router.Handle("/webhook", c.Then(s.SetWebhook())).Methods("POST") diff --git a/static/api/spec.yml b/static/api/spec.yml index d6d3a5ec..db45d4a5 100644 --- a/static/api/spec.yml +++ b/static/api/spec.yml @@ -338,7 +338,7 @@ paths: tags: - Session summary: connects to WhatsApp servers - description: "Initiates connection to WhatsApp servers.\n\nIf there is no previous session created, it will generate a QR code that can be retrieved via the [qr](#/Session/get_session_qr) API call.\n\nIf the optional Subscribe is supplied it will limit webhooks to the specified event types: Message,ReadReceipt,Presence,HistorySync,ChatPresence.\n\nIf no Subscribe is supplied it will subscribe to All events.\n\nIf Immediate is set to false, the action will wait for 10 seconds to retrieve actual connection status from whatsapp, otherwise it will return immediatly.\n\nWhen setting Immediate to true you should check for actual connection status after a few seconds via the [status](#/Session/get_session_status) API call as your connection might fail if the session was closed from another device." + description: "Initiates connection to WhatsApp servers.\n\nIf there is no previous session created, it will generate a QR code that can be retrieved via the [qr](#/Session/get_session_qr) API call. Alternatively, the device may initiate passkey pairing instead of QR — in that case, a PasskeyRequest webhook will be sent with the challenge data.\n\nIf the optional Subscribe is supplied it will limit webhooks to the specified event types: Message,ReadReceipt,Presence,HistorySync,ChatPresence.\n\nIf no Subscribe is supplied it will subscribe to All events.\n\nIf Immediate is set to false, the action will wait for 10 seconds to retrieve actual connection status from whatsapp, otherwise it will return immediatly.\n\nWhen setting Immediate to true you should check for actual connection status after a few seconds via the [status](#/Session/get_session_status) API call as your connection might fail if the session was closed from another device." security: - ApiKeyAuth: [] requestBody: @@ -435,7 +435,7 @@ paths: tags: - Session summary: Gets QR code for scanning - description: Gets QR code if the user is connected but not logged in. If the user is already logged in, QRCode will be empty. + description: Gets QR code if the user is connected but not logged in and the device is pairing via QR. If the user is already logged in, QRCode will be empty. If the device initiated passkey pairing instead of QR, QRCode will also be empty — in that case the pairing data is delivered via PasskeyRequest webhook. security: - ApiKeyAuth: [] responses: @@ -444,7 +444,70 @@ paths: content: application/json: schema: - example: { "code": 200, "data": { "QRCode": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAABlBMVEX///8AAABVwtN+AAAEw0lEQVR42uyZPa7zqhaGX0ThLmsCkZlGCktMKaU76FxmSkgUmQZWJkA6CuT3avlLvrNvvRMX9x6KXWQ/UhCsn2cR/Lv+v5YhudQ6njEs1bBjqGYDwlJJpoOAArtUbK4Pi5jN3qPAlCkstcAeBazMUaoj78RpxGW4yWYzWVfmzwFLlLX4O+VkkucN5tFDOxiIAvfoA/X4uVQ4sgUcCBTYCG7AEGGKvbdrBabQ8OOyvg3ovm4ynqfLXJ9rvi+303ie5vm/gvZXgK6BLC7fo5hiG4KwW7b6I/2+DJi1+ybVFQyx6o6bbKPVDCyjTwcBZB9uevBtAEafhiosCFH/4kNA8i1gg02B3KxezGbzEjUCDgIwYppR3SNdgtY3H0M1j8xFzCscvg/8uQvZAB9piidv1RXfZhbHdAwAlzsCNCaJDdMF4WQeeSGACZ8BMNl4FZYJA7j2YalPPhhngetHAaZPcyBg2wyYdAk0fKQ5yPja5PcBzTZW4uxJ2bTGwmxnu/BH4vwSgEsYItcCH+VZJt/AYhmHatbXdX8d2JvaTVzxCVW2aVhqheXSqvnR9b4L6AoUx3zX+jZd5rDB5jbLuv0txd8GRs+liuv+TsKloQWujxxRYf5s8gOA7fMVK9PQuDtMNCx2ibIdCMCy1s0yQU6Od9bqim1BuzoOAgzTHOiKv0d5Mt+XClN8DBxN/wxg2G2DbDYNJExCqE+Ne8poXoLxdUA/w5VrnxBQ9fjlqaJMwWgPAzLjtfKRW4A21ojnStX0dX2d5PeB0fawu2pChcuM4bk+tLmbMn0GMJslb5ptDXySbb5W1+0SyVcJOgRIQxSc7X0RUSvGs2DSeaz4gwCMNi/7XNACZc0KbPBtruv2KQA+DVFladBvt4xywhmh1Xd2fx8wzGTUltqCWrHWgqL7Jg8E0hSiFJfbUJ/Fpx3L1OHsVR8+APgoZMclUKvcft2+zTBrwjHArosim4ZcfW4Y4lVWnYXg2A8C9C5aEFXDoEJzmXFyfZoH/p0Wvw7oXoZbNQ823ase1wk2DQ3u7XK/BkzOqovwpM68Ko+jUyPFu6F8H4DvqsAuaUMZJ6+azjTPdS32KMBkLnpQ3VPnbsZgiktALW91/wDQEV5V7gT4JT6L62GRzeV0EDDC7rVFax2ZW6Aa6V5h/FEAgBlSbLrMVScU1s09+jxwG/9q87cB/Yxw3acBsk2Yw+nPf9Y1p88ARlNPtvPkF3LlPQYp8MtSx/FtpF8H4DNrZd8fOtTOxJSzXdo/c/fXAbN2DLeKs1dxHeEZZVWaju/3h18CcDk3qePZpllglDZ89MCq8nIQoDPAVaPi3iAFFwS1xjjr+HcYwD+hri216vBZzQbbZsE44RhAp+sQxfTpApGCoV1NOfsl4pX+nwC65a1uLnkK9TSuVTOhaQ4cBOzvtDcZXU5Bdl28SrF9HqrZJhwD7O/VsZpi7xSz7pXW6ahQ1/dB/RrYf2QhLBmr1lNINVRZfw9BBwArc4SszGlWWd2fxB9cFvJQYKnUUWAgV22y5v1e/ffHpiOAqMLCiOpymwNGtxvk9s8mfwcU2CiydqvJbdKuSX0K8a/KHQDsMQkyeVbtISFif8mRcfwRtF8F/l3/O+s/AQAA///lM0dZSaTeTQAAAABJRU5ErkJggg==" }, "success": true } + example: { "code": 200, "data": { "QRCode": "data:image/png;base64,...", "passkeyPending": true, "publicKey": { "allowCredentials": [], "challenge": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "extensions": { "uvm": true }, "rpId": "whatsapp.com", "timeout": 600000, "userVerification": "required" } }, "success": true } + + In addition to QRCode, the response also includes passkey pairing state: + - passkeyPending (boolean): true if the device initiated passkey pairing instead of QR + - publicKey (object|null): the WebAuthn challenge data when passkeyPending is true + /session/passkey-status: + get: + tags: + - Session + summary: Get passkey pairing status + description: "Checks if there is a pending passkey pairing request for this session.\n\nReturns passkeyPending=true with the publicKey challenge data if the device initiated passkey pairing instead of QR." + security: + - ApiKeyAuth: [] + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "passkeyPending": true, "publicKey": { "allowCredentials": [], "challenge": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", "extensions": { "uvm": true }, "rpId": "whatsapp.com", "timeout": 600000, "userVerification": "required" } }, "success": true } + /session/passkey-response: + post: + tags: + - Session + summary: Send passkey WebAuthn response + description: "Sends the WebAuthn response from the authenticator to complete passkey pairing.\n\nThis endpoint must be called after receiving a webhook with the PasskeyRequest event containing the challenge data." + security: + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/definitions/PasskeyResponse' + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "status": "passkey_response_sent" }, "success": true } + 400: + description: No pending passkey request + 500: + description: Failed to send passkey response + /session/passkey-confirm: + post: + tags: + - Session + summary: Confirm passkey pairing code + description: "Confirms that the 8-character pairing code was displayed to the user and matches the code on the phone.\n\nThis endpoint must be called after receiving a webhook with the PasskeyConfirmation event." + security: + - ApiKeyAuth: [] + responses: + 200: + description: Response + content: + application/json: + schema: + example: { "code": 200, "data": { "status": "passkey_confirmed" }, "success": true } + 400: + description: No pending passkey confirmation + 500: + description: Failed to send passkey confirmation /session/proxy: post: tags: @@ -3278,6 +3341,49 @@ definitions: description: Number of days to retain files (0 for no expiration) example: 30 + PasskeyResponse: + type: object + required: + - response + properties: + response: + type: object + description: WebAuthn response object from navigator.credentials.get() + properties: + id: + type: string + description: Base64url-encoded credential identifier + example: "ABCD1234..." + rawId: + type: string + description: Base64url-encoded raw credential identifier + example: "ABCD1234..." + type: + type: string + description: Credential type + example: "public-key" + response: + type: object + description: Authenticator response data + properties: + clientDataJSON: + type: string + description: Base64url-encoded client data + example: "eyJ0eXBlIjoi..." + authenticatorData: + type: string + description: Base64url-encoded authenticator data + example: "SZYN5YgO..." + signature: + type: string + description: Base64url-encoded signature + example: "MEUCIQD..." + userHandle: + type: string + nullable: true + description: Base64url-encoded user handle (can be null) + example: null + components: securitySchemes: ApiKeyAuth: diff --git a/static/dashboard/index.html b/static/dashboard/index.html index e31c8edb..b0a269b2 100644 --- a/static/dashboard/index.html +++ b/static/dashboard/index.html @@ -1309,6 +1309,44 @@

Current Invite Link

+ + + diff --git a/static/dashboard/js/app.js b/static/dashboard/js/app.js index 2a3dbcd4..e367d1a4 100644 --- a/static/dashboard/js/app.js +++ b/static/dashboard/js/app.js @@ -1185,6 +1185,192 @@ function init() { } } +// ===== Passkey functions ===== +let currentPasskeyPublicKey = null; +let passkeyPollInterval = null; +let passkeyPollTimeout = null; + +/** + * Clear any active passkey polling interval and timeout. + * Call this when modal closes, connection succeeds, or navigating away. + */ +function clearPasskeyPolling() { + if (passkeyPollInterval) { clearInterval(passkeyPollInterval); passkeyPollInterval = null; } + if (passkeyPollTimeout) { clearTimeout(passkeyPollTimeout); passkeyPollTimeout = null; } +} + +function formatPasskeyCode(publicKey) { + const pkStr = JSON.stringify(publicKey, null, 2); + return `const publicKey = ${pkStr};\n\n(async () => {\n const credential = await navigator.credentials.get({\n publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(publicKey)\n });\n console.log(JSON.stringify(credential.toJSON()));\n})();`; +} + +function copyPasskeyCode() { + const code = document.getElementById('passkeyCodeBlock').textContent; + navigator.clipboard.writeText(code).then(() => { + $.toast({ class: 'success', message: 'Code copied to clipboard!' }); + }).catch(() => { + const textarea = document.createElement('textarea'); + textarea.value = code; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + $.toast({ class: 'success', message: 'Code copied to clipboard!' }); + }); +} + +function showPasskeyModal(publicKey) { + currentPasskeyPublicKey = publicKey; + const code = formatPasskeyCode(publicKey); + document.getElementById('passkeyCodeBlock').textContent = code; + document.getElementById('passkeyResponseInput').value = ''; + $('#modalPasskeyPairing') + .modal({ + closable: true, + onHidden: function() { clearPasskeyPolling(); } + }) + .modal('show'); +} + +async function submitPasskeyResponse() { + const input = document.getElementById('passkeyResponseInput').value.trim(); + if (!input) { + $.toast({ class: 'error', message: 'Paste the console result before sending.' }); + return; + } + + let parsed; + try { + parsed = JSON.parse(input); + } catch (e) { + $.toast({ class: 'error', message: 'Invalid JSON. Check the format and try again.' }); + return; + } + + const btn = document.getElementById('sendPasskeyBtn'); + btn.classList.add('loading'); + + const token = getLocalStorageItem('token'); + const myHeaders = new Headers(); + myHeaders.append('token', token); + myHeaders.append('Content-Type', 'application/json'); + + try { + const res = await fetch(baseUrl + "/session/passkey-response", { + method: "POST", + headers: myHeaders, + body: JSON.stringify({ response: parsed }) + }); + const data = await res.json(); + const errorDetail = data.errors?.error || data.error; + + if (!res.ok || !data.success) { + $.toast({ class: 'error', message: 'Error sending passkey: ' + (errorDetail || 'Unknown error') }); + btn.classList.remove('loading'); + return; + } + + const status = data.data?.status || data.status; + + if (status === 'passkey_finished') { + $.toast({ class: 'success', message: 'Channel connected successfully!' }); + $('#modalPasskeyPairing').modal('hide'); + updateInterval = 5000; + btn.classList.remove('loading'); + return; + } + + if (status === 'passkey_response_sent') { + $.toast({ class: 'info', message: 'Response sent! Awaiting WhatsApp confirmation...' }); + + // Now call passkey-confirm to trigger the pairing code display + const confirmRes = await fetch(baseUrl + "/session/passkey-confirm", { + method: "POST", + headers: myHeaders, + body: JSON.stringify({}) + }); + const confirmData = await confirmRes.json(); + const confirmError = confirmData.errors?.error || confirmData.error; + + if (!confirmRes.ok || !confirmData.success) { + $.toast({ class: 'error', message: 'Error confirming: ' + (confirmError || 'Unknown') }); + btn.classList.remove('loading'); + return; + } + + const confirmStatus = confirmData.data?.status || confirmData.status; + + if (confirmStatus === 'passkey_finished') { + // Connected immediately (SkipHandoffUX was true) + $.toast({ class: 'success', message: 'Channel connected successfully!' }); + $('#modalPasskeyPairing').modal('hide'); + updateInterval = 5000; + } else if (confirmStatus === 'passkey_confirmed') { + // Confirmation sent — now wait for status to change via polling + $.toast({ class: 'success', message: 'Passkey confirmed! Waiting for connection...' }); + document.getElementById('passkeyCodeBlock').textContent = 'Waiting for connection...'; + document.getElementById('passkeyResponseInput').value = ''; + document.getElementById('passkeyResponseInput').placeholder = 'Waiting for WhatsApp to confirm...'; + + // Start polling — the modal stays open until loggedIn becomes true + startPasskeyPolling(token); + } else { + $.toast({ class: 'success', message: 'Passkey confirmed. Waiting for connection...' }); + $('#modalPasskeyPairing').modal('hide'); + } + } else { + $.toast({ class: 'success', message: 'Passkey response sent.' }); + $('#modalPasskeyPairing').modal('hide'); + } + } catch (err) { + $.toast({ class: 'error', message: 'Network error: ' + err.message }); + } + + btn.classList.remove('loading'); +} + +function startPasskeyPolling(token) { + // Clear any existing polling before starting a new one + clearPasskeyPolling(); + + passkeyPollInterval = setInterval(async () => { + try { + const myHeaders = new Headers(); + myHeaders.append('token', token); + const res = await fetch(baseUrl + "/session/status", { + method: "GET", + headers: myHeaders, + }); + const data = await res.json(); + + if (data.success && data.data) { + if (data.data.loggedIn) { + // Connected! + clearPasskeyPolling(); + $.toast({ class: 'success', message: 'Channel connected successfully!' }); + $('#modalPasskeyPairing').modal('hide'); + updateInterval = 5000; + } else if (!data.data.passkeyPending) { + // Passkey was consumed but not yet logged in — keep polling briefly + } else { + // Still waiting + } + } + } catch (e) { + // Silently continue polling on network errors + } + }, 2000); + + // Safety: stop polling after 60 seconds + passkeyPollTimeout = setTimeout(() => { + clearPasskeyPolling(); + const modalVisible = $('#modalPasskeyPairing').modal('is active'); + if (modalVisible) { + $.toast({ class: 'warning', message: 'Passkey polling timed out. Check connection status.' }); + } + }, 60000); +} + function populateInstances(instances) { const tableBody = $('#instances-body'); const cardsContainer = $('#instances-cards'); // Assuming you have a container for cards @@ -1196,7 +1382,7 @@ function populateInstances(instances) { const nodatarow = 'No instances found' tableBody.append(nodatarow); } - instances.forEach(instance => { + instances.forEach((instance, idx) => { const row = ` @@ -1286,9 +1472,23 @@ function populateInstances(instances) { - + ${!instance.loggedIn ? `
+ ${instance.passkeyPending && instance.publicKey ? ` + +
+ +

Passkey Required

+

+ WhatsApp requested passkey verification. +

+ +
+ ` : ` +
${instance.qrcode ? ` @@ -1302,6 +1502,7 @@ function populateInstances(instances) {
` }
+ `} ` : ` @@ -1327,7 +1528,16 @@ function populateInstances(instances) { if (currentInstanceObj) { currentInstanceData = currentInstanceObj; } - } + } + + // Bind passkey setup buttons + document.querySelectorAll('.passkey-setup-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + const idx = parseInt(this.getAttribute('data-passkey-index'), 10); + const pk = instances[idx] && instances[idx].publicKey; + if (pk) showPasskeyModal(pk); + }); + }); } /** diff --git a/stdio.go b/stdio.go index 31f81c70..b6edcb1b 100644 --- a/stdio.go +++ b/stdio.go @@ -260,6 +260,15 @@ func (ss *stdioServer) routeRequest(req *jsonRpcRequest) { case "session.pairphone": httpMethod = "POST" httpPath = "/session/pairphone" + case "session.passkey-response": + httpMethod = "POST" + httpPath = "/session/passkey-response" + case "session.passkey-confirm": + httpMethod = "POST" + httpPath = "/session/passkey-confirm" + case "session.passkey-status": + httpMethod = "GET" + httpPath = "/session/passkey-status" case "session.history": httpMethod = "GET" httpPath = "/session/history" diff --git a/wmiau.go b/wmiau.go index 624c7b34..8afeb662 100644 --- a/wmiau.go +++ b/wmiau.go @@ -32,6 +32,7 @@ import ( "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" "golang.org/x/net/proxy" + "sync" ) // db field declaration as *sqlx.DB @@ -63,6 +64,82 @@ func safeGo(name string, fn func()) { }() } +// PendingPasskeyState holds the state of an in-progress passkey pairing. +type PendingPasskeyState struct { + Request *events.PairPasskeyRequest + Client *whatsmeow.Client + CreatedAt time.Time +} + +// passkeyStateTTL is how long a pending passkey request lives before being +// automatically cleaned up. WhatsApp challenges typically expire after +// ~10 minutes; we keep a generous margin. +const passkeyStateTTL = 15 * time.Minute + +var ( + pendingPasskeyMu sync.Mutex + pendingPasskeyRequests = make(map[string]*PendingPasskeyState) // keyed by userID +) + +// startPasskeyCleanup runs a background goroutine that periodically removes +// stale pending passkey states. Call this once during server initialization. +func startPasskeyCleanup() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + pendingPasskeyMu.Lock() + now := time.Now() + for userID, state := range pendingPasskeyRequests { + if now.Sub(state.CreatedAt) > passkeyStateTTL { + delete(pendingPasskeyRequests, userID) + log.Info().Str("userID", userID).Msg("Passkey state expired and cleaned up") + } + } + pendingPasskeyMu.Unlock() + } + }() +} + +func storePendingPasskey(userID string, state *PendingPasskeyState) { + state.CreatedAt = time.Now() + pendingPasskeyMu.Lock() + pendingPasskeyRequests[userID] = state + pendingPasskeyMu.Unlock() +} + +func getAndConsumePendingPasskey(userID string) *PendingPasskeyState { + pendingPasskeyMu.Lock() + state, ok := pendingPasskeyRequests[userID] + if ok { + delete(pendingPasskeyRequests, userID) + } + pendingPasskeyMu.Unlock() + if !ok { + return nil + } + return state +} + +func deletePendingPasskey(userID string) { + pendingPasskeyMu.Lock() + delete(pendingPasskeyRequests, userID) + pendingPasskeyMu.Unlock() +} + +// peekPendingPasskey checks if there is a pending passkey request for this user +// WITHOUT consuming it. Used by /session/qr, /session/passkey-status, and +// /session/passkey-confirm. +func peekPendingPasskey(userID string) *PendingPasskeyState { + pendingPasskeyMu.Lock() + state, ok := pendingPasskeyRequests[userID] + pendingPasskeyMu.Unlock() + if !ok { + return nil + } + return state +} + // ensureS3ClientForUser loads S3 config from DB and initializes client if not already present (lazy init for reconnect-after-restart) func ensureS3ClientForUser(userID string) { GetS3Manager().EnsureClientFromDB(userID) @@ -598,6 +675,51 @@ func (s *server) startClient(userID string, textjid string, token string, kill c userinfocache.Set(token, v, cache.NoExpiration) } } + } else if evt.Event == "passkey-request" { + if evt.PasskeyRequest != nil { + storePendingPasskey(userID, &PendingPasskeyState{ + Request: evt.PasskeyRequest, + Client: client, + }) + postmap := make(map[string]interface{}) + postmap["event"] = "passkey-request" + postmap["type"] = "PasskeyRequest" + postmap["publicKey"] = evt.PasskeyRequest.PublicKey + sendEventWithWebHook(&mycli, postmap, "") + log.Info().Msg("Passkey request received, sent to frontend") + } + } else if evt.Event == "passkey-confirmation" { + if evt.PasskeyConfirmation != nil { + if evt.PasskeyConfirmation.SkipHandoffUX { + log.Info().Msg("Passkey confirmation: SkipHandoffUX=true, auto-confirming") + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.SendPasskeyConfirmation(ctx); err != nil { + log.Error().Err(err).Msg("Failed to auto-confirm passkey") + } else { + log.Info().Msg("Auto-confirmed passkey successfully") + } + }() + } else { + postmap := make(map[string]interface{}) + postmap["event"] = "passkey-confirmation" + postmap["type"] = "PasskeyConfirmation" + postmap["code"] = evt.PasskeyConfirmation.Code + postmap["skipHandoffUX"] = evt.PasskeyConfirmation.SkipHandoffUX + sendEventWithWebHook(&mycli, postmap, "") + log.Info().Str("code", evt.PasskeyConfirmation.Code).Msg("Passkey confirmation code sent to frontend") + } + } + } else if evt.Event == "error" { + log.Error().Str("event", evt.Event).Interface("error", evt.Error).Msg("QR channel error") + postmap := make(map[string]interface{}) + postmap["event"] = "error" + postmap["type"] = "PairError" + if evt.Error != nil { + postmap["error"] = evt.Error.Error() + } + sendEventWithWebHook(&mycli, postmap, "") } else { log.Info().Str("event", evt.Event).Msg("Login event") } @@ -1580,6 +1702,27 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { postmap["type"] = "FBMessage" dowebhook = 1 log.Info().Str("info", evt.Info.SourceString()).Msg("Facebook message received") + case *events.PairPasskeyRequest: + storePendingPasskey(mycli.userID, &PendingPasskeyState{ + Request: evt, + Client: mycli.WAClient, + }) + postmap["type"] = "PasskeyRequest" + postmap["publicKey"] = evt.PublicKey + dowebhook = 1 + log.Info().Msg("Passkey request received (event handler)") + case *events.PairPasskeyConfirmation: + postmap["type"] = "PasskeyConfirmation" + postmap["code"] = evt.Code + postmap["skipHandoffUX"] = evt.SkipHandoffUX + dowebhook = 1 + log.Info().Str("code", evt.Code).Bool("skipHandoffUX", evt.SkipHandoffUX).Msg("Passkey confirmation received") + case *events.PairPasskeyError: + postmap["type"] = "PairPasskeyError" + postmap["error"] = evt.Error.Error() + postmap["continuation"] = evt.Continuation + dowebhook = 1 + log.Warn().Err(evt.Error).Bool("continuation", evt.Continuation).Msg("Passkey pairing error") default: log.Warn().Str("event", fmt.Sprintf("%+v", evt)).Msg("Unhandled event") } diff --git a/wuzapi.exe b/wuzapi.exe new file mode 100644 index 00000000..b8313839 Binary files /dev/null and b/wuzapi.exe differ