@hybrd/channels 2.0.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/README.md +180 -0
- package/dist/adapters/xmtp/index.cjs +393 -0
- package/dist/adapters/xmtp/index.cjs.map +1 -0
- package/dist/adapters/xmtp/index.d.cts +36 -0
- package/dist/adapters/xmtp/index.d.ts +36 -0
- package/dist/adapters/xmtp/index.js +364 -0
- package/dist/adapters/xmtp/index.js.map +1 -0
- package/dist/index.cjs +69 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +12 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/package.json +60 -0
- package/src/adapters/xmtp/adapter.test.ts +167 -0
- package/src/adapters/xmtp/adapter.ts +307 -0
- package/src/adapters/xmtp/index.ts +138 -0
- package/src/dispatcher.ts +51 -0
- package/src/index.ts +11 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
4
|
+
|
|
5
|
+
const TEST_DIR = join("/tmp", "hybrid-xmtp-acl-test", Date.now().toString())
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
if (existsSync(TEST_DIR)) {
|
|
9
|
+
rmSync(TEST_DIR, { recursive: true })
|
|
10
|
+
}
|
|
11
|
+
mkdirSync(TEST_DIR, { recursive: true })
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
if (existsSync(TEST_DIR)) {
|
|
16
|
+
rmSync(TEST_DIR, { recursive: true })
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe("ACL Filtering Logic", () => {
|
|
21
|
+
describe("allowlist reading", () => {
|
|
22
|
+
it("returns empty array when file doesn't exist", async () => {
|
|
23
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
24
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
25
|
+
expect(allowFrom).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("returns allowlist from file", async () => {
|
|
29
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
30
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
31
|
+
|
|
32
|
+
writeFileSync(
|
|
33
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
34
|
+
JSON.stringify({
|
|
35
|
+
version: 1,
|
|
36
|
+
allowFrom: ["0xallowed1", "0xallowed2"]
|
|
37
|
+
})
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
41
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
42
|
+
expect(allowFrom).toEqual(["0xallowed1", "0xallowed2"])
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe("allowlist checking", () => {
|
|
47
|
+
it("allows all when allowlist is empty", async () => {
|
|
48
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
49
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
50
|
+
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
53
|
+
JSON.stringify({ version: 1, allowFrom: [] })
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
57
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
58
|
+
|
|
59
|
+
// Empty allowlist = open to all
|
|
60
|
+
expect(allowFrom.length).toBe(0)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("allows addresses on the list", async () => {
|
|
64
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
65
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
66
|
+
|
|
67
|
+
writeFileSync(
|
|
68
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
69
|
+
JSON.stringify({
|
|
70
|
+
version: 1,
|
|
71
|
+
allowFrom: ["0xabc", "0xdef"]
|
|
72
|
+
})
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
76
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
77
|
+
|
|
78
|
+
// Check membership
|
|
79
|
+
expect(allowFrom.includes("0xabc")).toBe(true)
|
|
80
|
+
expect(allowFrom.includes("0xdef")).toBe(true)
|
|
81
|
+
expect(allowFrom.includes("0xxyz")).toBe(false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("normalizes addresses to lowercase", async () => {
|
|
85
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
86
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
87
|
+
|
|
88
|
+
writeFileSync(
|
|
89
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
90
|
+
JSON.stringify({
|
|
91
|
+
version: 1,
|
|
92
|
+
allowFrom: ["0xABC123"]
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
97
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
98
|
+
|
|
99
|
+
// Should be normalized to lowercase
|
|
100
|
+
expect(allowFrom).toContain("0xabc123")
|
|
101
|
+
expect(allowFrom).not.toContain("0xABC123")
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("XMTP Adapter Integration", () => {
|
|
107
|
+
it("blocks message from non-allowed sender", async () => {
|
|
108
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
109
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
110
|
+
|
|
111
|
+
writeFileSync(
|
|
112
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
113
|
+
JSON.stringify({
|
|
114
|
+
version: 1,
|
|
115
|
+
allowFrom: ["0xallowed"]
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
120
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
121
|
+
|
|
122
|
+
// Simulate the check the adapter would do
|
|
123
|
+
const senderAddress = "0xblocked"
|
|
124
|
+
const isAllowed =
|
|
125
|
+
allowFrom.length === 0 || allowFrom.includes(senderAddress.toLowerCase())
|
|
126
|
+
|
|
127
|
+
expect(isAllowed).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("allows message from allowed sender", async () => {
|
|
131
|
+
const credentialsDir = join(TEST_DIR, "credentials")
|
|
132
|
+
mkdirSync(credentialsDir, { recursive: true })
|
|
133
|
+
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(credentialsDir, "xmtp-allowFrom.json"),
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
version: 1,
|
|
138
|
+
allowFrom: ["0xallowed"]
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
143
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
144
|
+
|
|
145
|
+
// Simulate the check the adapter would do
|
|
146
|
+
const senderAddress = "0xallowed"
|
|
147
|
+
const isAllowed =
|
|
148
|
+
allowFrom.length === 0 || allowFrom.includes(senderAddress.toLowerCase())
|
|
149
|
+
|
|
150
|
+
expect(isAllowed).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it("allows all messages when no allowlist configured", async () => {
|
|
154
|
+
const { readACLAllowFrom } = await import("@hybrid/memory")
|
|
155
|
+
const allowFrom = await readACLAllowFrom(TEST_DIR)
|
|
156
|
+
|
|
157
|
+
// No allowlist file = open
|
|
158
|
+
expect(allowFrom.length).toBe(0)
|
|
159
|
+
|
|
160
|
+
// Any sender should be allowed
|
|
161
|
+
const senderAddress = "0xanyone"
|
|
162
|
+
const isAllowed =
|
|
163
|
+
allowFrom.length === 0 || allowFrom.includes(senderAddress.toLowerCase())
|
|
164
|
+
|
|
165
|
+
expect(isAllowed).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto"
|
|
2
|
+
import type { Server } from "node:http"
|
|
3
|
+
import type {
|
|
4
|
+
ChannelAdapter,
|
|
5
|
+
ChannelId,
|
|
6
|
+
TriggerRequest,
|
|
7
|
+
TriggerResponse
|
|
8
|
+
} from "@hybrd/types"
|
|
9
|
+
import { readACLAllowFrom } from "@hybrid/memory"
|
|
10
|
+
import { Agent } from "@xmtp/agent-sdk"
|
|
11
|
+
import type { Conversation } from "@xmtp/node-sdk"
|
|
12
|
+
import express from "express"
|
|
13
|
+
import pc from "picocolors"
|
|
14
|
+
|
|
15
|
+
const log = {
|
|
16
|
+
info: (msg: string) => console.log(`${pc.magenta("[xmtp]")} ${msg}`),
|
|
17
|
+
error: (msg: string) => console.error(`${pc.red("[xmtp]")} ${msg}`),
|
|
18
|
+
warn: (msg: string) => console.log(`${pc.yellow("[xmtp]")} ${msg}`),
|
|
19
|
+
success: (msg: string) => console.log(`${pc.green("[xmtp]")} ${msg}`)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface XMTPAdapterConfig {
|
|
23
|
+
port: number
|
|
24
|
+
agentUrl: string
|
|
25
|
+
xmtpEnv?: "dev" | "production"
|
|
26
|
+
walletKey: `0x${string}`
|
|
27
|
+
dbEncryptionKey: Uint8Array
|
|
28
|
+
dbPath: string
|
|
29
|
+
workspaceDir?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface TextMessage {
|
|
33
|
+
id: string
|
|
34
|
+
content: string
|
|
35
|
+
senderInboxId: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class XMTPAdapter implements ChannelAdapter {
|
|
39
|
+
readonly channel: ChannelId = "xmtp"
|
|
40
|
+
readonly port: number
|
|
41
|
+
|
|
42
|
+
private config: XMTPAdapterConfig
|
|
43
|
+
private agent: Agent | null = null
|
|
44
|
+
private server: Server | null = null
|
|
45
|
+
private app: express.Application
|
|
46
|
+
private botInboxId: string | null = null
|
|
47
|
+
private processedMessages: Set<string> = new Set()
|
|
48
|
+
private addressCache: Map<string, string> = new Map()
|
|
49
|
+
|
|
50
|
+
constructor(config: XMTPAdapterConfig) {
|
|
51
|
+
this.port = config.port
|
|
52
|
+
this.config = config
|
|
53
|
+
this.app = express()
|
|
54
|
+
this.app.use(express.json())
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async start(): Promise<void> {
|
|
58
|
+
await this.startXMTPClient()
|
|
59
|
+
this.startTriggerServer()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async stop(): Promise<void> {
|
|
63
|
+
this.server?.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async startXMTPClient(): Promise<void> {
|
|
67
|
+
const { createUser } = await import("@xmtp/agent-sdk")
|
|
68
|
+
const { toBytes } = await import("viem")
|
|
69
|
+
|
|
70
|
+
const user = createUser(this.config.walletKey)
|
|
71
|
+
const identifier = {
|
|
72
|
+
identifier: user.account.address.toLowerCase(),
|
|
73
|
+
identifierKind: 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const signer = {
|
|
77
|
+
type: "EOA" as const,
|
|
78
|
+
getIdentifier: () => identifier,
|
|
79
|
+
getChainId: async () => BigInt(1),
|
|
80
|
+
signMessage: async (message: string) => {
|
|
81
|
+
const sig = await user.account.signMessage({ message })
|
|
82
|
+
return toBytes(sig)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.agent = await Agent.create(
|
|
87
|
+
signer as unknown as Parameters<typeof Agent.create>[0],
|
|
88
|
+
{
|
|
89
|
+
env: this.config.xmtpEnv ?? "dev",
|
|
90
|
+
dbEncryptionKey: this.config.dbEncryptionKey,
|
|
91
|
+
dbPath: this.config.dbPath
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
log.success("connected to XMTP network")
|
|
96
|
+
|
|
97
|
+
this.botInboxId = this.agent.client.inboxId
|
|
98
|
+
|
|
99
|
+
this.agent.on("text", async (ctx) => {
|
|
100
|
+
const { conversation, message } = ctx
|
|
101
|
+
await this.handleInbound(conversation, message as TextMessage)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
await this.agent.start()
|
|
105
|
+
log.success("listening for XMTP messages")
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private startTriggerServer(): void {
|
|
109
|
+
this.app.post("/api/trigger", async (req, res) => {
|
|
110
|
+
const result = await this.handleTrigger(req.body)
|
|
111
|
+
res.json(result)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
this.server = this.app.listen(this.port, "127.0.0.1", () => {
|
|
115
|
+
log.success(`trigger server listening on 127.0.0.1:${this.port}`)
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async resolveSenderAddress(
|
|
120
|
+
inboxId: string,
|
|
121
|
+
conversation: Conversation
|
|
122
|
+
): Promise<string> {
|
|
123
|
+
const cached = this.addressCache.get(inboxId)
|
|
124
|
+
if (cached) return cached
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const members = await conversation.members()
|
|
128
|
+
const sender = members.find(
|
|
129
|
+
(m: any) => m.inboxId.toLowerCase() === inboxId.toLowerCase()
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (sender) {
|
|
133
|
+
const ethIdentifier = sender.accountIdentifiers.find(
|
|
134
|
+
(id: any) => id.identifierKind === 0
|
|
135
|
+
)
|
|
136
|
+
if (ethIdentifier) {
|
|
137
|
+
const address = ethIdentifier.identifier.toLowerCase()
|
|
138
|
+
this.addressCache.set(inboxId, address)
|
|
139
|
+
return address
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const inboxState =
|
|
144
|
+
await this.agent?.client.preferences.inboxStateFromInboxIds([inboxId])
|
|
145
|
+
const firstState = inboxState?.[0]
|
|
146
|
+
if (firstState?.identifiers?.[0]?.identifier) {
|
|
147
|
+
const address = firstState.identifiers[0].identifier.toLowerCase()
|
|
148
|
+
this.addressCache.set(inboxId, address)
|
|
149
|
+
return address
|
|
150
|
+
}
|
|
151
|
+
} catch (err) {
|
|
152
|
+
log.warn(`failed to resolve address for ${inboxId.slice(0, 16)}...`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return inboxId
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private async isSenderAllowed(senderAddress: string): Promise<boolean> {
|
|
159
|
+
if (!this.config.workspaceDir) {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const allowFrom = await readACLAllowFrom(this.config.workspaceDir)
|
|
165
|
+
if (allowFrom.length === 0) {
|
|
166
|
+
return true
|
|
167
|
+
}
|
|
168
|
+
const normalized = senderAddress.toLowerCase()
|
|
169
|
+
return allowFrom.includes(normalized)
|
|
170
|
+
} catch (err) {
|
|
171
|
+
log.warn(`failed to read ACL, allowing sender: ${(err as Error).message}`)
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private async handleInbound(
|
|
177
|
+
conversation: Conversation,
|
|
178
|
+
message: TextMessage
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
log.info(`message ${pc.gray(message.id.slice(0, 8))}`)
|
|
181
|
+
|
|
182
|
+
if (this.processedMessages.has(message.id)) {
|
|
183
|
+
log.warn(`skipping duplicate: ${message.id.slice(0, 8)}`)
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
this.processedMessages.add(message.id)
|
|
187
|
+
|
|
188
|
+
if (this.processedMessages.size > 1000) {
|
|
189
|
+
const arr = Array.from(this.processedMessages)
|
|
190
|
+
arr.slice(0, 500).forEach((id) => this.processedMessages.delete(id))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check if sender is on the allowlist
|
|
194
|
+
const senderAddress = await this.resolveSenderAddress(
|
|
195
|
+
message.senderInboxId,
|
|
196
|
+
conversation
|
|
197
|
+
)
|
|
198
|
+
const isAllowed = await this.isSenderAllowed(senderAddress)
|
|
199
|
+
|
|
200
|
+
if (!isAllowed) {
|
|
201
|
+
log.warn(
|
|
202
|
+
`blocked message from ${senderAddress.slice(0, 10)}... (not on allowlist)`
|
|
203
|
+
)
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
await this.runAgentAndReply({
|
|
208
|
+
conversationId: conversation.id,
|
|
209
|
+
message: message.content,
|
|
210
|
+
conversation
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async trigger(req: TriggerRequest): Promise<TriggerResponse> {
|
|
215
|
+
return this.handleTrigger(req)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private async handleTrigger(req: TriggerRequest): Promise<TriggerResponse> {
|
|
219
|
+
if (!this.agent) {
|
|
220
|
+
return { delivered: false, error: "XMTP client not initialized" }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const conversations = await this.agent.client.conversations.list()
|
|
224
|
+
const conversation = conversations.find(
|
|
225
|
+
(c: Conversation) => c.id === req.to
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if (!conversation) {
|
|
229
|
+
return { delivered: false, error: "Conversation not found" }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return this.runAgentAndReply({
|
|
233
|
+
conversationId: req.to,
|
|
234
|
+
message: req.message,
|
|
235
|
+
conversation
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private async runAgentAndReply(params: {
|
|
240
|
+
conversationId: string
|
|
241
|
+
message: string
|
|
242
|
+
conversation: Conversation
|
|
243
|
+
}): Promise<TriggerResponse> {
|
|
244
|
+
const { conversation, message, conversationId } = params
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const res = await fetch(`${this.config.agentUrl}/api/chat`, {
|
|
248
|
+
method: "POST",
|
|
249
|
+
headers: {
|
|
250
|
+
"Content-Type": "application/json",
|
|
251
|
+
"X-Request-ID": randomUUID(),
|
|
252
|
+
"X-Source": "xmtp-adapter"
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
messages: [{ id: randomUUID(), role: "user", content: message }],
|
|
256
|
+
chatId: conversationId
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
log.error(`HTTP ${res.status}`)
|
|
262
|
+
return { delivered: false, error: `HTTP ${res.status}` }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const reader = res.body?.getReader()
|
|
266
|
+
if (!reader) {
|
|
267
|
+
return { delivered: false, error: "No response body" }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const decoder = new TextDecoder()
|
|
271
|
+
let reply = ""
|
|
272
|
+
|
|
273
|
+
while (true) {
|
|
274
|
+
const { done, value } = await reader.read()
|
|
275
|
+
if (done) break
|
|
276
|
+
|
|
277
|
+
for (const line of decoder.decode(value).split("\n")) {
|
|
278
|
+
if (line.startsWith("data: ") && line !== "data: [DONE]") {
|
|
279
|
+
try {
|
|
280
|
+
const p = JSON.parse(line.slice(6))
|
|
281
|
+
if (p.type === "text" && p.content) reply += p.content
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (reply) {
|
|
288
|
+
await conversation.send(reply)
|
|
289
|
+
log.success(`replied (${reply.length} chars)`)
|
|
290
|
+
return { delivered: true }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return { delivered: false, error: "No reply generated" }
|
|
294
|
+
} catch (err) {
|
|
295
|
+
log.error((err as Error).message)
|
|
296
|
+
return { delivered: false, error: (err as Error).message }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function createXMTPAdapter(
|
|
302
|
+
config: XMTPAdapterConfig
|
|
303
|
+
): Promise<XMTPAdapter> {
|
|
304
|
+
const adapter = new XMTPAdapter(config)
|
|
305
|
+
await adapter.start()
|
|
306
|
+
return adapter
|
|
307
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import { resolveAgentSecret } from "@hybrd/xmtp"
|
|
4
|
+
import { readACLAllowFrom } from "@hybrid/memory"
|
|
5
|
+
import { createUser } from "@xmtp/agent-sdk"
|
|
6
|
+
import pc from "picocolors"
|
|
7
|
+
import { type XMTPAdapterConfig } from "./adapter.js"
|
|
8
|
+
|
|
9
|
+
const log = {
|
|
10
|
+
info: (msg: string) => console.log(`${pc.magenta("[xmtp]")} ${msg}`),
|
|
11
|
+
error: (msg: string) => console.error(`${pc.red("[xmtp]")} ${msg}`),
|
|
12
|
+
warn: (msg: string) => console.log(`${pc.yellow("[xmtp]")} ${msg}`),
|
|
13
|
+
success: (msg: string) => console.log(`${pc.green("[xmtp]")} ${msg}`)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const AGENT_PORT = process.env.AGENT_PORT || "8454"
|
|
17
|
+
const XMTP_ENV = (process.env.XMTP_ENV || "dev") as "dev" | "production"
|
|
18
|
+
const XMTP_ADAPTER_PORT = Number.parseInt(
|
|
19
|
+
process.env.XMTP_ADAPTER_PORT || "8455",
|
|
20
|
+
10
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
process.on("uncaughtException", (err) => {
|
|
24
|
+
log.error(`FATAL: ${err.message}`)
|
|
25
|
+
process.exit(1)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
process.on("unhandledRejection", (reason) => {
|
|
29
|
+
log.error(`FATAL: ${reason}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
function printBanner(walletAddress?: string, aclCount?: number) {
|
|
34
|
+
const isHotReload = process.env.TSX_WATCH === "true"
|
|
35
|
+
|
|
36
|
+
console.log("")
|
|
37
|
+
console.log(
|
|
38
|
+
pc.magenta(" ╭───────────────────────────────────────────────────╮")
|
|
39
|
+
)
|
|
40
|
+
console.log(
|
|
41
|
+
pc.magenta(" │") +
|
|
42
|
+
pc.bold(pc.white(" XMTP Channel Adapter")) +
|
|
43
|
+
pc.magenta(" │")
|
|
44
|
+
)
|
|
45
|
+
console.log(
|
|
46
|
+
pc.magenta(" ╰───────────────────────────────────────────────────╯")
|
|
47
|
+
)
|
|
48
|
+
console.log("")
|
|
49
|
+
console.log(
|
|
50
|
+
` ${pc.bold("Network")} ${XMTP_ENV === "production" ? pc.green("production") : pc.cyan("dev")}`
|
|
51
|
+
)
|
|
52
|
+
console.log(
|
|
53
|
+
` ${pc.bold("Wallet")} ${walletAddress ? pc.cyan(walletAddress) : pc.gray("(not configured)")}`
|
|
54
|
+
)
|
|
55
|
+
console.log(` ${pc.bold("Agent")} http://localhost:${AGENT_PORT}`)
|
|
56
|
+
console.log(
|
|
57
|
+
` ${pc.bold("Trigger")} http://127.0.0.1:${XMTP_ADAPTER_PORT}/api/trigger`
|
|
58
|
+
)
|
|
59
|
+
if (aclCount !== undefined) {
|
|
60
|
+
const aclStatus =
|
|
61
|
+
aclCount > 0
|
|
62
|
+
? pc.green(`${aclCount} allowed`)
|
|
63
|
+
: pc.yellow("open (no allowlist)")
|
|
64
|
+
console.log(` ${pc.bold("ACL")} ${aclStatus}`)
|
|
65
|
+
}
|
|
66
|
+
console.log("")
|
|
67
|
+
|
|
68
|
+
if (isHotReload) {
|
|
69
|
+
console.log(
|
|
70
|
+
` ${pc.yellow("⚡")} Hot reload enabled - watching for changes...`
|
|
71
|
+
)
|
|
72
|
+
} else {
|
|
73
|
+
console.log(` ${pc.green("✓")} Listening for messages...`)
|
|
74
|
+
}
|
|
75
|
+
console.log("")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function start() {
|
|
79
|
+
const key = process.env.AGENT_WALLET_KEY
|
|
80
|
+
|
|
81
|
+
if (!key) {
|
|
82
|
+
log.warn("AGENT_WALLET_KEY not set")
|
|
83
|
+
printBanner()
|
|
84
|
+
await new Promise(() => {})
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const user = createUser(key as `0x${string}`)
|
|
89
|
+
|
|
90
|
+
// Read ACL count for banner
|
|
91
|
+
let aclCount: number | undefined
|
|
92
|
+
try {
|
|
93
|
+
const allowFrom = await readACLAllowFrom(process.cwd())
|
|
94
|
+
aclCount = allowFrom.length
|
|
95
|
+
} catch {
|
|
96
|
+
// ACL doesn't exist yet, that's fine
|
|
97
|
+
aclCount = 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
printBanner(user.account.address, aclCount)
|
|
101
|
+
|
|
102
|
+
const secret = resolveAgentSecret(key)
|
|
103
|
+
const dbEncryptionKey = new Uint8Array(Buffer.from(secret, "hex"))
|
|
104
|
+
|
|
105
|
+
const dbDir = path.join(process.cwd(), ".hybrid", ".xmtp")
|
|
106
|
+
if (!fs.existsSync(dbDir)) {
|
|
107
|
+
fs.mkdirSync(dbDir, { recursive: true })
|
|
108
|
+
}
|
|
109
|
+
const dbPath = path.join(
|
|
110
|
+
dbDir,
|
|
111
|
+
`xmtp-${XMTP_ENV}-${user.account.address.toLowerCase().slice(0, 8)}.db3`
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const config: XMTPAdapterConfig = {
|
|
115
|
+
port: XMTP_ADAPTER_PORT,
|
|
116
|
+
agentUrl: `http://localhost:${AGENT_PORT}`,
|
|
117
|
+
xmtpEnv: XMTP_ENV,
|
|
118
|
+
walletKey: key as `0x${string}`,
|
|
119
|
+
dbEncryptionKey,
|
|
120
|
+
dbPath,
|
|
121
|
+
workspaceDir: process.cwd()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await import("./adapter.js").then(({ createXMTPAdapter }) =>
|
|
125
|
+
createXMTPAdapter(config)
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
start().catch((e) => {
|
|
130
|
+
log.error(e.message)
|
|
131
|
+
process.exit(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
export {
|
|
135
|
+
XMTPAdapter,
|
|
136
|
+
type XMTPAdapterConfig,
|
|
137
|
+
createXMTPAdapter
|
|
138
|
+
} from "./adapter.js"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ChannelId, TriggerRequest, TriggerResponse } from "@hybrd/types"
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ADAPTER_PORTS: Record<string, number> = {
|
|
4
|
+
xmtp: 8455
|
|
5
|
+
// telegram: 8456, // future
|
|
6
|
+
// slack: 8457, // future
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function dispatchToChannel(params: {
|
|
10
|
+
channel: ChannelId
|
|
11
|
+
to: string
|
|
12
|
+
message: string
|
|
13
|
+
metadata?: TriggerRequest["metadata"]
|
|
14
|
+
}): Promise<TriggerResponse> {
|
|
15
|
+
const port = DEFAULT_ADAPTER_PORTS[params.channel]
|
|
16
|
+
|
|
17
|
+
if (!port) {
|
|
18
|
+
return { delivered: false, error: `Unknown channel: ${params.channel}` }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const url = `http://127.0.0.1:${port}/api/trigger`
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
to: params.to,
|
|
29
|
+
message: params.message,
|
|
30
|
+
metadata: params.metadata
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
return {
|
|
36
|
+
delivered: false,
|
|
37
|
+
error: `Channel adapter returned ${res.status}: ${res.statusText}`
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (await res.json()) as TriggerResponse
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
delivered: false,
|
|
45
|
+
error:
|
|
46
|
+
err instanceof Error
|
|
47
|
+
? err.message
|
|
48
|
+
: "Failed to connect to channel adapter"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/index.ts
ADDED