Skip to content

Commit

Permalink
feat(zulip): add adapter-zulip
Browse files Browse the repository at this point in the history
  • Loading branch information
XxLittleCxX committed Aug 17, 2023
1 parent 1d7f04c commit 24efdb8
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 0 deletions.
2 changes: 2 additions & 0 deletions adapters/zulip/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.DS_Store
tsconfig.tsbuildinfo
35 changes: 35 additions & 0 deletions adapters/zulip/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "@satorijs/adapter-zulip",
"description": "Zulip Adapter for Satorijs",
"version": "1.0.0",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"files": [
"lib"
],
"author": "LittleC <[email protected]>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/satorijs/satori.git",
"directory": "adapters/zulip"
},
"bugs": {
"url": "https://github.com/satorijs/satori/issues"
},
"homepage": "https://koishi.chat/plugins/adapter/zulip.html",
"keywords": [
"bot",
"zulip",
"adapter",
"chatbot",
"satori"
],
"peerDependencies": {
"@satorijs/satori": "^2.4.0"
},
"dependencies": {
"marked": "^7.0.3"
},
"devDependencies": {}
}
51 changes: 51 additions & 0 deletions adapters/zulip/src/bot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Bot, Context, Logger, Quester, Schema, Universal } from '@satorijs/satori'

Check failure on line 1 in adapters/zulip/src/bot.ts

View workflow job for this annotation

GitHub Actions / lint

'Universal' is defined but never used
import { HttpPolling } from './polling'
import { Internal } from './internal'
import { ZulipMessageEncoder } from './message'
// @ts-ignore
import { version } from '../package.json'

export class ZulipBot extends Bot<ZulipBot.Config> {
static MessageEncoder = ZulipMessageEncoder
public http: Quester
public logger: Logger
public internal: Internal
constructor(ctx: Context, config: ZulipBot.Config) {
super(ctx, config)

this.platform = 'zulip'
this.http = ctx.http.extend({
headers: {
Authorization: `Basic ${Buffer.from(`${config.email}:${config.key}`).toString('base64')}`,
'content-type': 'application/x-www-form-urlencoded',
'user-agent': `Koishi/${version}`,
},
}).extend(config)
this.internal = new Internal(this.http)
this.logger = ctx.logger('zulip')

ctx.plugin(HttpPolling, this)
}

async initliaze() {
const { avatar_url, user_id, full_name } = await this.internal.getOwnUser()
this.selfId = user_id.toString()
this.username = full_name
this.avatar = avatar_url
}
}

export namespace ZulipBot {
export interface Config extends Bot.Config, Quester.Config {
email: string
key: string
}

export const Config: Schema<Config> = Schema.intersect([
Schema.object({
email: Schema.string(),
key: Schema.string(),
}),
Quester.createConfig(),
])
}
7 changes: 7 additions & 0 deletions adapters/zulip/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { ZulipBot } from './bot'

export * from './bot'
export * from './utils'
export * from './polling'

export default ZulipBot
32 changes: 32 additions & 0 deletions adapters/zulip/src/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Quester } from '@satorijs/satori'

export class Internal {
constructor(public http: Quester) { }

async register() {
const response = await this.http.post<{
queue_id: string
}>('/api/v1/register', {
fetch_event_types: `["message"]`,
})
return response
}

async events(params: {
queue_id: string
last_event_id: number
}) {
return this.http.get('/api/v1/events', {
params,
timeout: 60000,
})
}

getOwnUser() {
return this.http.get('/api/v1/users/me')
}

getMessage(id: string) {
return this.http.get(`/api/v1/messages/${id}`)
}
}
83 changes: 83 additions & 0 deletions adapters/zulip/src/message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { h, MessageEncoder } from '@satorijs/satori'
import { ZulipBot } from './bot'
import FormData from 'form-data'
import { by_stream_topic_url, encodeHashComponent } from './utils'

