fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
Deploy olons-server production / deploy (push) Successful in 3m38s
This commit is contained in:
@@ -89,10 +89,10 @@ jobs:
|
||||
umask 077
|
||||
{
|
||||
printf "OLONS_IM_INTERNAL_SECRET=%s\n" "$internal_secret"
|
||||
printf "OLONS_IM_BASE_URL=https://im-api.yuexuan.shop\n"
|
||||
printf "OLONS_IM_HTTP_API_URL=https://im-api.yuexuan.shop/v1/im\n"
|
||||
printf "OLONS_IM_WS_URL=wss://im-api.yuexuan.shop/v0/channels\n"
|
||||
printf "OLONS_IM_FILE_URL=https://im-api.yuexuan.shop/v0/file\n"
|
||||
printf "OLONS_IM_BASE_URL=http://im-server:6060\n"
|
||||
printf "OLONS_IM_HTTP_API_URL=http://im-server:6060/v1/im\n"
|
||||
printf "OLONS_IM_WS_URL=ws://im-server:6060/v0/channels\n"
|
||||
printf "OLONS_IM_FILE_URL=http://im-server:6060/v0/file\n"
|
||||
printf "OLONS_IM_TENANT_CODE=default\n"
|
||||
printf "OLONS_IM_API_KEY=AQAAAAABAAAnbYhUP5GAkexs5qlwQ8fR\n"
|
||||
printf "OLONS_IM_INTERNAL_SERVICE=olons-server\n"
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
docker compose -f "$base_file" -f compose.olons-server-secret.yaml up -d --no-deps --wait olons-server
|
||||
docker compose -f "$base_file" -f compose.olons-server-secret.yaml exec -T olons-server sh -c '"'"'
|
||||
test -n "$OLONS_IM_INTERNAL_SECRET"
|
||||
test "$OLONS_IM_BASE_URL" = "https://im-api.yuexuan.shop"
|
||||
test "$OLONS_IM_HTTP_API_URL" = "https://im-api.yuexuan.shop/v1/im"
|
||||
test "$OLONS_IM_BASE_URL" = "http://im-server:6060"
|
||||
test "$OLONS_IM_HTTP_API_URL" = "http://im-server:6060/v1/im"
|
||||
'"'"'
|
||||
'
|
||||
|
||||
@@ -70,6 +70,12 @@ type Session struct {
|
||||
Agent Agent `json:"agent"`
|
||||
}
|
||||
|
||||
const (
|
||||
publicIMAPIPath = "/v1/im"
|
||||
publicIMWebSocketPath = "/v0/channels"
|
||||
publicIMFilePath = "/v0/file"
|
||||
)
|
||||
|
||||
func New(repository Repository, im IM, authConfig config.AuthConfig) *Service {
|
||||
return &Service{repository: repository, im: im, aead: credentialAEAD(authConfig)}
|
||||
}
|
||||
@@ -158,8 +164,8 @@ func (s *Service) OpenSession(ctx context.Context, tenantID, memberID, serviceID
|
||||
return nil, ErrRelationDenied
|
||||
}
|
||||
return &Session{
|
||||
TenantCode: cfg.TenantCode, APIBaseURL: cfg.HTTPAPIURL, WebSocketURL: cfg.WebSocketURL,
|
||||
FileURL: cfg.FileURL, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
||||
TenantCode: cfg.TenantCode, APIBaseURL: publicIMAPIPath, WebSocketURL: publicIMWebSocketPath,
|
||||
FileURL: publicIMFilePath, APIKey: cfg.APIKey, Token: imSession.Token, ExpiresAt: imSession.ExpiresAt,
|
||||
CurrentUserID: imSession.UserID, Topic: conversation.Topic, Relationship: "friend",
|
||||
Agent: Agent{ID: agent.ID, IMUserID: agent.IMUserID, Name: agent.Name, AvatarURL: agent.AvatarURL,
|
||||
OnlineStatus: customerServiceStatus(agent.OnlineStatus)},
|
||||
|
||||
@@ -91,6 +91,24 @@ func TestOpenSessionAutoAcceptsAndReturnsTopic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSessionReturnsSameOriginEndpoints(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
|
||||
identity: &store.MemberIMIdentity{TenantID: 0, MemberID: 9, IMTenantCode: "allrich", IMUserID: "usr_member", IMUsername: "member01"},
|
||||
}
|
||||
im := &imStub{}
|
||||
service := New(repository, im, config.AuthConfig{FinanceMasterKey: strings.Repeat("k", 32)})
|
||||
repository.identity.IMPasswordCipher, _ = service.encryptCredential("password")
|
||||
|
||||
session, err := service.OpenSession(context.Background(), 0, 9, 3, SessionRequest{DeviceID: "web-1", Platform: "web"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if session.APIBaseURL != "/v1/im" || session.WebSocketURL != "/v0/channels" || session.FileURL != "/v0/file" {
|
||||
t.Fatalf("browser endpoints must be same-origin paths: %+v", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenSessionRepairsDriftedIMCredential(t *testing.T) {
|
||||
repository := &repositoryStub{
|
||||
agent: &store.CustomerService{ID: 3, Name: "客服01", IMTenantCode: "allrich", IMUserID: "usr_agent", OnlineStatus: 1},
|
||||
|
||||
@@ -26,14 +26,21 @@ class DeployWorkflowContractTest(unittest.TestCase):
|
||||
self.assertIn("DEPLOY_SSH_KEY", self.workflow)
|
||||
self.assertIn("DEPLOY_KNOWN_HOSTS", self.workflow)
|
||||
self.assertIn("StrictHostKeyChecking=yes", self.workflow)
|
||||
self.assertIn("docker compose pull olons-server", self.workflow)
|
||||
self.assertIn("docker compose up -d --no-deps --wait olons-server", self.workflow)
|
||||
self.assertIn("pull olons-server", self.workflow)
|
||||
self.assertIn("up -d --no-deps --wait olons-server", self.workflow)
|
||||
|
||||
def test_removes_old_kubernetes_and_registry_targets(self):
|
||||
self.assertNotIn("kubectl", self.workflow)
|
||||
self.assertNotIn("KUBECONFIG", self.workflow)
|
||||
self.assertNotIn("git.118im.com", self.workflow)
|
||||
|
||||
def test_uses_internal_im_endpoints_for_server_calls(self):
|
||||
self.assertIn("OLONS_IM_BASE_URL=http://im-server:6060", self.workflow)
|
||||
self.assertIn("OLONS_IM_HTTP_API_URL=http://im-server:6060/v1/im", self.workflow)
|
||||
self.assertIn("OLONS_IM_WS_URL=ws://im-server:6060/v0/channels", self.workflow)
|
||||
self.assertIn("OLONS_IM_FILE_URL=http://im-server:6060/v0/file", self.workflow)
|
||||
self.assertNotIn("OLONS_IM_BASE_URL=https://im-api.yuexuan.shop", self.workflow)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ class DeploymentContractTest(unittest.TestCase):
|
||||
|
||||
self.assertIn("Deploy olons-server production", workflow)
|
||||
self.assertIn("git.olns.shop/pro/olons_server", workflow)
|
||||
self.assertIn("docker compose pull olons-server", workflow)
|
||||
self.assertIn("docker compose up -d --no-deps --wait olons-server", workflow)
|
||||
self.assertIn("pull olons-server", workflow)
|
||||
self.assertIn("up -d --no-deps --wait olons-server", workflow)
|
||||
self.assertNotIn("im-workbench", workflow)
|
||||
self.assertNotIn("workbench-system", workflow)
|
||||
|
||||
|
||||
+4
-2
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vicons/ionicons5": "^0.12.0",
|
||||
@@ -20,7 +21,8 @@
|
||||
"@types/node": "^22.5.2",
|
||||
"@vitejs/plugin-vue": "^5.1.3",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
Generated
+311
-12
@@ -13,7 +13,7 @@ importers:
|
||||
version: 0.12.0
|
||||
axios:
|
||||
specifier: ^1.7.7
|
||||
version: 1.7.7
|
||||
version: 1.7.7(debug@4.4.3)
|
||||
naive-ui:
|
||||
specifier: ^2.44.1
|
||||
version: 2.45.3(vue@3.4.38(typescript@5.5.4))
|
||||
@@ -39,6 +39,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^5.4.2
|
||||
version: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
vitest:
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -225,9 +228,6 @@ packages:
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
@@ -348,6 +348,35 @@ packages:
|
||||
vite: ^5.0.0
|
||||
vue: ^3.2.25
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
'@vitest/mocker@2.1.9':
|
||||
resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==}
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==}
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==}
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==}
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==}
|
||||
|
||||
'@vue/compiler-core@3.4.38':
|
||||
resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==}
|
||||
|
||||
@@ -385,6 +414,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async-validator@4.2.5:
|
||||
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||
|
||||
@@ -397,6 +430,18 @@ packages:
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
cac@6.7.14:
|
||||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -424,6 +469,19 @@ packages:
|
||||
date-fns@4.4.0:
|
||||
resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -436,6 +494,9 @@ packages:
|
||||
resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==}
|
||||
hasBin: true
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -444,9 +505,16 @@ packages:
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
evtd@0.2.4:
|
||||
resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
follow-redirects@1.15.6:
|
||||
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -495,9 +563,15 @@ packages:
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.11:
|
||||
resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
make-dir@2.1.0:
|
||||
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -515,6 +589,9 @@ packages:
|
||||
engines: {node: '>=4'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
naive-ui@2.45.3:
|
||||
resolution: {integrity: sha512-D83gJ+6zUdkQ5DuUbmlfyaM1t8pV5WMPa6KmL9mByqQuiL12qBdA0n/iEIth4dQw1x/duNGpOS2anpt5FKO1kw==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -535,6 +612,13 @@ packages:
|
||||
resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
pathe@1.1.2:
|
||||
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
|
||||
|
||||
pathval@2.0.1:
|
||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
picocolors@1.1.0:
|
||||
resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==}
|
||||
|
||||
@@ -570,6 +654,9 @@ packages:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.0:
|
||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -581,6 +668,12 @@ packages:
|
||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
terser@5.31.6:
|
||||
resolution: {integrity: sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -589,6 +682,24 @@ packages:
|
||||
tinode-sdk@0.25.4:
|
||||
resolution: {integrity: sha512-EFWWR3JDFH1oognJT3K8vH6l7TmcOqblvqx3FwiRY1RAsSUypxOzGfWMZZo0nfc7EmjK3SogRgqNWH6NxLS5+w==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinypool@1.1.1:
|
||||
resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
||||
tinyrainbow@1.2.0:
|
||||
resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tinyspy@3.0.2:
|
||||
resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
to-fast-properties@2.0.0:
|
||||
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -612,6 +723,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.11
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
vite@5.4.2:
|
||||
resolution: {integrity: sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
@@ -643,6 +759,31 @@ packages:
|
||||
terser:
|
||||
optional: true
|
||||
|
||||
vitest@2.1.9:
|
||||
resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@types/node': ^18.0.0 || >=20.0.0
|
||||
'@vitest/browser': 2.1.9
|
||||
'@vitest/ui': 2.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vooks@0.2.12:
|
||||
resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==}
|
||||
peerDependencies:
|
||||
@@ -666,6 +807,11 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.11
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@babel/helper-string-parser@7.24.8': {}
|
||||
@@ -780,10 +926,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
optional: true
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.25':
|
||||
dependencies:
|
||||
@@ -860,6 +1003,46 @@ snapshots:
|
||||
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
vue: 3.4.38(typescript@5.5.4)
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/mocker@2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
|
||||
'@vitest/pretty-format@2.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vitest/runner@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 2.1.9
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/snapshot@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
|
||||
'@vitest/spy@2.1.9':
|
||||
dependencies:
|
||||
tinyspy: 3.0.2
|
||||
|
||||
'@vitest/utils@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 1.2.0
|
||||
|
||||
'@vue/compiler-core@3.4.38':
|
||||
dependencies:
|
||||
'@babel/parser': 7.25.6
|
||||
@@ -919,13 +1102,15 @@ snapshots:
|
||||
acorn@8.18.0:
|
||||
optional: true
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
axios@1.7.7:
|
||||
axios@1.7.7(debug@4.4.3):
|
||||
dependencies:
|
||||
follow-redirects: 1.15.6
|
||||
follow-redirects: 1.15.6(debug@4.4.3)
|
||||
form-data: 4.0.0
|
||||
proxy-from-env: 1.1.0
|
||||
transitivePeerDependencies:
|
||||
@@ -934,6 +1119,18 @@ snapshots:
|
||||
buffer-from@1.1.2:
|
||||
optional: true
|
||||
|
||||
cac@6.7.14: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
check-error: 2.1.3
|
||||
deep-eql: 5.0.2
|
||||
loupe: 3.2.1
|
||||
pathval: 2.0.1
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -961,6 +1158,12 @@ snapshots:
|
||||
|
||||
date-fns@4.4.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
@@ -970,6 +1173,8 @@ snapshots:
|
||||
prr: 1.0.1
|
||||
optional: true
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.21.5
|
||||
@@ -998,9 +1203,17 @@ snapshots:
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.5
|
||||
|
||||
evtd@0.2.4: {}
|
||||
|
||||
follow-redirects@1.15.6: {}
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
follow-redirects@1.15.6(debug@4.4.3):
|
||||
optionalDependencies:
|
||||
debug: 4.4.3
|
||||
|
||||
form-data@4.0.0:
|
||||
dependencies:
|
||||
@@ -1046,9 +1259,15 @@ snapshots:
|
||||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.11:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
make-dir@2.1.0:
|
||||
dependencies:
|
||||
@@ -1065,6 +1284,8 @@ snapshots:
|
||||
mime@1.6.0:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
naive-ui@2.45.3(vue@3.4.38(typescript@5.5.4)):
|
||||
dependencies:
|
||||
'@css-render/plugin-bem': 0.15.14(css-render@0.15.14)
|
||||
@@ -1098,6 +1319,10 @@ snapshots:
|
||||
parse-node-version@1.0.1:
|
||||
optional: true
|
||||
|
||||
pathe@1.1.2: {}
|
||||
|
||||
pathval@2.0.1: {}
|
||||
|
||||
picocolors@1.1.0: {}
|
||||
|
||||
pify@4.0.1:
|
||||
@@ -1147,6 +1372,8 @@ snapshots:
|
||||
semver@5.7.2:
|
||||
optional: true
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -1158,6 +1385,10 @@ snapshots:
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
terser@5.31.6:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.6
|
||||
@@ -1168,6 +1399,16 @@ snapshots:
|
||||
|
||||
tinode-sdk@0.25.4: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinypool@1.1.1: {}
|
||||
|
||||
tinyrainbow@1.2.0: {}
|
||||
|
||||
tinyspy@3.0.2: {}
|
||||
|
||||
to-fast-properties@2.0.0: {}
|
||||
|
||||
treemate@0.3.11: {}
|
||||
@@ -1184,6 +1425,24 @@ snapshots:
|
||||
evtd: 0.2.4
|
||||
vue: 3.4.38(typescript@5.5.4)
|
||||
|
||||
vite-node@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 1.1.2
|
||||
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- less
|
||||
- lightningcss
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
@@ -1195,6 +1454,41 @@ snapshots:
|
||||
less: 4.2.0
|
||||
terser: 5.31.6
|
||||
|
||||
vitest@2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6):
|
||||
dependencies:
|
||||
'@vitest/expect': 2.1.9
|
||||
'@vitest/mocker': 2.1.9(vite@5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6))
|
||||
'@vitest/pretty-format': 2.1.9
|
||||
'@vitest/runner': 2.1.9
|
||||
'@vitest/snapshot': 2.1.9
|
||||
'@vitest/spy': 2.1.9
|
||||
'@vitest/utils': 2.1.9
|
||||
chai: 5.3.3
|
||||
debug: 4.4.3
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
pathe: 1.1.2
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 1.2.0
|
||||
vite: 5.4.2(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
vite-node: 2.1.9(@types/node@22.5.2)(less@4.2.0)(terser@5.31.6)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.5.2
|
||||
transitivePeerDependencies:
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- supports-color
|
||||
- terser
|
||||
|
||||
vooks@0.2.12(vue@3.4.38(typescript@5.5.4)):
|
||||
dependencies:
|
||||
evtd: 0.2.4
|
||||
@@ -1225,3 +1519,8 @@ snapshots:
|
||||
vdirs: 0.1.8(vue@3.4.38(typescript@5.5.4))
|
||||
vooks: 0.2.12(vue@3.4.38(typescript@5.5.4))
|
||||
vue: 3.4.38(typescript@5.5.4)
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
|
||||
|
||||
vi.mock('tinode-sdk', () => {
|
||||
class QueryBuilder {
|
||||
private kind = '';
|
||||
private since?: number;
|
||||
private limit?: number;
|
||||
|
||||
constructor(private topic?: { maxMsgSeq?: () => number }) {}
|
||||
|
||||
withData(_since?: number, _before?: number, limit?: number) {
|
||||
this.kind = 'initial';
|
||||
this.limit = limit;
|
||||
return this;
|
||||
}
|
||||
|
||||
withLaterData(limit?: number) {
|
||||
this.kind = 'later';
|
||||
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
|
||||
this.limit = limit;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { kind: this.kind, since: this.since, limit: this.limit };
|
||||
}
|
||||
}
|
||||
|
||||
class MockTinode {
|
||||
onConnect?: () => Promise<void> | void;
|
||||
onDisconnect?: () => void;
|
||||
onAutoreconnectIteration?: (timeoutMs: number) => void;
|
||||
_connection = { sendText: vi.fn() };
|
||||
connect = vi.fn(async () => undefined);
|
||||
loginToken = vi.fn(async () => ({ code: 200 }));
|
||||
disconnect = vi.fn();
|
||||
authorizeURL = vi.fn((value: string) => value);
|
||||
topic = {
|
||||
onData: undefined as (() => void) | undefined,
|
||||
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
|
||||
maxSeq: 0,
|
||||
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
|
||||
startMetaQuery: () => new QueryBuilder(this.topic),
|
||||
subscribe: vi.fn(async () => {
|
||||
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
|
||||
return { code: 200, params: { what: 'data', count: 0 } };
|
||||
}),
|
||||
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
|
||||
leave: vi.fn(),
|
||||
messages: vi.fn(),
|
||||
createMessage: vi.fn(),
|
||||
publishDraft: vi.fn(),
|
||||
};
|
||||
|
||||
constructor(public options: Record<string, unknown>) {
|
||||
mocks.instances.push(this);
|
||||
}
|
||||
|
||||
getTopic() {
|
||||
return this.topic;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Tinode: MockTinode,
|
||||
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
CustomerServiceChat,
|
||||
resolveCustomerServiceEndpoints,
|
||||
type CustomerServiceSession,
|
||||
} from './im';
|
||||
|
||||
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
|
||||
tenant_code: 'default',
|
||||
api_base_url: '/v1/im',
|
||||
file_url: '/v0/file',
|
||||
websocket_url: '/v0/channels',
|
||||
api_key: 'public-key',
|
||||
token: 'token-1',
|
||||
current_user_id: 'usr_member',
|
||||
topic: 'p2p-topic',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('customer service IM endpoints', () => {
|
||||
it('resolves API, file and websocket paths against the current page origin', () => {
|
||||
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
|
||||
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
|
||||
fileURL: 'https://www.yuexuan2.shop/v0/file',
|
||||
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not follow legacy absolute IM hosts from a cached session response', () => {
|
||||
expect(
|
||||
resolveCustomerServiceEndpoints(
|
||||
session({
|
||||
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
|
||||
file_url: 'https://im-api.yuexuan.shop/v0/file',
|
||||
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
|
||||
}),
|
||||
'https://www.yuexuan3.shop',
|
||||
),
|
||||
).toEqual({
|
||||
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
|
||||
fileURL: 'https://www.yuexuan3.shop/v0/file',
|
||||
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer service reconnect', () => {
|
||||
beforeEach(() => {
|
||||
mocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it('reauthenticates and resubscribes with later data after reconnect', async () => {
|
||||
const states: string[] = [];
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
||||
origin: 'https://www.yuexuan1.shop',
|
||||
onConnectionState: (state) => states.push(state),
|
||||
});
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
tinode.onDisconnect?.();
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
|
||||
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
|
||||
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
|
||||
expect(states.at(-1)).toBe('online');
|
||||
});
|
||||
|
||||
it('does not restore a deliberately disconnected chat', async () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
chat.disconnect();
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
|
||||
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reports offline without leaking a rejected reconnect callback', async () => {
|
||||
const states: string[] = [];
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), {
|
||||
origin: 'https://www.yuexuan3.shop',
|
||||
onConnectionState: (state) => states.push(state),
|
||||
});
|
||||
const tinode = mocks.instances[0];
|
||||
|
||||
await chat.connect('token-1');
|
||||
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
|
||||
tinode.onDisconnect?.();
|
||||
|
||||
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
|
||||
expect(states.at(-1)).toBe('offline');
|
||||
});
|
||||
|
||||
it('uses rotated login tokens for reconnect and media upload', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
tinode.loginToken
|
||||
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
|
||||
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
|
||||
|
||||
await chat.connect('token-1');
|
||||
await tinode.onConnect?.();
|
||||
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
||||
|
||||
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
|
||||
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('paginates all messages accumulated while disconnected', async () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
await chat.connect('token-1');
|
||||
|
||||
tinode.topic.subscribe.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 50;
|
||||
tinode.topic.onAllMessagesReceived?.(50);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 50 } };
|
||||
});
|
||||
tinode.topic.getMeta
|
||||
.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 100;
|
||||
tinode.topic.onAllMessagesReceived?.(50);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 50 } };
|
||||
})
|
||||
.mockImplementationOnce(async () => {
|
||||
setTimeout(() => {
|
||||
tinode.topic.maxSeq = 120;
|
||||
tinode.topic.onAllMessagesReceived?.(20);
|
||||
}, 10);
|
||||
return { code: 200, params: { what: 'data', count: 20 } };
|
||||
});
|
||||
|
||||
await tinode.onConnect?.();
|
||||
|
||||
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
|
||||
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
|
||||
{ kind: 'later', limit: 50, since: 51 },
|
||||
{ kind: 'later', limit: 50, since: 101 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('customer service media upload', () => {
|
||||
beforeEach(() => {
|
||||
mocks.instances.length = 0;
|
||||
});
|
||||
|
||||
it('keeps credentials in headers and out of the upload URL', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
|
||||
|
||||
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
|
||||
expect(String(url)).not.toContain('secret=');
|
||||
expect(init?.headers).toEqual({
|
||||
'X-MeetingIM-APIKey': 'public-key',
|
||||
Authorization: 'Token token-1',
|
||||
});
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
|
||||
it('rewrites historical absolute IM file references to the current frontend origin', () => {
|
||||
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
|
||||
const tinode = mocks.instances[0];
|
||||
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
|
||||
|
||||
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
|
||||
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
|
||||
);
|
||||
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
|
||||
'https://cdn.example.com/public/image.png',
|
||||
);
|
||||
});
|
||||
});
|
||||
+164
-20
@@ -25,18 +25,57 @@ export interface ChatMessage {
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
|
||||
|
||||
interface CustomerServiceChatOptions {
|
||||
origin?: string;
|
||||
onConnectionState?: (state: CustomerServiceConnectionState) => void;
|
||||
}
|
||||
|
||||
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
|
||||
const pageOrigin = new URL(origin);
|
||||
const sameOriginURL = (value: string, fallback: string) => {
|
||||
const configured = new URL(value || fallback, pageOrigin);
|
||||
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
|
||||
return resolved.toString().replace(/\/$/, '');
|
||||
};
|
||||
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
|
||||
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return {
|
||||
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
|
||||
fileURL: sameOriginURL(session.file_url, '/v0/file'),
|
||||
websocketURL: websocket.toString().replace(/\/$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
export class CustomerServiceChat {
|
||||
private static readonly reconnectPageSize = 50;
|
||||
private static readonly maxReconnectPages = 100;
|
||||
private tinode: any;
|
||||
private topic: any;
|
||||
private currentUserID: string;
|
||||
private onMessages: (messages: ChatMessage[]) => void;
|
||||
private session: CustomerServiceSession;
|
||||
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
|
||||
private token = '';
|
||||
private sessionActive = false;
|
||||
private intentionalDisconnect = false;
|
||||
private recovery: Promise<void> | null = null;
|
||||
private onConnectionState: (state: CustomerServiceConnectionState) => void;
|
||||
|
||||
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
|
||||
const endpoint = new URL(session.websocket_url);
|
||||
constructor(
|
||||
session: CustomerServiceSession,
|
||||
onMessages: (messages: ChatMessage[]) => void,
|
||||
options: CustomerServiceChatOptions = {},
|
||||
) {
|
||||
const origin = options.origin || window.location.origin;
|
||||
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
|
||||
const endpoint = new URL(this.endpoints.websocketURL);
|
||||
this.session = session;
|
||||
this.token = session.token;
|
||||
this.currentUserID = session.current_user_id;
|
||||
this.onMessages = onMessages;
|
||||
this.onConnectionState = options.onConnectionState || (() => undefined);
|
||||
this.tinode = new Tinode({
|
||||
appName: '悦选',
|
||||
host: endpoint.host,
|
||||
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
|
||||
this.installTenantPatch(session.tenant_code);
|
||||
this.topic = this.tinode.getTopic(session.topic);
|
||||
this.topic.onData = () => this.refreshMessages();
|
||||
this.tinode.onDisconnect = () => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
this.onConnectionState('reconnecting');
|
||||
}
|
||||
};
|
||||
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
|
||||
}
|
||||
};
|
||||
this.tinode.onConnect = () => {
|
||||
if (!this.intentionalDisconnect && this.sessionActive) {
|
||||
return this.restoreAfterReconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async connect(token: string) {
|
||||
this.intentionalDisconnect = false;
|
||||
this.token = token;
|
||||
this.onConnectionState('connecting');
|
||||
await this.tinode.connect();
|
||||
const login = await this.tinode.loginToken(token);
|
||||
const login = await this.tinode.loginToken(this.token);
|
||||
if (login?.code >= 300) throw new Error('客服会话登录失败');
|
||||
this.acceptRotatedToken(login);
|
||||
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
|
||||
const subscribed = await this.topic.subscribe(query);
|
||||
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
|
||||
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
|
||||
this.sessionActive = true;
|
||||
this.onConnectionState('online');
|
||||
this.refreshMessages();
|
||||
}
|
||||
|
||||
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.intentionalDisconnect = true;
|
||||
this.sessionActive = false;
|
||||
this.onConnectionState('offline');
|
||||
try {
|
||||
this.topic?.leave?.(false);
|
||||
} finally {
|
||||
@@ -108,6 +170,85 @@ export class CustomerServiceChat {
|
||||
}
|
||||
}
|
||||
|
||||
private restoreAfterReconnect() {
|
||||
if (this.recovery) return this.recovery;
|
||||
this.recovery = (async () => {
|
||||
this.onConnectionState('reconnecting');
|
||||
const login = await this.tinode.loginToken(this.token);
|
||||
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
|
||||
this.acceptRotatedToken(login);
|
||||
await this.restoreSubscriptionAndMessages();
|
||||
this.refreshMessages();
|
||||
this.onConnectionState('online');
|
||||
})()
|
||||
.catch(() => {
|
||||
this.onConnectionState('offline');
|
||||
})
|
||||
.finally(() => {
|
||||
this.recovery = null;
|
||||
});
|
||||
return this.recovery;
|
||||
}
|
||||
|
||||
private acceptRotatedToken(login: any) {
|
||||
const rotated = String(login?.params?.token || '');
|
||||
if (!rotated) return;
|
||||
this.token = rotated;
|
||||
this.session.token = rotated;
|
||||
}
|
||||
|
||||
private async restoreSubscriptionAndMessages() {
|
||||
const pageSize = CustomerServiceChat.reconnectPageSize;
|
||||
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
|
||||
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
|
||||
|
||||
for (let page = 1; count >= pageSize; page += 1) {
|
||||
if (page >= CustomerServiceChat.maxReconnectPages) {
|
||||
throw new Error('客服消息积压过多,请重新进入客服会话');
|
||||
}
|
||||
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
|
||||
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
|
||||
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
|
||||
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
|
||||
if (count > 0 && currentMax <= previousMax) {
|
||||
throw new Error('客服历史消息补拉未推进');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
|
||||
let observedCount: number | undefined;
|
||||
let resolveObserved: ((count: number) => void) | undefined;
|
||||
const observed = new Promise<number>((resolve) => {
|
||||
resolveObserved = resolve;
|
||||
});
|
||||
const previousHandler = this.topic.onAllMessagesReceived;
|
||||
this.topic.onAllMessagesReceived = (count: number) => {
|
||||
previousHandler?.(count);
|
||||
observedCount = Number(count || 0);
|
||||
resolveObserved?.(observedCount);
|
||||
};
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const response = await request();
|
||||
if (response?.code >= 300) throw new Error(errorMessage);
|
||||
if (observedCount !== undefined) return observedCount;
|
||||
return await Promise.race([
|
||||
observed,
|
||||
new Promise<number>((_, reject) => {
|
||||
// A Tinode request promise resolves before the SDK finishes routing
|
||||
// its data packets. Only this completion callback guarantees that
|
||||
// maxMsgSeq and the local message cache belong to the finished page.
|
||||
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.topic.onAllMessagesReceived = previousHandler;
|
||||
}
|
||||
}
|
||||
|
||||
private installTenantPatch(tenantCode: string) {
|
||||
const connection = this.tinode._connection;
|
||||
if (!connection || connection.__olonsTenantPatched) return;
|
||||
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
|
||||
}
|
||||
|
||||
private async upload(file: File, onProgress?: (progress: number) => void) {
|
||||
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url);
|
||||
const uploadURL = new URL(this.endpoints.fileURL);
|
||||
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
|
||||
uploadURL.searchParams.set('apikey', this.session.api_key);
|
||||
uploadURL.searchParams.set('auth', 'token');
|
||||
uploadURL.searchParams.set('secret', this.session.token);
|
||||
const form = new FormData();
|
||||
form.set('id', clientMID());
|
||||
form.set('file', file, file.name);
|
||||
onProgress?.(0.1);
|
||||
let response: Response;
|
||||
try {
|
||||
// Do not attach XMLHttpRequest upload progress listeners here. For a
|
||||
// cross-origin IM endpoint they trigger a CORS preflight which the file
|
||||
// service does not expose, while multipart/form-data itself is accepted.
|
||||
response = await fetch(uploadURL.toString(), { method: 'POST', body: form });
|
||||
// Keep uploads on fetch so API and token credentials stay in headers and
|
||||
// never leak into the URL, browser history, or proxy access logs.
|
||||
response = await fetch(uploadURL.toString(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-MeetingIM-APIKey': this.session.api_key,
|
||||
Authorization: `Token ${this.token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
} catch {
|
||||
throw new Error('媒体上传失败,请检查网络后重试');
|
||||
}
|
||||
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
|
||||
|
||||
private mediaURL(ref?: string) {
|
||||
if (!ref) return '';
|
||||
const apiBase = new URL(this.session.api_base_url);
|
||||
const apiBase = new URL(this.endpoints.apiBaseURL);
|
||||
const resolved = new URL(ref, apiBase);
|
||||
// The SDK signs relative file URLs. Also tolerate historical absolute IM
|
||||
// references by converting same-origin URLs back to a signable path.
|
||||
const signable = resolved.origin === apiBase.origin
|
||||
? `${resolved.pathname}${resolved.search}`
|
||||
: ref;
|
||||
// Historical messages may contain the old absolute IM host. Route only
|
||||
// known IM file paths through the current frontend origin; leave unrelated
|
||||
// external media untouched.
|
||||
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
|
||||
const signable = `${resolved.pathname}${resolved.search}`;
|
||||
const authorized = this.tinode.authorizeURL(signable);
|
||||
return new URL(authorized, this.session.api_base_url).toString();
|
||||
return new URL(authorized, this.endpoints.apiBaseURL).toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1493,6 +1493,48 @@ body {
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-chat-connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
color: #8a5a00;
|
||||
background: #fff3cd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.m-chat-connection.is-offline {
|
||||
color: #9f2d2d;
|
||||
background: #fde7e7;
|
||||
}
|
||||
|
||||
.m-chat-connection button,
|
||||
.m-chat-media-error button {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: currentColor;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m-chat-media-error {
|
||||
display: flex;
|
||||
min-width: 160px;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #8b3a3a;
|
||||
background: #f8eaea;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-empty {
|
||||
min-height: 530px;
|
||||
padding: 100px 30px 20px;
|
||||
|
||||
@@ -38,6 +38,10 @@
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="!chatLoading && connectionState !== 'online'" :class="['m-chat-connection', `is-${connectionState}`]">
|
||||
<span>{{ connectionStatusText }}</span>
|
||||
<button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
@@ -59,8 +63,11 @@
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||||
</div>
|
||||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
@@ -90,8 +97,11 @@
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||||
</div>
|
||||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
@@ -103,12 +113,12 @@
|
||||
</div>
|
||||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||||
<form class="m-chat-input" @submit.prevent="sendMessage">
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<button
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
:disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<img :src="mobileAssets.chatMore" alt="" />
|
||||
@@ -157,7 +167,12 @@
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||
import {
|
||||
CustomerServiceChat,
|
||||
type ChatMessage,
|
||||
type CustomerServiceConnectionState,
|
||||
type CustomerServiceSession,
|
||||
} from '../im';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
@@ -171,6 +186,12 @@
|
||||
);
|
||||
const chatLoading = ref(false);
|
||||
const chatError = ref('');
|
||||
const connectionState = ref<CustomerServiceConnectionState>('offline');
|
||||
const connectionStatusText = computed(() => {
|
||||
if (connectionState.value === 'connecting') return '正在连接客服…';
|
||||
if (connectionState.value === 'reconnecting') return '连接已断开,正在重新连接并同步消息…';
|
||||
return '连接已断开,请重新连接';
|
||||
});
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const historyBoundarySeq = ref(0);
|
||||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||
@@ -179,6 +200,8 @@
|
||||
const sending = ref(false);
|
||||
const mediaSending = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const failedMediaKeys = ref(new Set<string>());
|
||||
const mediaRetries = ref<Record<string, number>>({});
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
const toolsOpen = ref(false);
|
||||
@@ -247,6 +270,7 @@
|
||||
historyBoundarySeq.value = 0;
|
||||
chatLoading.value = false;
|
||||
chatError.value = '';
|
||||
connectionState.value = 'offline';
|
||||
draft.value = '';
|
||||
sending.value = false;
|
||||
mediaSending.value = false;
|
||||
@@ -267,11 +291,19 @@
|
||||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||
});
|
||||
if (version !== connectionVersion) return;
|
||||
const nextChat = new CustomerServiceChat(session, (rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
});
|
||||
const nextChat = new CustomerServiceChat(
|
||||
session,
|
||||
(rows) => {
|
||||
if (version !== connectionVersion) return;
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
},
|
||||
{
|
||||
onConnectionState: (state) => {
|
||||
if (version === connectionVersion) connectionState.value = state;
|
||||
},
|
||||
},
|
||||
);
|
||||
chat = nextChat;
|
||||
await nextChat.connect(session.token);
|
||||
if (version !== connectionVersion) {
|
||||
@@ -294,7 +326,7 @@
|
||||
}
|
||||
}
|
||||
async function sendMessage() {
|
||||
if (!chat || sending.value || !draft.value.trim()) return;
|
||||
if (!chat || connectionState.value !== 'online' || sending.value || !draft.value.trim()) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const value = draft.value;
|
||||
@@ -318,7 +350,7 @@
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
if (!file || !chat || connectionState.value !== 'online' || mediaSending.value) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
@@ -360,6 +392,26 @@
|
||||
draft.value += emoji;
|
||||
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||||
}
|
||||
function mediaIdentity(message: ChatMessage) {
|
||||
return `${message.from}:${message.seq}:${message.media_url || ''}`;
|
||||
}
|
||||
function mediaFailed(message: ChatMessage) {
|
||||
return failedMediaKeys.value.has(mediaIdentity(message));
|
||||
}
|
||||
function mediaRenderKey(message: ChatMessage) {
|
||||
const key = mediaIdentity(message);
|
||||
return `${key}:${mediaRetries.value[key] || 0}`;
|
||||
}
|
||||
function markMediaFailed(message: ChatMessage) {
|
||||
failedMediaKeys.value = new Set([...failedMediaKeys.value, mediaIdentity(message)]);
|
||||
}
|
||||
function retryMedia(message: ChatMessage) {
|
||||
const key = mediaIdentity(message);
|
||||
const nextFailures = new Set(failedMediaKeys.value);
|
||||
nextFailures.delete(key);
|
||||
failedMediaKeys.value = nextFailures;
|
||||
mediaRetries.value = { ...mediaRetries.value, [key]: (mediaRetries.value[key] || 0) + 1 };
|
||||
}
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||
|
||||
Reference in New Issue
Block a user