chore: complete Shangwutong connector integration
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: gochat/gochat
|
||||
SHANGWUTONG_IMAGE_NAME: gochat/shangwutong
|
||||
GOPROXY: https://goproxy.cn,direct
|
||||
|
||||
jobs:
|
||||
@@ -191,8 +192,8 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
# Build and push
|
||||
- name: Build and push
|
||||
# Build and push main GoChat image
|
||||
- name: Build and push GoChat image
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -205,14 +206,46 @@ jobs:
|
||||
VERSION=${{ github.ref_name }}
|
||||
COMMIT_SHA=${{ github.sha }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-from: type=gha,scope=gochat
|
||||
cache-to: type=gha,mode=max,scope=gochat
|
||||
|
||||
# Scan Docker image with Trivy
|
||||
- name: Scan Docker image
|
||||
- name: Extract Shangwutong connector metadata
|
||||
id: shangwutong_meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.SHANGWUTONG_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build and push Shangwutong connector image
|
||||
id: shangwutong_build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./channels/shangwutong/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.shangwutong_meta.outputs.tags }}
|
||||
labels: ${{ steps.shangwutong_meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=shangwutong
|
||||
cache-to: type=gha,mode=max,scope=shangwutong
|
||||
|
||||
# Scan Docker images with Trivy
|
||||
- name: Scan GoChat image
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
|
||||
format: 'table'
|
||||
exit-code: '1'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
- name: Scan Shangwutong connector image
|
||||
uses: aquasecurity/trivy-action@master
|
||||
with:
|
||||
image-ref: ${{ env.REGISTRY }}/${{ env.SHANGWUTONG_IMAGE_NAME }}@${{ steps.shangwutong_build.outputs.digest }}
|
||||
format: 'table'
|
||||
exit-code: '1'
|
||||
severity: 'CRITICAL,HIGH'
|
||||
|
||||
@@ -237,14 +237,38 @@ func RegisterRoutes(
|
||||
|
||||
// Long-lived Connector service principal routes are intentionally outside
|
||||
// the user JWT group and accept only a dedicated PlatformApp Bearer token.
|
||||
if handlers.ShangwutongConnector != nil {
|
||||
connector := engine.Group("/api/v1/connector/shangwutong")
|
||||
connector.Use(middleware.ConnectorServiceAuth(db))
|
||||
connector.GET("/inboxes", handlers.ShangwutongConnector.ListInboxes)
|
||||
connector.GET("/inboxes/:inbox_id", handlers.ShangwutongConnector.GetInbox)
|
||||
connector.PUT("/inboxes/:inbox_id/status", handlers.ShangwutongConnector.UpdateInboxStatus)
|
||||
connector.PUT("/inboxes/:inbox_id/messages/:message_id/status", handlers.ShangwutongConnector.UpdateMessageStatus)
|
||||
}
|
||||
// Register routes unconditionally so route parity tests catch accidental removals;
|
||||
// nil handler checks live inside each closure.
|
||||
connector := engine.Group("/api/v1/connector/shangwutong")
|
||||
connector.Use(middleware.ConnectorServiceAuth(db))
|
||||
connector.GET("/inboxes", func(c *gin.Context) {
|
||||
if handlers == nil || handlers.ShangwutongConnector == nil {
|
||||
webhookProviderUnavailable(c)
|
||||
return
|
||||
}
|
||||
handlers.ShangwutongConnector.ListInboxes(c)
|
||||
})
|
||||
connector.GET("/inboxes/:inbox_id", func(c *gin.Context) {
|
||||
if handlers == nil || handlers.ShangwutongConnector == nil {
|
||||
webhookProviderUnavailable(c)
|
||||
return
|
||||
}
|
||||
handlers.ShangwutongConnector.GetInbox(c)
|
||||
})
|
||||
connector.PUT("/inboxes/:inbox_id/status", func(c *gin.Context) {
|
||||
if handlers == nil || handlers.ShangwutongConnector == nil {
|
||||
webhookProviderUnavailable(c)
|
||||
return
|
||||
}
|
||||
handlers.ShangwutongConnector.UpdateInboxStatus(c)
|
||||
})
|
||||
connector.PUT("/inboxes/:inbox_id/messages/:message_id/status", func(c *gin.Context) {
|
||||
if handlers == nil || handlers.ShangwutongConnector == nil {
|
||||
webhookProviderUnavailable(c)
|
||||
return
|
||||
}
|
||||
handlers.ShangwutongConnector.UpdateMessageStatus(c)
|
||||
})
|
||||
|
||||
// API v1 routes — authenticated, account-scoped
|
||||
apiV1 := engine.Group("/api/v1")
|
||||
|
||||
@@ -63,6 +63,10 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"GET /tiktok/callback",
|
||||
"GET /app",
|
||||
"GET /app/*params",
|
||||
"GET /api/v1/connector/shangwutong/inboxes",
|
||||
"GET /api/v1/connector/shangwutong/inboxes/:inbox_id",
|
||||
"PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/status",
|
||||
"PUT /api/v1/connector/shangwutong/inboxes/:inbox_id/messages/:message_id/status",
|
||||
"GET /api/v1/accounts/:account_id/captain/assistants/tools",
|
||||
"GET /api/v1/accounts/:account_id/captain/assistants/:assistant_id",
|
||||
"PATCH /api/v1/accounts/:account_id/captain/assistants/:assistant_id",
|
||||
@@ -207,15 +211,24 @@ func TestWebhookNilHandlerReturnsProviderUnavailable(t *testing.T) {
|
||||
nil,
|
||||
)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/webhooks/telegram/bot-token", nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, w.Code)
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{method: http.MethodPost, path: "/webhooks/telegram/bot-token"},
|
||||
{method: http.MethodGet, path: "/api/v1/connector/shangwutong/inboxes"},
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "not implemented") || strings.Contains(w.Body.String(), "placeholder") {
|
||||
t.Fatalf("nil webhook fallback returned placeholder body: %s", w.Body.String())
|
||||
for _, tc := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
engine.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusServiceUnavailable && w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s expected unavailable/auth failure, got %d", tc.method, tc.path, w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "not implemented") || strings.Contains(w.Body.String(), "placeholder") {
|
||||
t.Fatalf("nil webhook fallback returned placeholder body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ VOLUME ["/data", "/backup"]
|
||||
EXPOSE 9100
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q -T 3 -O /dev/null http://127.0.0.1:9100/healthz || exit 1
|
||||
CMD wget -q -T 3 -O /dev/null http://127.0.0.1:9100/readyz || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/shangwutong"]
|
||||
CMD ["serve"]
|
||||
|
||||
@@ -99,7 +99,7 @@ services:
|
||||
cpus: '1.0'
|
||||
|
||||
shangwutong:
|
||||
image: gochat/shangwutong:${SHANGWUTONG_VERSION:-latest}
|
||||
image: ${SHANGWUTONG_IMAGE:-ghcr.io/gochat/shangwutong:latest}
|
||||
container_name: gochat-shangwutong
|
||||
restart: always
|
||||
stop_grace_period: ${SWT_SHUTDOWN_TIMEOUT:-30s}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 商务通 Connector 生产运行手册
|
||||
|
||||
|
||||
## 架构说明
|
||||
|
||||
Shangwutong 是刻意采用的外部 Connector 产品形态,不是内嵌在 GoChat 主进程里的标准 `ChannelProvider` 插件。GoChat 侧负责 Inbox 配置、凭据下发、durable webhook 投递、消息导入与状态回写;`channels/shangwutong` 进程负责登录商务通、维护会话、轮询/映射真实协议事件、执行出站发送。这样做的原因是商务通协议需要长连接/心跳/本地游标/不确定投递消歧,独立进程可以隔离故障域、持久化协议状态,并避免把站点密码与会话状态塞进主 API 进程。
|
||||
|
||||
因此,Shangwutong 不需要实现 `internal/channel.ChannelProvider` 的 14 方法接口,也不注册 `/webhooks/shangwutong/*` 平台回调路由。其稳定契约是:
|
||||
|
||||
- GoChat → Connector:`/webhooks/gochat/v1`,使用 `X-Chatwoot-Timestamp` + `X-Chatwoot-Signature` HMAC 签名。
|
||||
- Connector → GoChat:`/api/v1/connector/shangwutong/*` 配置/状态 API,以及允许列表中的 account-scoped message/conversation API。
|
||||
- GoChat Inbox 类型仍序列化为 `Channel::Shangwutong`,前端按 API-like inbox 展示和配置。
|
||||
|
||||
## 1. 上线前检查
|
||||
|
||||
1. GoChat 已部署 `Channel::Shangwutong` migration、Connector service principal、配置/状态 API 和 durable webhook worker。
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import { nextTick } from 'vue';
|
||||
import Shangwutong from '../Shangwutong.vue';
|
||||
|
||||
vi.mock('../../../../../index', () => ({
|
||||
default: {
|
||||
replace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
useAlert: vi.fn(),
|
||||
}));
|
||||
|
||||
const t = key => key;
|
||||
|
||||
const mountComponent = ({
|
||||
dispatch = vi.fn().mockResolvedValue({ id: 42 }),
|
||||
} = {}) =>
|
||||
shallowMount(Shangwutong, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: t,
|
||||
$store: {
|
||||
dispatch,
|
||||
getters: {
|
||||
'inboxes/getUIFlags': { isCreating: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
PageHeader: true,
|
||||
NextButton: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('Shangwutong channel setup', () => {
|
||||
it('submits the exact shangwutong channel payload', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue({ id: 42 });
|
||||
const wrapper = mountComponent({ dispatch });
|
||||
|
||||
await wrapper.setData({
|
||||
channelName: ' 商务通站点 ',
|
||||
sessionId: 'LZA69557093',
|
||||
username: ' operator ',
|
||||
password: 'secret-password',
|
||||
desiredPresence: 'busy',
|
||||
webhookUrl: 'http://shangwutong-connector:9100/webhooks/gochat/v1',
|
||||
});
|
||||
|
||||
await wrapper.vm.createChannel();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('inboxes/createChannel', {
|
||||
name: '商务通站点',
|
||||
channel: {
|
||||
type: 'shangwutong',
|
||||
session_id: 'LZA69557093',
|
||||
username: 'operator',
|
||||
password: 'secret-password',
|
||||
desired_presence: 'busy',
|
||||
webhook_url: 'http://shangwutong-connector:9100/webhooks/gochat/v1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid session IDs before dispatching', async () => {
|
||||
const dispatch = vi.fn();
|
||||
const wrapper = mountComponent({ dispatch });
|
||||
|
||||
await wrapper.setData({
|
||||
channelName: '商务通站点',
|
||||
sessionId: 'short',
|
||||
username: 'operator',
|
||||
password: 'secret-password',
|
||||
webhookUrl: 'http://shangwutong-connector:9100/webhooks/gochat/v1',
|
||||
});
|
||||
|
||||
await wrapper.vm.createChannel();
|
||||
await nextTick();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(wrapper.vm.v$.sessionId.$error).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects webhook URLs with userinfo or fragments', async () => {
|
||||
const dispatch = vi.fn();
|
||||
const wrapper = mountComponent({ dispatch });
|
||||
|
||||
await wrapper.setData({
|
||||
channelName: '商务通站点',
|
||||
sessionId: 'LZA69557093',
|
||||
username: 'operator',
|
||||
password: 'secret-password',
|
||||
webhookUrl: 'http://user:pass@connector:9100/webhooks/gochat/v1#frag',
|
||||
});
|
||||
|
||||
await wrapper.vm.createChannel();
|
||||
await nextTick();
|
||||
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(wrapper.vm.v$.webhookUrl.$error).toBe(true);
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -10,7 +10,11 @@
|
||||
"build:frontend": "cd frontend && pnpm build",
|
||||
"build:sdk": "cd frontend && pnpm build:sdk",
|
||||
"lint:frontend": "cd frontend && pnpm eslint",
|
||||
"test:frontend": "cd frontend && pnpm test"
|
||||
"test:frontend": "cd frontend && pnpm test",
|
||||
"dev:shangwutong": "cd channels/shangwutong && go run ./cmd/shangwutong serve",
|
||||
"test:shangwutong": "cd channels/shangwutong && go test ./...",
|
||||
"build:shangwutong": "cd channels/shangwutong && go build -o bin/shangwutong ./cmd/shangwutong",
|
||||
"dev:all": "concurrently -n backend,frontend,shangwutong -c blue,green,magenta \"pnpm dev:backend\" \"pnpm dev:frontend\" \"pnpm dev:shangwutong\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
|
||||
Reference in New Issue
Block a user