Skip to content

Commit

Permalink
feat(chat): support quote reply
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Apr 7, 2021
1 parent 59ebaa4 commit 2abdd45
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 44 deletions.
13 changes: 2 additions & 11 deletions packages/koishi-core/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { simplify, defineProperty, Time, Observed, coerce, escapeRegExp, makeArray, noop, template, trimSlash, merge } from 'koishi-utils'
import { simplify, defineProperty, Time, Observed, coerce, escapeRegExp, makeArray, template, trimSlash, merge } from 'koishi-utils'
import { Context, Middleware, NextFunction, Plugin } from './context'
import { Argv } from './parser'
import { BotOptions, Adapter, createBots } from './adapter'
Expand Down Expand Up @@ -198,19 +198,10 @@ export class App extends Context {
}

private async _process(session: Session, next: NextFunction) {
let content = this.options.processMessage(session.content)

let capture: RegExpMatchArray
let atSelf = false, appel = false, prefix: string = null
const pattern = /^\[CQ:(\w+)((,\w+=[^,\]]*)*)\]/
if ((capture = content.match(pattern)) && capture[1] === 'quote') {
content = content.slice(capture[0].length).trimStart()
for (const str of capture[2].slice(1).split(',')) {
if (!str.startsWith('id=')) continue
session.quote = await session.bot.getMessage(session.channelId, str.slice(3)).catch(noop)
break
}
}
let content = await session.preprocess()

// strip prefix
if (session.subtype !== 'private' && (capture = content.match(pattern)) && capture[1] === 'at' && capture[2].includes('id=' + session.selfId)) {
Expand Down
22 changes: 21 additions & 1 deletion packages/koishi-core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import LruCache from 'lru-cache'
import { distance } from 'fastest-levenshtein'
import { User, Channel, TableType, Tables } from './database'
import { Command } from './command'
import { contain, observe, Logger, defineProperty, Random, template, remove } from 'koishi-utils'
import { contain, observe, Logger, defineProperty, Random, template, remove, noop } from 'koishi-utils'
import { Argv } from './parser'
import { Middleware, NextFunction } from './context'
import { App } from './app'
Expand Down Expand Up @@ -109,6 +109,7 @@ export class Session<
private _delay?: number
private _queued: Promise<void>
private _hooks: (() => void)[]
private _promise: Promise<string>

static readonly send = Symbol.for('koishi.session.send')

Expand All @@ -133,6 +134,25 @@ export class Session<
}))
}

private async _preprocess() {
let capture: RegExpMatchArray
let content = this.app.options.processMessage(this.content)
const pattern = /^\[CQ:(\w+)((,\w+=[^,\]]*)*)\]/
if ((capture = this.content.match(pattern)) && capture[1] === 'quote') {
content = content.slice(capture[0].length).trimStart()
for (const str of capture[2].slice(1).split(',')) {
if (!str.startsWith('id=')) continue
this.quote = await this.bot.getMessage(this.channelId, str.slice(3)).catch(noop)
break
}
}
return content
}

async preprocess() {
return this._promise ||= this._preprocess()
}

