mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user