feat(channel): add WeChat (weixin) adapter with QR code (#278)

* feat(channel): add WeChat (weixin) adapter with QR code

* fix(channel): fix weixin block streaming

* chore(channel): update weixin logo
This commit is contained in:
晨苒
2026-03-22 23:28:57 +08:00
committed by GitHub
parent 897cc32194
commit e2e3b69acf
31 changed files with 3712 additions and 49 deletions
+2
View File
@@ -36,6 +36,7 @@
"monaco-editor": "^0.52.2",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.4",
"shiki": "^3.21.0",
"stream-markdown": "^0.0.13",
"stream-monaco": "^0.0.18",
@@ -52,6 +53,7 @@
"devDependencies": {
"@memoh/config": "workspace:*",
"@types/node": "^24.10.1",
"@types/qrcode": "^1.5.6",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
@@ -27,6 +27,7 @@ const channelIcons: Record<string, Component> = {
slack: Slack,
feishu: Feishu,
wechat: Wechat,
weixin: Wechat,
matrix: Matrix,
}
+13
View File
@@ -892,12 +892,24 @@
"feishuWebhookSecurityHint": "For security, webhook mode requires either an Encrypt Key or a Verification Token; an unprotected public callback URL should not be exposed.",
"feishuWebhookSecretRequired": "For security, configure at least one of Encrypt Key or Verification Token.",
"noAvailableTypes": "All platform types have been configured",
"weixinQr": {
"title": "QR Code Login",
"description": "Scan the QR code with WeChat to connect your account.",
"startScan": "Get QR Code",
"waitingScan": "Open WeChat and scan this QR code",
"scanned": "Scanned — confirm on your phone",
"expired": "QR code expired",
"refresh": "Refresh QR Code",
"success": "WeChat connected successfully!",
"retry": "Try Again"
},
"types": {
"feishu": "Feishu",
"discord": "Discord",
"qq": "QQ",
"matrix": "Matrix",
"telegram": "Telegram",
"weixin": "WeChat",
"web": "Web",
"local": "Local"
},
@@ -907,6 +919,7 @@
"qq": "QQ",
"matrix": "MX",
"telegram": "TG",
"weixin": "WX",
"web": "Web",
"local": "CLI"
}
+13
View File
@@ -888,12 +888,24 @@
"feishuWebhookSecurityHint": "出于安全考虑,Webhook 模式必须配置 Encrypt Key 或 Verification Token 之一;未受保护的回调地址不应直接暴露在公网上。",
"feishuWebhookSecretRequired": "出于安全考虑,请至少配置 Encrypt Key 或 Verification Token 之一。",
"noAvailableTypes": "所有平台类型均已配置",
"weixinQr": {
"title": "扫码登录",
"description": "使用微信扫描二维码以连接微信账号。",
"startScan": "获取二维码",
"waitingScan": "打开微信扫描此二维码",
"scanned": "已扫码 — 请在手机上确认",
"expired": "二维码已过期",
"refresh": "刷新二维码",
"success": "微信连接成功!",
"retry": "重试"
},
"types": {
"feishu": "飞书",
"discord": "Discord",
"qq": "QQ",
"matrix": "Matrix",
"telegram": "Telegram",
"weixin": "微信",
"web": "Web",
"local": "本地"
},
@@ -903,6 +915,7 @@
"qq": "QQ",
"matrix": "MX",
"telegram": "TG",
"weixin": "WX",
"web": "Web",
"local": "CLI"
}
@@ -39,7 +39,7 @@
:aria-pressed="selectedType === item.meta.type"
class="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm transition-colors hover:bg-accent"
:class="{ 'bg-accent': selectedType === item.meta.type }"
@click="selectedType = item.meta.type as string"
@click="selectedType = item.meta.type ?? ''"
>
<span class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type as string" size="1.25em" />
@@ -98,7 +98,7 @@
:key="item.meta.type"
type="button"
class="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors"
@click="addChannel(item.meta.type)"
@click="addChannel(item.meta.type ?? '')"
>
<span class="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ChannelIcon :channel="item.meta.type" size="1em" />
@@ -168,7 +168,7 @@ const { data: channels, isLoading, refetch } = useQuery({
configurableTypes.map(async (meta) => {
try {
const { data: config } = await getBotsByIdChannelByPlatform({
path: { id: botIdRef.value, platform: meta.type },
path: { id: botIdRef.value, platform: meta.type ?? '' },
throwOnError: true,
})
return { meta, config: config ?? null, configured: true } as BotChannelItem
@@ -194,10 +194,10 @@ const selectedItem = computed(() =>
allChannels.value.find((c) => c.meta.type === selectedType.value) ?? null,
)
watch(allChannels, (list) => {
if (list.length === 0) {
selectedType.value = null
return
watch(configuredChannels, (list) => {
const first = list[0]
if (first && !selectedType.value) {
selectedType.value = first.meta.type ?? null
}
const current = selectedType.value
@@ -213,5 +213,4 @@ function addChannel(type: string) {
addPopoverOpen.value = false
selectedType.value = type
}
</script>
@@ -86,7 +86,16 @@
</p>
</div>
<Separator />
<!-- WeChat QR Login -->
<div v-if="channelItem.meta.type === 'weixin'">
<WeixinQrLogin
:bot-id="botId"
@login-success="handleWeixinLoginSuccess"
/>
<Separator class="mt-4" />
</div>
<Separator v-else />
<!-- Credentials form (dynamic from config_schema) -->
<div class="space-y-4">
@@ -259,6 +268,7 @@ import type { HandlersChannelMeta, ChannelChannelConfig, ChannelFieldSchema, Cha
import { client } from '@memoh/sdk/client'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import ChannelIcon from '@/components/channel-icon/index.vue'
import WeixinQrLogin from './weixin-qr-login.vue'
interface BotChannelItem {
meta: HandlersChannelMeta
@@ -571,6 +581,11 @@ function isAbsoluteHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value)
}
function handleWeixinLoginSuccess() {
queryCache.invalidateQueries({ key: ['bot-channels', botIdRef.value] })
emit('saved')
}
async function copyWebhookCallback() {
if (!webhookCallbackUrl.value) return
try {
@@ -0,0 +1,292 @@
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h4 class="text-sm font-medium">
{{ $t('bots.channels.weixinQr.title') }}
</h4>
<p class="text-xs text-muted-foreground mt-1">
{{ $t('bots.channels.weixinQr.description') }}
</p>
</div>
</div>
<!-- QR code display -->
<div
v-if="qrState === 'idle'"
class="flex flex-col items-center gap-3 py-4"
>
<Button
:disabled="isStarting"
@click="startLogin"
>
<Spinner
v-if="isStarting"
class="mr-1.5"
/>
<FontAwesomeIcon
v-else
:icon="['fas', 'qrcode']"
class="mr-1.5 size-3.5"
/>
{{ $t('bots.channels.weixinQr.startScan') }}
</Button>
</div>
<div
v-else-if="qrState === 'showing'"
class="flex flex-col items-center gap-4 py-4"
>
<div class="relative rounded-lg border bg-white p-3">
<img
v-if="qrImageDataUrl"
:src="qrImageDataUrl"
alt="WeChat QR Code"
class="size-52"
>
<div
v-else
class="size-52 flex items-center justify-center text-muted-foreground"
>
<Spinner />
</div>
<!-- Overlay for scanned state -->
<div
v-if="pollStatus === 'scaned'"
class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80"
>
<div class="text-center">
<FontAwesomeIcon
:icon="['fas', 'mobile-screen']"
class="size-8 text-primary mb-2"
/>
<p class="text-sm font-medium text-foreground">
{{ $t('bots.channels.weixinQr.scanned') }}
</p>
</div>
</div>
<!-- Overlay for expired state -->
<div
v-if="pollStatus === 'expired'"
class="absolute inset-0 flex flex-col items-center justify-center rounded-lg bg-background/80 gap-2"
>
<p class="text-sm text-muted-foreground">
{{ $t('bots.channels.weixinQr.expired') }}
</p>
<Button
size="sm"
variant="outline"
@click="startLogin"
>
{{ $t('bots.channels.weixinQr.refresh') }}
</Button>
</div>
</div>
<p class="text-sm text-muted-foreground text-center max-w-xs">
{{ statusText }}
</p>
<Button
variant="ghost"
size="sm"
@click="cancel"
>
{{ $t('common.cancel') }}
</Button>
</div>
<div
v-else-if="qrState === 'success'"
class="flex flex-col items-center gap-3 py-4"
>
<div class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900">
<FontAwesomeIcon
:icon="['fas', 'check']"
class="size-5 text-green-600 dark:text-green-400"
/>
</div>
<p class="text-sm font-medium">
{{ $t('bots.channels.weixinQr.success') }}
</p>
</div>
<div
v-else-if="qrState === 'error'"
class="flex flex-col items-center gap-3 py-4"
>
<p class="text-sm text-destructive">
{{ errorMessage }}
</p>
<Button
variant="outline"
size="sm"
@click="startLogin"
>
{{ $t('bots.channels.weixinQr.retry') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue'
import { Button, Spinner } from '@memoh/ui'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { client } from '@memoh/sdk/client'
import QRCode from 'qrcode'
const props = defineProps<{
botId: string
}>()
const emit = defineEmits<{
loginSuccess: []
}>()
const { t } = useI18n()
type QRState = 'idle' | 'showing' | 'success' | 'error'
const qrState = ref<QRState>('idle')
const qrCode = ref('')
const qrImageDataUrl = ref('')
const pollStatus = ref('')
const isStarting = ref(false)
const errorMessage = ref('')
let pollTimer: ReturnType<typeof setTimeout> | null = null
let aborted = false
const statusText = computed(() => {
switch (pollStatus.value) {
case 'wait':
return t('bots.channels.weixinQr.waitingScan')
case 'scaned':
return t('bots.channels.weixinQr.scanned')
case 'expired':
return t('bots.channels.weixinQr.expired')
default:
return t('bots.channels.weixinQr.waitingScan')
}
})
async function startLogin() {
aborted = false
isStarting.value = true
errorMessage.value = ''
pollStatus.value = ''
qrImageDataUrl.value = ''
try {
const baseUrl = client.getConfig().baseUrl || ''
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/start`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
},
body: JSON.stringify({}),
})
if (!resp.ok) {
const body = await resp.text()
throw new Error(body || `HTTP ${resp.status}`)
}
const data = await resp.json() as { qr_code_url: string; qr_code: string; message: string }
const qrContent = data.qr_code_url || data.qr_code || ''
if (!qrContent) {
throw new Error('No QR code data returned')
}
qrCode.value = data.qr_code || ''
qrImageDataUrl.value = await QRCode.toDataURL(qrContent, { width: 208, margin: 1 })
qrState.value = 'showing'
startPolling()
} catch (err) {
errorMessage.value = err instanceof Error ? err.message : String(err)
qrState.value = 'error'
} finally {
isStarting.value = false
}
}
function startPolling() {
if (aborted) return
pollOnce()
}
async function pollOnce() {
if (aborted || qrState.value !== 'showing') return
try {
const baseUrl = client.getConfig().baseUrl || ''
const resp = await fetch(`${baseUrl}/bots/${encodeURIComponent(props.botId)}/channel/weixin/qr/poll`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token') || ''}`,
},
body: JSON.stringify({
qr_code: qrCode.value,
}),
})
if (!resp.ok) {
const body = await resp.text()
throw new Error(body || `HTTP ${resp.status}`)
}
const data = await resp.json() as { status: string; message: string }
pollStatus.value = data.status
switch (data.status) {
case 'confirmed':
qrState.value = 'success'
toast.success(t('bots.channels.weixinQr.success'))
emit('loginSuccess')
return
case 'expired':
return
case 'wait':
case 'scaned':
if (!aborted) {
pollTimer = setTimeout(pollOnce, 1500)
}
return
default:
if (!aborted) {
pollTimer = setTimeout(pollOnce, 2000)
}
}
} catch {
if (!aborted) {
pollTimer = setTimeout(pollOnce, 3000)
}
}
}
function cancel() {
aborted = true
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
qrState.value = 'idle'
qrCode.value = ''
qrImageDataUrl.value = ''
pollStatus.value = ''
}
onUnmounted(() => {
aborted = true
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
}
})
</script>
+34
View File
@@ -0,0 +1,34 @@
/**
* Local channel icons under public/channels/.
* getChannelImage: URL to local icon when available.
* getChannelIcon: FontAwesome fallback when no local image.
*/
const LOCAL_CHANNEL_IMAGES: Record<string, string> = {
feishu: '/channels/feishu.png',
matrix: '/channels/matrix.svg',
telegram: '/channels/telegram.webp',
}
const CHANNEL_ICONS: Record<string, [string, string]> = {
qq: ['fab', 'qq'],
telegram: ['fab', 'telegram'],
matrix: ['fas', 'hashtag'],
feishu: ['fas', 'comment-dots'],
web: ['fas', 'globe'],
slack: ['fab', 'slack'],
discord: ['fab', 'discord'],
email: ['fas', 'envelope'],
}
const DEFAULT_ICON: [string, string] = ['far', 'comment']
export function getChannelIcon(platformKey: string): [string, string] {
if (!platformKey) return DEFAULT_ICON
return CHANNEL_ICONS[platformKey] ?? DEFAULT_ICON
}
export function getChannelImage(platformKey: string): string | null {
if (!platformKey) return null
return LOCAL_CHANNEL_IMAGES[platformKey] ?? null
}