chore: complete Shangwutong connector integration

This commit is contained in:
Rogee
2026-08-04 18:00:10 +08:00
parent 4611c0201f
commit 3d7ff5a6b1
8 changed files with 215 additions and 26 deletions
+40 -7
View File
@@ -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'
+32 -8
View File
@@ -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")
+21 -8
View File
@@ -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())
}
}
}
+1 -1
View File
@@ -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"]
+1 -1
View File
@@ -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}
+11
View File
@@ -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。
@@ -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
View File
@@ -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"