Skip to content

Commit

Permalink
feat(chat): support channel view
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed May 15, 2022
1 parent 0880eb8 commit 775989c
Show file tree
Hide file tree
Showing 9 changed files with 157 additions and 69 deletions.
2 changes: 1 addition & 1 deletion packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export class DatabaseService extends Database<Tables> {
this.extend('user', {
// TODO v5: change to number
id: 'string(63)',
name: 'string(63)',
name: { type: 'string', length: 63 },
flag: 'unsigned(20)',
authority: 'unsigned(4)',
locale: 'string(63)',
Expand Down
94 changes: 83 additions & 11 deletions plugins/frontend/chat/client/chat.vue
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
<template>
<k-card-aside class="page-chat">
<virtual-list :data="messages" pinned v-model:active-key="index">
<template #header><div class="header-padding"></div></template>
<template #="data">
<chat-message @click="handleClick(data)" :successive="isSuccessive(data, data.index)" :data="data"></chat-message>
<template #aside>
<el-scrollbar>
<template v-for="({ name, channels }, id) in guilds" :key="id">
<div class="k-tab-group-title">{{ name }}</div>
<template v-for="({ name }, key) in channels" :key="key">
<k-tab-item v-model="current" :label="id + ':' + key">
{{ name }}
</k-tab-item>
</template>
</template>
</el-scrollbar>
</template>
<keep-alive #default>
<template v-if="current" :key="current">
<div class="card-header">{{ header }}</div>
<virtual-list :data="filtered" pinned v-model:active-key="index" key-name="messageId">
<template #header><div class="header-padding"></div></template>
<template #="data">
<chat-message @click="handleClick(data)" :successive="isSuccessive(data, data.index)" :data="data"></chat-message>
</template>
<template #footer><div class="footer-padding"></div></template>
</virtual-list>
<div class="card-footer">
<chat-input @send="handleSend"></chat-input>
</div>
</template>
<template #footer><div class="footer-padding"></div></template>
</virtual-list>
<div class="card-footer">
<chat-input @send="handleSend"></chat-input>
</div>
<template v-else>
<k-empty>
<div>请在左侧选择频道</div>
</k-empty>
</template>
</keep-alive>
</k-card-aside>
</template>

<script lang="ts" setup>
import { config, receive, send, ChatInput, VirtualList } from '@koishijs/client'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import type { Dict } from 'cosmokit'
import type { Message } from '@koishijs/plugin-chat/src'
import { storage } from './utils'
import ChatMessage from './message.vue'
Expand All @@ -25,12 +48,46 @@ const pinned = ref(true)
const index = ref<string>()
const activeMessage = ref<Message>()
const messages = storage.create<Message[]>('chat', [])
const current = ref<string>('')
receive('chat', (body) => {
messages.value.push(body)
messages.value.splice(0, messages.value.length - config.maxMessages)
})
const guilds = computed(() => {
const guilds: Dict<{
name: string
channels: Dict<{ name: string }>
}> = { '': { name: '私聊', channels: {} } }
for (const message of messages.value) {
const guild = guilds[message.platform + ':' + message.guildId] ||= {
name: message.guildName || '未知群组',
channels: {},
}
guild.channels[message.channelId] ||= {
name: message.channelName,
}
}
return guilds
})
const header = computed(() => {
if (!current.value) return
const [platform, guildId, channelId] = current.value.split(':')
const guild = guilds.value[platform + ':' + guildId]
if (!guild) return
return `${guild.name} / ${guild.channels[channelId]?.name}`
})
const filtered = computed(() => {
if (!current.value) return []
const [platform, guildId, channelId] = current.value.split(':')
return messages.value.filter((data) => {
return data.platform === platform && data.guildId === guildId && data.channelId === channelId
})
})
function isSuccessive({ quote, userId, channelId }: Message, index: number) {
const prev = messages.value[index - 1]
return !quote && !!prev && prev.userId === userId && prev.channelId === channelId
Expand All @@ -55,13 +112,28 @@ function handleSend(content: string) {
position: relative;
height: calc(100vh - 4rem);
aside .el-scrollbar__view {
padding: 1rem 0;
line-height: 2.25rem;
}
main {
display: flex;
flex-direction: column;
}
.card-header {
font-size: 1.05rem;
font-weight: 500;
padding: 0 1.25rem;
height: 3.5rem;
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
}
.header-padding, .footer-padding {
padding: 0.5rem 0;
padding: 0.25rem 0;
}
.card-footer {
Expand Down
8 changes: 6 additions & 2 deletions plugins/frontend/chat/client/message.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,18 @@
<span class="timestamp">{{ formatDateTime(new Date(data.timestamp)) }}</span>
</div>
</template>
<message-content :content="data.content"/>
<message-content :content="data.content">
<template #image="{ url }">
<chat-image :src="url"></chat-image>
</template>
</message-content>
</div>
</template>

<script lang="ts" setup>
import { Message } from '@koishijs/plugin-chat/src'
import { MessageContent } from '@koishijs/client'
import { MessageContent, ChatImage } from '@koishijs/client'
defineEmits(['locate'])
Expand Down
17 changes: 12 additions & 5 deletions plugins/frontend/chat/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Context, Logger, Schema } from 'koishi'
import { Context, Logger, Schema, segment } from 'koishi'
import { resolve } from 'path'
import receiver, { Message, RefreshConfig } from './receiver'
import {} from '@koishijs/plugin-console'
Expand Down Expand Up @@ -68,8 +68,8 @@ export function apply(ctx: Context, options: Config = {}) {

ctx.on('chat/receive', async (message, session) => {
if (session.subtype !== 'private' && ctx.database) {
const { assignee } = await ctx.database.getChannel(session.platform, session.channelId, ['assignee'])
if (assignee !== session.selfId) return
const channel = await ctx.database.getChannel(session.platform, session.channelId, ['assignee'])
if (!channel || channel.assignee !== session.selfId) return
}

// render template with fallback options
Expand All @@ -96,15 +96,22 @@ export function apply(ctx: Context, options: Config = {}) {
}, { authority: 3 })

ctx.on('chat/receive', async (message) => {
message.content = segment.transform(message.content, {
image: (data) => {
if (whitelist.includes(data.url)) {
data.url = apiPath + '/proxy/' + encodeURIComponent(data.url)
}
return segment('image', data)
},
})
Object.values(ctx.console.ws.handles).forEach((handle) => {
handle.socket.send(JSON.stringify({ type: 'chat', body: message }))
})
})

const { get } = ctx.http
ctx.router.get(apiPath + '/assets/:url', async (ctx) => {
ctx.router.get(apiPath + '/proxy/:url', async (ctx) => {
if (!whitelist.some(prefix => ctx.params.url.startsWith(prefix))) {
console.log(ctx.params.url)
return ctx.status = 403
}
return ctx.body = await get<internal.Readable>(ctx.params.url, { responseType: 'stream' })
Expand Down
1 change: 0 additions & 1 deletion plugins/frontend/chat/src/receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ export default function receiver(ctx: Context, config: RefreshConfig = {}) {
params.abstract += `[${stl(code.type as SegmentType)}]`
}
}
params.content = codes.map(({ type, data }) => segment(type, data)).join('')
}

async function prepareContent(session: Session, message: Message, timestamp: number) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,68 @@
import { Bot, Context, Logger, segment, Service, Session } from 'koishi'
import { Message } from './types'

export interface Message {
id?: number
content: string
messageId: string
platform: string
guildId: string
userId: string
timestamp: Date
quoteId?: string
username: string
nickname: string
channelId: string
selfId: string
lastUpdated?: Date
deleted?: number
}

declare module 'koishi' {
interface Tables {
message: Message
}
namespace Context {
interface Services {
messages: MessageDatabase
messages: MessageService
}
}
interface EventMap {
'messages/synced'(cid: string)
'messages/syncFailed'(cid: string, error: Error)
'messages/syncing'(cid: string)
'messages/synced'(cid: string): void
'messages/syncFailed'(cid: string, error: Error): void
'messages/syncing'(cid: string): void
}
}

const logger = new Logger('messages')

enum ChannelStatus {
SYNCING, SYNCED, FAILED
SYNCING,
SYNCED,
FAILED,
}

export class MessageDatabase extends Service {
export class MessageService extends Service {
constructor(ctx: Context) {
super(ctx, 'messages', true)

this.ctx.model.extend('message', {
id: 'integer',
content: 'text',
platform: 'string',
guildId: 'string',
messageId: 'string',
userId: 'string',
timestamp: 'timestamp',
quoteId: 'string',
username: 'string',
nickname: 'string',
channelId: 'string',
selfId: 'string',
lastUpdated: 'timestamp',
deleted: 'integer',
}, {
autoInc: true,
})
}

#status: Record<string, ChannelStatus> = {}
Expand Down Expand Up @@ -54,26 +91,6 @@ export class MessageDatabase extends Service {
}

async start() {
this.ctx.model.extend('message', {
id: 'integer',
content: 'text',
platform: 'string',
guildId: 'string',
messageId: 'string',
userId: 'string',
timestamp: 'timestamp',
quoteId: 'string',
username: 'string',
nickname: 'string',
channelId: 'string',
selfId: 'string',
lastUpdated: 'timestamp',
deleted: 'integer',
}, {
primary: 'id',
autoInc: true,
})

// 如果是一个 platform 有多个 bot, bot 状态变化, 频道状态变化待解决
this.ctx.on('message', this.#onMessage.bind(this))
this.ctx.on('send', async (session) => {
Expand Down Expand Up @@ -291,14 +308,14 @@ export class MessageDatabase extends Service {
if (!inDatabase.length) {
const latestMessages = await bot.getChannelMessageHistory(channelId)
// 获取一次
await this.ctx.database.upsert('message', latestMessages.map(session => MessageDatabase.adaptMessage(session, bot, guildId)))
await this.ctx.database.upsert('message', latestMessages.map(session => MessageService.adaptMessage(session, bot, guildId)))
} else {
// 数据库中最后一条消息

const newMessages = await this.getMessageBetween(bot, channelId, inDatabase[0].messageId, this.#messageRecord[cid]?.received)
logger.debug('get new messages')
if (newMessages.length) {
await this.ctx.database.upsert('message', newMessages.map(session => MessageDatabase.adaptMessage(session, bot, guildId)))
await this.ctx.database.upsert('message', newMessages.map(session => MessageService.adaptMessage(session, bot, guildId)))
}
}
} catch (e) {
Expand All @@ -315,7 +332,7 @@ export class MessageDatabase extends Service {

const newLocal = this.#messageQueue[cid]
if (newLocal?.length) {
await this.ctx.database.upsert('message', newLocal.map(session => MessageDatabase.adaptMessage(session)))
await this.ctx.database.upsert('message', newLocal.map(session => MessageService.adaptMessage(session)))
this.#messageQueue[cid] = []
}
this.#status[cid] = ChannelStatus.SYNCED
Expand All @@ -330,7 +347,7 @@ export class MessageDatabase extends Service {
this.#status[session.cid] === ChannelStatus.SYNCED || this.#status[session.cid] === ChannelStatus.FAILED
) && !this.inSyncQueue(session.cid)) {
logger.debug('on message, cid: %s, id: %s', session.cid, session.messageId)
await this.ctx.database.create('message', MessageDatabase.adaptMessage(session))
await this.ctx.database.create('message', MessageService.adaptMessage(session))
} else if (this.inSyncQueue(session.cid)) {
// in queue, not synced
if (!this.#messageRecord[session.cid]) {
Expand All @@ -353,9 +370,3 @@ export class MessageDatabase extends Service {
}
}
}

export async function apply(ctx: Context) {
ctx.plugin(MessageDatabase)
}

export * from './types'
8 changes: 1 addition & 7 deletions plugins/frontend/client/client/components/chat/image.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
<template>
<img class="k-image" :src="normalizeUrl(src)" @click="handleClick"/>
<img class="chat-image" :src="src" @click="handleClick"/>
</template>

<script lang="ts" setup>
import { shared } from './utils'
import { config } from '@koishijs/client'
const props = defineProps<{ src: string }>()
function normalizeUrl(url: string) {
return url
// return config.endpoint + '/assets/' + encodeURIComponent(url)
}
function handleClick(ev: MouseEvent) {
ev.preventDefault()
if (ev.metaKey) return window.open(props.src, '_blank')
Expand Down
Loading

0 comments on commit 775989c

Please sign in to comment.