export const escape = (val: string) =>
val
.replace(/(?<!\u200b)[\*_~`\->[\](#!@]/g, '\u200b$&')
.replace(/^\s+/gm, (match) => Array(match.length + 1).join('&nbsp;'))

export const unescape = (val: string) =>
val
.replace(/^(&nbsp;)+/g, (match) => Array(match.length + 1).join(' '))
.replace(/\u200b([\*_~`\->[\](#!@])/g, '$1')

export class ZulipMessageEncoder extends MessageEncoder<ZulipBot> {
buffer: string = ''
async flush() {
if (!this.buffer.length) return
const form = new FormData()
form.append('type', this.session.isDirect ? 'private' : 'stream')
form.append('to', this.session.isDirect
? `[${this.options.session.userId}]` : this.session.guildId)
form.append('content', this.buffer)
if (!this.session.isDirect) form.append('topic', this.session.channelId)

await this.bot.http.post('/api/v1/messages', form, {
headers: form.getHeaders(),
})
}

async uploadMedia(element: h) {
const { attrs } = element
const { filename, data, mime } = await this.bot.ctx.http.file(attrs.url, attrs)
const form = new FormData()
// https://github.com/form-data/form-data/issues/468
const value = process.env.KOISHI_ENV === 'browser'
? new Blob([data], { type: mime })
: Buffer.from(data)
form.append('file', value, attrs.file || filename)
const response = await this.bot.http.post<{
uri: string
}>('/api/v1/user_uploads', form, {
headers: form.getHeaders(),
})
return [response.uri, filename]
}

async visit(element: h) {
const { type, attrs, children } = element
if (type === 'text') {
this.buffer += escape(attrs.content)
} else if (type === 'p') {
this.buffer += '\n'
await this.render(children)
this.buffer += '\n'
} else if (type === 'b' || type === 'strong') {
this.buffer += ` **`
await this.render(children)
this.buffer += `** `
} else if (type === 'i' || type === 'em') {
this.buffer += ` *`
await this.render(children)
this.buffer += `* `
} else if (type === 'a' && attrs.href) {
this.buffer += `[`
await this.render(children)
this.buffer += `](${encodeURI(attrs.href)})`
} else if (['audio', 'video', 'file', 'image'].includes(type)) {
const [uri, filename] = await this.uploadMedia(element)
this.buffer += `[${filename}](${encodeURI(uri)})\n`
} else if (type === 'quote') {
const quoteMsg = await this.bot.internal.getMessage(attrs.id)
const suffix = '/near/' + encodeHashComponent(attrs.id)
const path = by_stream_topic_url(Number(this.guildId), this.channelId) + suffix

this.buffer = `@_**${quoteMsg.message.sender_full_name}|${quoteMsg.message.sender_id}** [Said](${path}):\n`
+ '```quote\n' + quoteMsg.raw_content + '\n```\n\n' + this.buffer
} else if (type === 'message') {
await this.render(children)
}
}
}
42 changes: 42 additions & 0 deletions adapters/zulip/src/polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Adapter, Quester } from '@satorijs/satori'
import { ZulipBot } from './bot'
import { decodeMessage } from './utils'

export class HttpPolling extends Adapter.Client<ZulipBot> {
async start(bot: ZulipBot) {
await bot.initliaze()
const r = await bot.internal.register()
let last = -1
const polling = async () => {
if (bot.status === 'disconnect') {
return bot.offline()
}
try {
const updates = await bot.internal.events({
queue_id: r.queue_id,
last_event_id: last,
})
for (const e of updates.events) {
bot.logger.debug(require('util').inspect(e, false, null, true))

last = Math.max(last, e.id)
if (e.type === 'message') {
const session = await decodeMessage(bot, e.message)
if (session.selfId === session.userId) continue
if (session) bot.dispatch(session)
bot.logger.debug(require('util').inspect(session, false, null, true))
}
}
setTimeout(polling, 0)
} catch (e) {
if (!Quester.isAxiosError(e) || !e.response?.data) {
bot.logger.warn('failed to get updates. reason: %s', e.stack)
} else {
bot.logger.error(e.stack)
}
}
}
polling()
bot.logger.debug('listening updates %c', bot.sid)
}
}
79 changes: 79 additions & 0 deletions adapters/zulip/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Dict, h } from '@satorijs/satori'
import { ZulipBot } from './bot'
import marked from 'marked'
import { unescape } from './message'

const tagRegExp = /^<(\/?)([^!\s>/]+)([^>]*?)\s*(\/?)>$/

Check failure on line 6 in adapters/zulip/src/utils.ts

View workflow job for this annotation

GitHub Actions / lint

'tagRegExp' is assigned a value but never used

// internal_url.ts
const hashReplacements = new Map([
['%', '.'],
['(', '.28'],
[')', '.29'],
['.', '.2E'],
])
export function encodeHashComponent(str: string): string {
return encodeURIComponent(str).replace(/[%().]/g, (matched) => hashReplacements.get(matched)!)
}
export function by_stream_topic_url(
stream_id: number,
topic: string

Check failure on line 20 in adapters/zulip/src/utils.ts

View workflow job for this annotation

GitHub Actions / lint

Missing trailing comma
): string {
return `#narrow/stream/${encodeHashComponent(`${stream_id}-unknown`)}/topic/${encodeHashComponent(topic)}`
}

// @TODO u200b in quote's content?
function renderToken(token: marked.Token): h {
if (token.type === 'code') {
return h('text', { content: unescape(token.text) + '\n' })
} else if (token.type === 'paragraph') {
return h('p', render(token.tokens))
} else if (token.type === 'image') {
return h.image(token.href)
} else if (token.type === 'blockquote') {
return h('text', { content: token.text + '\n' })
} else if (token.type === 'text') {
return h('text', { content: token.text })
} else if (token.type === 'em') {
return h('em', render(token.tokens))
} else if (token.type === 'strong') {
return h('strong', render(token.tokens))
} else if (token.type === 'del') {
return h('del', render(token.tokens))
} else if (token.type === 'link') {
return h('a', { href: token.href }, render(token.tokens))
}
return h('text', { content: token.raw })
}

function render(tokens: marked.Token[]): h[] {
return tokens.map(renderToken).filter(Boolean)
}

export async function decodeMessage(bot: ZulipBot, message: Dict) {
const session = bot.session()
session.isDirect = message.type === 'private'
session.messageId = message.id.toString()
session.timestamp = message.timestamp * 1000
if (!session.isDirect) {
session.guildId = message.stream_id.toString()
session.channelId = message.subject
}
session.userId = message.sender_id.toString()
session.type = 'message'

const quoteMatch = message.content.match(/^@_\*\*\w+\|(\d+)\*\* \[.*\]\(.*\/near\/(\d+)\)/)
if (quoteMatch) {
const quoteMsg = await bot.internal.getMessage(quoteMatch[2])
quoteMsg.message.content = quoteMsg.raw_content
session.quote = await decodeMessage(bot, quoteMsg.message)
message.content = message.content.slice(message.content.indexOf('\n') + 1)
}

session.elements = render(marked.lexer(message.content))
if (session.elements?.[0]?.type === 'p') {
session.elements = session.elements[0].children
}

return session
}
10 changes: 10 additions & 0 deletions adapters/zulip/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"rootDir": "src",
},
"include": [
"src",
],
}

0 comments on commit 24efdb8

Please sign in to comment.