feat(channels): add Shangwutong connector

This commit is contained in:
2026-08-03 10:13:40 +08:00
parent 96f5c796a6
commit 897b4e018f
126 changed files with 20207 additions and 272 deletions
@@ -4,6 +4,7 @@ import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
export function useChannelIcon(inbox) {
const channelTypeIconMap = {
'Channel::Api': 'i-woot-api',
'Channel::Shangwutong': 'i-ri-customer-service-2-line',
'Channel::Email': 'i-woot-mail',
'Channel::FacebookPage': 'i-woot-messenger',
'Channel::Line': 'i-woot-line',
@@ -5,6 +5,7 @@ export const INBOX_TYPES = {
TWILIO: 'Channel::TwilioSms',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
SHANGWUTONG: 'Channel::Shangwutong',
EMAIL: 'Channel::Email',
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
@@ -45,6 +46,7 @@ const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill',
[INBOX_TYPES.API]: 'i-ri-cloudy-fill',
[INBOX_TYPES.SHANGWUTONG]: 'i-ri-customer-service-2-fill',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
@@ -60,6 +62,7 @@ const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.TWITTER]: 'i-woot-x',
[INBOX_TYPES.WHATSAPP]: 'i-woot-whatsapp',
[INBOX_TYPES.API]: 'i-woot-api',
[INBOX_TYPES.SHANGWUTONG]: 'i-ri-customer-service-2-line',
[INBOX_TYPES.EMAIL]: 'i-woot-mail',
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
@@ -105,6 +108,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
case INBOX_TYPES.API:
return 'api';
case INBOX_TYPES.SHANGWUTONG:
return 'shangwutong';
case INBOX_TYPES.EMAIL:
return 'email';
@@ -141,6 +147,9 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.API:
return 'cloud';
case INBOX_TYPES.SHANGWUTONG:
return 'customer-service-2';
case INBOX_TYPES.EMAIL:
return 'mail';
@@ -35,6 +35,11 @@ describe('#Inbox Helpers', () => {
it('should return correct class for Api', () => {
expect(getInboxClassByType('Channel::Api')).toEqual('cloud');
});
it('should return correct class for Shangwutong', () => {
expect(getInboxClassByType(INBOX_TYPES.SHANGWUTONG)).toEqual(
'customer-service-2'
);
});
it('should return correct class for Email', () => {
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
});
@@ -69,6 +74,12 @@ describe('#Inbox Helpers', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Shangwutong', () => {
expect(getInboxIconByType(INBOX_TYPES.SHANGWUTONG)).toBe(
'i-ri-customer-service-2-fill'
);
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
@@ -384,6 +384,21 @@
"ERROR_MESSAGE": "We were not able to save the api channel"
}
},
"SHANGWUTONG_CHANNEL": {
"TITLE": "Shangwutong channel",
"DESC": "Connect a Shangwutong site account to a shared GoChat inbox.",
"CHANNEL_NAME": "Inbox name",
"SESSION_ID": "Site session ID",
"SESSION_ID_ERROR": "Enter the 11-letter or digit Shangwutong session ID.",
"USERNAME": "Login username",
"PASSWORD": "Login password",
"DESIRED_PRESENCE": "Initial presence",
"WEBHOOK_URL": "Connector webhook URL",
"SUBMIT_BUTTON": "Create Shangwutong channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the Shangwutong channel"
}
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate your email inbox.",
@@ -479,6 +494,10 @@
"TITLE": "API",
"DESCRIPTION": "Make a custom channel using our API"
},
"SHANGWUTONG": {
"TITLE": "Shangwutong",
"DESCRIPTION": "Connect a Shangwutong site account"
},
"TELEGRAM": {
"TITLE": "Telegram",
"DESCRIPTION": "Configure Telegram channel using Bot token"
@@ -1155,6 +1174,17 @@
"DESCRIPTION": "Connect with Other Providers"
}
},
"SHANGWUTONG_SETTINGS": {
"RUNTIME_STATUS": "Connector status",
"RUNTIME_STATUS_HELP": "The latest connection and credential state reported by the Connector.",
"REFRESH": "Refresh status",
"DESIRED_PRESENCE": "Desired presence",
"DESIRED_PRESENCE_HELP": "Changes are applied online. Offline logs out only this Shangwutong account.",
"PASSWORD": "Replace password",
"PASSWORD_HELP": "Leave blank to keep the current password. A new password is verified online before replacing the working credential.",
"WEBHOOK_URL": "Connector webhook URL",
"WEBHOOK_URL_HELP": "All enabled Shangwutong inboxes must use the same Connector URL."
},
"CHANNELS": {
"MESSENGER": "Messenger",
"WEB_WIDGET": "Website",
@@ -1166,6 +1196,7 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel",
"SHANGWUTONG": "Shangwutong",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"VOICE": "Voice"
@@ -384,6 +384,21 @@
"ERROR_MESSAGE": "我们无法保存 api 频道"
}
},
"SHANGWUTONG_CHANNEL": {
"TITLE": "商务通渠道",
"DESC": "将一个商务通站点账号接入共享的 GoChat 收件箱。",
"CHANNEL_NAME": "收件箱名称",
"SESSION_ID": "站点 Session ID",
"SESSION_ID_ERROR": "请输入由 11 位字母或数字组成的商务通 Session ID。",
"USERNAME": "登录账号",
"PASSWORD": "登录密码",
"DESIRED_PRESENCE": "初始在线状态",
"WEBHOOK_URL": "Connector Webhook 地址",
"SUBMIT_BUTTON": "创建商务通渠道",
"API": {
"ERROR_MESSAGE": "无法保存商务通渠道"
}
},
"EMAIL_CHANNEL": {
"TITLE": "电子邮件频道",
"DESC": "集成您的电子邮件收件箱。",
@@ -479,6 +494,10 @@
"TITLE": "API",
"DESCRIPTION": "使用我们的 API 创建一个自定义频道"
},
"SHANGWUTONG": {
"TITLE": "商务通",
"DESCRIPTION": "连接一个商务通站点账号"
},
"TELEGRAM": {
"TITLE": "Telegram",
"DESCRIPTION": "使用 Bot 令牌配置 Telegram 频道"
@@ -1155,6 +1174,17 @@
"DESCRIPTION": "与其他提供商关联"
}
},
"SHANGWUTONG_SETTINGS": {
"RUNTIME_STATUS": "Connector 运行状态",
"RUNTIME_STATUS_HELP": "显示 Connector 最近回报的连接、在线与凭据状态。",
"REFRESH": "刷新状态",
"DESIRED_PRESENCE": "期望在线状态",
"DESIRED_PRESENCE_HELP": "状态在线切换;选择 offline 只会退出当前商务通账号。",
"PASSWORD": "修改密码",
"PASSWORD_HELP": "留空表示保持原密码。新密码在线验证成功后才替换当前可用凭据。",
"WEBHOOK_URL": "Connector Webhook 地址",
"WEBHOOK_URL_HELP": "所有启用的商务通收件箱必须使用同一个 Connector 地址。"
},
"CHANNELS": {
"MESSENGER": "Messenger",
"WEB_WIDGET": "网站",
@@ -1166,6 +1196,7 @@
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API 频道",
"SHANGWUTONG": "商务通",
"INSTAGRAM": "Instagram",
"TIKTOK": "TikTok",
"VOICE": "语音"
@@ -4,6 +4,7 @@ import Facebook from './channels/Facebook.vue';
import Website from './channels/Website.vue';
import Twitter from './channels/Twitter.vue';
import Api from './channels/Api.vue';
import Shangwutong from './channels/Shangwutong.vue';
import Email from './channels/Email.vue';
import Sms from './channels/Sms.vue';
import Whatsapp from './channels/Whatsapp.vue';
@@ -18,6 +19,7 @@ const channelViewList = {
website: Website,
twitter: Twitter,
api: Api,
shangwutong: Shangwutong,
email: Email,
sms: Sms,
whatsapp: Whatsapp,
@@ -55,6 +55,14 @@ const channelList = computed(() => {
description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.API.DESCRIPTION'),
icon: 'i-woot-api',
},
{
key: 'shangwutong',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.SHANGWUTONG.TITLE'),
description: t(
'INBOX_MGMT.ADD.AUTH.CHANNEL.SHANGWUTONG.DESCRIPTION'
),
icon: 'i-ri-customer-service-2-line',
},
{
key: 'telegram',
title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.TELEGRAM.TITLE'),
@@ -199,6 +199,7 @@ export default {
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isShangwutongInbox ||
(this.isAnEmailChannel && !this.inbox.provider) ||
this.shouldShowWhatsAppConfiguration ||
this.isAWebWidgetInbox
@@ -292,6 +293,7 @@ export default {
this.isAWhatsAppChannel ||
this.isAFacebookInbox ||
this.isAPIInbox ||
this.isShangwutongInbox ||
this.isAnInstagramChannel ||
this.isALineChannel ||
this.isATiktokChannel ||
@@ -591,6 +593,9 @@ export default {
this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.isShangwutongInbox) {
payload.channel = {};
}
if (this.avatarFile) {
payload.avatar = this.avatarFile;
}
@@ -0,0 +1,176 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const validSessionId = value => /^[A-Za-z0-9]{11}$/.test(value || '');
const validWebhookUrl = value => {
try {
const parsed = new URL(value);
return (
['http:', 'https:'].includes(parsed.protocol) &&
!parsed.username &&
!parsed.password &&
!parsed.hash
);
} catch {
return false;
}
};
export default {
components: { PageHeader, NextButton },
setup() {
return { v$: useVuelidate() };
},
data() {
return {
channelName: '',
sessionId: '',
username: '',
password: '',
desiredPresence: 'online',
webhookUrl: '',
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
},
validations: {
channelName: { required },
sessionId: { required, validSessionId },
username: { required },
password: { required },
webhookUrl: { required, validWebhookUrl },
},
methods: {
async createChannel() {
this.v$.$touch();
if (this.v$.$invalid) return;
try {
const inbox = await this.$store.dispatch(
'inboxes/createChannel',
{
name: this.channelName.trim(),
channel: {
type: 'shangwutong',
session_id: this.sessionId.trim(),
username: this.username.trim(),
password: this.password,
desired_presence: this.desiredPresence,
webhook_url: this.webhookUrl.trim(),
},
}
);
router.replace({
name: 'settings_inboxes_add_agents',
params: { page: 'new', inbox_id: inbox.id },
});
} catch (error) {
useAlert(
error.message ||
this.$t(
'INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.API.ERROR_MESSAGE'
)
);
}
},
},
};
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<PageHeader
:header-title="$t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.TITLE')"
:header-content="$t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.DESC')"
/>
<form class="flex flex-col" @submit.prevent="createChannel">
<label :class="{ error: v$.channelName.$error }">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.CHANNEL_NAME') }}
<input
v-model="channelName"
type="text"
@blur="v$.channelName.$touch"
/>
</label>
<label :class="{ error: v$.sessionId.$error }">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.SESSION_ID') }}
<input
v-model="sessionId"
type="text"
maxlength="11"
autocomplete="off"
@blur="v$.sessionId.$touch"
/>
<span v-if="v$.sessionId.$error" class="message">
{{
$t(
'INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.SESSION_ID_ERROR'
)
}}
</span>
</label>
<label :class="{ error: v$.username.$error }">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.USERNAME') }}
<input
v-model="username"
type="text"
maxlength="255"
autocomplete="username"
@blur="v$.username.$touch"
/>
</label>
<label :class="{ error: v$.password.$error }">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.PASSWORD') }}
<input
v-model="password"
type="password"
maxlength="4096"
autocomplete="new-password"
@blur="v$.password.$touch"
/>
</label>
<label>
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.DESIRED_PRESENCE') }}
<select v-model="desiredPresence">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
</select>
</label>
<label :class="{ error: v$.webhookUrl.$error }">
{{ $t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.WEBHOOK_URL') }}
<input
v-model="webhookUrl"
type="url"
placeholder="http://shangwutong-connector:9100/webhooks/gochat/v1"
@blur="v$.webhookUrl.$touch"
/>
</label>
<div class="w-full mt-4">
<NextButton
type="submit"
solid
blue
:is-loading="uiFlags.isCreating"
:label="
$t('INBOX_MGMT.ADD.SHANGWUTONG_CHANNEL.SUBMIT_BUTTON')
"
/>
</div>
</form>
</div>
</template>
@@ -0,0 +1,194 @@
<script>
import { useAlert } from 'dashboard/composables';
import InboxHealthAPI from 'dashboard/api/inboxHealth';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { SettingsFieldSection, NextButton },
props: {
inbox: { type: Object, required: true },
},
data() {
return {
desiredPresence: 'online',
password: '',
webhookUrl: '',
health: null,
isSaving: false,
isLoadingHealth: false,
};
},
watch: {
inbox: {
immediate: true,
handler(value) {
this.desiredPresence = value.desired_presence || 'online';
this.webhookUrl = value.webhook_url || '';
},
},
},
mounted() {
this.fetchHealth();
},
methods: {
async fetchHealth() {
this.isLoadingHealth = true;
try {
const response = await InboxHealthAPI.getHealthStatus(
this.inbox.id
);
this.health = response.data;
} catch (error) {
useAlert(
error.message ||
this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')
);
} finally {
this.isLoadingHealth = false;
}
},
async save() {
this.isSaving = true;
const channel = {
desired_presence: this.desiredPresence,
webhook_url: this.webhookUrl.trim(),
};
if (this.password) channel.password = this.password;
try {
await this.$store.dispatch('inboxes/updateInbox', {
id: this.inbox.id,
formData: false,
channel,
});
this.password = '';
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
await this.fetchHealth();
} catch (error) {
useAlert(
error.message ||
this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE')
);
} finally {
this.isSaving = false;
}
},
},
};
</script>
<template>
<div class="space-y-6">
<SettingsFieldSection
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.RUNTIME_STATUS')"
:help-text="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.RUNTIME_STATUS_HELP')
"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
connection_status
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.connection_status ||
inbox.connection_status ||
'pending'
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
actual_presence
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.actual_presence ||
inbox.actual_presence ||
'offline'
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
credential_status
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.credential_status ||
inbox.credential_status ||
'pending'
}}
</p>
</div>
<div class="rounded-lg border border-n-weak p-3">
<p class="text-label-small text-n-slate-10">
last_heartbeat_at
</p>
<p class="text-body-main text-n-slate-12">
{{
health?.last_heartbeat_at ||
inbox.last_heartbeat_at ||
'—'
}}
</p>
</div>
</div>
<p
v-if="health?.last_error_code"
class="mt-3 text-sm text-n-ruby-11"
>
{{ health.last_error_code }}
</p>
<NextButton
class="mt-3"
:is-loading="isLoadingHealth"
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.REFRESH')"
@click="fetchHealth"
/>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.DESIRED_PRESENCE')"
:help-text="
$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.DESIRED_PRESENCE_HELP')
"
>
<select v-model="desiredPresence" class="w-full">
<option value="online">online</option>
<option value="busy">busy</option>
<option value="away">away</option>
<option value="offline">offline</option>
</select>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PASSWORD')"
:help-text="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.PASSWORD_HELP')"
>
<input
v-model="password"
type="password"
maxlength="4096"
autocomplete="new-password"
class="w-full"
/>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.WEBHOOK_URL')"
:help-text="$t('INBOX_MGMT.SHANGWUTONG_SETTINGS.WEBHOOK_URL_HELP')"
>
<input v-model="webhookUrl" type="url" class="w-full" />
</SettingsFieldSection>
<div class="flex justify-end">
<NextButton
:is-loading="isSaving"
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
@click="save"
/>
</div>
</div>
</template>
@@ -32,6 +32,7 @@ const i18nMap = {
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
'Channel::Shangwutong': 'SHANGWUTONG',
'Channel::Instagram': 'INSTAGRAM',
'Channel::Tiktok': 'TIKTOK',
};
@@ -11,6 +11,7 @@ import { required } from '@vuelidate/validators';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TextArea from 'next/textarea/TextArea.vue';
import WhatsappReauthorize from '../channels/whatsapp/Reauthorize.vue';
import ShangwutongConfiguration from '../channels/ShangwutongConfiguration.vue';
import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
@@ -23,6 +24,7 @@ export default {
NextButton,
TextArea,
WhatsappReauthorize,
ShangwutongConfiguration,
},
mixins: [inboxMixin],
props: {
@@ -189,6 +191,7 @@ export default {
</script>
<template>
<ShangwutongConfiguration v-if="isShangwutongInbox" :inbox="inbox" />
<div v-if="isATwilioChannel">
<SettingsFieldSection
:label="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
@@ -310,11 +310,9 @@ const actions = {
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
const errorMessage = error.response
@@ -290,6 +290,39 @@ describe('#actions', () => {
});
});
describe('#sendMessageWithData', () => {
it('keeps the status returned by the server', async () => {
const localCommit = vi.fn();
const pendingMessage = {
id: 'echo-1',
conversation_id: 7,
message: 'hello',
status: 'progress',
content_attributes: {},
};
const responseMessage = {
id: 42,
conversation_id: 7,
content: 'hello',
status: 'progress',
};
axios.mockResolvedValue({ data: responseMessage });
await actions.sendMessageWithData({ commit: localCommit }, pendingMessage);
expect(localCommit).toHaveBeenNthCalledWith(
2,
types.ADD_MESSAGE,
responseMessage
);
expect(localCommit).toHaveBeenNthCalledWith(
3,
types.ADD_CONVERSATION_ATTACHMENTS,
responseMessage
);
});
});
describe('#markMessagesRead', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -16,6 +16,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
INBOX_TYPES.SHANGWUTONG,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
@@ -24,6 +25,7 @@ export const INBOX_FEATURE_MAP = {
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
INBOX_TYPES.SHANGWUTONG,
],
};
@@ -44,6 +46,9 @@ export default {
isAPIInbox() {
return this.channelType === INBOX_TYPES.API;
},
isShangwutongInbox() {
return this.channelType === INBOX_TYPES.SHANGWUTONG;
},
isATwitterInbox() {
return this.channelType === INBOX_TYPES.TWITTER;
},