{"id":15146993,"url":"https://github.com/thefirstlineofcode/chalk","last_synced_at":"2025-10-24T01:31:17.634Z","repository":{"id":169018143,"uuid":"644883746","full_name":"TheFirstLineOfCode/chalk","owner":"TheFirstLineOfCode","description":"Java XMPP Client Library","archived":false,"fork":false,"pushed_at":"2024-06-07T19:27:41.000Z","size":300,"stargazers_count":2,"open_issues_count":0,"forks_count":2,"subscribers_count":0,"default_branch":"main","last_synced_at":"2025-01-30T22:35:07.839Z","etag":null,"topics":["client-library","java","plugin-architecture","xmpp"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"lgpl-2.1","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/TheFirstLineOfCode.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2023-05-24T13:04:00.000Z","updated_at":"2024-06-07T19:27:43.000Z","dependencies_parsed_at":"2024-04-21T11:42:55.856Z","dependency_job_id":"ea00fb04-d3a4-4ede-9f08-f126bd1eefcb","html_url":"https://github.com/TheFirstLineOfCode/chalk","commit_stats":{"total_commits":12,"total_committers":4,"mean_commits":3.0,"dds":0.5833333333333333,"last_synced_commit":"f2c6bc4e05c9e0df9e48939ae0d078dc8645fe7d"},"previous_names":["thefirstlineofcode/chalk"],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TheFirstLineOfCode%2Fchalk","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TheFirstLineOfCode%2Fchalk/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TheFirstLineOfCode%2Fchalk/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/TheFirstLineOfCode%2Fchalk/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/TheFirstLineOfCode","download_url":"https://codeload.github.com/TheFirstLineOfCode/chalk/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":237901378,"owners_count":19384384,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["client-library","java","plugin-architecture","xmpp"],"created_at":"2024-09-26T12:21:20.666Z","updated_at":"2025-10-24T01:31:15.619Z","avatar_url":"https://github.com/TheFirstLineOfCode.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Chalk开发指南\n\n## Chalk简介\n\nChalk是一个Java开发的XMPP客户端通讯库，可以用于开发Java桌面和Android的XMPP客户端。Chalk基于插件体系设计，这使得它易于使用及易于扩展。\n\n## 如何使用Chalk\n\n### 依赖配置(Maven)\n\nChalk是一个纯粹的Java库，在使用它时，我们需要将它配置为工程的依赖库。以Maven工程为例，我们需要做以下的配置。\n\n#### 1.添加FirstLineCode的Maven仓库\n\n在pom.xml中添加FirstLineCode的Maven仓库。\n\n```\n\u003crepository\u003e\n\t\u003cid\u003ecom.thefirstlineofcode.release\u003c/id\u003e\n\t\u003cname\u003eTheFirstLineOfCode Repository - Releases\u003c/name\u003e\n\t\u003curl\u003ehttp://repo.thefirstlineofcode.com/content/repositories/releases\u003c/url\u003e\n\u003c/repository\u003e\n```\n\n#### 2.添加Chalk依赖\n\nChalk库被设计成插件架构，以保证良好的扩展性，不同的功能被封装在不同的插件依赖库里，开发者可以根据自己需要，选择要使用的依赖。\n\n##### 基础依赖\n\n我们最少需要配置以下的基础依赖。\n\n```\n\u003cdependency\u003e\n\t\u003cgroupId\u003ecom.thefirstlineofcode.chalk\u003c/groupId\u003e\n\t\u003cartifactId\u003ecom.thefirstlineofcode.chalk\u003c/artifactId\u003e\n\t\u003cversion\u003e0.2.0-RELEASE\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n##### 可选插件依赖\n\n可以根据需要添加可选的插件依赖，例如添加在线注册(In Band Registration)插件依赖。\n\n```\n\u003cdependency\u003e\n\t\u003cgroupId\u003ecom.thefirstlineofcode.chalk.xeps\u003c/groupId\u003e\n\t\u003cartifactId\u003ecom.thefirstlineofcode.chalk.xeps.ibr\u003c/artifactId\u003e\n\t\u003cversion\u003e0.2.0-RELEASE\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n下表列出了Chalk提供了的常用插件。\n\n插件名 | groupId | artifactId | 插件描述 | 备注 |\n----- | ------- | ---------- | ------- | --- |\nIM     | com.thefirstlineofcode.chalk | com.thefirstlineofcode.chalk | 实现[RFC3921(Instant Messaging and Presence)](https://xmpp.org/rfcs/rfc3921.html) | IM插件内置在基础依赖包中 |\nPing   | com.thefirstlineofcode.chalk.xeps | com.thefirstlineofcode.chalk.xeps.ping | 实现[XEP-0199(XMPP Ping)](https://xmpp.org/extensions/xep-0199.html) | |\nIBR    | com.thefirstlineofcode.chalk.xeps | com.thefirstlineofcode.chalk.xeps.ibr | 实现[XEP-0077(In-Band Registration)](https://xmpp.org/extensions/xep-0077.html) | |\nMUC    | com.thefirstlineofcode.chalk.xeps | com.thefirstlineofcode.chalk.xeps.muc | 实现[XEP-0045(Multi-User Chat)](https://xmpp.org/extensions/xep-0045.html) | |\nLEP IM | com.thefirstlineofcode.chalk.leps | com.thefirstlineofcode.chalk.leps.im | 实现LEP-0011(Traceable Message) | 非标准协议，解决XMPP IM的一些缺陷，如：双向订阅、可信赖消息；消息状态跟踪等 |\n\n### IChatClient\n\n使用Chalk库最重要的接口就是IChatClient，IChatClient提供了两个核心的功能：\n\n* 建立客户端到服务器端的连接(Stream)。\n* 创建插件的API，应用开发者通过插件API来使用库提供的各种功能。\n\n#### 建立连接(Stream)\n\n根据XMPP协议，在客户端-服务器之间，我们需要：（1）先建立一个信息通道(Stream) 。（2）在建立好的Stream上，交换信息(Stanza)。\n\n使用以下的代码，可以建立Stream。\n\n```\nStreamConfig config = new StreamConfig(\"im.thefirstlineofcode.com\", 5222);\nconfig.setResource(\"my_android_mobile\");\nIChatClient chatClient = new StandardChatClient(config);\ntry {\n\tchatClient.connect(\"my_user_name\", \"my_password\");\n} catch (ConnectionException e) {\n\tthrow new RuntimeException(\"can't connect to host\", e);\n} catch (AuthFailureException e) {\n\tthrow new RuntimeException(\"auth failed\", e);\n}\n```\n\nXMPP规范定义了Stream建立的几个协商阶段：\n\n* Initial Stream\n* TLS\n* SASL\n* Resource Binding\n* Session Establishment\n\n如果需要监控Stream建立的细节，可以使用INegotiationListener。\n\n```\nchatClient.addNegotiationListener(new INegotiationListsener() {\n\tpublic void before(IStreamNegotiant source) {\n\t\tif (source instanceof TlsNegotiation) {\n\t\t\tSystem.out.println(\"Ready to negotiate TLS\");\n\t\t}\n\t}\n\n\tpublic void after(IStreamNegotiant source) {\n\t\tif (source instanceof TlsNegotiation) {\n\t\t\tSystem.out.println(\"TLS negotiation has done\");\n\t\t}\n\t}\n\t\n\tpublic void occurred(NegotiationException exception) {\n\t\tIStreamNegotiant source = exception.getSource();\n\t\tif (source instnaceof SaslNegotiant) {\n\t\t\tSaslError saslError = (SaslError)exception.getAdditionalErrorInfo();\n\t\t\tSystem.out.println(\"Error occured in SASL negotiation. Additional error info: \" + saslError);\n\t\t}\n\t}\n\t\n\tpublic void done(IStream stream) {\n\t\tSystem.out.println(\"Stream has created\");\n\t}\n});\n\nchatClient.connect(\"my_user_name\", \"my_password\");\n```\n\n在TLS协商过程中，如果客户端需要检查服务器证书有效性，可以使用IPeerCertificateTruster。\n\n```\n((StandardChatClient)chatClient).setPeerCertificateTruster(new IPeerCertificateTruster() {\n\t\tpublic boolean accept(X509Certificate[] certificates) {\n\t\t\t// check certificates\n\t\t}\n\t}\n);\n```\n\n#### 使用插件\n\nXMPP是一组协议族，包括RFC协议(例如：RFC3920、RFC3921)和XEPs(例如：XEP-0045 Multi-User Chat、XEP-0077 In-Band Registration)协议。在很多情况下，用户甚至可能会基于XMPP协议标准，定义自己的私有协议。\n\nChalk基于插件架构设计，以对应XMPP的扩展性和灵活性。除了少数基础功能(例如：建立Stream)之外，Chalk的所有功能都由插件来提供。\n\nChalk基于一个简单、灵活的插件框架，使用插件功能，一般使用插件需要以下的步骤：\n\n* 注册插件\n* 获取插件API\n* 使用插件功能\n\n我们用一个简单的插件PingPlugin(实现[XEP-0199 XMPP Ping](https://xmpp.org/extensions/xep-0199.html))来演示插件的使用方法。\n\n##### 注册插件\n\n使用插件前，需要先注册插件，IChatClient提供了注册插件的接口。\n\n注册PingPlugin的代码如下。\n\n```\nchatClient.register(PingPlugin.class);\n```\n\n##### 获取插件API\n\n注册插件后，就可以获取插件的API。每个插件根据自己协议的细节，设计并提供API，应用开发者需要通过对应的文档了解相关细节。\n\nPing插件提供了一个名为IPing的接口，定义如下。\n\n```\npublic interface IPing {\n\tpublic enum Result {\n\t\tPONG,\n\t\tSERVICE_UNAVAILABLE,\n\t\tTIME_OUT\n\t}\n\t\n\tResult ping();\n\tvoid setTimeout(int timeout);\n\tint getTimeout();\n}\n```\n\n获取IPing接口的代码如下。\n\n```\nIPing ping = chatClient.createApi(IPing.class);\n```\n\n##### 使用插件功能\n\n```\nping.setTimeout(4 * 1000); // Set timeout to the ping operation. Default is 2 * 1000ms.\nResult result = ping.ping(); // Ping the server and waiting for result.\n\nif (result == Ping.Result.PONG) {\n\tSystem.out.println(\"Pong.\");\n} else if (result == SERVICE_UNAVAILABLE) {\n\tSystem.out.println(\"Server doesn't support the protocol.\");\n} else {\n\tSystem.out.println(\"Ping timed out.\");\n}\n\n```\n\n### 插件(IPlugin)\n\nChalk的一个重要设计目的，是为了充分体现XMPP协议的灵活性及扩展性，以便于我们可以根据自己需要，灵活的定义和开发IM通讯协议。\n\nXMPP协议为一组松散的协议族，除了RFC3920 XMPP Core，RFC3921 XMPP IM之外，其它的协议(主要是XEPs)被视为可选的协议。不同的IM产品会选择实现其中一些协议，\nChalk也实现了部分XMPP标准协议，并提供一些改善标准XMPP的非标准协议扩展。\n\n以下为Chalk已经实现的插件(协议)。\n\n#### IbrPlugin\n\nIbrPlugin实现了[XEP-0077(In-Band Registration)](https://xmpp.org/extensions/xep-0077.html)，为客户端提供在线注册功能。\n\n##### 注册插件\n\n```\nchaClient.register(IbrPlugin.class);\n```\n\n##### 使用IRegistration接口注册用户\n\n```\ntry {\n\tIRegistration registration = chatClient.createApi(IRegistration.class);\n\tregistration.register(new IRegistrationCallback() {\n\t\tpublic Object fillOut(IqRegister iqRegister) {\n\t\t\tif (iqRegister.getRegister() instanceof RegistrationForm) {\n\t\t\t\tRegistrationForm form = new RegistrationForm();\n\t\t\t\tform.getFields().add(new RegistrationField(\"username\", \"my_user_name\"));\n\t\t\t\tform.getFields().add(new RegistrationField(\"password\", \"my_password\"));\n\t\t\t\t\n\t\t\t\treturn form;\n\t\t\t} else {\n\t\t\t\tthrow new RuntimeException(\"Can't get registration form\");\n\t\t\t}\n\t\t}\n\t});\n} catch(RegistrationException e) {\n\tIbrError error = e.getError();\n\tif (error instanceof IbrError.Conflict) {\n\t\tSystem.out.println(\"User has existed. Please change your name.\");\n\t} else if (error instanceof IbrError.NOT_ACCEPTABLE) {\n\t\tSystem.out.println(\"Illegal user name. Please change your name.\");\n\t} else {\n\t\tSystem.out.println(\"Registration failed.\");\n\t}\n}\n\n```\n\n#### InstantingMessengerPlugin\n\nInstantingMessengerPlugin实现了[RFC3921(Instant Messaging and Presence)](https://xmpp.org/rfcs/rfc3921.html)，提供Roster管理、Subscription管理，及发送接收Presence和Message的功能。\n\n\u003e为简化文档，我们在后续说明中，可能会使用IM插件作为InstantingMessengerPlugin的同义词，当提及IM插件时，意味着是InstantingMessengerPlugin。\n\n##### 注册插件\n\n```\nchaClient.register(InstantingMessengerPlugin.class);\n```\n\n##### 发送和接收Presence\n\n###### Initial Presence\n\n在成功建立Stream后，根据RFC3921要求，客户端应该要立即发送一个Initial Presence，服务器端在收到Initial Presence后，才会将客户端的状态置为Available。\n\n```\nIInstantingMessenger im = chatClient.createApi(IInstantingMessenger.class);\n\nim.send(new Presence()); // Send initial presence\n```\n\n###### 发送Presence\n\n在此后任何时刻，都可以通过发送Presence，更改自己的当前状态。\n\n```\nPresence presence = new Presence(Show.DND);\npresence.getStatuses().add(new LangText(\"I'm being in a meeting.\"));\n\nim.send(presence);\n```\n\n###### 监听Presence\n\n可以通过IPresenceListener监听联系人的Presence变化。\n\n```\nim.addPresenceListener(new IPresenceListener() {\n\tpublic void received(Presence presence) {\n\t\tSystem.out.println(\"Contact \" + presence.getFrom() + \" changed it's presence to \" + presence.toString());\n\t}\n});\n```\n\n##### Roster管理\n\nXMPP IM协议中，使用Roster来管理联系人列表。IM插件提供IRosterService和IRosterListener来管理Roster。\n\n###### 获取Roster\n\n使用IRosterService的retrieve()方法来从服务器端获取Roster列表。\n\n```\nIRosterService rosterService = im.getRosterService();\nrosterService.addRosterListener(new IRosterListener() {\n\tvoid retrieved(Roster roster) {\n\t\t// Process the roster that is retrieved from server.\n\t}\n\n\tvoid occurred(RosterError error) {\n\t\t// An error occurred\n\t}\n\t...\n});\nrosterService.retrieve();\n```\n\n\u003e 注意：retrieve()是一个异步方法，并不会直接返回结果，所以我们需要注册一个IRosterListener来监听获取的结果。\n\n###### 监听Roster变化\n\nIRosterListener还提供了updated()和deleted()方法，可以用于监听Roster的变更。\n\n```\nrosterService.addRosterListener(new IRosterListener() {\n\t...\n\tpublic void updated(Roster roster) {\n\t\t// Process the updated roster\n\t}\n\t\n\tpublic void deleted(Roster roster) {\n\t\t// process the deleted roster\n\t}\n\t...\n});\n```\n\n###### 变更Roster\n\n\u003e 大部分时候，我们并不需要直接变更Roster，Roster管理往往和Subscription管理相关，当Subscription状态变更时，会自动导致Roster变更。\n\n在某些情况下，我们需要直接变更Roster，例如修改用户的分组，IRosterService提供了以下的方法。\n\n```\npublic interface IRosterService {\n\t...\n\tvoid add(Roster roster);\n\tvoid update(Roster roster);\n\tvoid delete(Roster roster);\n\t...\n}\n```\n\n##### Subscription管理\n\nXMPP IM协议使用Subscription来管理联系人之间的关联关系。IM插件提供了ISubscriptionService和ISubscriptionListener来管理Subscription。\n\n###### 发起订阅\n\n```\nJabberId contact = JabberId.parse(\"smartsheep@im.thefirstlineofcode.com\");\nISubscriptionService subscriptionService = im.getSubscriptionService();\nsubscriptionService.subscribe(contact);\n```\n\n###### 接收订阅消息\n\n注册ISubscriptionListener，可以接收订阅消息。\n\n```\nsubscriptionService.addSubscriptionListener(new ISubscriptionListener() {\n\t...\n\tpublic void asked(JabberId user) {\n\t\tSystem.out.println(\"User \" + user + \" wants to subscribe you.\");\n\t}\n\t...\n});\n```\n\n###### 接受订阅\n\n如果用户决定通过对方的订阅，可以使用以下代码。\n\n```\nsubscriptionService.approve(contact);\n```\n\n###### 拒绝订阅\n\n如果拒绝对方订阅，可以使用以下代码。\n\n```\nsubscriptionService.refuse(contact);\n```\n\n###### 接收反馈消息\n\n使用ISubscriptionListener监听订阅反馈信息。\n\n```\nsubscriptionService.addSubscriptionListener(new ISubscriptionListener() {\n\t...\n\tpublic void approved(JabberId contact) {\n\t\tSystem.out.println(\"User \" + contact + \" approved your subscription.\");\n\t}\n\n\tpublic void refused(JabberId contact) {\n\t\tSystem.out.println(\"User \" + user + \" refused your subscription.\");\n\t}\n\t...\n});\n```\n\n##### 发送和接收Message\n\n###### 发送Message\n\n```\nJabberId contact = JabberId.parse(\"agilest@im.thefirstlineofcode.com\");\n\nMessage message = new Message(\"Hello, Agilest!\");\nmessage.setTo(contact);\n\nim.send(message);\n```\n\n或者，采用更简洁的方式。\n\n```\nJabberId contact = JabberId.parse(\"agilest@im.thefirstlineofcode.com\");\nim.send(contact, new Message(\"Hello, Agilest!\"))\n```\n\n###### 监听Message\n\n```\nim.addMessageListener(new IMessageListener() {\n\tpublic void received(Message message) {\n\t\tSystem.out.println(\"Received a message from user \" + message.getFrom());\n\t}\n});\n```\n\n#### InstantingMessengerPlugin2\n\nInstantingMessengerPlugin2实现了LEP-0011(Traceable Message)。主要是提供可靠消息服务，以及可以跟踪消息状态。\n\n\u003e LEP-0011是非标准协议，需要支持LEP协议的服务器配合，例如：Granite XMPP Server。\n\n##### 注册插件\n\n```\nchaClient.register(InstantingMessengerPlugin2.class);\n```\n\n##### 跟踪消息状态\n\nInstantingMessengerPlugin2插件，使用IMessageListener2的接口来替代标准的IMessageListener。IMessageListeners接口提供了一个traced方法来跟踪消息状态。\n\n```\nIInstantingMessenger2 im2 = chatClient.createApi(IInstantingMessenger2.class);\nim2.addMessageListener(new IMessageListener2() {\n\tpublic void received(Message message) {\n\t\tSystem.out.println(\"Received a message from user \" + message.getFrom());\n\t}\n\n\tpublic void traced(Trace trace) {\n\t\tfor (MsgStatus status : trace.getMsgStatuses()) {\n\t\t\tif (status.getStatus() == MsgStatus.Status.SERVER_REACHED) {\n\t\t\t\tSystem.out.println(\"Message which id is \" + status.getId() + \" has reached server at time \" + status.getStamp());\n\t\t\t} else if (status.getStatus() == MsgStatus.Status.PEER_REACHED) {\n\t\t\t\tSystem.out.println(\"Message which id is \" + status.getId() + \" has reached peer at time \" + status.getStamp());\n\t\t\t} else { // status.getStatus() == MsgStatus.Status.MESSAGE_READ\n\t\t\t\tSystem.out.println(\"Message which id is \" + status.getId() + \" has read by contact at time \" + status.getStamp());\n\t\t\t}\n\t\t}\n\t}\n});\n\n```\n\n#### MucPlugin\n\nMucPlugin实现了[XEP-0045(Multi-User Chat)](https://xmpp.org/extensions/xep-0045.html)，提供聊天室多人聊天功能。\n\n##### 注册插件\n\n```\nchaClient.register(MucPlugin.class);\n```\n\n##### 查询Muc主机\n\n```\nIMucService muc = chatClient.createApi(IMucService.class);\nJabberId[] hosts = muc.getMucHosts();\n```\n\n##### 查询某台主机上的聊天室\n\n```\nJabberId[] rooms = muc.getMucRooms();\n```\n\n##### 获取一个聊天室实例\n\n```\nIRoom room = muc.getRoom(roomJid);\n```\n\n##### 获取聊天室信息\n\n```\nRoomInfo roomInfo = room.getRoomInfo();\n```\n\n##### 进入聊天室\n\n```\nroom.enter(\"my_nick_name\");\n```\n\n##### 退出聊天室\n\n```\nroom.exit();\n```\n\n##### 获取聊天室人员列表\n\n```\nOccupant[] occupants = room.getOccupants();\n```\n\n##### 发送消息到聊天室\n\n可以给聊天室发送消息，聊天室里的所有用户都能收到该消息。\n\n```\nroom.send(new Message(\"Hello, everyone!\"));\n```\n\n##### 私聊\n\n可以发送私聊消息给聊天室中某个用户。\n\n```\nroom.send(\"user_nick_name\", new Message(\"Hello, everyone!\"));\n```\n\n##### 创建聊天室\n\n###### 用默认配置创建一个聊天室\n\n```\nJabberId roomJid = JabberId.parse(\"my_chat_room_name@im.thefirstlineofcode.com\");\nmuc.createInstantRoom(roomJid, \"my_nick_name\");\n```\n\n###### 用自定义配置创建聊天室\n\n```\nJabberId roomJid = JabberId.parse(\"my_chat_room_name@im.thefirstlineofcode.com\");\nmuc.createReservedRoom(roomJid, \"my_nick_name\", new StandardRoomConfigurator() {\n\tprotected RoomConfig configure(RoomConfig roomConfig) {\n\t\troomConfig.setRoomName(\"my first room\");\n\t\troomConfig.setRoomDesc(\"Hope you have happy hours here!\");\n\t\troomConfig.setMembersOnly(true);\n\t\troomConfig.setAllowInvites(true);\n\t\troomConfig.setPasswordProtectedRoom(true);\n\t\troomConfig.setRoomSecret(\"simple_password\");\n\t\troomConfig.getGetMemberList().setParticipant(false);\n\t\troomConfig.getGetMemberList().setVisitor(false);\n\t\troomConfig.setWhoIs(WhoIs.MODERATORS);\n\t\troomConfig.setModeratedRoom(true);\n\t\t\n\t\treturn roomConfig;\n\t}\n}\n);\n```\n\n##### 邀请其它用户加入聊天室\n\n```\nJabberId myColleague = JabberId.parse(\"my_colleague_name@im.thefirstlineofcode.com\");\nroom.invite(myColleague, \"Let's discuss our plan\")\n```\n\n##### 监听聊天室事件\n\n通过IRoomListener可以监听多种Room相关的事件。当Room产生事件时，IRoomListener接收到RoomEvent类型的对象。\n\nRoomEvent对象有两个关键属性，roomJid和eventObject，roomJid表示Event来自哪个Rooom，而eventObject则根据RoomEvent类型的不同，可以是不同类型的对象，表示Event的具体细节。\n\n处理RoomEvent的代码大概如下。\n\n```\nmuc.addRoomListener(new IRoomListener() {\n\tpublic void received(RoomEvent\u003c?\u003e event) {\n\t\tif (event instanceof InvitationEvent) {\n\t\t\tInvitationEvent invitationEvent = (InvitationEvent)event;\n\t\t\tJabberId roomJid = invitationEvent.getRoomJid();\n\t\t\tInvitation invitation = invitationEvent.getEventObject();\n\t\t\tSystem.out.println(String.format(\"'%s' invites you to join room '%s'\", invitation.getInvitor(), roomJid));\n\t\t} else if (event instanceof EnterEvent) {\n\t\t\tEnter enter = ((EnterEvent)event).getEventObject();\n\t\t\tOccupant occupant = muc.getRoom(event.getRoomJid()).getOccupant(enter.getNick());\n\t\t\tint sessions = occupant == null ? 0 : occupant.getSessions();\n\t\t\tSystem.out.println(String.format(\"'%s'[sessions:%d] has joined room '%s'\", enter.getNick(), sessions, event.getRoomJid()));\n\t\t} else if (event instanceof ExitEvent) {\n\t\t\tExit exit = ((ExitEvent)event).getEventObject();\n\t\t\tOccupant occupant = muc.getRoom(event.getRoomJid()).getOccupant(exit.getNick());\n\t\t\tint sessions = occupant == null ? 0 : occupant.getSessions();\n\t\t\tSystem.out.println(String.format(\"'%s'[sessions:%d] has exited room '%s'\", exit.getNick(), sessions, event.getRoomJid()));\n\t\t} else if (event instanceof ChangeAvailabilityStatusEvent) {\n\t\t\tChangeAvailabilityStatus changeAvailabilityStatus = ((ChangeAvailabilityStatusEvent)event).getEventObject();\n\t\t\tSystem.out.println(String.format(\"'%s' has changed it's availability status to: %s\", changeAvailabilityStatus.getNick(),\n\t\t\t\t\tgetAvailabilityStatus(changeAvailabilityStatus)));\n\t\t} else if (event instanceof RoomMessageEvent) {\n\t\t\tRoomMessageEvent messageEvent = (RoomMessageEvent)event;\n\t\t\tSystem.out.println(String.format(\"groupchat message received[from '%s' at room '%s']: %s\", messageEvent.getEventObject().getNick(),\n\t\t\t\t\tmessageEvent.getRoomJid(), messageEvent.getEventObject().getMessage()));\n\t\t} else if (event instanceof PrivateMessageEvent) {\n\t\t\tPrivateMessageEvent privateMessageEvent = (PrivateMessageEvent)event;\n\t\t\tSystem.out.println(String.format(\"groupchat private message received[from '%s' at room '%s']: %s\", privateMessageEvent.getEventObject().getNick(),\n\t\t\t\t\tprivateMessageEvent.getRoomJid(), privateMessageEvent.getEventObject().getMessage()));\n\t\t} else if (event instanceof DiscussionHistoryEvent) {\n\t\t\tDiscussionHistoryEvent discussionHistoryEvent = (DiscussionHistoryEvent)event;\n\t\t\tSystem.out.println(String.format(\"groupchat discussion history message received[from '%s' at room '%s']: %s\", discussionHistoryEvent.getEventObject().getNick(),\n\t\t\t\t\tdiscussionHistoryEvent.getRoomJid(), discussionHistoryEvent.getEventObject().getMessage()));\n\t\t} else if (event instanceof ChangeNickEvent) {\n\t\t\tChangeNickEvent changeNickEvent = (ChangeNickEvent)event;\n\t\t\tSystem.out.println(String.format(\"user '%s'[sessions: %d] changed his nick[at room '%s']: %s\", changeNickEvent.getEventObject().getOldNick(),\n\t\t\t\t\tchangeNickEvent.getEventObject().getOldNickSessions(), changeNickEvent.getRoomJid(),\n\t\t\t\t\tchangeNickEvent.getEventObject().getNewNick()));\n\t\t} else if (event instanceof RoomSubjectEvent) {\n\t\t\tRoomSubjectEvent roomSubjectEvent = (RoomSubjectEvent)event;\n\t\t\tif (\"\".equals(roomSubjectEvent.getEventObject().getSubject())) {\n\t\t\t\tSystem.out.println(String.format(\"there are no room subject in room '%s'\", roomSubjectEvent.getRoomJid()));\n\t\t\t} else {\n\t\t\t\tSystem.out.println(String.format(\"room subject received[from '%s' in room '%s']: %s\", roomSubjectEvent.getEventObject().getNick(),\n\t\t\t\t\t\troomSubjectEvent.getRoomJid(),  roomSubjectEvent.getEventObject().getSubject()));\n\t\t\t}\n\t\t} else if (event instanceof KickedEvent) {\n\t\t\tKickedEvent kickedEvent = (KickedEvent)event;\n\t\t\tSystem.out.println(String.format(\"you are kicked by '%s' from room '%s'. reason is '%s'\", kickedEvent.getEventObject().getNick(),\n\t\t\t\t\t\tkickedEvent.getEventObject().getActor().getNick(), kickedEvent.getRoomJid(),\n\t\t\t\t\t\t\tkickedEvent.getEventObject().getReason()));\n\t\t} else if (event instanceof KickEvent) {\n\t\t\tKickEvent kickEvent = (KickEvent)event;\n\t\t\tSystem.out.println(String.format(\"'%s' is kicked from room '%s'\", kickEvent.getEventObject().getNick(), kickEvent.getRoomJid()));\n\t\t}\n\t}\n});\n\n```\n\n以下是RoomEvent对象列表。\n\n对象类型 | eventObject类型 | 描述 |\n------- | -------------- | ---- |\nChangeAvailabilityStatusEvent | ChangeAvailabilityStatus | 有聊天室用户修改了他的Presence状态 |\nChangeNickEvent | ChangeNick | 有聊天室用户修改了他的昵称 |\nDiscussionHistoryEvent | RoomMessage | 进入聊天室时，会收到聊天室最近的聊天历史消息 |\nEnterEvent | Enter | 有新用户进入聊天室 |\nExitEvent | Exit | 有用户退出了聊天室 |\nInvitationEvent | Invitation | 加入聊天室的邀请 |\nKickedEvent | Kicked | 用户自己被踢出了聊天室 |\nKickEvent | Kick | 有用户被踢出了聊天室 |\nPrivateMessageEvent | RoomMessage | 私聊消息 |\nRoomMessageEvent | RoomMessage | 有用户在聊天室发了消息 |\nRoomSubjectEvent | RoomSubject | 聊天室主题变更 |\n\n## 开发Chalk插件\n### Chalk架构设计\n\n![](https://cdn.jsdelivr.net/gh/XDongger/dongger_s_img_repo/images/chalk_architecture.png)\n\nChalk的架构设计将系统分为两部分。\n\n* 主程序框架 \n* 插件\n\n在主程序框架中，灰色的框是系统的骨架，是系统中比较稳定的部分，虚线框的部分是系统中灵活及充满变化的地方。\n\n这种灵活性和变化从何而来？因为XMPP被设计成一个内核稳定，但高度可扩展的协议，通过XML的namespace语义，我们可以在stanza(iq, message, presence)中添加任意的新协议元素，从而扩展XMPP协议。通过这样的方式，XMPP被扩展成一个庞大的协议族，仅公开的[XEPs(XMPP Extension Protocols)](https://xmpp.org/extensions/)就有近200个。\n\n对应XMPP协议的设计原则，Chalk也将系统设计成稳定的框架+可扩展的插件子系统。框架部分封装了通讯细节和XMPP基础概念，插件子系统允许通过插件任意扩展系统的能力。\n\n值得注意的是，系统的扩展和变化主要出现这几个地方。\n\n* 协议定义\n* 协议-协议对象的转化\n* 协议逻辑处理\n\n\u003e大部分情况下，我们希望插件和协议有清楚的映射关系。这意味着，我们希望尽量能够在一个插件中，封装一个或一组相关的XMPP协议。应该尽量避免将一个独立协议的逻辑，拆分在多个插件中。\n\n### 插件子系统\n\n为简化Plugin的开发，Chalk提供了IChatSystem和IChatServices，希望能够把Plugin和系统之间的联系，简化限制在这两个接口内。\n\n#### IChatSystem\n\nIChatSystem允许Plugin将特定的扩展注册到系统当中，主要包括：\n\n* 协议对象(Protocol Object)\n* 协议-协议对象转换器(Parser \u0026 Translator)\n* 插件的Api(Api)\n* 插件Api的实现(Api Impl)\n\n#### IChatServices\n\nIChatServices封装了下层通讯细节及XMPP基础概念，插件的Api实现可以调用IChatServices提供的服务，处理协议的细节逻辑。\n\n### 开发一个最简单的插件\n\n我们通过一个简单的案例，说明如何开发一个Chalk插件。\n\n#### 协议定义\n\n我们选择实现一个简单的协议[XEP-0199(XMPP Ping)](https://xmpp.org/extensions/xep-0199.html)。\n\nXMPP Ping是一个基于iq的协议，简单来说，我们需要处理以下的逻辑。\n\n客户端向服务器端发送一个ping请求。\n\n```\n\u003ciq from='juliet@capulet.lit/balcony' to='capulet.lit' id='c2s1' type='get'\u003e\n\t\u003cping xmlns='urn:xmpp:ping'/\u003e\n\u003c/iq\u003e\n```\n\n如果服务器支持XMPP Ping协议，服务器返回pong响应。\n\n```\n\u003ciq from='capulet.lit' to='juliet@capulet.lit/balcony' id='c2s1' type='result'/\u003e\n```\n\n如果服务器不支持XMPP Ping协议，则返回\u003cservice-unavailable/\u003e错误。\n\n```\n\u003ciq from='capulet.lit' to='juliet@capulet.lit/balcony' id='c2s1' type='error'\u003e\n\t\u003cping xmlns='urn:xmpp:ping'/\u003e\n\t\u003cerror type='cancel'\u003e\n\t\t\u003cservice-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/\u003e\n\t\u003c/error\u003e\n\u003c/iq\u003e\n```\n\n#### 协议对象（Protocol Object）\n\n为了便于逻辑处理，我们一般会对应网络上传输协议XML文档，设计一个Java业务对象，这样便于用业务类来进行处理。这个Java业务对象，我们称之为协议对象(Protocol Object)。\n\n在本案例中，协议对象结构非常简单，类定义如下。\n\n```\npublic class Ping {\n\tpublic static final Protocol PROTOCOL = new Protocol(\"urn:xmpp:ping\", \"ping\");\n}\n```\n\nPing类不包含任何信息，只是用类型来表示ping协议。\n\n#### 解析器（IParser）和转换器（ITranslator）\n\n有了Protocol Object，引发了另外的问题，就是如何处理XMPP协议文档-Protocol Object之间的转换。\n\n当我们收到网络上的XML协议文档，需要将其内容转换成一个Protocol Object实例。对应的，当我们发送一个Protocol Object时，需要将Protocol Object包含的协议信息，转换成对应的XMPP协议文档，再发送到网络上去。\n\nChalk用IParser和ITranslator来处理Protocol Object-XMPP协议文档之间的转换。IParser负责将一个XMPP协议文档转换成Protocol Object实例。ITranslator负责将一个Protocol Object实例翻译成对应的XMPP协议文档。\n\n为了简化这些转换逻辑，Chalk使用Basalt项目提供的一个OXM(Protocol Object-XMPP Document Mapping)框架。在大部分情况下，我们并不需要为对象和XMPP协议文档之间的转换，编写逻辑代码。我们只需要选择系统内置的IParser和ITranslator实现。\n\n因为Ping对象结构非常简单，我们可以选择使用SimpleObjectParser和SimpleObjectTranslator来处理对象和XML文档之间的转换。\n\n\u003eBasalt是一个XMPP协议库，定义了基础的XMPP协议对象，并提供一个简单易用的OXM(Protocol Object-XMPP Document Mapping)框架。\n\n\u003e最常用的IParser和ITranslator，是NamingConventionParser和NamingConventionTranslator，它们采用命名约定的方法，将Protocol Object的Field和XMPP协议文档中的Element做对应的拷贝。\n\n\u003e关于Basalt OXM(Protocol Object-XMPP Document Mapping)框架的更多信息，请参考Basalt项目相关文档。\n\n#### Api\n在理想的情况下，我们将Chalk的开发者分为两类：\n\n* 插件开发者\n* 应用开发者\n\n插件开发者理解XMPP协议细节，以及Chalk的插件架构体系。他们将XEPs或者自定义的非标准协议，开发成对应的Plugin。\n\n应用开发者不需要理解XMPP协议的细节，他们只是使用Chalk基础框架和选用插件，在此之上开发具体XMPP应用，例如IM，物联网应用等。\n\n在现实中，开发者可能需要兼插件开发者和应用开发者。即使是这样的情况，在设计插件时，也应该遵循一些原则：\n\n* 提供易于使用的Api，使得应用开发更简单、直观。\n* 屏蔽XMPP协议的底层细节。\n* 避免绑定具体的应用逻辑，提升插件的复用性。\n\n插件开发者一个重要的任务，是设计良好的Api，给应用开发者调用。\n\n在本案例中，PingPlugin提供以下的Api给应用开发者使用。\n\n```\npublic interface IPing {\n\tpublic enum Result {\n\t\tPONG, // Server returned a pong.\n\t\tSERVICE_UNAVAILABLE, // Server doesn't support the protocol.\n\t\tTIME_OUT // Ping timed out.\n\t}\n\t\n\t/**\n\t * Send a ping request to server and waiting for result. \n\t */\n\tResult ping();\n\t\n\t/**\n\t * Set timeout for ping operation.\n\t */\n\tvoid setTimeout(int timeout);\n\t\n\t/**\n\t * Get the ping timeout.\n\t */\n\tint getTimeout();\n}\n```\n\n#### Api实现\n\n基于IChatServices接口，有多种办法可以实现协议逻辑。\n\n\u003e 只要正确的注册Api和Api Impl，框架会在在Api Impl里自动注入IChatServices对象，所以我们总是可以获取到IChatServices来使用。\n\n##### Legacy模式\n\n最常规的想法，我们向服务器端发送一个带id的ping消息，然后监控所有收到的信息，如果有一条相同id的消息返回，我们检查是pong还是server-unavailable，如果超过一定的时间还没收到响应消息，我们就返回超时。\n\n如果是这样的思路，我们可以：\n\n* 使用IIqService发送一个ping消息到服务器。\n\n```\nIIqService iqService = chatServices.getIqService();\nIq ping = new Iq(Iq.Type.GET);\nping.setObject(new Ping());\n\nString pingId = ping.getId();\n\niqService.send(ping);\n```\n\n\u003e 注意，我们需要记录下发送的iq的id，便于后面找到服务器端对应的响应消息。\n\n* 我们当然需要增加一个IIqListener，监听从服务器端来的消息，检查是否pong的响应。\n\n```\niqService.addListener(new IIqListener() {\n\tpublic void received(Iq iq) {\n\t\tif (pingId.equals(iq.getId()) {\n\t\t\t// Received response from server\n\t\t}\n\t}\n});\n```\n\n* 我们还需要监听错误消息，检查服务器可能返回service-unavailable错误。\n\n```\nIErrorService errorService = chatServices.getErrorService();\nerrorService.addListener(new IErrorListener() {\n\tpublic void occurred(IError error) {\n\t\tif (pingId.equals(error.getId()) {\n\t\t\tif (error instanceof ServerUnavailable) {\n\t\t\t\t// Received server-unavailable error from server\t\t\t\n\t\t\t}\n\t\t}\n\t}\n});\n```\n\n* 我们当然还需要一个Timer定时器，来处理超时的情况。\n\n```\nTimer timer = new Timer();\ntimer.schedule(pingTimeoutTask, timeout);\n```\n\n这些处理看上去非常繁琐，Chalk提供了稍微简便一些的方法，我们可以使用SyncOperationTemplate类来简化一些代码逻辑。\n\n以下是使用SyncOperationTemplate大概的代码逻辑。\n\n```\npublic class PingImpl implements IPing {\n\tprivate IChatServices chatServices;\n\tprivate String id;\n\tprivate int timeout;\n\n\tpublic Result ping() {\n\t\tSyncOperationTemplate\u003cIq, IPing.Result\u003e template = new SyncOperationTemplate\u003cIq, IPing.Result\u003e(chatServices);\n\t\t\n\t\ttry {\n\t\t\treturn template.execute(new ISyncIqOperation\u003cIPing.Result\u003e() {\n\n\t\t\t\tpublic void trigger(IUnidirectionalStream\u003cIq\u003e stream) {\n\t\t\t\t\tIq iq = new Iq(Iq.Type.SET);\n\t\t\t\t\tiq.setObject(new Ping());\n\t\t\t\t\tid = iq.getId();\n\t\t\t\t\t\n\t\t\t\t\tstream.send(iq, timeout);\n\t\t\t\t}\n\n\t\t\t\tpublic boolean isErrorOccurred(StanzaError error) {\n\t\t\t\t\tif (id.equals(error.getId()))\n\t\t\t\t\t\treturn true;\n\t\t\t\t\t\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\n\t\t\t\tpublic boolean isResultReceived(Iq iq) {\n\t\t\t\t\tif (id.equals(iq.getId()))\n\t\t\t\t\t\treturn true;\n\t\t\t\t\t\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\n\t\t\t\tpublic Result processResult(Iq iq) {\n\t\t\t\t\treturn IPing.Result.PONG;\n\t\t\t\t}\n\t\t\t});\n\t\t} catch (ErrorException e) {\n\t\t\tif (e.getError().getDefinedCondition().equals(RemoteServerTimeout.DEFINED_CONDITION)) {\n\t\t\t\treturn IPing.Result.TIME_OUT;\n\t\t\t} else {\n\t\t\t\treturn IPing.Result.SERVICE_UNAVAILABLE;\n\t\t\t}\n\t\t}\n\t}\n\t}\n\n\t...\n}\n```\n\n这里简化之处在于，我们可以在一个ISyncIqOperation内部类中处理所有逻辑，而不需要去访问IIqService，IIqListener，IErrorService，IErrorListener及Timer等诸多细节。\n\n##### Task模式\n\nLegacy模式还是比较复杂，特别我们会注意到一个问题，我们总是需要在代码中跟踪相同id的消息，这似乎意味着跟踪id的处理，应该移交给框架去进行处理。\n\nChalk提供了Task模式的处理框架，可以避免我们琐碎的去跟踪相同id的消息，更加简化协议逻辑的处理。\n\n###### Sync Task\n\n注意，我们在IPing接口里，采用同步阻塞等待结果的方法，调用ping()方法后，程序会阻塞直到获得pong响应，或者接收到server-unavailable错误，或者等待超时返回。\n\n在同步的情况下，最方便是使用ITaskService和ISyncTask接口。\n\n```\npublic class PingImpl implements IPing {\n\tprivate IChatServices chatServices;\n\tprivate int timeout;\n\t\n\t...\n\n\tpublic Result ping() {\n\t\tITaskService taskService = chatServices.getTaskService();\n\t\ttry {\n\t\t\treturn taskService.execute(new ISyncTask\u003cIq, IPing.Result\u003e() {\n\n\t\t\t\tpublic void trigger(IUnidirectionalStream\u003cIq\u003e stream) {\n\t\t\t\t\tIq iq = new Iq(Iq.Type.SET);\n\t\t\t\t\tiq.setObject(new Ping());\n\n\t\t\t\t\tstream.send(iq, timeout);\n\t\t\t\t}\n\n\t\t\t\tpublic Result processResult(Iq iq) {\n\t\t\t\t\treturn IPing.Result.PONG;\n\t\t\t\t}\n\n\t\t\t});\n\t\t} catch (ErrorException e) {\n\t\t\tif (e.getError().getDefinedCondition().equals(RemoteServerTimeout.DEFINED_CONDITION)) {\n\t\t\t\treturn IPing.Result.TIME_OUT;\n\t\t\t} else {\n\t\t\t\treturn IPing.Result.SERVICE_UNAVAILABLE;\n\t\t\t}\n\t\t}\n\t}\n\n\t...\n\t\n}\n```\n\n可以看到，在Task模式下，框架默认监控相同id的消息，如果是接收到相同id的iq result，则回调processResult()方法进行处理。\n\n如果服务器端返回相同id的错误，以及超时错误，都会统一封装成ErrorException，可以catch例外根据具体情况进行处理。\n\n###### Async Task\n\n在实时消息系统中，Sync的场景会比较少，大多数场景下，等待来自服务器或联系人的消息，但是消息何时会来到，我们并不能预期。\n\n在大多数情况下，我们应该使用Async Task而不是Sync Task，等消息到达的时候，触发回调方法。\n\nITaskService提供了执行Async Task的方法：\n\n```\npublic interface ITaskService {\n\t...\n\n\tvoid execute(ITask\u003c?\u003e task);\n\n\t...\n}\n```\n\n\u003e 关于Sync Task和Async Task的区别，还有一个值得注意的地方。所有的Sync Task的回调处理，都是在主消息接收线程里执行的，这意味着，如果有一个回调方法执行时，占用太多时间，会导致其它的Task被阻塞，有可能导致的一个结果是，应用程序一些业务被阻塞变慢。\n\n\u003e 当然我们可以在一些耗时的Sync Task回调方法里，启动新的线程，避免阻塞主消息接收线程。这是一个解决办法，但是我们有时候可能会容易忘记需要启动新线程。\n\n\u003e Async Task采用了不同的处理方法，框架提供了一个线程池，每当接收到一个需要处理的Async Task回调时，系统会从线程池中启动一个线程，将回调逻辑放在新线程中去处理。这样，Async Task可以更好的避免系统阻塞变慢问题。\n\n\u003e 如果可能，应该尽可能的使用Async Task模式来处理协议的逻辑。\n\n#### 打包所有\n\n现在，我们已经处理好了所有的协议细节和业务逻辑，需要将所有的代码和逻辑，通过插件注册到系统中去。我们在上面已经提到过了，我们使用IChatSystem来帮助完成这项工作。\n\n我们需要编写一个Plugin类，在本案例中，这个类是PingPlugin。\n\n在本案例中PingPlugin中，我们需要：\n* 注册协议的协议对象Ping，以及对应的Parser和Translator。\n* 注册提供的Api接口IPing，以及IPing的具体实现PingImpl。\n\n我们在Plugin类的init()方法里，注册插件给系统提供的扩展。在destroy()方法里，我们移除这些扩展。\n\nPingPlugin的代码如下。\n\n```\npublic class PingPlugin implements IPlugin {\n\tpublic void init(IChatSystem chatSystem, Properties properties) {\n\t\tchatSystem.registerParser(\n\t\t\t\tProtocolChain.first(Iq.PROTOCOL).next(Ping.PROTOCOL),\n\t\t\t\tnew SimpleObjectParserFactory\u003cPing\u003e(Ping.PROTOCOL, Ping.class));\n\t\tchatSystem.registerTranslator(\n\t\t\t\tPing.class,\n\t\t\t\tnew SimpleObjectTranslatorFactory\u003cPing\u003e(Ping.class, Ping.PROTOCOL));\n\t\t\n\t\tchatSystem.registerApi(IPing.class, PingImpl.class, properties);\t\t\t\n\t}\n\n\tpublic void destroy(IChatSystem chatSystem) {\n\t\tchatSystem.unregisterApi(IPing.class);\n\t\tchatSystem.unregisterTranslator(Ping.class);\n\t\tchatSystem.unregisterParser(ProtocolChain.first(Iq.PROTOCOL).next(Ping.PROTOCOL));\n\t}\n\n}\n```\n\n#### 最后\n\n插件已经开发完成，现在我们可以注册插件：\n\n```\nchatClient.register(PingPlugin.class);\n```\n\n创建Api：\n\n```\nIPing ping = chatClient.createApi(IPing.class);\n```\n\n并执行协议逻辑：\n\n```\nIPing.Result result = ping.ping();\n```\n\nPingPlugin是一个非常简单的插件，虽然它很简单，但是开发这样一个插件，依然需要完成一个完整的插件开发的过程。\n\n这样一个简单而又完整的插件案例，是我们开发更复杂协议的起点。\n\n如果要开发更复杂的协议，最好的办法就是阅读XMPP文档，以及阅读Chalk的代码。如果你对XMPP和开源充满热情，那现在就开始吧。\n\n## 其它\n### 流协商（Stream Negotiation）\n\nTBD\n\n### Android平台\n\nTBD\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthefirstlineofcode%2Fchalk","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fthefirstlineofcode%2Fchalk","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fthefirstlineofcode%2Fchalk/lists"}