@meet-im/meet 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/monitor.ts ADDED
@@ -0,0 +1,197 @@
1
+ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk"
2
+ import { KeyedAsyncQueue } from "openclaw/plugin-sdk"
3
+ import type { ResolvedMeetAccount } from "./types.js"
4
+ import type { MsgContent } from "@meet-im/meet-bot-jssdk"
5
+ import type { QuoteMsgMap } from "./sdk-bridge.js"
6
+ import { resolveMeetAccount, listEnabledMeetAccounts } from "./accounts.js"
7
+ import { createMeetClient, closeMeetClient, closeAllMeetClients, getPollingOptions } from "./client.js"
8
+ import { handleMeetMessage } from "./bot.js"
9
+ import { msgContentToContext } from "./sdk-bridge.js"
10
+
11
+ export type MonitorMeetOpts = {
12
+ config?: ClawdbotConfig
13
+ runtime?: RuntimeEnv
14
+ abortSignal?: AbortSignal
15
+ accountId?: string
16
+ }
17
+
18
+ export async function monitorMeetProvider(opts: MonitorMeetOpts = {}): Promise<void> {
19
+ const cfg = opts.config
20
+ if (!cfg) {
21
+ throw new Error("Config is required for Meet monitor")
22
+ }
23
+
24
+ const log = opts.runtime?.log ?? console.log
25
+ const error = opts.runtime?.error ?? console.error
26
+
27
+ if (opts.accountId) {
28
+ const account = resolveMeetAccount({ cfg, accountId: opts.accountId })
29
+ if (!account.enabled || !account.configured) {
30
+ throw new Error(`Meet account "${opts.accountId}" not configured or disabled`)
31
+ }
32
+ return monitorSingleAccount({
33
+ cfg,
34
+ account,
35
+ runtime: opts.runtime,
36
+ abortSignal: opts.abortSignal,
37
+ })
38
+ }
39
+
40
+ const accounts = listEnabledMeetAccounts(cfg)
41
+ if (accounts.length === 0) {
42
+ throw new Error("No enabled Meet accounts configured")
43
+ }
44
+
45
+ log(`starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`)
46
+
47
+ await Promise.all(
48
+ accounts.map((account) =>
49
+ monitorSingleAccount({
50
+ cfg,
51
+ account,
52
+ runtime: opts.runtime,
53
+ abortSignal: opts.abortSignal,
54
+ }),
55
+ ),
56
+ )
57
+ }
58
+
59
+ async function monitorSingleAccount(params: {
60
+ cfg: ClawdbotConfig
61
+ account: ResolvedMeetAccount
62
+ runtime?: RuntimeEnv
63
+ abortSignal?: AbortSignal
64
+ }): Promise<void> {
65
+ const { cfg, account, runtime, abortSignal } = params
66
+ const { accountId } = account
67
+ const log = runtime?.log ?? console.log
68
+ const error = runtime?.error ?? console.error
69
+
70
+ const pollTimeoutMs = account.config.pollTimeout ?? 30000
71
+ log(`[${accountId}]: starting with pollTimeout=${pollTimeoutMs}ms`)
72
+
73
+ const bot = createMeetClient(account)
74
+ const botUserId = extractBotUserId(account.apiToken ?? "")
75
+ const groupHistories = new Map<string, HistoryEntry[]>()
76
+ const messageQueue = new KeyedAsyncQueue()
77
+
78
+ return new Promise((resolve, reject) => {
79
+ let isCleaningUp = false
80
+
81
+ const cleanup = () => {
82
+ if (isCleaningUp) return
83
+ isCleaningUp = true
84
+ bot.stopPolling()
85
+ closeMeetClient(accountId)
86
+ }
87
+
88
+ const handleAbort = () => {
89
+ log(`[${accountId}]: abort signal received, stopping`)
90
+ cleanup()
91
+ resolve()
92
+ }
93
+
94
+ if (abortSignal?.aborted) {
95
+ cleanup()
96
+ resolve()
97
+ return
98
+ }
99
+
100
+ abortSignal?.addEventListener("abort", handleAbort, { once: true })
101
+
102
+ // TODO: SDK 更新后移除类型断言
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ bot.on("message", ((data: { message: MsgContent; quoteMsgMap: QuoteMsgMap }) => {
105
+ const { message: msg, quoteMsgMap } = data
106
+ let queueKey: string
107
+ try {
108
+ const ctx = msgContentToContext(msg, botUserId, quoteMsgMap)
109
+ queueKey = ctx.chatId
110
+ } catch {
111
+ error(`[${accountId}]: failed to parse message for queue key`)
112
+ return
113
+ }
114
+
115
+ const tailMap = messageQueue.getTailMapForTesting()
116
+ const queueSize = tailMap.size
117
+ const pendingInQueue = tailMap.has(queueKey)
118
+ log(
119
+ `[${accountId}]: enqueue message to queue=${queueKey}, queues=${queueSize}, pending=${pendingInQueue}`,
120
+ )
121
+
122
+ messageQueue.enqueue(
123
+ queueKey,
124
+ async () => {
125
+ try {
126
+ await handleMeetMessage({
127
+ cfg,
128
+ msg,
129
+ botUserId,
130
+ runtime,
131
+ accountId,
132
+ account,
133
+ bot,
134
+ groupHistories,
135
+ quoteMsgMap,
136
+ })
137
+ } catch (err) {
138
+ error(`[${accountId}]: error handling message: ${String(err)}`)
139
+ }
140
+ },
141
+ {
142
+ onEnqueue: () => {
143
+ const size = messageQueue.getTailMapForTesting().size
144
+ log(`[${accountId}]: queue=${queueKey} enqueued, total_queues=${size}`)
145
+ },
146
+ onSettle: () => {
147
+ const size = messageQueue.getTailMapForTesting().size
148
+ log(`[${accountId}]: queue=${queueKey} settled, total_queues=${size}`)
149
+ },
150
+ },
151
+ )
152
+ }) as any)
153
+
154
+ bot.on("error", (err) => {
155
+ error(`[${accountId}]: polling error: ${String(err)}`)
156
+ })
157
+
158
+ bot.on("polling_start", () => {
159
+ log(`[${accountId}]: polling started`)
160
+ })
161
+
162
+ bot.on("polling_stop", () => {
163
+ if (!isCleaningUp) {
164
+ log(`[${accountId}]: polling stopped`)
165
+ }
166
+ })
167
+
168
+ const pollingOptions = getPollingOptions(account)
169
+
170
+ bot.startPolling(pollingOptions)
171
+ .then(() => {
172
+ log(`[${accountId}]: polling completed`)
173
+ cleanup()
174
+ abortSignal?.removeEventListener("abort", handleAbort)
175
+ resolve()
176
+ })
177
+ .catch((err) => {
178
+ error(`[${accountId}]: polling failed: ${String(err)}`)
179
+ cleanup()
180
+ abortSignal?.removeEventListener("abort", handleAbort)
181
+ reject(err)
182
+ })
183
+ })
184
+ }
185
+
186
+ export function stopMeetMonitor(accountId?: string): void {
187
+ if (accountId) {
188
+ closeMeetClient(accountId)
189
+ } else {
190
+ closeAllMeetClients()
191
+ }
192
+ }
193
+
194
+ function extractBotUserId(token: string): string {
195
+ const parts = token.split(":")
196
+ return parts[0] ?? ""
197
+ }
@@ -0,0 +1,35 @@
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
2
+ import { getMeetRuntime } from "./runtime.js";
3
+ import { sendMessageMeet, sendMediaMeet } from "./send.js";
4
+
5
+ export const meetOutbound: ChannelOutboundAdapter = {
6
+ deliveryMode: "direct",
7
+ chunker: (text, limit) => {
8
+ const runtime = getMeetRuntime();
9
+ return runtime.channel.text.chunkText(text, limit);
10
+ },
11
+ chunkerMode: "text",
12
+ textChunkLimit: 4000,
13
+ sendText: async ({ cfg, to, text, accountId }) => {
14
+ const result = await sendMessageMeet({ cfg, to, text, accountId });
15
+ return { channel: "meet", ...result };
16
+ },
17
+ sendMedia: async ({
18
+ cfg,
19
+ to,
20
+ text,
21
+ mediaUrl,
22
+ mediaLocalRoots,
23
+ accountId,
24
+ }) => {
25
+ const result = await sendMediaMeet({
26
+ cfg,
27
+ to,
28
+ text: text || undefined,
29
+ mediaUrl,
30
+ mediaLocalRoots,
31
+ accountId,
32
+ });
33
+ return { channel: "meet", ...result };
34
+ },
35
+ };
package/src/policy.ts ADDED
@@ -0,0 +1,131 @@
1
+ import type { MeetConfig, MeetGroupConfig } from "./types.js"
2
+
3
+ export function resolveMeetAllowlistMatch(params: {
4
+ allowFrom: Array<string | number>
5
+ senderId: string
6
+ senderName?: string
7
+ }): { allowed: boolean } {
8
+ const { allowFrom, senderId } = params
9
+
10
+ if (allowFrom.length === 0) {
11
+ return { allowed: false }
12
+ }
13
+
14
+ if (allowFrom.includes("*")) {
15
+ return { allowed: true }
16
+ }
17
+
18
+ const normalizedSenderId = senderId.trim().toLowerCase()
19
+ const normalizedAllowFrom = allowFrom.map((entry) =>
20
+ String(entry).trim().toLowerCase(),
21
+ )
22
+
23
+ if (normalizedAllowFrom.includes(normalizedSenderId)) {
24
+ return { allowed: true }
25
+ }
26
+
27
+ return { allowed: false }
28
+ }
29
+
30
+ export function resolveMeetGroupPolicy(params: {
31
+ groupPolicy?: "open" | "allowlist" | "disabled"
32
+ groupAllowFrom: Array<string | number>
33
+ chatId: string
34
+ groups?: Record<string, MeetGroupConfig>
35
+ }): { allowed: boolean } {
36
+ const { groupPolicy = "allowlist", groupAllowFrom, chatId, groups } = params
37
+
38
+ if (groupPolicy === "disabled") {
39
+ return { allowed: false }
40
+ }
41
+
42
+ // Normalize chatId for matching (strip "channel:" prefix if present)
43
+ const normalizedChatId = chatId.replace(/^channel:/, "")
44
+ const groupConfig = groups?.[chatId] ?? groups?.[normalizedChatId]
45
+
46
+ if (groupPolicy === "open") {
47
+ if (groupConfig?.enabled === false) {
48
+ return { allowed: false }
49
+ }
50
+ return { allowed: true }
51
+ }
52
+
53
+ // groupPolicy === "allowlist"
54
+
55
+ // Check if group is explicitly configured in groups
56
+ if (groupConfig && groupConfig.enabled !== false) {
57
+ return { allowed: true }
58
+ }
59
+
60
+ if (groupConfig?.enabled === false) {
61
+ return { allowed: false }
62
+ }
63
+
64
+ if (groupAllowFrom.length === 0) {
65
+ return { allowed: false }
66
+ }
67
+
68
+ if (groupAllowFrom.includes("*")) {
69
+ return { allowed: true }
70
+ }
71
+
72
+ const normalizedAllowFrom = groupAllowFrom.map((entry) =>
73
+ String(entry).trim().toLowerCase(),
74
+ )
75
+
76
+ // Match both full chatId and normalized chatId
77
+ const fullChatIdLower = chatId.trim().toLowerCase()
78
+ const shortChatIdLower = normalizedChatId.trim().toLowerCase()
79
+
80
+ if (normalizedAllowFrom.includes(fullChatIdLower) ||
81
+ normalizedAllowFrom.includes(shortChatIdLower)) {
82
+ return { allowed: true }
83
+ }
84
+
85
+ return { allowed: false }
86
+ }
87
+
88
+ export function resolveMeetGroupConfig(params: {
89
+ meetConfig: MeetConfig
90
+ chatId: string
91
+ }): {
92
+ requireMention: boolean
93
+ systemPrompt?: string
94
+ groupConfig?: MeetGroupConfig
95
+ } {
96
+ const { meetConfig, chatId } = params
97
+ const normalizedChatId = chatId.replace(/^channel:/, "")
98
+ const groupConfig = meetConfig.groups?.[chatId] ?? meetConfig.groups?.[normalizedChatId]
99
+
100
+ return {
101
+ requireMention: groupConfig?.requireMention ?? meetConfig.requireMention ?? true,
102
+ systemPrompt: groupConfig?.systemPrompt ?? meetConfig.systemPrompt,
103
+ groupConfig,
104
+ }
105
+ }
106
+
107
+ export function resolveMeetGroupUserPolicy(params: {
108
+ groupConfig?: MeetGroupConfig
109
+ senderId: string
110
+ }): { allowed: boolean } {
111
+ const { groupConfig, senderId } = params
112
+
113
+ if (!groupConfig?.users || groupConfig.users.length === 0) {
114
+ return { allowed: true }
115
+ }
116
+
117
+ if (groupConfig.users.includes("*")) {
118
+ return { allowed: true }
119
+ }
120
+
121
+ const normalizedSenderId = senderId.trim().toLowerCase()
122
+ const normalizedUsers = groupConfig.users.map((entry) =>
123
+ String(entry).trim().toLowerCase(),
124
+ )
125
+
126
+ if (normalizedUsers.includes(normalizedSenderId)) {
127
+ return { allowed: true }
128
+ }
129
+
130
+ return { allowed: false }
131
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { RuntimeEnv } from "openclaw/plugin-sdk"
2
+ import type { ResolvedMeetAccount } from "./types.js"
3
+ import { getMeetClient } from "./client.js"
4
+
5
+ let _logger: RuntimeEnv | null = null
6
+
7
+ export function setProbeLogger(logger: RuntimeEnv): void {
8
+ _logger = logger
9
+ }
10
+
11
+ function log(message: string): void {
12
+ if (_logger) {
13
+ _logger.log(message)
14
+ } else {
15
+ console.log(message)
16
+ }
17
+ }
18
+
19
+ export type MeetProbeResult = {
20
+ ok: boolean
21
+ error?: string
22
+ botId?: string
23
+ }
24
+
25
+ const probeCache = new Map<string, { result: MeetProbeResult; timestamp: number }>()
26
+ const PROBE_CACHE_TTL_MS = 5 * 60 * 1000
27
+
28
+ export async function probeMeet(
29
+ account: ResolvedMeetAccount,
30
+ ): Promise<MeetProbeResult> {
31
+ if (!account.configured) {
32
+ return { ok: false, error: "Not configured" }
33
+ }
34
+
35
+ const cacheKey = account.accountId
36
+ const cached = probeCache.get(cacheKey)
37
+ if (cached && Date.now() - cached.timestamp < PROBE_CACHE_TTL_MS) {
38
+ return cached.result
39
+ }
40
+
41
+ try {
42
+ // 尝试获取 bot 实例或创建
43
+ let bot = getMeetClient(account.accountId)
44
+ if (!bot) {
45
+ const { createMeetClient } = await import("./client.js")
46
+ bot = createMeetClient(account)
47
+ }
48
+
49
+ // 尝试获取一次更新来验证连接
50
+ const updates = await bot.getUpdates({ limit: 1, timeout: 1 })
51
+
52
+ const result: MeetProbeResult = {
53
+ ok: true,
54
+ // 从 token 中提取 bot id (格式: bot_id:secret)
55
+ botId: account.apiToken?.split(":")[0],
56
+ }
57
+
58
+ if (updates && updates.length > 0) {
59
+ log(`[${account.accountId}] probe: received ${updates.length} update(s)`)
60
+ }
61
+
62
+ probeCache.set(cacheKey, { result, timestamp: Date.now() })
63
+ return result
64
+ } catch (error) {
65
+ const errorMessage = error instanceof Error ? error.message : String(error)
66
+ return { ok: false, error: errorMessage }
67
+ }
68
+ }
69
+
70
+ export function clearProbeCache(accountId?: string): void {
71
+ if (accountId) {
72
+ probeCache.delete(accountId)
73
+ } else {
74
+ probeCache.clear()
75
+ }
76
+ }
@@ -0,0 +1,130 @@
1
+ import type { MeetBot } from "@meet-im/meet-bot-jssdk"
2
+ import type { ClawdbotConfig, RuntimeEnv, ReplyPayload } from "openclaw/plugin-sdk"
3
+ import { createReplyPrefixContext } from "openclaw/plugin-sdk"
4
+ import { getMeetRuntime } from "./runtime.js"
5
+ import { sendMessageMeet } from "./send.js"
6
+
7
+ /**
8
+ * 匹配完整的 mention 格式: <@userId>
9
+ */
10
+ const COMPLETE_MENTION_REGEX = /<@(-?\d+)>/g
11
+
12
+ /**
13
+ * 匹配末尾不完整的 mention 开始: <@ 或 <@xxx (没有闭合的 >)
14
+ */
15
+ const INCOMPLETE_MENTION_START = /<@(-?\d*)$/
16
+
17
+ /**
18
+ * 匹配开头不完整的 mention 结束: xxx> (没有开始的 <@)
19
+ */
20
+ const INCOMPLETE_MENTION_END = /^(-?\d+)>/
21
+
22
+ /**
23
+ * 保护 mention 格式在分片后不被截断
24
+ *
25
+ * 当文本被分片后,`<@userId>` 格式可能被截断成:
26
+ * - 第一个 chunk 末尾: `<@123`
27
+ * - 第二个 chunk 开头: `456>`
28
+ *
29
+ * 此函数检测并修复这种情况,确保 mention 格式完整。
30
+ */
31
+ export function protectMentionsInChunks(chunks: string[]): string[] {
32
+ if (chunks.length <= 1) {
33
+ return chunks
34
+ }
35
+
36
+ const result: string[] = []
37
+ let pendingSuffix = ""
38
+
39
+ for (let i = 0; i < chunks.length; i++) {
40
+ let chunk = pendingSuffix + chunks[i]
41
+ pendingSuffix = ""
42
+
43
+ // 检测末尾是否有不完整的 mention 开始 (<@ 或 <@xxx)
44
+ const startMatch = chunk.match(INCOMPLETE_MENTION_START)
45
+ if (startMatch) {
46
+ // 检查下一个 chunk 是否有对应的结束部分
47
+ const nextChunk = chunks[i + 1]
48
+ if (nextChunk !== undefined) {
49
+ const endMatch = nextChunk.match(INCOMPLETE_MENTION_END)
50
+ if (endMatch) {
51
+ // 将不完整的部分移到下一个 chunk 前面
52
+ const splitIndex = chunk.lastIndexOf("<@")
53
+ if (splitIndex > 0) {
54
+ result.push(chunk.slice(0, splitIndex))
55
+ pendingSuffix = chunk.slice(splitIndex)
56
+ continue
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ result.push(chunk)
63
+ }
64
+
65
+ return result
66
+ }
67
+
68
+ export type CreateMeetReplyDispatcherOpts = {
69
+ cfg: ClawdbotConfig
70
+ agentId: string
71
+ runtime: RuntimeEnv
72
+ chatId: string
73
+ replyToMessageId?: string
74
+ accountId: string
75
+ bot: MeetBot
76
+ botUserId: string
77
+ }
78
+
79
+ export async function createMeetReplyDispatcher(
80
+ opts: CreateMeetReplyDispatcherOpts,
81
+ ) {
82
+ const { cfg, agentId, chatId, replyToMessageId, accountId } = opts
83
+ const core = getMeetRuntime()
84
+
85
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "meet", accountId, {
86
+ fallbackLimit: 4000,
87
+ })
88
+
89
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "meet", accountId)
90
+
91
+ const prefixContext = createReplyPrefixContext({ cfg, agentId })
92
+
93
+ const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
94
+ responsePrefix: prefixContext.responsePrefix,
95
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
96
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
97
+ onReplyStart: async () => {
98
+ },
99
+ deliver: async (payload: ReplyPayload, info) => {
100
+ const text = payload.text ?? ""
101
+ if (!text.trim()) {
102
+ return
103
+ }
104
+
105
+ const rawChunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)
106
+ const protectedChunks = protectMentionsInChunks(rawChunks)
107
+
108
+ for (const chunk of protectedChunks) {
109
+ await sendMessageMeet({
110
+ cfg,
111
+ to: chatId,
112
+ text: chunk,
113
+ accountId,
114
+ replyToMessageId,
115
+ })
116
+ }
117
+ },
118
+ onError: async (error, info) => {
119
+ opts.runtime.error?.(`meet[${accountId}] ${info.kind} reply failed: ${String(error)}`)
120
+ },
121
+ onIdle: async () => {
122
+ },
123
+ })
124
+
125
+ return {
126
+ dispatcher,
127
+ replyOptions,
128
+ markDispatchIdle,
129
+ }
130
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+
3
+ let meetRuntime: OpenClawPluginApi["runtime"] | null = null
4
+
5
+ export function setMeetRuntime(runtime: OpenClawPluginApi["runtime"]): void {
6
+ meetRuntime = runtime
7
+ }
8
+
9
+ export function getMeetRuntime(): OpenClawPluginApi["runtime"] {
10
+ if (!meetRuntime) {
11
+ throw new Error("Meet runtime not initialized. Call setMeetRuntime first.")
12
+ }
13
+ return meetRuntime
14
+ }