-
Notifications
You must be signed in to change notification settings - Fork 47
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
1d7f04c
commit 24efdb8
Showing
9 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
.DS_Store | ||
tsconfig.tsbuildinfo |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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": {} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { Bot, Context, Logger, Quester, Schema, Universal } from '@satorijs/satori' | ||
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(), | ||
]) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(' ')) | ||
|
||
export const unescape = (val: string) => | ||
val | ||
.replace(/^( )+/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) | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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*(\/?)>$/ | ||
|
||
// 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 | ||
): 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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
{ | ||
"extends": "../../tsconfig.base", | ||
"compilerOptions": { | ||
"outDir": "lib", | ||
"rootDir": "src", | ||
}, | ||
"include": [ | ||
"src", | ||
], | ||
} |