typeclaw 0.32.1 → 0.34.0
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/auth.schema.json +66 -0
- package/cron.schema.json +26 -2
- package/package.json +1 -1
- package/secrets.schema.json +66 -0
- package/src/agent/index.ts +7 -3
- package/src/agent/session-origin.ts +17 -0
- package/src/agent/subagent-completion-reminder.ts +14 -1
- package/src/agent/subagent-drain.ts +2 -0
- package/src/agent/subagents.ts +21 -7
- package/src/agent/tools/channel-disengage.ts +66 -0
- package/src/agent/tools/channel-log.ts +3 -2
- package/src/agent/tools/spawn-subagent.ts +25 -5
- package/src/agent/tools/subagent-output.ts +13 -1
- package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
- package/src/bundled-plugins/memory/memory-logger.ts +7 -0
- package/src/bundled-plugins/researcher/researcher.ts +14 -11
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
- package/src/channels/adapters/line-channel-resolver.ts +129 -0
- package/src/channels/adapters/line-classify.ts +80 -0
- package/src/channels/adapters/line-format.ts +11 -0
- package/src/channels/adapters/line.ts +350 -0
- package/src/channels/engagement.ts +4 -2
- package/src/channels/manager.ts +65 -6
- package/src/channels/router.ts +186 -41
- package/src/channels/schema.ts +6 -1
- package/src/cli/channel.ts +112 -1
- package/src/cli/cron.ts +22 -4
- package/src/cli/init.ts +267 -82
- package/src/cli/model.ts +5 -1
- package/src/cli/oauth-callbacks.ts +5 -4
- package/src/cli/provider.ts +41 -10
- package/src/config/providers.ts +366 -7
- package/src/cron/consumer.ts +33 -0
- package/src/cron/count-state.ts +208 -0
- package/src/cron/index.ts +4 -17
- package/src/cron/list.ts +24 -6
- package/src/cron/scheduler.ts +84 -9
- package/src/cron/schema.ts +100 -13
- package/src/doctor/channel-checks.ts +28 -0
- package/src/hostd/daemon.ts +14 -6
- package/src/hostd/protocol.ts +6 -2
- package/src/init/gitignore.ts +1 -1
- package/src/init/index.ts +36 -3
- package/src/init/line-auth.ts +98 -0
- package/src/init/models-dev.ts +3 -0
- package/src/init/run-owner-claim.ts +1 -0
- package/src/init/validate-api-key.ts +15 -0
- package/src/inspect/label.ts +1 -0
- package/src/permissions/match-rule.ts +28 -12
- package/src/permissions/resolve.ts +8 -1
- package/src/role-claim/match-rule.ts +5 -1
- package/src/run/index.ts +41 -4
- package/src/secrets/line-store.ts +112 -0
- package/src/secrets/oauth-xai.ts +342 -0
- package/src/secrets/schema.ts +25 -0
- package/src/secrets/storage.ts +2 -0
- package/src/server/index.ts +17 -4
- package/src/shared/protocol.ts +4 -1
- package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
- package/src/skills/typeclaw-channels/SKILL.md +153 -0
- package/src/skills/typeclaw-config/SKILL.md +54 -184
- package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
- package/src/skills/typeclaw-cron/SKILL.md +68 -14
- package/src/skills/typeclaw-permissions/SKILL.md +3 -3
- package/typeclaw.schema.json +185 -3
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { LineChat, LineClient } from 'agent-messenger/line'
|
|
2
|
+
|
|
3
|
+
import type { ChannelKey, ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000
|
|
6
|
+
|
|
7
|
+
// LINE's chat list is fetched in a bounded page; there is no `{ all: true }`
|
|
8
|
+
// equivalent the way KakaoTalk has. This cap is generous for a personal
|
|
9
|
+
// account but keeps the GETCHATS payload bounded.
|
|
10
|
+
const CHAT_FETCH_LIMIT = 500
|
|
11
|
+
|
|
12
|
+
export type LineWorkspace = '@line-dm' | '@line-group' | '@line-square'
|
|
13
|
+
|
|
14
|
+
// `user` is a 1:1 DM; `group` and `room` are both multi-party invite chats
|
|
15
|
+
// (LINE's legacy "room" vs modern "group" distinction is immaterial to
|
|
16
|
+
// engagement, so they share a bucket); `square` is an OpenChat-style public
|
|
17
|
+
// community, kept separate because it is the most public surface and least-
|
|
18
|
+
// privilege rules want to target it on its own.
|
|
19
|
+
export function lineWorkspaceForType(type: LineChat['type']): LineWorkspace {
|
|
20
|
+
if (type === 'user') return '@line-dm'
|
|
21
|
+
if (type === 'square') return '@line-square'
|
|
22
|
+
return '@line-group'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LineChatLookupValue = {
|
|
26
|
+
workspace: LineWorkspace
|
|
27
|
+
isDm: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type LineChannelResolver = {
|
|
31
|
+
resolve: ChannelNameResolver
|
|
32
|
+
lookupChat: (chatId: string) => LineChatLookupValue | null
|
|
33
|
+
refresh: () => Promise<void>
|
|
34
|
+
// Register a chat learned from an inbound push event when `refresh()` did
|
|
35
|
+
// not surface it (a new chat that hasn't propagated to GETCHATS yet).
|
|
36
|
+
// Provisional entries default to @line-group — the strictest multi-party
|
|
37
|
+
// bucket — so allow-rule enforcement stays conservative until the next real
|
|
38
|
+
// refresh upgrades the entry to its authoritative type.
|
|
39
|
+
ingestProvisional: (chatId: string) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type LineChannelResolverOptions = {
|
|
43
|
+
client: Pick<LineClient, 'getChats'>
|
|
44
|
+
now?: () => number
|
|
45
|
+
ttlMs?: number
|
|
46
|
+
logger?: { warn: (msg: string) => void }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Entry = {
|
|
50
|
+
workspace: LineWorkspace
|
|
51
|
+
isDm: boolean
|
|
52
|
+
chatName: string | null
|
|
53
|
+
expiresAt: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createLineChannelResolver(options: LineChannelResolverOptions): LineChannelResolver {
|
|
57
|
+
const now = options.now ?? Date.now
|
|
58
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS
|
|
59
|
+
const cache = new Map<string, Entry>()
|
|
60
|
+
let inflight: Promise<void> | null = null
|
|
61
|
+
|
|
62
|
+
const refresh = async (): Promise<void> => {
|
|
63
|
+
if (inflight !== null) {
|
|
64
|
+
await inflight
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
const promise = loadAll().finally(() => {
|
|
68
|
+
inflight = null
|
|
69
|
+
})
|
|
70
|
+
inflight = promise
|
|
71
|
+
await promise
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const loadAll = async (): Promise<void> => {
|
|
75
|
+
try {
|
|
76
|
+
const chats = await options.client.getChats({ limit: CHAT_FETCH_LIMIT })
|
|
77
|
+
const expiresAt = now() + ttlMs
|
|
78
|
+
for (const chat of chats) ingest(chat, expiresAt)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
options.logger?.warn(`[line] channel resolver refresh failed: ${describe(err)}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ingest = (chat: LineChat, expiresAt: number): void => {
|
|
85
|
+
const workspace = lineWorkspaceForType(chat.type)
|
|
86
|
+
cache.set(chat.chat_id, {
|
|
87
|
+
workspace,
|
|
88
|
+
isDm: chat.type === 'user',
|
|
89
|
+
chatName: chat.display_name === '' ? null : chat.display_name,
|
|
90
|
+
expiresAt,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const resolve: ChannelNameResolver = async (key: ChannelKey): Promise<ResolvedChannelNames> => {
|
|
95
|
+
const entry = cache.get(key.chat)
|
|
96
|
+
if (entry === undefined || entry.expiresAt <= now()) await refresh()
|
|
97
|
+
const fresh = cache.get(key.chat)
|
|
98
|
+
if (fresh === undefined) return {}
|
|
99
|
+
const result: ResolvedChannelNames = {}
|
|
100
|
+
if (fresh.chatName !== null && fresh.chatName !== '') result.chatName = fresh.chatName
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Sync lookup. Returns null when the entry is missing OR stale; callers MUST
|
|
105
|
+
// treat null as "refresh needed", not "unknown forever" — the adapter awaits
|
|
106
|
+
// refresh() and re-checks before dropping a message as unknown_chat.
|
|
107
|
+
const lookupChat = (chatId: string): LineChatLookupValue | null => {
|
|
108
|
+
const entry = cache.get(chatId)
|
|
109
|
+
if (entry === undefined || entry.expiresAt <= now()) return null
|
|
110
|
+
return { workspace: entry.workspace, isDm: entry.isDm }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const ingestProvisional = (chatId: string): void => {
|
|
114
|
+
const existing = cache.get(chatId)
|
|
115
|
+
if (existing !== undefined && existing.expiresAt > now()) return
|
|
116
|
+
cache.set(chatId, {
|
|
117
|
+
workspace: '@line-group',
|
|
118
|
+
isDm: false,
|
|
119
|
+
chatName: null,
|
|
120
|
+
expiresAt: now() + ttlMs,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { resolve, lookupChat, refresh, ingestProvisional }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function describe(err: unknown): string {
|
|
128
|
+
return err instanceof Error ? err.message : String(err)
|
|
129
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { LinePushMessageEvent } from 'agent-messenger/line'
|
|
2
|
+
|
|
3
|
+
import { matchesAnyAlias } from '@/channels/engagement'
|
|
4
|
+
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
5
|
+
import type { InboundMessage } from '@/channels/types'
|
|
6
|
+
|
|
7
|
+
export type InboundDropReason = 'self_author' | 'empty_text' | 'unknown_chat' | 'pre_connect'
|
|
8
|
+
|
|
9
|
+
export type InboundClassification =
|
|
10
|
+
| { kind: 'drop'; reason: InboundDropReason }
|
|
11
|
+
| { kind: 'route'; payload: InboundMessage }
|
|
12
|
+
|
|
13
|
+
export type LineChatLookup = (chatId: string) => {
|
|
14
|
+
workspace: '@line-dm' | '@line-group' | '@line-square'
|
|
15
|
+
isDm: boolean
|
|
16
|
+
} | null
|
|
17
|
+
|
|
18
|
+
export type LineInboundContext = {
|
|
19
|
+
selfUserId: string | null
|
|
20
|
+
lookupChat: LineChatLookup
|
|
21
|
+
selfAliases?: readonly string[]
|
|
22
|
+
// LINE push events lack `author_name`, so the adapter resolves it (best
|
|
23
|
+
// effort) and passes it here; falls back to the raw author id.
|
|
24
|
+
authorName?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function classifyInbound(
|
|
28
|
+
event: LinePushMessageEvent,
|
|
29
|
+
_config: ChannelAdapterConfig,
|
|
30
|
+
context: LineInboundContext,
|
|
31
|
+
): InboundClassification {
|
|
32
|
+
if (context.selfUserId === null) {
|
|
33
|
+
return { kind: 'drop', reason: 'pre_connect' }
|
|
34
|
+
}
|
|
35
|
+
if (event.author_id === context.selfUserId) {
|
|
36
|
+
return { kind: 'drop', reason: 'self_author' }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const text = event.text ?? ''
|
|
40
|
+
if (text === '') return { kind: 'drop', reason: 'empty_text' }
|
|
41
|
+
|
|
42
|
+
const chatInfo = context.lookupChat(event.chat_id)
|
|
43
|
+
if (chatInfo === null) {
|
|
44
|
+
return { kind: 'drop', reason: 'unknown_chat' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// LINE has no native @-mention the push protocol surfaces. Like KakaoTalk,
|
|
48
|
+
// mention-equivalent engagement comes solely from plain-text alias matching,
|
|
49
|
+
// which the engagement layer ranks alongside an explicit mention.
|
|
50
|
+
const aliasMatched = matchesAnyAlias(text, context.selfAliases ?? [])
|
|
51
|
+
const authorName = context.authorName ?? event.author_id
|
|
52
|
+
|
|
53
|
+
// LINE's `sent_at` is an ISO-ish string (vs KakaoTalk's Unix seconds). The
|
|
54
|
+
// contract wants ms since epoch; a malformed timestamp degrades to 0
|
|
55
|
+
// ("unknown") so the formatter omits the time prefix rather than stamping a
|
|
56
|
+
// wrong clock.
|
|
57
|
+
const parsed = Date.parse(event.sent_at)
|
|
58
|
+
const ts = Number.isNaN(parsed) ? 0 : parsed
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
kind: 'route',
|
|
62
|
+
payload: {
|
|
63
|
+
adapter: 'line',
|
|
64
|
+
workspace: chatInfo.workspace,
|
|
65
|
+
chat: event.chat_id,
|
|
66
|
+
thread: null,
|
|
67
|
+
text,
|
|
68
|
+
externalMessageId: event.message_id,
|
|
69
|
+
authorId: event.author_id,
|
|
70
|
+
authorName,
|
|
71
|
+
authorIsBot: false,
|
|
72
|
+
isBotMention: aliasMatched,
|
|
73
|
+
replyToBotMessageId: null,
|
|
74
|
+
mentionsOthers: false,
|
|
75
|
+
replyToOtherMessageId: null,
|
|
76
|
+
isDm: chatInfo.isDm,
|
|
77
|
+
ts,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { toKakaoPlainText } from './kakaotalk-format'
|
|
2
|
+
|
|
3
|
+
// LINE chat renders no rich text, exactly like KakaoTalk's LOCO surface:
|
|
4
|
+
// `**bold**`, `### headings`, `| tables |`, and fenced code blocks all show
|
|
5
|
+
// their literal markers. The markdown-stripping rules are identical, so this
|
|
6
|
+
// reuses the KakaoTalk stripper rather than maintaining a second copy that
|
|
7
|
+
// would drift. If LINE ever grows a formatting quirk KakaoTalk lacks, fork the
|
|
8
|
+
// implementation here.
|
|
9
|
+
export function toLinePlainText(input: string): string {
|
|
10
|
+
return toKakaoPlainText(input)
|
|
11
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LineClient as RealLineClient,
|
|
3
|
+
LineListener as RealLineListener,
|
|
4
|
+
type LineAccountCredentials,
|
|
5
|
+
type LineChat,
|
|
6
|
+
type LineConfig,
|
|
7
|
+
type LineListenerEventMap,
|
|
8
|
+
type LineMessage,
|
|
9
|
+
type LineProfile,
|
|
10
|
+
type LinePushMessageEvent,
|
|
11
|
+
type LineSendResult,
|
|
12
|
+
} from 'agent-messenger/line'
|
|
13
|
+
|
|
14
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
15
|
+
import type { ChannelAdapterConfig } from '@/channels/schema'
|
|
16
|
+
import type {
|
|
17
|
+
ChannelHistoryMessage,
|
|
18
|
+
FetchHistoryArgs,
|
|
19
|
+
FetchHistoryResult,
|
|
20
|
+
HistoryCallback,
|
|
21
|
+
OutboundCallback,
|
|
22
|
+
OutboundMessage,
|
|
23
|
+
ResolvedChannelNames,
|
|
24
|
+
SendResult,
|
|
25
|
+
} from '@/channels/types'
|
|
26
|
+
|
|
27
|
+
import { createLineChannelResolver } from './line-channel-resolver'
|
|
28
|
+
import { classifyInbound } from './line-classify'
|
|
29
|
+
import { toLinePlainText } from './line-format'
|
|
30
|
+
|
|
31
|
+
// Structural duck-type of the upstream LineClient class. Declaring this as an
|
|
32
|
+
// interface (rather than reusing the nominal class type) lets test fakes
|
|
33
|
+
// satisfy the public surface without inheriting the class's private fields.
|
|
34
|
+
// The cast on the const below bridges the runtime class onto this interface.
|
|
35
|
+
export interface LineClient {
|
|
36
|
+
login(credentials?: LineAccountCredentials): Promise<this>
|
|
37
|
+
getProfile(): Promise<LineProfile>
|
|
38
|
+
getChats(options?: { limit?: number }): Promise<LineChat[]>
|
|
39
|
+
getMessages(chatId: string, options?: { count?: number }): Promise<LineMessage[]>
|
|
40
|
+
sendMessage(chatId: string, text: string): Promise<LineSendResult>
|
|
41
|
+
close(): void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LineListener {
|
|
45
|
+
start(): Promise<void>
|
|
46
|
+
stop(): void
|
|
47
|
+
on<K extends keyof LineListenerEventMap>(event: K, listener: (...args: LineListenerEventMap[K]) => void): this
|
|
48
|
+
off<K extends keyof LineListenerEventMap>(event: K, listener: (...args: LineListenerEventMap[K]) => void): this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type LineCredentialStore = {
|
|
52
|
+
load(): Promise<LineConfig>
|
|
53
|
+
getAccount(id?: string): Promise<LineAccountCredentials | null>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const LineClient = RealLineClient as unknown as new () => LineClient
|
|
57
|
+
const LineListener = RealLineListener as unknown as new (client: LineClient) => LineListener
|
|
58
|
+
|
|
59
|
+
export type LineAdapterLogger = {
|
|
60
|
+
info: (msg: string) => void
|
|
61
|
+
warn: (msg: string) => void
|
|
62
|
+
error: (msg: string) => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const consoleLogger: LineAdapterLogger = {
|
|
66
|
+
info: (m) => console.log(m),
|
|
67
|
+
warn: (m) => console.warn(m),
|
|
68
|
+
error: (m) => console.error(m),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type LineAdapterOptions = {
|
|
72
|
+
router: ChannelRouter
|
|
73
|
+
configRef: () => ChannelAdapterConfig
|
|
74
|
+
logger?: LineAdapterLogger
|
|
75
|
+
selfAliasesRef?: () => readonly string[]
|
|
76
|
+
credentialsStore?: LineCredentialStore
|
|
77
|
+
client?: LineClient
|
|
78
|
+
listenerFactory?: (client: LineClient) => LineListener
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type LineAdapter = {
|
|
82
|
+
start: () => Promise<void>
|
|
83
|
+
stop: () => Promise<void>
|
|
84
|
+
isConnected: () => boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const LINE_HISTORY_LIMIT_MAX = 200
|
|
88
|
+
|
|
89
|
+
export function createOutboundCallback(deps: {
|
|
90
|
+
client: Pick<LineClient, 'sendMessage'>
|
|
91
|
+
logger: LineAdapterLogger
|
|
92
|
+
formatChannelTag: (workspace: string, chat: string) => Promise<string>
|
|
93
|
+
}): OutboundCallback {
|
|
94
|
+
const { client, logger, formatChannelTag } = deps
|
|
95
|
+
return async (msg: OutboundMessage): Promise<SendResult> => {
|
|
96
|
+
if (msg.adapter !== 'line') {
|
|
97
|
+
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
98
|
+
}
|
|
99
|
+
// LINE's SDK exposes text sends only — there is no attachment upload
|
|
100
|
+
// primitive, so an outbound carrying attachments is rejected loudly
|
|
101
|
+
// rather than silently dropping the files.
|
|
102
|
+
if (msg.attachments !== undefined && msg.attachments.length > 0) {
|
|
103
|
+
return { ok: false, error: 'line adapter does not support outbound attachments' }
|
|
104
|
+
}
|
|
105
|
+
const text = toLinePlainText(msg.text ?? '')
|
|
106
|
+
if (text === '') {
|
|
107
|
+
return { ok: false, error: 'message has no text' }
|
|
108
|
+
}
|
|
109
|
+
const tag = await formatChannelTag(msg.workspace, msg.chat)
|
|
110
|
+
logger.info(`[line] outbound ${tag} text_len=${text.length}`)
|
|
111
|
+
try {
|
|
112
|
+
const result = await client.sendMessage(msg.chat, text)
|
|
113
|
+
if (!result.success) {
|
|
114
|
+
logger.error(`[line] sendMessage non-success ${tag}`)
|
|
115
|
+
return { ok: false, error: 'line send failed' }
|
|
116
|
+
}
|
|
117
|
+
logger.info(`[line] sent message_id=${result.message_id} ${tag}`)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const message = describe(err)
|
|
120
|
+
logger.error(`[line] sendMessage failed: ${message}`)
|
|
121
|
+
return { ok: false, error: message }
|
|
122
|
+
}
|
|
123
|
+
return { ok: true }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createLineHistoryCallback(deps: {
|
|
128
|
+
client: Pick<LineClient, 'getMessages'>
|
|
129
|
+
logger: LineAdapterLogger
|
|
130
|
+
selfUserIdRef: () => string | null
|
|
131
|
+
}): HistoryCallback {
|
|
132
|
+
const { client, logger, selfUserIdRef } = deps
|
|
133
|
+
return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
|
|
134
|
+
const limit = clampLimit(args.limit, LINE_HISTORY_LIMIT_MAX)
|
|
135
|
+
try {
|
|
136
|
+
const messages = await client.getMessages(args.chat, { count: limit })
|
|
137
|
+
const selfId = selfUserIdRef()
|
|
138
|
+
const mapped: ChannelHistoryMessage[] = messages.map((m) => {
|
|
139
|
+
const parsed = Date.parse(m.sent_at)
|
|
140
|
+
return {
|
|
141
|
+
externalMessageId: m.message_id,
|
|
142
|
+
authorId: m.author_id,
|
|
143
|
+
authorName: m.author_name ?? m.author_id,
|
|
144
|
+
text: m.text ?? '',
|
|
145
|
+
ts: Number.isNaN(parsed) ? 0 : parsed,
|
|
146
|
+
isBot: selfId !== null && m.author_id === selfId,
|
|
147
|
+
replyToBotMessageId: null,
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
return { ok: true, messages: mapped }
|
|
151
|
+
} catch (err) {
|
|
152
|
+
const message = describe(err)
|
|
153
|
+
logger.warn(`[line] history fetch failed: ${message}`)
|
|
154
|
+
return { ok: false, error: message }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function clampLimit(requested: number, max: number): number {
|
|
160
|
+
if (!Number.isFinite(requested) || requested <= 0) return max
|
|
161
|
+
return Math.min(Math.floor(requested), max)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createLineAdapter(options: LineAdapterOptions): LineAdapter {
|
|
165
|
+
const logger = options.logger ?? consoleLogger
|
|
166
|
+
const client = options.client ?? new LineClient()
|
|
167
|
+
let listener: LineListener | null = null
|
|
168
|
+
let selfUserId: string | null = null
|
|
169
|
+
let connected = false
|
|
170
|
+
let started = false
|
|
171
|
+
let inflightInbounds = 0
|
|
172
|
+
let stopWaiters: Array<() => void> = []
|
|
173
|
+
|
|
174
|
+
const channelResolver = createLineChannelResolver({ client, logger })
|
|
175
|
+
|
|
176
|
+
const formatChannelTag = async (workspace: string, chat: string): Promise<string> => {
|
|
177
|
+
const names = await channelResolver
|
|
178
|
+
.resolve({ adapter: 'line', workspace, chat, thread: null })
|
|
179
|
+
.catch(() => ({}) as ResolvedChannelNames)
|
|
180
|
+
return `bucket=${workspace} chat=${formatLabel(names.chatName, chat)}`
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const historyCallback = createLineHistoryCallback({
|
|
184
|
+
client,
|
|
185
|
+
logger,
|
|
186
|
+
selfUserIdRef: () => selfUserId,
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
const outboundCallback = createOutboundCallback({ client, logger, formatChannelTag })
|
|
190
|
+
|
|
191
|
+
const processInbound = async (event: LinePushMessageEvent): Promise<void> => {
|
|
192
|
+
inflightInbounds++
|
|
193
|
+
try {
|
|
194
|
+
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
195
|
+
await channelResolver.refresh()
|
|
196
|
+
if (channelResolver.lookupChat(event.chat_id) === null) {
|
|
197
|
+
// The push event itself proves the chat exists even when GETCHATS
|
|
198
|
+
// hasn't surfaced it yet. Register a provisional @line-group entry
|
|
199
|
+
// (the strictest multi-party bucket) so the message is not silently
|
|
200
|
+
// dropped as unknown_chat; the next refresh upgrades it.
|
|
201
|
+
channelResolver.ingestProvisional(event.chat_id)
|
|
202
|
+
logger.warn(
|
|
203
|
+
`[line] provisional chat=${event.chat_id} message_id=${event.message_id} bucket=@line-group reason=not_in_getchats`,
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const bucket = channelResolver.lookupChat(event.chat_id)?.workspace ?? '@line-group'
|
|
209
|
+
const inboundTag = await formatChannelTag(bucket, event.chat_id)
|
|
210
|
+
logger.info(
|
|
211
|
+
`[line] inbound message_id=${event.message_id} author=${event.author_id} ${inboundTag} text_len=${(event.text ?? '').length}`,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
const verdict = classifyInbound(event, options.configRef(), {
|
|
215
|
+
selfUserId,
|
|
216
|
+
lookupChat: (id) => channelResolver.lookupChat(id),
|
|
217
|
+
...(options.selfAliasesRef ? { selfAliases: options.selfAliasesRef() } : {}),
|
|
218
|
+
})
|
|
219
|
+
if (verdict.kind === 'drop') {
|
|
220
|
+
logger.info(`[line] dropped message_id=${event.message_id} reason=${verdict.reason}`)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
logger.info(
|
|
225
|
+
`[line] routed message_id=${event.message_id} ${inboundTag} mention=${verdict.payload.isBotMention} dm=${verdict.payload.isDm}`,
|
|
226
|
+
)
|
|
227
|
+
await options.router.route(verdict.payload)
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logger.error(`[line] handleInbound failed: ${describe(err)}`)
|
|
230
|
+
} finally {
|
|
231
|
+
inflightInbounds--
|
|
232
|
+
if (inflightInbounds === 0 && stopWaiters.length > 0) {
|
|
233
|
+
const waiters = stopWaiters
|
|
234
|
+
stopWaiters = []
|
|
235
|
+
for (const w of waiters) w()
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
async start(): Promise<void> {
|
|
242
|
+
if (started) return
|
|
243
|
+
started = true
|
|
244
|
+
try {
|
|
245
|
+
const credentialStore = options.credentialsStore ?? null
|
|
246
|
+
if (credentialStore !== null) {
|
|
247
|
+
const account = await credentialStore.getAccount()
|
|
248
|
+
if (account === null) {
|
|
249
|
+
throw new Error('no LINE account in secrets.json#channels.line (run typeclaw init to authenticate)')
|
|
250
|
+
}
|
|
251
|
+
await client.login(account)
|
|
252
|
+
} else {
|
|
253
|
+
await client.login()
|
|
254
|
+
}
|
|
255
|
+
} catch (err) {
|
|
256
|
+
started = false
|
|
257
|
+
logger.error(`[line] login failed: ${describe(err)}`)
|
|
258
|
+
throw err
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const profile = await client.getProfile()
|
|
263
|
+
selfUserId = profile.mid
|
|
264
|
+
logger.info(`[line] authenticated as ${profile.display_name || profile.mid} (${profile.mid})`)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
started = false
|
|
267
|
+
logger.error(`[line] getProfile failed: ${describe(err)}`)
|
|
268
|
+
throw err
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
await channelResolver.refresh()
|
|
273
|
+
} catch (err) {
|
|
274
|
+
logger.warn(`[line] initial chat list fetch failed: ${describe(err)}`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
listener = options.listenerFactory ? options.listenerFactory(client) : new LineListener(client)
|
|
278
|
+
listener.on('connected', (info) => {
|
|
279
|
+
connected = true
|
|
280
|
+
logger.info(`[line] connected (account_id=${info.account_id})`)
|
|
281
|
+
})
|
|
282
|
+
listener.on('disconnected', () => {
|
|
283
|
+
connected = false
|
|
284
|
+
logger.warn('[line] disconnected; SDK will reconnect with backoff')
|
|
285
|
+
})
|
|
286
|
+
listener.on('error', (err) => {
|
|
287
|
+
logger.error(`[line] listener error: ${describe(err)}`)
|
|
288
|
+
})
|
|
289
|
+
listener.on('message', (event) => {
|
|
290
|
+
void processInbound(event)
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await listener.start()
|
|
295
|
+
} catch (err) {
|
|
296
|
+
try {
|
|
297
|
+
listener.stop()
|
|
298
|
+
} catch {
|
|
299
|
+
// best-effort cleanup; the start failure is what we surface
|
|
300
|
+
}
|
|
301
|
+
listener = null
|
|
302
|
+
started = false
|
|
303
|
+
logger.error(`[line] listener start failed: ${describe(err)}`)
|
|
304
|
+
throw err
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Registration happens AFTER listener.start() resolves so a start
|
|
308
|
+
// failure cannot leave the router pointing at callbacks for a
|
|
309
|
+
// half-initialized adapter. stop() unregisters in inverse order.
|
|
310
|
+
options.router.registerOutbound('line', outboundCallback)
|
|
311
|
+
options.router.registerChannelNameResolver('line', channelResolver.resolve)
|
|
312
|
+
options.router.registerHistory('line', historyCallback)
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async stop(): Promise<void> {
|
|
316
|
+
if (!started) return
|
|
317
|
+
started = false
|
|
318
|
+
options.router.unregisterOutbound('line', outboundCallback)
|
|
319
|
+
options.router.unregisterChannelNameResolver('line', channelResolver.resolve)
|
|
320
|
+
options.router.unregisterHistory('line', historyCallback)
|
|
321
|
+
if (inflightInbounds > 0) {
|
|
322
|
+
await new Promise<void>((resolve) => {
|
|
323
|
+
stopWaiters.push(resolve)
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
listener?.stop()
|
|
327
|
+
listener = null
|
|
328
|
+
try {
|
|
329
|
+
client.close()
|
|
330
|
+
} catch {
|
|
331
|
+
// close() throwing on a half-initialized client is benign.
|
|
332
|
+
}
|
|
333
|
+
selfUserId = null
|
|
334
|
+
connected = false
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
isConnected(): boolean {
|
|
338
|
+
return connected && selfUserId !== null
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function formatLabel(name: string | undefined, id: string): string {
|
|
344
|
+
if (name === undefined || name === '' || name === id) return id
|
|
345
|
+
return `${name}(${id})`
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function describe(err: unknown): string {
|
|
349
|
+
return err instanceof Error ? err.message : String(err)
|
|
350
|
+
}
|
|
@@ -37,8 +37,10 @@ export class StickyLedger {
|
|
|
37
37
|
return expiresAt !== undefined && expiresAt > now
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
clear(key: string):
|
|
40
|
+
clear(key: string): number {
|
|
41
|
+
const cleared = this.byKey.get(key)?.size ?? 0
|
|
41
42
|
this.byKey.delete(key)
|
|
43
|
+
return cleared
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -60,7 +62,7 @@ export type EngagementInput = {
|
|
|
60
62
|
// once. Empty list means alias-based engagement is off — useful for
|
|
61
63
|
// tests and for agents that explicitly want strict-mention behavior.
|
|
62
64
|
// Match semantics: case-insensitive substring of inbound text. This is
|
|
63
|
-
// the operator contract documented in typeclaw-
|
|
65
|
+
// the operator contract documented in typeclaw-channels; if a name is too
|
|
64
66
|
// generic ("bot", "ai") it WILL produce false matches and the operator
|
|
65
67
|
// owns curation.
|
|
66
68
|
selfAliases: readonly string[]
|