get username(): string {
const defaultName = this.user && this.user['name']
? this.user['name']
Expand Down
86 changes: 78 additions & 8 deletions packages/plugin-chat/client/chat.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
<template>
<k-chat-panel class="page-chat" :messages="messages" pinned @click="handleClick" @send="handleSend">
<template #default="{ avatar, channelName, username, timestamp, content }">
<template #default="{ avatar, messageId, channelName, username, timestamp, content, quote }">
<div class="quote" v-if="quote" @click="onClickQuote(quote.messageId)">
<img class="quote-avatar" :src="quote.author.avatar"/>
<span class="username">{{ quote.author.username }}</span>
<span class="abstract">{{ formatAbstract(quote.abstract) }}</span>
</div>
<img class="avatar" :src="avatar"/>
<div class="header">
<div class="header" :ref="el => divs[messageId] = el?.['parentElement']">
<span class="channel">{{ channelName || '私聊' }}</span>
<span class="username">{{ username }}</span>
<span class="timestamp">{{ formatDateTime(timestamp) }}</span>
</div>
<k-message :content="content"/>
</template>
<template #footer v-if="activeMessage">
发送到频道:{{ activeMessage.channelName }}
<template #footer>
<p class="hint">
<template v-if="activeMessage">发送到频道:{{ activeMessage.channelName }}</template>
<template v-else>点击消息已选择要发送的频道。</template>
</p>
</template>
</k-chat-panel>
</template>
Expand All @@ -24,6 +32,7 @@ import type { Message } from '../src'
const pinned = ref(true)
const activeMessage = ref<Message>()
const messages = storage.create<Message[]>('chat', [])
const divs = ref<Record<string, HTMLElement>>({})
receive('chat', (body) => {
messages.value.push(body)
Expand All @@ -41,6 +50,17 @@ function handleSend(content: string) {
send('chat', { token, id, content, platform, selfId, channelId })
}
function onClickQuote(id: string) {
const el = divs.value[id]
if (!el) return
el.scrollIntoView({ behavior: 'smooth' })
}
function formatAbstract(content: string) {
if (content.length < 50) return content
return content.slice(0, 48) + '……'
}
function formatDateTime(timestamp: number) {
const date = new Date(timestamp)
const now = new Date()
Expand All @@ -56,21 +76,65 @@ function formatDateTime(timestamp: number) {
<style lang="scss">
$avatarSize: 2.5rem;
$padding: $avatarSize + 1rem;
.page-chat {
position: relative;
.avatar {
position: absolute;
margin-top: 4px;
width: $avatarSize;
height: $avatarSize;
border-radius: $avatarSize;
user-select: none;
}
.quote {
position: relative;
font-size: 0.875rem;
margin-left: $padding;
cursor: pointer;
* + span {
margin-left: 0.5rem;
}
&::before {
content: '';
display: block;
position: absolute;
box-sizing: border-box;
top: 50%;
right: 100%;
bottom: 0;
left: -36px;
margin-right: 4px;
margin-top: -1px;
margin-left: -1px;
margin-bottom: calc(.125rem - 4px);
border-left: 1px solid #4f545c;
border-top: 1px solid #4f545c;
border-top-left-radius: 6px;
}
.quote-avatar {
width: 1rem;
height: 1rem;
border-radius: 1rem;
vertical-align: text-top;
}
.abstract {
text-overflow: ellipsis;
white-space: nowrap;
}
}
.header {
padding-left: $avatarSize + 1rem;
margin-left: $padding;
color: #72767d;
span {
margin-right: 0.5rem;
* + span {
margin-left: 0.5rem;
}
}
Expand All @@ -82,10 +146,16 @@ $avatarSize: 2.5rem;
.timestamp {
color: #72767d;
font-size: 0.875rem;
}
.k-message {
padding-left: $avatarSize + 1rem;
margin-left: $padding;
}
p.hint {
color: #72767d;
margin: 0.5rem 0 -0.5rem;
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin-chat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function apply(ctx: Context, options: Config = {}) {
ctx.bots[`${platform}:${selfId}`]?.sendMessage(channelId, content)
})

ctx.on('chat/receive', (message) => {
ctx.on('chat/receive', async (message) => {
Object.values(ctx.webui.adapter.handles).forEach((handle) => {
if (handle.authority >= 4) handle.socket.send(JSON.stringify({ type: 'chat', body: message }))
})
Expand Down
43 changes: 31 additions & 12 deletions packages/plugin-chat/src/receiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface ReceiverConfig {

const textSegmentTypes = ['text', 'header', 'section']

const cqTypes = {
const segmentTypes = {
face: '表情',
record: '语音',
video: '短视频',
Expand All @@ -32,13 +32,15 @@ export interface Message {
username?: string
nickname?: string
platform?: string
messageId?: string
userId?: string
channelId?: string
groupId?: string
selfId?: string
channelName?: string
groupName?: string
timestamp?: number
quote?: Message
}

async function getUserName(bot: Bot, groupId: string, userId: string) {
Expand Down Expand Up @@ -84,7 +86,7 @@ export default function apply(ctx: Context, config: ReceiverConfig = {}) {
ctx.bots.forEach(bot => userMap[bot.sid] = [Promise.resolve(bot.username), timestamp])
})

async function prepareChannelName(session: Session, params: Message, timestamp: number) {
async function prepareChannel(session: Session, params: Message, timestamp: number) {
const { cid, groupId, channelName } = session
if (channelName) {
channelMap[cid] = [Promise.resolve(channelName), timestamp]
Expand All @@ -97,7 +99,7 @@ export default function apply(ctx: Context, config: ReceiverConfig = {}) {
params.channelName = await channelMap[cid][0]
}

async function prepareGroupName(session: Session, params: Message, timestamp: number) {
async function prepareGroup(session: Session, params: Message, timestamp: number) {
const { cid, gid, groupId, groupName } = session
if (groupName) {
groupMap[gid] = [Promise.resolve(groupName), timestamp]
Expand All @@ -111,7 +113,7 @@ export default function apply(ctx: Context, config: ReceiverConfig = {}) {
}

async function prepareAbstract(session: Session, params: Message, timestamp: number) {
const codes = segment.parse(session.content.split('\n', 1)[0])
const codes = segment.parse(params.content.split('\n', 1)[0])
params.abstract = ''
for (const code of codes) {
if (textSegmentTypes.includes(code.type)) {
Expand All @@ -133,13 +135,26 @@ export default function apply(ctx: Context, config: ReceiverConfig = {}) {
} else if (code.type === 'contact') {
params.abstract += `[推荐${code.data.type === 'qq' ? '好友' : '群'}:${code.data.id}]`
} else {
params.abstract += `[${cqTypes[code.type] || '未知'}]`
params.abstract += `[${segmentTypes[code.type] || '未知'}]`
}
}
}

function handleMessage(session: Session) {
const params: Message = pick(session, ['content', 'timestamp', 'platform', 'channelId', 'channelName', 'groupId', 'groupName', 'userId', 'selfId'])
async function prepareContent(session: Session, message: Message, timestamp: number) {
message.content = await session.preprocess()
const tasks = [prepareAbstract(session, message, timestamp)]
// eslint-disable-next-line no-cond-assign
if (message.quote = session.quote) {
tasks.push(prepareAbstract(session, message.quote, timestamp))
}
await Promise.all(tasks)
}

async function handleMessage(session: Session) {
const params: Message = pick(session, [
'content', 'timestamp', 'messageId', 'platform', 'selfId',
'channelId', 'channelName', 'groupId', 'groupName', 'userId',
])
Object.assign(params, pick(session.author, ['username', 'nickname', 'avatar']))
if (session.type === 'message') {
userMap[session.uid] = [Promise.resolve(session.author.username), Date.now()]
Expand All @@ -150,11 +165,15 @@ export default function apply(ctx: Context, config: ReceiverConfig = {}) {
session.channelName = channelName
channelMap[cid] = [Promise.resolve(channelName), timestamp]
}
Promise
.all([prepareChannelName, prepareGroupName, prepareAbstract].map(cb => cb(session, params, timestamp)))
.then(() => ctx.emit('chat/receive', params, session))
await Promise.all([prepareChannel, prepareGroup, prepareContent].map(cb => cb(session, params, timestamp)))
ctx.emit('chat/receive', params, session)
}

ctx.on('message', handleMessage)
ctx.before('send', handleMessage)
ctx.on('message', (session) => {
handleMessage(session)
})

ctx.before('send', (session) => {
handleMessage(session)
})
}
12 changes: 4 additions & 8 deletions packages/plugin-webui/client/components/chat-panel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ $padding: 1.5rem;
height: 100%;
.k-card-body {
padding: 1rem 0.5rem;
padding: 1rem 0;
display: flex;
flex-direction: column;
height: -webkit-fill-available;
Expand All @@ -77,25 +77,21 @@ $padding: 1.5rem;
}
.k-chat-footer {
padding: 0 0.5rem;
padding: 0 1rem;
}
.k-chat-message {
position: relative;
line-height: 1.5rem;
padding: 0 0.5rem;
padding: 0.25rem 1rem;
&:hover {
background-color: rgba(4, 4, 5, 0.2);
}
& + .k-chat-message {
margin-top: 0.5rem;
}
}
.k-input {
padding-top: 1rem;
margin-top: 1rem;
width: -webkit-fill-available;
}
}
Expand Down
Loading

0 comments on commit 2abdd45

Please sign in to comment.